mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-19 16:14:37 +00:00
chore: initial import
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Build Outputs
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
.cursor
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://github.com/zuiidea/antd-admin" target="_blank">
|
||||||
|
<img alt="antd-admin-logo" height="80" src="./apps/with-lingui/public/favicon.svg">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Antd Admin</h1>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
|
||||||
|
|
||||||
|
[](https://github.com/ant-design/ant-design)
|
||||||
|
[](https://react.dev)
|
||||||
|
[](https://viteplus.dev)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/issues)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/stargazers)
|
||||||
|
[](http://opensource.org/licenses/MIT)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/pulls)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
| ------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
|
||||||
|
| UI Framework | [Ant Design 6.x](https://ant.design) |
|
||||||
|
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
|
||||||
|
| Async State | [TanStack Query v5](https://tanstack.com/query) |
|
||||||
|
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
|
||||||
|
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
|
||||||
|
| i18n | [LinguiJS](https://lingui.dev) (en + zh catalogs) |
|
||||||
|
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
|
||||||
|
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
|
||||||
|
| E2E Testing | [Playwright](https://playwright.dev) |
|
||||||
|
| Language | TypeScript 5.9 (strict mode) |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
|
||||||
|
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
|
||||||
|
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
|
||||||
|
- **i18n** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales wired in root
|
||||||
|
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
|
||||||
|
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
|
||||||
|
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org) >= 20
|
||||||
|
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
|
||||||
|
|
||||||
|
### Install & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp install
|
||||||
|
vp dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
|
||||||
|
|
||||||
|
**Default credentials:** `admin` / `admin`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp build
|
||||||
|
vp preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp run test:e2e
|
||||||
|
vp run test:e2e:core
|
||||||
|
vp run test:e2e:ui # interactive UI mode
|
||||||
|
```
|
||||||
|
|
||||||
|
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # Zod schemas, endpoint constants, type exports
|
||||||
|
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||||
|
│ ├── auth.ts # Auth endpoint constants
|
||||||
|
│ └── user.ts # User CRUD endpoint constants
|
||||||
|
├── components/
|
||||||
|
│ ├── Aurora/ # Login background effect
|
||||||
|
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||||
|
│ ├── DataTable/ # Shared table shell, skeleton, empty state
|
||||||
|
│ ├── FilterToolbar/ # Shared filter/action toolbar
|
||||||
|
│ ├── FormModal/ # Reusable modal + form shell
|
||||||
|
│ ├── Icon/ # Shared icons and theme toggle icon
|
||||||
|
│ └── Layout/ # Admin shell (sidebar, header, main)
|
||||||
|
│ ├── MainLayout/ # Main layout shell: index.tsx
|
||||||
|
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
|
||||||
|
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
|
||||||
|
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
|
||||||
|
│ └── Header/ # Top bar: index.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── usePermission.ts # Permission check hook
|
||||||
|
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
|
||||||
|
├── utils/
|
||||||
|
│ ├── constants.ts # API base URL, static assets
|
||||||
|
│ └── http.ts # HTTP client with JWT injection & error handling
|
||||||
|
├── locales/ # LinguiJS catalogs (.po + compiled .js)
|
||||||
|
│ ├── en/messages.po
|
||||||
|
│ └── zh/messages.po
|
||||||
|
├── mocks/
|
||||||
|
│ ├── browser.ts # MSW worker setup
|
||||||
|
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||||
|
│ ├── data.ts # Mock seed data (users, menus)
|
||||||
|
│ ├── filterUtils.ts # Mock-only filtering and pagination helpers
|
||||||
|
│ └── handlers/ # Request handlers (auth, user CRUD)
|
||||||
|
├── routes/ # TanStack Router file-based routes
|
||||||
|
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
||||||
|
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||||
|
│ ├── _auth/dashboard/index.tsx
|
||||||
|
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||||
|
│ ├── _auth/403/index.tsx
|
||||||
|
│ ├── login/index.tsx
|
||||||
|
│ ├── 404/index.tsx
|
||||||
|
│ └── index.tsx # Redirects / → /login
|
||||||
|
├── stores/
|
||||||
|
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
|
||||||
|
│ ├── createPersistentStore.ts # Shared persisted-store factory
|
||||||
|
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
||||||
|
├── theme/
|
||||||
|
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
|
||||||
|
│ └── useAppTheme.ts # Theme selection hook
|
||||||
|
└── main.tsx # Entry point (MSW init → React render)
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── e2e/ # Playwright E2E tests
|
||||||
|
├── helpers.ts
|
||||||
|
├── login.spec.ts
|
||||||
|
└── users.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp run i18n:extract
|
||||||
|
vp run i18n:compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
|
||||||
|
|
||||||
|
## Extending the template
|
||||||
|
|
||||||
|
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
||||||
|
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
|
||||||
|
|
||||||
|
## Developer Notes
|
||||||
|
|
||||||
|
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
|
||||||
|
- Run `vp check --no-fmt` for type/lint validation.
|
||||||
|
- Core regression coverage lives in the login and users E2E flows.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| ------------ | ----------------------------------------------------------- |
|
||||||
|
| `/login` | Login form with validation |
|
||||||
|
| `/dashboard` | Statistics overview cards |
|
||||||
|
| `/users` | User CRUD table with search, pagination, create/edit/delete |
|
||||||
|
| `/403` | Forbidden error page |
|
||||||
|
| `/404` | Not found error page |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# LinguiJS compiled catalogs
|
||||||
|
src/locales/*/messages.js
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.agent
|
||||||
|
.pnpm-store
|
||||||
|
.cursor
|
||||||
|
.tanstack
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# oxfmt configuration for antd-admin
|
||||||
|
# Reference: https://oxc.rs/docs/guide/formatter
|
||||||
|
|
||||||
|
# Line width for formatting
|
||||||
|
line_width = 100
|
||||||
|
|
||||||
|
# Indentation
|
||||||
|
indent_width = 2
|
||||||
|
use_tabs = false
|
||||||
|
|
||||||
|
# Semicolons
|
||||||
|
semi = true
|
||||||
|
|
||||||
|
# Quotes
|
||||||
|
single_quote = false
|
||||||
|
|
||||||
|
# Trailing commas
|
||||||
|
trailing_comma = "es5"
|
||||||
|
|
||||||
|
# Arrows
|
||||||
|
arrow_parens = "always"
|
||||||
|
|
||||||
|
# Spaces
|
||||||
|
bracket_spacing = true
|
||||||
|
space_before_function_parentheses = false
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
vp staged
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Using Vite+, the Unified Toolchain for the Web
|
||||||
|
|
||||||
|
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
|
||||||
|
|
||||||
|
## Vite+ Workflow
|
||||||
|
|
||||||
|
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
|
||||||
|
|
||||||
|
### Start
|
||||||
|
|
||||||
|
- create - Create a new project from a template
|
||||||
|
- migrate - Migrate an existing project to Vite+
|
||||||
|
- config - Configure hooks and agent integration
|
||||||
|
- staged - Run linters on staged files
|
||||||
|
- install (`i`) - Install dependencies
|
||||||
|
- env - Manage Node.js versions
|
||||||
|
|
||||||
|
### Develop
|
||||||
|
|
||||||
|
- dev - Run the development server
|
||||||
|
- check - Run format, lint, and TypeScript type checks
|
||||||
|
- lint - Lint code
|
||||||
|
- fmt - Format code
|
||||||
|
- test - Run tests
|
||||||
|
|
||||||
|
### Execute
|
||||||
|
|
||||||
|
- run - Run monorepo tasks
|
||||||
|
- exec - Execute a command from local `node_modules/.bin`
|
||||||
|
- dlx - Execute a package binary without installing it as a dependency
|
||||||
|
- cache - Manage the task cache
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- build - Build for production
|
||||||
|
- pack - Build libraries
|
||||||
|
- preview - Preview production build
|
||||||
|
|
||||||
|
### Manage Dependencies
|
||||||
|
|
||||||
|
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
|
||||||
|
|
||||||
|
- add - Add packages to dependencies
|
||||||
|
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
|
||||||
|
- update (`up`) - Update packages to latest versions
|
||||||
|
- dedupe - Deduplicate dependencies
|
||||||
|
- outdated - Check for outdated packages
|
||||||
|
- list (`ls`) - List installed packages
|
||||||
|
- why (`explain`) - Show why a package is installed
|
||||||
|
- info (`view`, `show`) - View package information from the registry
|
||||||
|
- link (`ln`) / unlink - Manage local package links
|
||||||
|
- pm - Forward a command to the package manager
|
||||||
|
|
||||||
|
### Maintain
|
||||||
|
|
||||||
|
- upgrade - Update `vp` itself to the latest version
|
||||||
|
|
||||||
|
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
|
||||||
|
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
|
||||||
|
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vp test` command, run it using `vp run test`.
|
||||||
|
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
|
||||||
|
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
|
||||||
|
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
|
||||||
|
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
|
||||||
|
|
||||||
|
## Review Checklist for Agents
|
||||||
|
|
||||||
|
- Run `vp install` after pulling remote changes and before getting started.
|
||||||
|
- Run `vp check` and `vp test` to validate changes.
|
||||||
|
|
||||||
|
## AI Instruction Files
|
||||||
|
|
||||||
|
This repository also uses scoped AI instruction files in `.github/instructions/`.
|
||||||
|
|
||||||
|
- `frontend.instructions.md` for UI/routes/components/style work
|
||||||
|
- `testing.instructions.md` for Playwright/MSW/test updates
|
||||||
|
- `api.instructions.md` for API/schema/handler changes
|
||||||
|
- `refactor.instructions.md` for behavior-preserving cleanup
|
||||||
|
|
||||||
|
See `.github/instructions/README.md` for usage and authoring rules.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://github.com/zuiidea/antd-admin" target="_blank">
|
||||||
|
<img alt="antd-admin-logo" height="80" src="./public/favicon.svg">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Antd Admin</h1>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
|
||||||
|
|
||||||
|
[](https://github.com/ant-design/ant-design)
|
||||||
|
[](https://react.dev)
|
||||||
|
[](https://viteplus.dev)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/issues)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/stargazers)
|
||||||
|
[](http://opensource.org/licenses/MIT)
|
||||||
|
[](https://github.com/zuiidea/antd-admin/pulls)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
| ------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
|
||||||
|
| UI Framework | [Ant Design 6.x](https://ant.design) |
|
||||||
|
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
|
||||||
|
| Async State | [TanStack Query v5](https://tanstack.com/query) |
|
||||||
|
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
|
||||||
|
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
|
||||||
|
| i18n | [LinguiJS](https://lingui.dev) (en + zh catalogs) |
|
||||||
|
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
|
||||||
|
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
|
||||||
|
| E2E Testing | [Playwright](https://playwright.dev) |
|
||||||
|
| Language | TypeScript 5.9 (strict mode) |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
|
||||||
|
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
|
||||||
|
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
|
||||||
|
- **i18n** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales wired in root
|
||||||
|
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
|
||||||
|
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
|
||||||
|
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org) >= 20
|
||||||
|
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
|
||||||
|
|
||||||
|
### Install & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp install
|
||||||
|
vp dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
|
||||||
|
|
||||||
|
**Default credentials:** `admin` / `admin`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp build
|
||||||
|
vp preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp run test:e2e
|
||||||
|
vp run test:e2e:core
|
||||||
|
vp run test:e2e:ui # interactive UI mode
|
||||||
|
```
|
||||||
|
|
||||||
|
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # Zod schemas, endpoint constants, type exports
|
||||||
|
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||||
|
│ ├── auth.ts # Auth endpoint constants
|
||||||
|
│ └── user.ts # User CRUD endpoint constants
|
||||||
|
├── components/
|
||||||
|
│ ├── Aurora/ # Login background effect
|
||||||
|
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||||
|
│ ├── DataTable/ # Shared table shell, skeleton, empty state
|
||||||
|
│ ├── FilterToolbar/ # Shared filter/action toolbar
|
||||||
|
│ ├── FormModal/ # Reusable modal + form shell
|
||||||
|
│ ├── Icon/ # Shared icons and theme toggle icon
|
||||||
|
│ └── Layout/ # Admin shell (sidebar, header, main)
|
||||||
|
│ ├── MainLayout/ # Main layout shell: index.tsx
|
||||||
|
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
|
||||||
|
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
|
||||||
|
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
|
||||||
|
│ └── Header/ # Top bar: index.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── usePermission.ts # Permission check hook
|
||||||
|
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
|
||||||
|
├── utils/
|
||||||
|
│ ├── constants.ts # API base URL, static assets
|
||||||
|
│ └── http.ts # HTTP client with JWT injection & error handling
|
||||||
|
├── locales/ # LinguiJS catalogs (.po + compiled .js)
|
||||||
|
│ ├── en/messages.po
|
||||||
|
│ └── zh/messages.po
|
||||||
|
├── mocks/
|
||||||
|
│ ├── browser.ts # MSW worker setup
|
||||||
|
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||||
|
│ ├── data.ts # Mock seed data (users, menus)
|
||||||
|
│ ├── filterUtils.ts # Mock-only filtering and pagination helpers
|
||||||
|
│ └── handlers/ # Request handlers (auth, user CRUD)
|
||||||
|
├── routes/ # TanStack Router file-based routes
|
||||||
|
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
||||||
|
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||||
|
│ ├── _auth/dashboard/index.tsx
|
||||||
|
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||||
|
│ ├── _auth/403/index.tsx
|
||||||
|
│ ├── login/index.tsx
|
||||||
|
│ ├── 404/index.tsx
|
||||||
|
│ └── index.tsx # Redirects / → /login
|
||||||
|
├── stores/
|
||||||
|
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
|
||||||
|
│ ├── createPersistentStore.ts # Shared persisted-store factory
|
||||||
|
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
||||||
|
├── theme/
|
||||||
|
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
|
||||||
|
│ └── useAppTheme.ts # Theme selection hook
|
||||||
|
└── main.tsx # Entry point (MSW init → React render)
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── e2e/ # Playwright E2E tests
|
||||||
|
├── helpers.ts
|
||||||
|
├── login.spec.ts
|
||||||
|
└── users.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vp run i18n:extract
|
||||||
|
vp run i18n:compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
|
||||||
|
|
||||||
|
## Extending the template
|
||||||
|
|
||||||
|
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
||||||
|
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
|
||||||
|
|
||||||
|
## Developer Notes
|
||||||
|
|
||||||
|
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
|
||||||
|
- Run `vp check --no-fmt` for type/lint validation.
|
||||||
|
- Core regression coverage lives in the login and users E2E flows.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| ------------ | ----------------------------------------------------------- |
|
||||||
|
| `/login` | Login form with validation |
|
||||||
|
| `/dashboard` | Statistics overview cards |
|
||||||
|
| `/users` | User CRUD table with search, pagination, create/edit/delete |
|
||||||
|
| `/403` | Forbidden error page |
|
||||||
|
| `/404` | Not found error page |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function loginAsAdmin(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/Username|用户名/).fill("admin");
|
||||||
|
await page.getByLabel(/Password|密码/).fill("admin");
|
||||||
|
await page.getByRole("button", { name: /Sign In|登录/ }).click();
|
||||||
|
await expect(page).toHaveURL(/dashboard/);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoUsers(page: Page) {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page).toHaveURL(/users/);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginAsAdmin } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Login Flow", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display login form", async ({ page }) => {
|
||||||
|
await expect(page.getByLabel(/Username|用户名/)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/Password|密码/)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /Sign In|登录/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should login successfully with admin/admin", async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByText("Total Revenue")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show error for wrong credentials", async ({ page }) => {
|
||||||
|
await page.getByLabel(/Username|用户名/).fill("wrong");
|
||||||
|
await page.getByLabel(/Password|密码/).fill("wrong");
|
||||||
|
await page.getByRole("button", { name: /Sign In|登录/ }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Invalid username or password")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { gotoUsers, loginAsAdmin } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("User Management", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await gotoUsers(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display user table", async ({ page }) => {
|
||||||
|
const thead = page.locator(".ant-table-thead");
|
||||||
|
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
|
||||||
|
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
|
||||||
|
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should search users", async ({ page }) => {
|
||||||
|
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
|
||||||
|
await searchInput.fill("zhao.ming");
|
||||||
|
await searchInput.press("Enter");
|
||||||
|
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||||
|
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
|
||||||
|
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should open create user modal", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: /Create User|创建用户/ }).click();
|
||||||
|
await expect(page.getByRole("dialog").getByText(/New User|新建用户/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
|
||||||
|
test.describe("User Management — role in URL", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto("/users?role=admin");
|
||||||
|
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should filter users by role", async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/role=admin/);
|
||||||
|
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<title>Antd Admin</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const config = {
|
||||||
|
locales: ["en", "zh"],
|
||||||
|
sourceLocale: "en",
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "src/locales/{locale}/messages",
|
||||||
|
include: ["src"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: "po",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "antd-admin",
|
||||||
|
"version": "6.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vp dev",
|
||||||
|
"build": "tsc && vp build",
|
||||||
|
"preview": "vp preview",
|
||||||
|
"prepare": "vp config",
|
||||||
|
"fmt": "vp fmt",
|
||||||
|
"lint": "vp lint",
|
||||||
|
"check": "vp check --no-fmt",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:core": "playwright test tests/e2e/login.spec.ts tests/e2e/users.spec.ts",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"i18n:extract": "vp exec lingui extract",
|
||||||
|
"i18n:compile": "vp exec lingui compile"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lingui/core": "^5.9.3",
|
||||||
|
"@lingui/react": "^5.9.3",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@tanstack/react-router": "^1.167.4",
|
||||||
|
"antd": "^6.3.3",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lingui/cli": "^5.9.3",
|
||||||
|
"@lingui/swc-plugin": "^5.11.0",
|
||||||
|
"@lingui/vite-plugin": "^5.9.3",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@tanstack/router-plugin": "^1.166.13",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
|
"msw": "^2.12.12",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||||
|
"vite-plus": "latest"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.32.1",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||||
|
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||||
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"msw"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
|
||||||
|
declare const process: { env: { CI?: string } };
|
||||||
|
|
||||||
|
const ci = Boolean(process.env.CI);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: ci,
|
||||||
|
retries: ci ? 2 : 0,
|
||||||
|
workers: ci ? 1 : undefined,
|
||||||
|
reporter: "html",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:5173",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "vp dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: !ci,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+4876
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
<svg width="169px" height="141px" viewBox="0 0 169 141" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient x1="54.0428975%" y1="4.39752391%" x2="54.0428975%" y2="108.456714%" id="linearGradient-1">
|
||||||
|
<stop stop-color="#29CDFF" offset="0%"></stop>
|
||||||
|
<stop stop-color="#148EFF" offset="62.3089445%"></stop>
|
||||||
|
<stop stop-color="#0A60FF" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient x1="50%" y1="14.2201464%" x2="50%" y2="113.263844%" id="linearGradient-2">
|
||||||
|
<stop stop-color="#FA816E" offset="0%"></stop>
|
||||||
|
<stop stop-color="#F74A5C" offset="65.9092442%"></stop>
|
||||||
|
<stop stop-color="#F51D2C" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="Group" transform="translate(0.000000, -5.000000)">
|
||||||
|
<rect id="Rectangle" fill="url(#linearGradient-1)" transform="translate(83.718923, 75.312358) rotate(-24.000000) translate(-83.718923, -75.312358) " x="68.7189234" y="0.312357954" width="30" height="150" rx="15"></rect>
|
||||||
|
<rect id="Rectangle" fill="url(#linearGradient-1)" transform="translate(129.009910, 75.580213) rotate(-24.000000) translate(-129.009910, -75.580213) " x="114.00991" y="0.580212739" width="30" height="150" rx="15"></rect>
|
||||||
|
<circle id="Oval" fill="url(#linearGradient-2)" cx="25" cy="120" r="25"></circle>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,336 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = "2.12.12";
|
||||||
|
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
|
||||||
|
const activeClientIds = new Set();
|
||||||
|
|
||||||
|
addEventListener("install", function () {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("activate", function (event) {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("message", async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, "id");
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case "KEEPALIVE_REQUEST": {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "KEEPALIVE_RESPONSE",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "INTEGRITY_CHECK_REQUEST": {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "INTEGRITY_CHECK_RESPONSE",
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "MOCK_ACTIVATE": {
|
||||||
|
activeClientIds.add(clientId);
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: "MOCKING_ENABLED",
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CLIENT_CLOSED": {
|
||||||
|
activeClientIds.delete(clientId);
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener("fetch", function (event) {
|
||||||
|
const requestInterceptedAt = Date.now();
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event);
|
||||||
|
const requestCloneForEvents = event.request.clone();
|
||||||
|
const response = await getResponse(event, client, requestId, requestInterceptedAt);
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone();
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: "RESPONSE",
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId);
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === "top-level") {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
});
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === "visible";
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone();
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers);
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get("accept");
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(",").map((value) => value.trim());
|
||||||
|
const filteredValues = values.filter((value) => value !== "msw/passthrough");
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set("accept", filteredValues.join(", "));
|
||||||
|
} else {
|
||||||
|
headers.delete("accept");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request);
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: "REQUEST",
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case "MOCK_RESPONSE": {
|
||||||
|
return respondWithMock(clientMessage.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "PASSTHROUGH": {
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response);
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
|
||||||
|
|
||||||
|
export const AUTH_ENDPOINTS = {
|
||||||
|
login: "/api/auth/login",
|
||||||
|
refresh: "/api/auth/refresh",
|
||||||
|
logout: "/api/auth/logout",
|
||||||
|
user: "/api/auth/user",
|
||||||
|
permissions: "/api/auth/permissions",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList };
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const UserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
avatar: z.string().nullable(),
|
||||||
|
email: z.string().nullable(),
|
||||||
|
roles: z.array(z.string()),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|
||||||
|
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
|
||||||
|
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
||||||
|
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
||||||
|
|
||||||
|
export const AuthTokensSchema = z.object({
|
||||||
|
accessToken: z.string().min(1),
|
||||||
|
refreshToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||||
|
|
||||||
|
export const LoginRequestSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||||
|
|
||||||
|
export const PermissionsListSchema = z.array(z.string());
|
||||||
|
|
||||||
|
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
|
||||||
|
|
||||||
|
const BaseMenuNodeSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
icon: z.string().nullable(),
|
||||||
|
permissions: z.array(z.string()).nullable(),
|
||||||
|
sort: z.int(),
|
||||||
|
hidden: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MenuItem =
|
||||||
|
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||||
|
kind: "item";
|
||||||
|
path: string;
|
||||||
|
children: MenuItem[] | null;
|
||||||
|
})
|
||||||
|
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||||
|
kind: "group";
|
||||||
|
path: null;
|
||||||
|
children: MenuItem[];
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
|
||||||
|
z.discriminatedUnion("kind", [
|
||||||
|
BaseMenuNodeSchema.extend({
|
||||||
|
kind: z.literal("item"),
|
||||||
|
path: z.string(),
|
||||||
|
children: z.array(MenuItemSchema).nullable(),
|
||||||
|
}),
|
||||||
|
BaseMenuNodeSchema.extend({
|
||||||
|
kind: z.literal("group"),
|
||||||
|
path: z.null(),
|
||||||
|
children: z.array(MenuItemSchema),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||||
|
return z.object({
|
||||||
|
code: z.int(),
|
||||||
|
data: dataSchema,
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponse<T> = {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||||
|
return z.object({
|
||||||
|
code: z.int(),
|
||||||
|
data: z.object({
|
||||||
|
list: z.array(itemSchema),
|
||||||
|
total: z.int(),
|
||||||
|
}),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginatedData<T> = {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchParamsSchema = z.object({
|
||||||
|
page: z.number().int().positive().catch(1),
|
||||||
|
pageSize: z.number().int().positive().catch(10),
|
||||||
|
sortField: z.string().nullable().catch(null),
|
||||||
|
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SearchParams = z.infer<typeof SearchParamsSchema>;
|
||||||
|
|
||||||
|
/** Ant Design Input submits `""` when empty; treat as null for optional email */
|
||||||
|
const createUserEmailSchema = z
|
||||||
|
.union([z.string(), z.null(), z.undefined()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
const t = String(v).trim();
|
||||||
|
return t === "" ? null : t;
|
||||||
|
})
|
||||||
|
.pipe(z.union([z.string().email(), z.null()]));
|
||||||
|
|
||||||
|
export const CreateUserRequestSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
email: createUserEmailSchema,
|
||||||
|
roles: z.array(z.string()).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
|
||||||
|
|
||||||
|
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
|
||||||
|
|
||||||
|
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas";
|
||||||
|
|
||||||
|
export const USER_ENDPOINTS = {
|
||||||
|
list: "/api/users",
|
||||||
|
create: "/api/users",
|
||||||
|
update: (id: string) => `/api/users/${id}`,
|
||||||
|
delete: (id: string) => `/api/users/${id}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type { CreateUserRequest, UpdateUserRequest, User };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { httpClient } from "@/utils/http";
|
||||||
|
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||||
|
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
|
||||||
|
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
|
||||||
|
export async function fetchSessionAndApplyToStore(): Promise<void> {
|
||||||
|
const [userBase, permissions] = await Promise.all([
|
||||||
|
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
|
||||||
|
httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)),
|
||||||
|
]);
|
||||||
|
const user = UserSchema.parse({ ...userBase, permissions });
|
||||||
|
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
|
||||||
|
const { setUser, setMenus } = useAuthStore.getState();
|
||||||
|
setUser(user);
|
||||||
|
setMenus(menus);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
.aurora__root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
--aurora: repeating-linear-gradient(
|
||||||
|
100deg,
|
||||||
|
#3b82f6 10%,
|
||||||
|
#a5b4fc 15%,
|
||||||
|
#93c5fd 20%,
|
||||||
|
#ddd6fe 25%,
|
||||||
|
#60a5fa 30%
|
||||||
|
);
|
||||||
|
--dark-gradient: repeating-linear-gradient(
|
||||||
|
100deg,
|
||||||
|
#000 0%,
|
||||||
|
#000 7%,
|
||||||
|
transparent 10%,
|
||||||
|
transparent 12%,
|
||||||
|
#000 16%
|
||||||
|
);
|
||||||
|
--white-gradient: repeating-linear-gradient(
|
||||||
|
100deg,
|
||||||
|
#fff 0%,
|
||||||
|
#fff 7%,
|
||||||
|
transparent 10%,
|
||||||
|
transparent 12%,
|
||||||
|
#fff 16%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aurora__glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -10px;
|
||||||
|
background-image: var(--white-gradient), var(--aurora);
|
||||||
|
background-size: 300% 200%;
|
||||||
|
background-position:
|
||||||
|
50% 50%,
|
||||||
|
50% 50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: blur(10px) invert(1);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
|
||||||
|
mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .aurora__glow {
|
||||||
|
background-image: var(--dark-gradient), var(--aurora);
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aurora__glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: var(--white-gradient), var(--aurora);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-position:
|
||||||
|
50% 50%,
|
||||||
|
50% 50%;
|
||||||
|
background-attachment: fixed;
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .aurora__glow::after {
|
||||||
|
background-image: var(--dark-gradient), var(--aurora);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora-keyframes {
|
||||||
|
0% {
|
||||||
|
background-position: 50%, 50%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 350%, 350%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.aurora__glow::after {
|
||||||
|
animation: aurora-keyframes 60s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.aurora__glow::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export type AuroraProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Aurora({ className }: AuroraProps) {
|
||||||
|
const rootClass = className ? `aurora__root ${className}` : "aurora__root";
|
||||||
|
return (
|
||||||
|
<div className={rootClass} aria-hidden>
|
||||||
|
<div className="aurora__glow" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePermission } from "@/hooks/usePermission";
|
||||||
|
|
||||||
|
interface AuthProps {
|
||||||
|
permission: string;
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Auth({ permission, children, fallback = null }: AuthProps) {
|
||||||
|
const allowed = usePermission(permission);
|
||||||
|
return allowed ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Flex, theme } from "antd";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { BarChart3 } from "lucide-react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
|
||||||
|
*/
|
||||||
|
export function DataTableEmpty(): ReactElement {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap={token.marginMD}
|
||||||
|
className="data-table-empty"
|
||||||
|
style={{
|
||||||
|
paddingBlock: token.paddingXL,
|
||||||
|
paddingInline: token.paddingLG,
|
||||||
|
maxWidth: 360,
|
||||||
|
marginInline: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
background: token.colorFillTertiary,
|
||||||
|
color: token.colorTextQuaternary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BarChart3 size={22} strokeWidth={1.75} aria-hidden />
|
||||||
|
</Flex>
|
||||||
|
<Flex vertical align="center" gap={token.marginXXS} style={{ textAlign: "center" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeLG,
|
||||||
|
fontWeight: token.fontWeightStrong,
|
||||||
|
color: token.colorText,
|
||||||
|
lineHeight: token.lineHeightLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`No data`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSize,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
lineHeight: token.lineHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`Nothing to show in this list yet`}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { Skeleton, Table, theme } from "antd";
|
||||||
|
import type { TableProps } from "antd";
|
||||||
|
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
|
||||||
|
import type { GlobalToken } from "antd/es/theme/interface";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function rowHeight(size: TableProps["size"]): number {
|
||||||
|
if (size === "large") return 56;
|
||||||
|
if (size === "middle") return 48;
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericScrollY(scroll: TableProps<object>["scroll"]): number | undefined {
|
||||||
|
const y = scroll?.y;
|
||||||
|
return typeof y === "number" && Number.isFinite(y) && y > 0 ? y : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampRows(bodyHeight: number, rowH: number): number {
|
||||||
|
return Math.max(3, Math.floor(bodyHeight / rowH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialRowCount(scroll: TableProps<object>["scroll"], size: TableProps["size"]): number {
|
||||||
|
const y = numericScrollY(scroll);
|
||||||
|
return y != null ? clampRows(y, rowHeight(size)) : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataTableSkeletonProps<RecordType extends object> = Pick<
|
||||||
|
TableProps<RecordType>,
|
||||||
|
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
|
||||||
|
> & {
|
||||||
|
/**
|
||||||
|
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
|
||||||
|
* or from the table frame height so the body roughly fills the visible area.
|
||||||
|
*/
|
||||||
|
rowCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isActionColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
|
||||||
|
return (
|
||||||
|
col.key === "actions" ||
|
||||||
|
col.dataIndex === "actions" ||
|
||||||
|
(typeof col.title === "string" && col.title.toLowerCase() === "actions")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
|
||||||
|
return col.key === "id" || col.dataIndex === "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColumnGroup<RecordType extends object>(
|
||||||
|
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
|
||||||
|
): col is ColumnGroupType<RecordType> {
|
||||||
|
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ~ text icon button, capped small */
|
||||||
|
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
|
||||||
|
if (typeof col.width === "number") {
|
||||||
|
return Math.min(24, Math.max(20, col.width - 36));
|
||||||
|
}
|
||||||
|
return 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_CELL_MIN = 48;
|
||||||
|
|
||||||
|
function dataCellMinWidth<RecordType extends object>(col: ColumnType<RecordType>): number {
|
||||||
|
const cap = typeof col.minWidth === "number" ? col.minWidth : col.width;
|
||||||
|
if (typeof cap !== "number") return DATA_CELL_MIN;
|
||||||
|
return Math.max(DATA_CELL_MIN, Math.min(cap, 320));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellJustify(align: ColumnType<object>["align"]): CSSProperties["justifyContent"] {
|
||||||
|
if (align === "right") return "flex-end";
|
||||||
|
if (align === "center") return "center";
|
||||||
|
return "flex-start";
|
||||||
|
}
|
||||||
|
|
||||||
|
function skeletonInputBarStyle(
|
||||||
|
token: GlobalToken,
|
||||||
|
opts: { floor: number; maxWidth: number | string; flex: string },
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
width: "100%",
|
||||||
|
minWidth: opts.floor,
|
||||||
|
maxWidth: opts.maxWidth,
|
||||||
|
height: token.controlHeightSM,
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
flex: opts.flex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSkeletonColumn<RecordType extends object>(
|
||||||
|
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
|
||||||
|
token: GlobalToken,
|
||||||
|
): ColumnType<RecordType> | ColumnGroupType<RecordType> {
|
||||||
|
if (isColumnGroup(col)) {
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
children: (col.children as ColumnsType<RecordType>).map((child) =>
|
||||||
|
mapSkeletonColumn(child, token),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = col as ColumnType<RecordType>;
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
sorter: false,
|
||||||
|
sortOrder: undefined,
|
||||||
|
render: (_: unknown, __: RecordType) => {
|
||||||
|
if (isActionColumn(c)) {
|
||||||
|
const w = actionSkeletonDiameter(c);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: cellJustify(c.align),
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Button
|
||||||
|
active
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
style={{ width: w, height: w, minWidth: w, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const justify = cellJustify(c.align);
|
||||||
|
const id = isIdColumn(c);
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const floor = 28;
|
||||||
|
const maxBar = typeof c.width === "number" ? Math.max(28, Math.min(c.width - 10, 52)) : 40;
|
||||||
|
return (
|
||||||
|
<Skeleton.Input
|
||||||
|
active
|
||||||
|
size="small"
|
||||||
|
style={skeletonInputBarStyle(token, {
|
||||||
|
floor,
|
||||||
|
maxWidth: maxBar,
|
||||||
|
flex: "0 1 auto",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const floor = dataCellMinWidth(c);
|
||||||
|
const bar = skeletonInputBarStyle(token, {
|
||||||
|
floor,
|
||||||
|
maxWidth: "100%",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: floor,
|
||||||
|
maxWidth: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: justify,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
...(c.ellipsis ? { overflow: "hidden" as const } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton.Input active size="small" style={bar} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableSkeleton<RecordType extends object = object>({
|
||||||
|
columns,
|
||||||
|
rowSelection,
|
||||||
|
pagination,
|
||||||
|
size = "small",
|
||||||
|
scroll,
|
||||||
|
rootClassName,
|
||||||
|
style,
|
||||||
|
rowCount,
|
||||||
|
}: DataTableSkeletonProps<RecordType>) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [measuredRows, setMeasuredRows] = useState(() => initialRowCount(scroll, size));
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (rowCount != null) return;
|
||||||
|
|
||||||
|
const y = numericScrollY(scroll);
|
||||||
|
if (y != null) {
|
||||||
|
setMeasuredRows(clampRows(y, rowHeight(size)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = frameRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const rowH = rowHeight(size);
|
||||||
|
const measure = () => {
|
||||||
|
const thead = el.querySelector<HTMLElement>(".ant-table-thead");
|
||||||
|
const pag = pagination === false ? null : el.querySelector<HTMLElement>(".ant-pagination");
|
||||||
|
const theadH = thead?.offsetHeight ?? 39;
|
||||||
|
const pagH = pag ? pag.offsetHeight + 8 : 0;
|
||||||
|
const bodyH = Math.max(0, el.clientHeight - theadH - pagH - 2);
|
||||||
|
setMeasuredRows((prev) => {
|
||||||
|
const next = clampRows(bodyH, rowH);
|
||||||
|
return prev === next ? prev : next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => requestAnimationFrame(measure));
|
||||||
|
ro.observe(el);
|
||||||
|
let raf = requestAnimationFrame(() => {
|
||||||
|
raf = requestAnimationFrame(measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, [rowCount, pagination, scroll?.y, size]);
|
||||||
|
|
||||||
|
const effectiveRowCount = rowCount ?? measuredRows;
|
||||||
|
|
||||||
|
const skeletonColumns = useMemo(() => {
|
||||||
|
if (!columns?.length) return [];
|
||||||
|
return (columns as ColumnsType<RecordType>).map((col) => mapSkeletonColumn(col, token));
|
||||||
|
}, [columns, token]);
|
||||||
|
|
||||||
|
const skeletonData = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: effectiveRowCount }, (_, i) => ({
|
||||||
|
__sk: String(i),
|
||||||
|
})) as unknown as RecordType[],
|
||||||
|
[effectiveRowCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletonPagination = useMemo((): TableProps<RecordType>["pagination"] => {
|
||||||
|
if (pagination === false) return false;
|
||||||
|
if (typeof pagination === "object" && pagination !== null) {
|
||||||
|
return {
|
||||||
|
...pagination,
|
||||||
|
disabled: true,
|
||||||
|
onChange: undefined,
|
||||||
|
onShowSizeChange: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 100,
|
||||||
|
disabled: true,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: () => (
|
||||||
|
<Skeleton.Input active size="small" style={{ width: 88, height: token.fontSize }} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [pagination, token.fontSize]);
|
||||||
|
|
||||||
|
const selection =
|
||||||
|
rowSelection != null
|
||||||
|
? { ...rowSelection, getCheckboxProps: () => ({ disabled: true }) }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={frameRef}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table<RecordType>
|
||||||
|
rootClassName={rootClassName}
|
||||||
|
style={{ flex: 1, minHeight: 0, width: "100%" }}
|
||||||
|
size={size}
|
||||||
|
columns={skeletonColumns}
|
||||||
|
dataSource={skeletonData}
|
||||||
|
rowKey={(record) => String((record as unknown as { __sk: string }).__sk)}
|
||||||
|
pagination={skeletonPagination}
|
||||||
|
rowSelection={selection}
|
||||||
|
scroll={scroll}
|
||||||
|
showSorterTooltip={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.data-table__table.ant-table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table__table .ant-spin-nested-loading,
|
||||||
|
.data-table__table .ant-spin-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table__table .ant-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table__table .ant-table-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state: let placeholder row breathe in flex-height tables */
|
||||||
|
.data-table__table .ant-table-tbody > tr.ant-table-placeholder > td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table__table .ant-table-tbody > tr.ant-table-placeholder .ant-table-cell {
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Flex, Table, theme } from "antd";
|
||||||
|
import type { TableProps } from "antd";
|
||||||
|
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { DataTableEmpty } from "./DataTableEmpty";
|
||||||
|
import { DataTableSkeleton } from "./DataTableSkeleton";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
/** Merged onto `Table` `rootClassName` with the flex layout class. */
|
||||||
|
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
|
||||||
|
|
||||||
|
export type DataTableProps<RecordType extends object = object> = {
|
||||||
|
/**
|
||||||
|
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
|
||||||
|
* and optional `frameHeight` can pin the outer height.
|
||||||
|
*/
|
||||||
|
lockScrollHeight?: boolean;
|
||||||
|
/** Outer `max-height` (px) */
|
||||||
|
maxHeight?: number;
|
||||||
|
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
|
||||||
|
frameHeight?: number;
|
||||||
|
/** Flex column wrapping frame + optional `bottomExtra` */
|
||||||
|
layoutRef?: Ref<HTMLDivElement | null>;
|
||||||
|
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
|
||||||
|
frameRef?: Ref<HTMLDivElement | null>;
|
||||||
|
/** Optional content below the bordered table frame */
|
||||||
|
bottomExtra?: ReactNode;
|
||||||
|
} & TableProps<RecordType>;
|
||||||
|
|
||||||
|
export function DataTable<RecordType extends object = object>(
|
||||||
|
props: DataTableProps<RecordType>,
|
||||||
|
): ReactElement {
|
||||||
|
const {
|
||||||
|
lockScrollHeight,
|
||||||
|
maxHeight,
|
||||||
|
frameHeight,
|
||||||
|
layoutRef,
|
||||||
|
frameRef,
|
||||||
|
bottomExtra,
|
||||||
|
rootClassName,
|
||||||
|
style: tableStyle,
|
||||||
|
...tableProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const { loading, ...restTableProps } = tableProps;
|
||||||
|
const spinLoading =
|
||||||
|
loading === true ||
|
||||||
|
(typeof loading === "object" &&
|
||||||
|
loading !== null &&
|
||||||
|
(loading as { spinning?: boolean }).spinning !== false);
|
||||||
|
|
||||||
|
const outerStyle: CSSProperties = {
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: lockScrollHeight ? 0 : 1,
|
||||||
|
flexBasis: "auto",
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight,
|
||||||
|
...(lockScrollHeight && frameHeight != null ? { height: frameHeight } : {}),
|
||||||
|
alignSelf: "stretch",
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedRootClassName = [DATA_TABLE_ROOT_CLASS, rootClassName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const mergedLocale = useMemo(
|
||||||
|
() => ({
|
||||||
|
...restTableProps.locale,
|
||||||
|
emptyText: restTableProps.locale?.emptyText ?? <DataTableEmpty />,
|
||||||
|
}),
|
||||||
|
[restTableProps.locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
ref={layoutRef}
|
||||||
|
vertical
|
||||||
|
gap={token.marginSM}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 0%",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={frameRef} style={outerStyle}>
|
||||||
|
<div
|
||||||
|
className="data-table__inner"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{spinLoading ? (
|
||||||
|
<DataTableSkeleton<RecordType>
|
||||||
|
columns={restTableProps.columns}
|
||||||
|
rowSelection={restTableProps.rowSelection}
|
||||||
|
pagination={restTableProps.pagination}
|
||||||
|
size={restTableProps.size}
|
||||||
|
scroll={restTableProps.scroll}
|
||||||
|
rootClassName={mergedRootClassName}
|
||||||
|
style={{ flex: 1, minHeight: 0, ...tableStyle }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table<RecordType>
|
||||||
|
{...restTableProps}
|
||||||
|
locale={mergedLocale}
|
||||||
|
loading={loading}
|
||||||
|
rootClassName={mergedRootClassName}
|
||||||
|
style={{ flex: 1, minHeight: 0, ...tableStyle }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bottomExtra}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Button, Flex, Popover, theme, Tooltip } from "antd";
|
||||||
|
import { ListFilter } from "lucide-react";
|
||||||
|
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type FilterToolbarSlot = {
|
||||||
|
key: string;
|
||||||
|
/** Used to decide how many filters fit in one row before the rest go into the popover */
|
||||||
|
minWidth: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterToolbarProps = {
|
||||||
|
slots: FilterToolbarSlot[];
|
||||||
|
/** Right side: primary actions (not collapsed) */
|
||||||
|
actions: React.ReactNode;
|
||||||
|
/** Reserve width for the “more filters” control when some slots are in the popover */
|
||||||
|
collapseTriggerReserve?: number;
|
||||||
|
/** Accessible label + optional popover title */
|
||||||
|
moreFiltersLabel: React.ReactNode;
|
||||||
|
moreFiltersTitle?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function maxVisibleSlots(
|
||||||
|
slotWidths: number[],
|
||||||
|
availableForLeft: number,
|
||||||
|
innerGap: number,
|
||||||
|
triggerReserve: number,
|
||||||
|
): number {
|
||||||
|
const n = slotWidths.length;
|
||||||
|
if (n === 0) return 0;
|
||||||
|
const sum = (count: number) => {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < count; i++) s += slotWidths[i] ?? 0;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const inlineCost = (m: number) => {
|
||||||
|
if (m <= 0) return 0;
|
||||||
|
return sum(m) + (m - 1) * innerGap;
|
||||||
|
};
|
||||||
|
const totalLeft = (m: number) => {
|
||||||
|
if (m <= 0) return n > 0 ? triggerReserve : 0;
|
||||||
|
const base = inlineCost(m);
|
||||||
|
if (m >= n) return base;
|
||||||
|
return base + innerGap + triggerReserve;
|
||||||
|
};
|
||||||
|
for (let m = n; m >= 0; m--) {
|
||||||
|
if (totalLeft(m) <= availableForLeft) return m;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterToolbar = forwardRef<HTMLDivElement, FilterToolbarProps>(function FilterToolbar(
|
||||||
|
{ slots, actions, collapseTriggerReserve = 40, moreFiltersLabel, moreFiltersTitle },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const gap = token.marginSM;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const actionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(slots.length);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const widths = useMemo(() => slots.map((s) => s.minWidth), [slots]);
|
||||||
|
|
||||||
|
const assignRef = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remeasure = useCallback(() => {
|
||||||
|
const root = containerRef.current;
|
||||||
|
const act = actionsRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const actionsW = act?.offsetWidth ?? 0;
|
||||||
|
const hasActions = Boolean(act);
|
||||||
|
const between = hasActions ? gap : 0;
|
||||||
|
const available = Math.max(0, root.clientWidth - actionsW - between);
|
||||||
|
const next = maxVisibleSlots(widths, available, gap, collapseTriggerReserve);
|
||||||
|
setVisibleCount((prev) => (prev === next ? prev : next));
|
||||||
|
}, [widths, gap, collapseTriggerReserve]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
remeasure();
|
||||||
|
const root = containerRef.current;
|
||||||
|
const act = actionsRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const ro = new ResizeObserver(() => remeasure());
|
||||||
|
ro.observe(root);
|
||||||
|
if (act) ro.observe(act);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [remeasure, slots.length, widths]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setPopoverOpen(false);
|
||||||
|
}, [visibleCount]);
|
||||||
|
|
||||||
|
const overflowSlots = slots.slice(visibleCount);
|
||||||
|
const inlineSlots = slots.slice(0, visibleCount);
|
||||||
|
|
||||||
|
const popoverContent =
|
||||||
|
overflowSlots.length > 0 ? (
|
||||||
|
<Flex vertical gap={token.marginMD} style={{ width: "100%" }}>
|
||||||
|
{overflowSlots.map((slot) => (
|
||||||
|
<div key={slot.key} style={{ minWidth: slot.minWidth }}>
|
||||||
|
{slot.children}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
ref={assignRef}
|
||||||
|
wrap={false}
|
||||||
|
gap={gap}
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
style={{ flexShrink: 0, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
wrap={false}
|
||||||
|
gap={gap}
|
||||||
|
align="center"
|
||||||
|
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{inlineSlots.map((slot) => (
|
||||||
|
<div
|
||||||
|
key={slot.key}
|
||||||
|
style={{
|
||||||
|
flex: "0 0 auto",
|
||||||
|
minWidth: slot.minWidth,
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slot.children}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{overflowSlots.length > 0 ? (
|
||||||
|
<Popover
|
||||||
|
title={moreFiltersTitle ?? moreFiltersLabel}
|
||||||
|
content={popoverContent}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
overlayInnerStyle={{ maxWidth: "min(100vw - 32px, 360px)" }}
|
||||||
|
>
|
||||||
|
<Tooltip title={moreFiltersLabel}>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
icon={<ListFilter size={token.fontSize} />}
|
||||||
|
aria-label={typeof moreFiltersLabel === "string" ? moreFiltersLabel : undefined}
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
<Flex ref={actionsRef} wrap={false} gap={gap} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
{actions}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Form, Modal, theme } from "antd";
|
||||||
|
import type { FormInstance } from "antd/es/form";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type BaseFormModalProps<TValues> = {
|
||||||
|
open: boolean;
|
||||||
|
title: ReactNode;
|
||||||
|
okText: ReactNode;
|
||||||
|
cancelText: ReactNode;
|
||||||
|
form: FormInstance<TValues>;
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onFinish: (values: TValues) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseFormModal<TValues>({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
okText,
|
||||||
|
cancelText,
|
||||||
|
form,
|
||||||
|
confirmLoading,
|
||||||
|
onCancel,
|
||||||
|
onFinish,
|
||||||
|
children,
|
||||||
|
}: BaseFormModalProps<TValues>) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
centered
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
okText={okText}
|
||||||
|
cancelText={cancelText}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
paddingTop: token.padding,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" scrollToFirstError onFinish={onFinish}>
|
||||||
|
{children}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function GitHub({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.833.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function Theme({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||||
|
<path d="M12 3l0 18" />
|
||||||
|
<path d="M12 9l4.65 -4.65" />
|
||||||
|
<path d="M12 14.3l7.37 -7.37" />
|
||||||
|
<path d="M12 19.6l8.85 -8.85" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { Theme } from "./Theme";
|
||||||
|
export { GitHub } from "./GitHub";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Flex, Typography, theme } from "antd";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { GitHub } from "@/components/Icon";
|
||||||
|
|
||||||
|
const ANTD_ADMIN_REPO = "https://github.com/zuiidea/antd-admin";
|
||||||
|
|
||||||
|
export function AppFooter() {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const iconSize = Math.max(12, Math.round(Number(token.fontSizeSM)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
wrap
|
||||||
|
gap={4}
|
||||||
|
style={{
|
||||||
|
lineHeight: token.lineHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM, marginBottom: 0 }}>
|
||||||
|
{t`Powered by`}
|
||||||
|
</Typography.Text>
|
||||||
|
<a
|
||||||
|
href={ANTD_ADMIN_REPO}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
color: token.colorLink,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GitHub size={iconSize} />
|
||||||
|
antd-admin
|
||||||
|
</a>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
|
||||||
|
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { useSettingsStore, type Locale } from "@/stores/settings";
|
||||||
|
import { Link, useLocation, useMatches } from "@tanstack/react-router";
|
||||||
|
import { Home, Languages, PanelLeft, ShieldAlert, Users } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Theme } from "@/components/Icon";
|
||||||
|
|
||||||
|
const { Header: AntHeader } = Layout;
|
||||||
|
|
||||||
|
const PATH_LABEL: Record<string, string> = {
|
||||||
|
"/dashboard": "Dashboard",
|
||||||
|
"/users": "Users",
|
||||||
|
"/403": "403",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePath(pathname: string): string {
|
||||||
|
if (pathname === "/") return pathname;
|
||||||
|
return pathname.replace(/\/+$/, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderProps = {
|
||||||
|
/**
|
||||||
|
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
|
||||||
|
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
|
||||||
|
*/
|
||||||
|
showBreadcrumb?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
|
||||||
|
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
||||||
|
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
|
||||||
|
const locale = useSettingsStore((s) => s.locale);
|
||||||
|
const setLocale = useSettingsStore((s) => s.setLocale);
|
||||||
|
const location = useLocation();
|
||||||
|
const matches = useMatches();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.lg;
|
||||||
|
|
||||||
|
const iconSize = token.fontSize;
|
||||||
|
|
||||||
|
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
|
||||||
|
const row = (
|
||||||
|
<>
|
||||||
|
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const rowStyle = {
|
||||||
|
display: "inline-flex" as const,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
gap: token.marginXS,
|
||||||
|
color: "inherit" as const,
|
||||||
|
};
|
||||||
|
if (linkTo) {
|
||||||
|
return (
|
||||||
|
<Link to={linkTo} style={rowStyle}>
|
||||||
|
{row}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span style={rowStyle}>{row}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = normalizePath(location.pathname);
|
||||||
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
|
||||||
|
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
|
||||||
|
|
||||||
|
const leafIcon: LucideIcon =
|
||||||
|
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
|
||||||
|
|
||||||
|
const leafLabel =
|
||||||
|
leafLabelKey === "Dashboard"
|
||||||
|
? t`Dashboard`
|
||||||
|
: leafLabelKey === "Users"
|
||||||
|
? t`Users`
|
||||||
|
: leafLabelKey === "403"
|
||||||
|
? t`403`
|
||||||
|
: leafLabelKey;
|
||||||
|
|
||||||
|
const breadcrumbItems: ItemType[] = [];
|
||||||
|
|
||||||
|
const onDashboard = path === "/dashboard" || path === "/";
|
||||||
|
|
||||||
|
if (onDashboard) {
|
||||||
|
breadcrumbItems.push({
|
||||||
|
title: crumb(Home, t`Dashboard`),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
breadcrumbItems.push({
|
||||||
|
title: crumb(Home, t`Dashboard`, "/dashboard"),
|
||||||
|
});
|
||||||
|
|
||||||
|
breadcrumbItems.push({
|
||||||
|
title: crumb(leafIcon, leafLabel),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (segments.length > 1) {
|
||||||
|
const tail = segments.slice(1).join(" / ");
|
||||||
|
if (tail) {
|
||||||
|
breadcrumbItems.push({ title: tail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
|
||||||
|
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
|
||||||
|
const showBreadcrumb =
|
||||||
|
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
|
||||||
|
|
||||||
|
const toggleLocale = () => {
|
||||||
|
const next: Locale = locale === "en" ? "zh" : "en";
|
||||||
|
setLocale(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntHeader
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
padding: `0 ${token.padding}px`,
|
||||||
|
gap: token.sizeLG,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
|
||||||
|
{isMobile ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
icon={<PanelLeft size={token.size} />}
|
||||||
|
aria-label={t`Toggle sidebar`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showBreadcrumb ? (
|
||||||
|
<>
|
||||||
|
{isMobile ? <Divider vertical /> : null}
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={toggleLocale}
|
||||||
|
icon={<Languages size={token.size} />}
|
||||||
|
aria-label={t`Switch language`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
icon={<Theme size={token.size} />}
|
||||||
|
aria-label={t`Toggle Theme`}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</AntHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Layout, theme, Flex } from "antd";
|
||||||
|
import { Outlet } from "@tanstack/react-router";
|
||||||
|
import { Sidebar } from "../Sidebar";
|
||||||
|
import { Header } from "../Header";
|
||||||
|
|
||||||
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
|
<Sidebar />
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<Content
|
||||||
|
className="main-layout-main"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: token.paddingLG,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Flex>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* Allow collapsed Menu popup submenus to overflow the Sider rail */
|
||||||
|
.ant-layout-sider-collapsed > .ant-layout-sider-children {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand {
|
||||||
|
position: relative;
|
||||||
|
width: min(40px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: auto;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: var(--ant-border-radius-lg, 12px);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand__logoLayer,
|
||||||
|
.sidebar-collapsed-brand__toggleLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand__logoLayer {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand__toggleLayer {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__logoLayer {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__toggleLayer {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__logoLayer {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__toggleLayer {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed-brand__toggle.ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { Menu, Layout, theme, Flex, Grid, Drawer, Button } from "antd";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useLocation } from "@tanstack/react-router";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import {
|
||||||
|
Book,
|
||||||
|
Briefcase,
|
||||||
|
CircleDashed,
|
||||||
|
Folder,
|
||||||
|
Home,
|
||||||
|
PanelLeft,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Star,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import type { MenuItem as MenuItemType } from "@/api/schemas";
|
||||||
|
import type { MenuProps } from "antd";
|
||||||
|
import { UserMenu } from "../UserMenu";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const { Sider } = Layout;
|
||||||
|
/** API menu `name` → Lingui descriptors (extracted into catalogs). */
|
||||||
|
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
|
||||||
|
Platform: msg`Platform`,
|
||||||
|
Projects: msg`Projects`,
|
||||||
|
Dashboard: msg`Dashboard`,
|
||||||
|
Users: msg`Users`,
|
||||||
|
"Design Engineering": msg`Design Engineering`,
|
||||||
|
"Sales & Marketing": msg`Sales & Marketing`,
|
||||||
|
};
|
||||||
|
|
||||||
|
type AntMenuItem = Required<MenuProps>["items"][number];
|
||||||
|
type BuildMenuResult = {
|
||||||
|
items: AntMenuItem[];
|
||||||
|
keyToPath: Record<string, string>;
|
||||||
|
pathToKeyChain: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
IconLucideLayoutDashboard: Home,
|
||||||
|
IconLucideUsers: User,
|
||||||
|
IconLucideUserList: Users,
|
||||||
|
/** Back-compat for older menu payloads still using IconLucideHistory */
|
||||||
|
IconLucideHistory: Users,
|
||||||
|
IconLucideStar: Star,
|
||||||
|
IconLucideSettings: SlidersHorizontal,
|
||||||
|
IconLucideBriefcase: Briefcase,
|
||||||
|
IconLucideBookOpen: Book,
|
||||||
|
IconLucideFolderKanban: Folder,
|
||||||
|
IconLucideSparkles: Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderMenuIcon(icon: string | null, size = 16) {
|
||||||
|
const Icon = (icon && MENU_ICON_MAP[icon]) || CircleDashed;
|
||||||
|
return <Icon size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenuItems(
|
||||||
|
menus: MenuItemType[],
|
||||||
|
t: ReturnType<typeof useLingui>["t"],
|
||||||
|
token: ReturnType<typeof theme.useToken>["token"],
|
||||||
|
collapsed = false,
|
||||||
|
iconSize = 16,
|
||||||
|
parentKeys: string[] = [],
|
||||||
|
): BuildMenuResult {
|
||||||
|
const sorted = menus.filter((m) => !m.hidden).sort((a, b) => a.sort - b.sort);
|
||||||
|
const keyToPath: Record<string, string> = {};
|
||||||
|
const pathToKeyChain: Record<string, string[]> = {};
|
||||||
|
const items: AntMenuItem[] = [];
|
||||||
|
|
||||||
|
for (const menu of sorted) {
|
||||||
|
const label = MENU_LABELS[menu.name] ? t(MENU_LABELS[menu.name]) : menu.name;
|
||||||
|
const key = menu.id;
|
||||||
|
|
||||||
|
if (menu.kind === "group") {
|
||||||
|
const built = buildMenuItems(menu.children, t, token, collapsed, iconSize, parentKeys);
|
||||||
|
Object.assign(keyToPath, built.keyToPath);
|
||||||
|
Object.assign(pathToKeyChain, built.pathToKeyChain);
|
||||||
|
if (built.items.length > 0) {
|
||||||
|
items.push({
|
||||||
|
type: "group",
|
||||||
|
key,
|
||||||
|
label: collapsed ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-label={String(label)}
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: token.colorTextQuaternary,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
color: token.colorTextQuaternary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: built.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextParents = [...parentKeys, key];
|
||||||
|
keyToPath[key] = menu.path;
|
||||||
|
const existing = pathToKeyChain[menu.path];
|
||||||
|
if (!existing || nextParents.length > existing.length) {
|
||||||
|
pathToKeyChain[menu.path] = nextParents;
|
||||||
|
}
|
||||||
|
|
||||||
|
let children: AntMenuItem[] | undefined;
|
||||||
|
if (menu.children?.length) {
|
||||||
|
const built = buildMenuItems(menu.children, t, token, collapsed, iconSize, nextParents);
|
||||||
|
Object.assign(keyToPath, built.keyToPath);
|
||||||
|
Object.assign(pathToKeyChain, built.pathToKeyChain);
|
||||||
|
children = built.items.length ? built.items : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
icon: renderMenuIcon(menu.icon, iconSize),
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, keyToPath, pathToKeyChain };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const menus = useAuthStore((s) => s.menus);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const collapsed = useSettingsStore((s) => s.sidebarCollapsed);
|
||||||
|
const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
|
||||||
|
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.lg;
|
||||||
|
const mobileOpen = collapsed;
|
||||||
|
const builtMenu = useMemo(
|
||||||
|
() => buildMenuItems(menus, t, token, !isMobile && collapsed, token.size),
|
||||||
|
[menus, t, token, isMobile, collapsed, token.size],
|
||||||
|
);
|
||||||
|
const { selectedKey, routeOpenKeys } = useMemo(() => {
|
||||||
|
const chain = builtMenu.pathToKeyChain[location.pathname] ?? [];
|
||||||
|
return { selectedKey: chain.at(-1), routeOpenKeys: chain.slice(0, -1) };
|
||||||
|
}, [builtMenu, location.pathname]);
|
||||||
|
const routeOpenKeysSig = routeOpenKeys.join("\0");
|
||||||
|
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}
|
||||||
|
}, [isMobile, setSidebarCollapsed]);
|
||||||
|
|
||||||
|
/** Merge open keys required by the route with any submenu the user already expanded. */
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const k of routeOpenKeys) next.add(k);
|
||||||
|
return [...next];
|
||||||
|
});
|
||||||
|
}, [location.pathname, routeOpenKeysSig]);
|
||||||
|
|
||||||
|
const userMenuItems: MenuProps["items"] = [
|
||||||
|
{
|
||||||
|
key: "logout",
|
||||||
|
label: t`Sign Out`,
|
||||||
|
onClick: () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}
|
||||||
|
logout();
|
||||||
|
void navigate({ to: "/login" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebarContent = (isCollapsed: boolean, omitBrandToggle = false) => (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
justify="center"
|
||||||
|
align={isCollapsed ? "center" : "stretch"}
|
||||||
|
style={{
|
||||||
|
paddingBlock: token.paddingSM,
|
||||||
|
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
|
||||||
|
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
|
||||||
|
minHeight: 64,
|
||||||
|
flexShrink: 0,
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={token.marginSM}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
|
||||||
|
paddingInline: isCollapsed ? 0 : token.paddingXS,
|
||||||
|
minHeight: 40,
|
||||||
|
justifyContent: isCollapsed ? "center" : "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="sidebar-collapsed-brand">
|
||||||
|
<div className="sidebar-collapsed-brand__logoLayer">
|
||||||
|
<img
|
||||||
|
src={APP_FAVICON_SRC}
|
||||||
|
alt="logo"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
style={{
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
display: "block",
|
||||||
|
objectFit: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-collapsed-brand__toggleLayer">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="sidebar-collapsed-brand__toggle"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
icon={<PanelLeft size={token.size} />}
|
||||||
|
aria-label={t`Toggle sidebar`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={token.marginSM}
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={APP_FAVICON_SRC}
|
||||||
|
alt="logo"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
style={{
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
display: "block",
|
||||||
|
flexShrink: 0,
|
||||||
|
objectFit: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: token.fontSizeLG,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{APP_BRAND_NAME}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
{!omitBrandToggle ? (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
icon={<PanelLeft size={token.size} />}
|
||||||
|
aria-label={t`Toggle sidebar`}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
|
openKeys={openKeys}
|
||||||
|
onOpenChange={(keys) => setOpenKeys(keys as string[])}
|
||||||
|
items={builtMenu.items}
|
||||||
|
getPopupContainer={() => document.body}
|
||||||
|
onClick={({ key }) => {
|
||||||
|
const path = builtMenu.keyToPath[String(key)];
|
||||||
|
if (!path) return;
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}
|
||||||
|
void navigate({ to: path });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRight: "none",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflowX: "hidden",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={mobileOpen}
|
||||||
|
placement="left"
|
||||||
|
onClose={() => setSidebarCollapsed(false)}
|
||||||
|
size={320}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxWidth: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
header: { display: "none" },
|
||||||
|
mask: { opacity: 0.5 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarContent(false, true)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sider
|
||||||
|
key={collapsed ? "collapsed" : "expanded"}
|
||||||
|
theme="light"
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
trigger={null}
|
||||||
|
width={240}
|
||||||
|
collapsedWidth={64}
|
||||||
|
breakpoint="lg"
|
||||||
|
onBreakpoint={(broken) => {
|
||||||
|
if (broken) {
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
alignSelf: "stretch",
|
||||||
|
minHeight: "100vh",
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarContent(collapsed)}
|
||||||
|
</Sider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.user-menu-trigger {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-trigger:hover {
|
||||||
|
background-color: var(--user-menu-hover-bg);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Avatar, Button, Dropdown, Flex, Typography, theme } from "antd";
|
||||||
|
import type { MenuProps } from "antd";
|
||||||
|
import type { User } from "@/api/schemas";
|
||||||
|
import { vercelAvatarUrl } from "@/utils/avatarUrl";
|
||||||
|
import { MoreVertical } from "lucide-react";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
user: User | null;
|
||||||
|
userMenuItems: MenuProps["items"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const avatarSrc =
|
||||||
|
(user?.avatar ?? "").trim() || (user?.username ? vercelAvatarUrl(user.username) : undefined);
|
||||||
|
const avatarSize = collapsed ? 32 : token.controlHeight;
|
||||||
|
|
||||||
|
const avatar = (
|
||||||
|
<Avatar
|
||||||
|
size={avatarSize}
|
||||||
|
src={avatarSrc || undefined}
|
||||||
|
shape="circle"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: avatarSize,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.username?.[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
|
||||||
|
const moreButton = (
|
||||||
|
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} trigger={["click"]}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: token.marginXS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
className="user-menu-trigger"
|
||||||
|
align="center"
|
||||||
|
justify="flex-start"
|
||||||
|
gap={token.marginSM}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
padding: token.paddingXS,
|
||||||
|
["--user-menu-hover-bg" as string]: token.colorFillTertiary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
{!collapsed ? (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
justify="center"
|
||||||
|
gap={2}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 0%",
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ellipsis
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.username ?? "—"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
ellipsis
|
||||||
|
style={{
|
||||||
|
lineHeight: 1,
|
||||||
|
display: "block",
|
||||||
|
maxWidth: "100%",
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
color: token.colorTextQuaternary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user?.email ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
{moreButton}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { ArrowLeft, Home, SearchX } from "lucide-react";
|
||||||
|
import { Button, Flex, Result, Space, theme } from "antd";
|
||||||
|
|
||||||
|
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
|
||||||
|
export function NotFound() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const goDashboard = () => {
|
||||||
|
void navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (typeof window !== "undefined" && window.history.length > 1) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
goDashboard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
|
||||||
|
height: "100dvh",
|
||||||
|
maxHeight: "100dvh",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
color: token.colorText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
flex={1}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
minHeight: 0,
|
||||||
|
width: "100%",
|
||||||
|
padding: token.paddingXL,
|
||||||
|
overflow: "hidden",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
// Avoid status="404"|"403"|"500": antd ignores `icon` and shows built-in art; keep Lucide as the main icon.
|
||||||
|
icon={
|
||||||
|
<SearchX size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
|
||||||
|
}
|
||||||
|
title="404"
|
||||||
|
subTitle={t`Sorry, the page you visited does not exist.`}
|
||||||
|
extra={
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
|
||||||
|
{t`Back to Home`}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ArrowLeft size={16} aria-hidden />} onClick={goBack}>
|
||||||
|
{t`Go back`}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
export function hasPermission(point: string): boolean {
|
||||||
|
return useAuthStore.getState().hasPermission(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermission(point: string): boolean {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
return user?.permissions.includes(point) ?? false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type MutationLifecycle<TValues> = {
|
||||||
|
onMutate?: (values: TValues) => void;
|
||||||
|
onSuccess?: (values: TValues) => void;
|
||||||
|
onError?: (values: TValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
invalidateKey: readonly unknown[];
|
||||||
|
queryFn: () => Promise<unknown>;
|
||||||
|
select: (raw: unknown) => TListData;
|
||||||
|
createFn: (values: TCreateValues) => Promise<unknown>;
|
||||||
|
updateFn: (values: TUpdateValues) => Promise<unknown>;
|
||||||
|
deleteFn: (id: string) => Promise<unknown>;
|
||||||
|
createLifecycle?: MutationLifecycle<TCreateValues>;
|
||||||
|
updateLifecycle?: MutationLifecycle<TUpdateValues>;
|
||||||
|
deleteLifecycle?: MutationLifecycle<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||||
|
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
queryFn: options.queryFn,
|
||||||
|
select: options.select,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: options.createFn,
|
||||||
|
onMutate: (values) => {
|
||||||
|
options.createLifecycle?.onMutate?.(values);
|
||||||
|
},
|
||||||
|
onSuccess: (_data, values) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||||
|
options.createLifecycle?.onSuccess?.(values);
|
||||||
|
},
|
||||||
|
onError: (_error, values) => {
|
||||||
|
options.createLifecycle?.onError?.(values);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: options.updateFn,
|
||||||
|
onMutate: (values) => {
|
||||||
|
options.updateLifecycle?.onMutate?.(values);
|
||||||
|
},
|
||||||
|
onSuccess: (_data, values) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||||
|
options.updateLifecycle?.onSuccess?.(values);
|
||||||
|
},
|
||||||
|
onError: (_error, values) => {
|
||||||
|
options.updateLifecycle?.onError?.(values);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: options.deleteFn,
|
||||||
|
onMutate: (id) => {
|
||||||
|
options.deleteLifecycle?.onMutate?.(id);
|
||||||
|
},
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||||
|
options.deleteLifecycle?.onSuccess?.(id);
|
||||||
|
},
|
||||||
|
onError: (_error, id) => {
|
||||||
|
options.deleteLifecycle?.onError?.(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: query.data,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
createMutation,
|
||||||
|
updateMutation,
|
||||||
|
deleteMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
:root {
|
||||||
|
--app-scrollbar-size: 8px;
|
||||||
|
--app-scrollbar-track: rgba(0, 0, 0, 0);
|
||||||
|
--app-scrollbar-thumb: rgba(0, 0, 0, 0.22);
|
||||||
|
--app-scrollbar-thumb-hover: rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--app-scrollbar-track: rgba(255, 255, 255, 0);
|
||||||
|
--app-scrollbar-thumb: rgba(255, 255, 255, 0.22);
|
||||||
|
--app-scrollbar-thumb-hover: rgba(255, 255, 255, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--app-scrollbar-thumb) var(--app-scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chromium / Safari / Edge */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: var(--app-scrollbar-size);
|
||||||
|
height: var(--app-scrollbar-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--app-scrollbar-track);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--app-scrollbar-thumb);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--app-scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.po" {
|
||||||
|
import type { Messages } from "@lingui/core";
|
||||||
|
export const messages: Messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2026-04-07 15:30+0800\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: en\n"
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:464
|
||||||
|
#~ msgid "{n} rows"
|
||||||
|
#~ msgstr "{n} rows"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:386
|
||||||
|
msgid "{total} rows"
|
||||||
|
msgstr "{total} rows"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:35
|
||||||
|
msgid "+180.1% from last month"
|
||||||
|
msgstr "+180.1% from last month"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:41
|
||||||
|
msgid "+19% from last month"
|
||||||
|
msgstr "+19% from last month"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:29
|
||||||
|
msgid "+20.1% from last month"
|
||||||
|
msgstr "+20.1% from last month"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:47
|
||||||
|
msgid "+201 since last hour"
|
||||||
|
msgstr "+201 since last hour"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:78
|
||||||
|
msgid "403"
|
||||||
|
msgstr "403"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:45
|
||||||
|
msgid "Active Now"
|
||||||
|
msgstr "Active Now"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:58
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:64
|
||||||
|
msgid "Admin"
|
||||||
|
msgstr "Admin"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:164
|
||||||
|
msgid "Are you absolutely sure?"
|
||||||
|
msgstr "Are you absolutely sure?"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:185
|
||||||
|
msgid "Auto login"
|
||||||
|
msgstr "Auto login"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:54
|
||||||
|
#: src/routes/_auth/403/index.tsx:40
|
||||||
|
msgid "Back to Home"
|
||||||
|
msgstr "Back to Home"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:32
|
||||||
|
#: src/routes/_auth/users/index.tsx:168
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "Cancel"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:134
|
||||||
|
msgid "Chart Placeholder"
|
||||||
|
msgstr "Chart Placeholder"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:106
|
||||||
|
msgid "Create failed"
|
||||||
|
msgstr "Create failed"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:89
|
||||||
|
msgid "Create User"
|
||||||
|
msgstr "Create User"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:100
|
||||||
|
msgid "Created successfully"
|
||||||
|
msgstr "Created successfully"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:74
|
||||||
|
#: src/components/Layout/Header/index.tsx:87
|
||||||
|
#: src/components/Layout/Header/index.tsx:91
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:32
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr "Dashboard"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:166
|
||||||
|
#: src/routes/_auth/users/index.tsx:257
|
||||||
|
msgid "Delete"
|
||||||
|
msgstr "Delete"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:156
|
||||||
|
msgid "Delete failed"
|
||||||
|
msgstr "Delete failed"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:149
|
||||||
|
msgid "Deleted successfully"
|
||||||
|
msgstr "Deleted successfully"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:142
|
||||||
|
msgid "Deleting…"
|
||||||
|
msgstr "Deleting…"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:35
|
||||||
|
msgid "Design Engineering"
|
||||||
|
msgstr "Design Engineering"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:247
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr "Edit"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
|
msgid "Edit User"
|
||||||
|
msgstr "Edit User"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:59
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:65
|
||||||
|
msgid "Editor"
|
||||||
|
msgstr "Editor"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:63
|
||||||
|
#: src/routes/_auth/users/index.tsx:202
|
||||||
|
msgid "Email"
|
||||||
|
msgstr "Email"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users.tsx:353
|
||||||
|
#~ msgid "Filter by role"
|
||||||
|
#~ msgstr "Filter by role"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:192
|
||||||
|
msgid "Forgot password?"
|
||||||
|
msgstr "Forgot password?"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:57
|
||||||
|
msgid "Go back"
|
||||||
|
msgstr "Go back"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:68
|
||||||
|
msgid "Login failed"
|
||||||
|
msgstr "Login failed"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:64
|
||||||
|
msgid "Login successful"
|
||||||
|
msgstr "Login successful"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:92
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:93
|
||||||
|
msgid "More filters"
|
||||||
|
msgstr "More filters"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
|
msgid "New User"
|
||||||
|
msgstr "New User"
|
||||||
|
|
||||||
|
#: src/components/DataTable/DataTableEmpty.tsx:49
|
||||||
|
msgid "No data"
|
||||||
|
msgstr "No data"
|
||||||
|
|
||||||
|
#: src/components/DataTable/DataTableEmpty.tsx:59
|
||||||
|
msgid "Nothing to show in this list yet"
|
||||||
|
msgstr "Nothing to show in this list yet"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||||
|
msgid "OK"
|
||||||
|
msgstr "OK"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:115
|
||||||
|
msgid "Overview"
|
||||||
|
msgstr "Overview"
|
||||||
|
|
||||||
|
#: src/routes/404/index.tsx:87
|
||||||
|
#~ msgid "Page not found"
|
||||||
|
#~ msgstr "Page not found"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:171
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Password"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:30
|
||||||
|
msgid "Platform"
|
||||||
|
msgstr "Platform"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:172
|
||||||
|
msgid "Please enter password"
|
||||||
|
msgstr "Please enter password"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||||
|
#: src/routes/login/index.tsx:164
|
||||||
|
msgid "Please enter username"
|
||||||
|
msgstr "Please enter username"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||||
|
msgid "Please select roles"
|
||||||
|
msgstr "Please select roles"
|
||||||
|
|
||||||
|
#: src/components/Layout/AppFooter/index.tsx:36
|
||||||
|
msgid "Powered by"
|
||||||
|
msgstr "Powered by"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:31
|
||||||
|
msgid "Projects"
|
||||||
|
msgstr "Projects"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:142
|
||||||
|
msgid "Recent Sales"
|
||||||
|
msgstr "Recent Sales"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||||
|
msgid "Role"
|
||||||
|
msgstr "Role"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||||
|
#: src/routes/_auth/users/index.tsx:209
|
||||||
|
msgid "Roles"
|
||||||
|
msgstr "Roles"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:39
|
||||||
|
msgid "Sales"
|
||||||
|
msgstr "Sales"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:36
|
||||||
|
msgid "Sales & Marketing"
|
||||||
|
msgstr "Sales & Marketing"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:43
|
||||||
|
msgid "Search User"
|
||||||
|
msgstr "Search User"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users.tsx:341
|
||||||
|
#~ msgid "Search username or email"
|
||||||
|
#~ msgstr "Search username or email"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:204
|
||||||
|
msgid "Sign In"
|
||||||
|
msgstr "Sign In"
|
||||||
|
|
||||||
|
#: src/routes/login.tsx:100
|
||||||
|
#~ msgid "Sign in with your credentials."
|
||||||
|
#~ msgstr "Sign in with your credentials."
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:194
|
||||||
|
msgid "Sign Out"
|
||||||
|
msgstr "Sign Out"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:50
|
||||||
|
msgid "Sorry, the page you visited does not exist."
|
||||||
|
msgstr "Sorry, the page you visited does not exist."
|
||||||
|
|
||||||
|
#: src/routes/_auth/403/index.tsx:37
|
||||||
|
msgid "Sorry, you don't have permission to access this page."
|
||||||
|
msgstr "Sorry, you don't have permission to access this page."
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:33
|
||||||
|
msgid "Subscriptions"
|
||||||
|
msgstr "Subscriptions"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:145
|
||||||
|
#: src/routes/login/index.tsx:225
|
||||||
|
msgid "Switch language"
|
||||||
|
msgstr "Switch language"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:165
|
||||||
|
msgid "This action cannot be undone. This will permanently delete the user."
|
||||||
|
msgstr "This action cannot be undone. This will permanently delete the user."
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:133
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:263
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:311
|
||||||
|
msgid "Toggle sidebar"
|
||||||
|
msgstr "Toggle sidebar"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:151
|
||||||
|
#: src/routes/login/index.tsx:232
|
||||||
|
msgid "Toggle Theme"
|
||||||
|
msgstr "Toggle Theme"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:27
|
||||||
|
msgid "Total Revenue"
|
||||||
|
msgstr "Total Revenue"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:132
|
||||||
|
msgid "Update failed"
|
||||||
|
msgstr "Update failed"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:122
|
||||||
|
msgid "Updated successfully"
|
||||||
|
msgstr "Updated successfully"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:115
|
||||||
|
msgid "Updating…"
|
||||||
|
msgstr "Updating…"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
|
msgid "User List"
|
||||||
|
msgstr "User List"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:45
|
||||||
|
#: src/routes/_auth/users/index.tsx:182
|
||||||
|
#: src/routes/login/index.tsx:163
|
||||||
|
msgid "Username"
|
||||||
|
msgstr "Username"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:76
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:33
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "Users"
|
||||||
|
|
||||||
|
#: src/routes/login.tsx:101
|
||||||
|
#~ msgid "Welcome back"
|
||||||
|
#~ msgstr "Welcome back"
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"POT-Creation-Date: 2026-04-07 15:30+0800\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"X-Generator: @lingui/cli\n"
|
||||||
|
"Language: zh\n"
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Plural-Forms: \n"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:464
|
||||||
|
#~ msgid "{n} rows"
|
||||||
|
#~ msgstr "{n} 行"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:386
|
||||||
|
msgid "{total} rows"
|
||||||
|
msgstr "共 {total} 条"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:35
|
||||||
|
msgid "+180.1% from last month"
|
||||||
|
msgstr "较上月 +180.1%"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:41
|
||||||
|
msgid "+19% from last month"
|
||||||
|
msgstr "较上月 +19%"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:29
|
||||||
|
msgid "+20.1% from last month"
|
||||||
|
msgstr "较上月 +20.1%"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:47
|
||||||
|
msgid "+201 since last hour"
|
||||||
|
msgstr "较上一小时 +201"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:78
|
||||||
|
msgid "403"
|
||||||
|
msgstr "403"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:45
|
||||||
|
msgid "Active Now"
|
||||||
|
msgstr "当前在线"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:58
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:64
|
||||||
|
msgid "Admin"
|
||||||
|
msgstr "管理员"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:164
|
||||||
|
msgid "Are you absolutely sure?"
|
||||||
|
msgstr "确定要执行此操作吗?"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:185
|
||||||
|
msgid "Auto login"
|
||||||
|
msgstr "自动登录"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:54
|
||||||
|
#: src/routes/_auth/403/index.tsx:40
|
||||||
|
msgid "Back to Home"
|
||||||
|
msgstr "返回首页"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:32
|
||||||
|
#: src/routes/_auth/users/index.tsx:168
|
||||||
|
msgid "Cancel"
|
||||||
|
msgstr "取消"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:134
|
||||||
|
msgid "Chart Placeholder"
|
||||||
|
msgstr "图表占位"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:106
|
||||||
|
msgid "Create failed"
|
||||||
|
msgstr "创建失败"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:89
|
||||||
|
msgid "Create User"
|
||||||
|
msgstr "创建用户"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:100
|
||||||
|
msgid "Created successfully"
|
||||||
|
msgstr "创建成功"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:74
|
||||||
|
#: src/components/Layout/Header/index.tsx:87
|
||||||
|
#: src/components/Layout/Header/index.tsx:91
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:32
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr "仪表盘"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:166
|
||||||
|
#: src/routes/_auth/users/index.tsx:257
|
||||||
|
msgid "Delete"
|
||||||
|
msgstr "删除"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:156
|
||||||
|
msgid "Delete failed"
|
||||||
|
msgstr "删除失败"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:149
|
||||||
|
msgid "Deleted successfully"
|
||||||
|
msgstr "删除成功"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:142
|
||||||
|
msgid "Deleting…"
|
||||||
|
msgstr "正在删除…"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:35
|
||||||
|
msgid "Design Engineering"
|
||||||
|
msgstr "设计工程"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:247
|
||||||
|
msgid "Edit"
|
||||||
|
msgstr "编辑"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
|
msgid "Edit User"
|
||||||
|
msgstr "编辑用户"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:59
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:65
|
||||||
|
msgid "Editor"
|
||||||
|
msgstr "编辑者"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:63
|
||||||
|
#: src/routes/_auth/users/index.tsx:202
|
||||||
|
msgid "Email"
|
||||||
|
msgstr "邮箱"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users.tsx:353
|
||||||
|
#~ msgid "Filter by role"
|
||||||
|
#~ msgstr "按角色筛选"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:192
|
||||||
|
msgid "Forgot password?"
|
||||||
|
msgstr "忘记密码?"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:57
|
||||||
|
msgid "Go back"
|
||||||
|
msgstr "返回上一页"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:68
|
||||||
|
msgid "Login failed"
|
||||||
|
msgstr "登录失败"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:64
|
||||||
|
msgid "Login successful"
|
||||||
|
msgstr "登录成功"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:92
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:93
|
||||||
|
msgid "More filters"
|
||||||
|
msgstr "更多筛选"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
|
msgid "New User"
|
||||||
|
msgstr "新建用户"
|
||||||
|
|
||||||
|
#: src/components/DataTable/DataTableEmpty.tsx:49
|
||||||
|
msgid "No data"
|
||||||
|
msgstr "暂无数据"
|
||||||
|
|
||||||
|
#: src/components/DataTable/DataTableEmpty.tsx:59
|
||||||
|
msgid "Nothing to show in this list yet"
|
||||||
|
msgstr "当前列表为空,暂无可展示的记录"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||||
|
msgid "OK"
|
||||||
|
msgstr "确定"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:115
|
||||||
|
msgid "Overview"
|
||||||
|
msgstr "概览"
|
||||||
|
|
||||||
|
#: src/routes/404/index.tsx:87
|
||||||
|
#~ msgid "Page not found"
|
||||||
|
#~ msgstr "页面不存在"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:171
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "密码"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:30
|
||||||
|
msgid "Platform"
|
||||||
|
msgstr "平台"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:172
|
||||||
|
msgid "Please enter password"
|
||||||
|
msgstr "请输入密码"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||||
|
#: src/routes/login/index.tsx:164
|
||||||
|
msgid "Please enter username"
|
||||||
|
msgstr "请输入用户名"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||||
|
msgid "Please select roles"
|
||||||
|
msgstr "请选择角色"
|
||||||
|
|
||||||
|
#: src/components/Layout/AppFooter/index.tsx:36
|
||||||
|
msgid "Powered by"
|
||||||
|
msgstr "由"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:31
|
||||||
|
msgid "Projects"
|
||||||
|
msgstr "项目"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:142
|
||||||
|
msgid "Recent Sales"
|
||||||
|
msgstr "近期销售"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||||
|
msgid "Role"
|
||||||
|
msgstr "角色"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||||
|
#: src/routes/_auth/users/index.tsx:209
|
||||||
|
msgid "Roles"
|
||||||
|
msgstr "角色"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:39
|
||||||
|
msgid "Sales"
|
||||||
|
msgstr "销售额"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:36
|
||||||
|
msgid "Sales & Marketing"
|
||||||
|
msgstr "销售与市场"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-Toolbar.tsx:43
|
||||||
|
msgid "Search User"
|
||||||
|
msgstr "搜索用户"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users.tsx:341
|
||||||
|
#~ msgid "Search username or email"
|
||||||
|
#~ msgstr "搜索用户名或邮箱"
|
||||||
|
|
||||||
|
#: src/routes/login/index.tsx:204
|
||||||
|
msgid "Sign In"
|
||||||
|
msgstr "登录"
|
||||||
|
|
||||||
|
#: src/routes/login.tsx:100
|
||||||
|
#~ msgid "Sign in with your credentials."
|
||||||
|
#~ msgstr "凭用户名与密码登录"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:194
|
||||||
|
msgid "Sign Out"
|
||||||
|
msgstr "退出登录"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:50
|
||||||
|
msgid "Sorry, the page you visited does not exist."
|
||||||
|
msgstr "抱歉,您访问的页面不存在。"
|
||||||
|
|
||||||
|
#: src/routes/_auth/403/index.tsx:37
|
||||||
|
msgid "Sorry, you don't have permission to access this page."
|
||||||
|
msgstr "抱歉,您没有权限访问此页面。"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:33
|
||||||
|
msgid "Subscriptions"
|
||||||
|
msgstr "订阅"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:145
|
||||||
|
#: src/routes/login/index.tsx:225
|
||||||
|
msgid "Switch language"
|
||||||
|
msgstr "切换语言"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:165
|
||||||
|
msgid "This action cannot be undone. This will permanently delete the user."
|
||||||
|
msgstr "此操作无法撤销,将永久删除该用户。"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:133
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:263
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:311
|
||||||
|
msgid "Toggle sidebar"
|
||||||
|
msgstr "折叠侧栏"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:151
|
||||||
|
#: src/routes/login/index.tsx:232
|
||||||
|
msgid "Toggle Theme"
|
||||||
|
msgstr "切换主题"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:27
|
||||||
|
msgid "Total Revenue"
|
||||||
|
msgstr "总收入"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:132
|
||||||
|
msgid "Update failed"
|
||||||
|
msgstr "更新失败"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:122
|
||||||
|
msgid "Updated successfully"
|
||||||
|
msgstr "更新成功"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:115
|
||||||
|
msgid "Updating…"
|
||||||
|
msgstr "正在更新…"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
|
msgid "User List"
|
||||||
|
msgstr "用户列表"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/-FormModal.tsx:45
|
||||||
|
#: src/routes/_auth/users/index.tsx:182
|
||||||
|
#: src/routes/login/index.tsx:163
|
||||||
|
msgid "Username"
|
||||||
|
msgstr "用户名"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:76
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:33
|
||||||
|
msgid "Users"
|
||||||
|
msgstr "用户"
|
||||||
|
|
||||||
|
#: src/routes/login.tsx:101
|
||||||
|
#~ msgid "Welcome back"
|
||||||
|
#~ msgstr "欢迎回来"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { messages as enMessages } from "./locales/en/messages.po";
|
||||||
|
import { messages as zhMessages } from "./locales/zh/messages.po";
|
||||||
|
import { useSettingsStore } from "./stores/settings";
|
||||||
|
import { useAuthStore } from "./stores/auth";
|
||||||
|
import { fetchSessionAndApplyToStore } from "./auth/session";
|
||||||
|
|
||||||
|
i18n.load("en", enMessages);
|
||||||
|
i18n.load("zh", zhMessages);
|
||||||
|
i18n.activate(useSettingsStore.getState().locale);
|
||||||
|
|
||||||
|
const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableMocking() {
|
||||||
|
if (!import.meta.env.DEV) return;
|
||||||
|
const { worker } = await import("./mocks/browser");
|
||||||
|
return worker.start({ onUnhandledRequest: "bypass" });
|
||||||
|
}
|
||||||
|
|
||||||
|
enableMocking()
|
||||||
|
.then(async () => {
|
||||||
|
await useAuthStore.persist.rehydrate();
|
||||||
|
const { isAuthenticated, tokens } = useAuthStore.getState();
|
||||||
|
if (isAuthenticated && tokens) {
|
||||||
|
try {
|
||||||
|
await fetchSessionAndApplyToStore();
|
||||||
|
} catch {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to initialize app:", err);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { setupWorker } from "msw/browser";
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
export const worker = setupWorker(...handlers);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { HttpResponse, delay as mswDelay } from "msw";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API response format
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSW handler factory functions
|
||||||
|
* Provides consistent response wrapping and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a handler function with standard response format
|
||||||
|
* Automatically handles delay, error responses, and data wrapping
|
||||||
|
*/
|
||||||
|
export async function withDelay(ms: number = 200): Promise<void> {
|
||||||
|
await mswDelay(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a success response
|
||||||
|
*/
|
||||||
|
export function successResponse<T>(data: T, message: string = "ok") {
|
||||||
|
return HttpResponse.json({
|
||||||
|
code: 0,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error response
|
||||||
|
*/
|
||||||
|
export function errorResponse(code: number, message: string, data: unknown = null) {
|
||||||
|
return HttpResponse.json({
|
||||||
|
code,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined error codes
|
||||||
|
*/
|
||||||
|
export const ERROR_CODES = {
|
||||||
|
INVALID_CREDENTIALS: 1001,
|
||||||
|
NOT_FOUND: 1002,
|
||||||
|
UNAUTHORIZED: 1003,
|
||||||
|
FORBIDDEN: 1004,
|
||||||
|
BAD_REQUEST: 1005,
|
||||||
|
INTERNAL_ERROR: 1006,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { User } from "@/api/schemas";
|
||||||
|
import { vercelAvatarUrl } from "@/utils/avatarUrl";
|
||||||
|
|
||||||
|
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
|
||||||
|
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
||||||
|
["admin", "ops.admin@northstar.io"],
|
||||||
|
["zhao.ming", "zhao.ming@northstar.io"],
|
||||||
|
["sarah.chen", "sarah.chen@northstar.io"],
|
||||||
|
["james.park", "james.park@northstar.io"],
|
||||||
|
["emma.garcia", "emma.garcia@northstar.io"],
|
||||||
|
["ryan.kim", "ryan.kim@northstar.io"],
|
||||||
|
["olivia.tanaka", "olivia.tanaka@northstar.io"],
|
||||||
|
["liam.patel", "liam.patel@northstar.io"],
|
||||||
|
["ava.nguyen", "ava.nguyen@northstar.io"],
|
||||||
|
["noah.berg", "noah.berg@northstar.io"],
|
||||||
|
["mia.silva", "mia.silva@northstar.io"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
username,
|
||||||
|
avatar: vercelAvatarUrl(username),
|
||||||
|
email,
|
||||||
|
roles: i === 0 ? ["admin"] : ["editor"],
|
||||||
|
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
|
||||||
|
}));
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Common filtering utilities for mock user handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
|
roles: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
keyword?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterUsers(users: User[], options: FilterOptions): User[] {
|
||||||
|
let filtered = [...users];
|
||||||
|
|
||||||
|
if (options.keyword) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(u) =>
|
||||||
|
u.username.includes(options.keyword!) || (u.email && u.email.includes(options.keyword!)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.role) {
|
||||||
|
filtered = filtered.filter((u) => u.roles.includes(options.role!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginateList<T>(items: T[], limit: number, offset: number): T[] {
|
||||||
|
return items.slice(offset, offset + limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePaginationParams(searchParams: URLSearchParams) {
|
||||||
|
const limit = Number(searchParams.get("limit") ?? searchParams.get("pageSize") ?? 50);
|
||||||
|
const offset = Number(searchParams.get("offset") ?? 0);
|
||||||
|
const page = Number(searchParams.get("page") ?? 1);
|
||||||
|
|
||||||
|
const actualOffset = searchParams.get("offset") !== null ? offset : (page - 1) * limit;
|
||||||
|
|
||||||
|
return { limit, offset: actualOffset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { http } from "msw";
|
||||||
|
import { MOCK_USERS } from "../data";
|
||||||
|
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
||||||
|
|
||||||
|
export const authHandlers = [
|
||||||
|
http.post("/api/auth/login", async ({ request }) => {
|
||||||
|
await withDelay(300);
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
if (body.username === "admin" && body.password === "admin") {
|
||||||
|
return successResponse({
|
||||||
|
accessToken: "mock-access-token",
|
||||||
|
refreshToken: "mock-refresh-token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/api/auth/refresh", async () => {
|
||||||
|
await withDelay(100);
|
||||||
|
return successResponse({
|
||||||
|
accessToken: "mock-new-access-token",
|
||||||
|
refreshToken: "mock-new-refresh-token",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/api/auth/logout", () => successResponse(null)),
|
||||||
|
|
||||||
|
http.get("/api/auth/user", () => {
|
||||||
|
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
|
||||||
|
return successResponse(userWithoutPermissions);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
|
||||||
|
];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { authHandlers } from "./auth";
|
||||||
|
import { userHandlers } from "./user";
|
||||||
|
|
||||||
|
export const handlers = [...authHandlers, ...userHandlers];
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { http } from "msw";
|
||||||
|
import { MOCK_USERS } from "../data";
|
||||||
|
import { filterUsers, paginateList, parsePaginationParams } from "../filterUtils";
|
||||||
|
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
||||||
|
|
||||||
|
let users = [...MOCK_USERS];
|
||||||
|
|
||||||
|
export const userHandlers = [
|
||||||
|
http.get("/api/users", async ({ request }) => {
|
||||||
|
await withDelay(200);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const { limit, offset } = parsePaginationParams(url.searchParams);
|
||||||
|
const keyword = url.searchParams.get("keyword") ?? "";
|
||||||
|
const role = url.searchParams.get("role") ?? "";
|
||||||
|
|
||||||
|
const filtered = filterUsers(users, { keyword, role });
|
||||||
|
const list = paginateList(filtered, limit, offset);
|
||||||
|
|
||||||
|
return successResponse({ list, total: filtered.length });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/api/users", async ({ request }) => {
|
||||||
|
await withDelay(200);
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const newUser = {
|
||||||
|
id: String(users.length + 1),
|
||||||
|
username: String(body.username),
|
||||||
|
avatar: null,
|
||||||
|
email: typeof body.email === "string" ? body.email : null,
|
||||||
|
roles: (body.roles as string[]) ?? [],
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
users.push(newUser);
|
||||||
|
return successResponse(newUser);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.put("/api/users/:id", async ({ params, request }) => {
|
||||||
|
await withDelay(200);
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const idx = users.findIndex((u) => u.id === params.id);
|
||||||
|
if (idx === -1) {
|
||||||
|
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
|
||||||
|
}
|
||||||
|
users[idx] = { ...users[idx], ...body };
|
||||||
|
return successResponse(users[idx]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.delete("/api/users/:id", async ({ params }) => {
|
||||||
|
await withDelay(200);
|
||||||
|
users = users.filter((u) => u.id !== params.id);
|
||||||
|
return successResponse(null);
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as LoginIndexRouteImport } from './routes/login/index'
|
||||||
|
import { Route as R404IndexRouteImport } from './routes/404/index'
|
||||||
|
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
|
||||||
|
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
||||||
|
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
|
||||||
|
|
||||||
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
id: '/_auth',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const LoginIndexRoute = LoginIndexRouteImport.update({
|
||||||
|
id: '/login/',
|
||||||
|
path: '/login/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const R404IndexRoute = R404IndexRouteImport.update({
|
||||||
|
id: '/404/',
|
||||||
|
path: '/404/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
|
||||||
|
id: '/users/',
|
||||||
|
path: '/users/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
||||||
|
id: '/dashboard/',
|
||||||
|
path: '/dashboard/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const Auth403IndexRoute = Auth403IndexRouteImport.update({
|
||||||
|
id: '/403/',
|
||||||
|
path: '/403/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/404/': typeof R404IndexRoute
|
||||||
|
'/login/': typeof LoginIndexRoute
|
||||||
|
'/403/': typeof Auth403IndexRoute
|
||||||
|
'/dashboard/': typeof AuthDashboardIndexRoute
|
||||||
|
'/users/': typeof AuthUsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/404': typeof R404IndexRoute
|
||||||
|
'/login': typeof LoginIndexRoute
|
||||||
|
'/403': typeof Auth403IndexRoute
|
||||||
|
'/dashboard': typeof AuthDashboardIndexRoute
|
||||||
|
'/users': typeof AuthUsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
|
'/404/': typeof R404IndexRoute
|
||||||
|
'/login/': typeof LoginIndexRoute
|
||||||
|
'/_auth/403/': typeof Auth403IndexRoute
|
||||||
|
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
||||||
|
'/_auth/users/': typeof AuthUsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/_auth'
|
||||||
|
| '/404/'
|
||||||
|
| '/login/'
|
||||||
|
| '/_auth/403/'
|
||||||
|
| '/_auth/dashboard/'
|
||||||
|
| '/_auth/users/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
|
R404IndexRoute: typeof R404IndexRoute
|
||||||
|
LoginIndexRoute: typeof LoginIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/_auth': {
|
||||||
|
id: '/_auth'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof AuthRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/login/': {
|
||||||
|
id: '/login/'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login/'
|
||||||
|
preLoaderRoute: typeof LoginIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/404/': {
|
||||||
|
id: '/404/'
|
||||||
|
path: '/404'
|
||||||
|
fullPath: '/404/'
|
||||||
|
preLoaderRoute: typeof R404IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_auth/users/': {
|
||||||
|
id: '/_auth/users/'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/users/'
|
||||||
|
preLoaderRoute: typeof AuthUsersIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/dashboard/': {
|
||||||
|
id: '/_auth/dashboard/'
|
||||||
|
path: '/dashboard'
|
||||||
|
fullPath: '/dashboard/'
|
||||||
|
preLoaderRoute: typeof AuthDashboardIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/403/': {
|
||||||
|
id: '/_auth/403/'
|
||||||
|
path: '/403'
|
||||||
|
fullPath: '/403/'
|
||||||
|
preLoaderRoute: typeof Auth403IndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthRouteChildren {
|
||||||
|
Auth403IndexRoute: typeof Auth403IndexRoute
|
||||||
|
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
||||||
|
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
|
Auth403IndexRoute: Auth403IndexRoute,
|
||||||
|
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
||||||
|
AuthUsersIndexRoute: AuthUsersIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
AuthRoute: AuthRouteWithChildren,
|
||||||
|
R404IndexRoute: R404IndexRoute,
|
||||||
|
LoginIndexRoute: LoginIndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { NotFound } from "@/components/NotFound";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/404/")({
|
||||||
|
component: NotFound,
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
|
import { ConfigProvider, App } from "antd";
|
||||||
|
import enUS from "antd/locale/en_US";
|
||||||
|
import zhCN from "antd/locale/zh_CN";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { useAppTheme } from "@/theme";
|
||||||
|
import { NotFound } from "@/components/NotFound";
|
||||||
|
import "@/index.css";
|
||||||
|
|
||||||
|
const antdLocaleMap = { en: enUS, zh: zhCN } as const;
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
const locale = useSettingsStore((s) => s.locale);
|
||||||
|
const darkMode = useSettingsStore((s) => s.darkMode);
|
||||||
|
const configProviderProps = useAppTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
i18n.activate(locale);
|
||||||
|
document.documentElement.lang = locale === "zh" ? "zh" : "en";
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
|
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
|
||||||
|
<App>
|
||||||
|
<Outlet />
|
||||||
|
</App>
|
||||||
|
</ConfigProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
notFoundComponent: NotFound,
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { MainLayout } from "@/components/Layout/MainLayout";
|
||||||
|
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth")({
|
||||||
|
beforeLoad: ({ location }) => {
|
||||||
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = normalizeAppPath(location.pathname);
|
||||||
|
if (path === "/403") return;
|
||||||
|
|
||||||
|
if (!canAccessPath(location.pathname, user?.permissions)) {
|
||||||
|
throw redirect({ to: "/403" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: MainLayout,
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Home, ShieldAlert } from "lucide-react";
|
||||||
|
import { Button, Flex, Result, theme } from "antd";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/403/")({
|
||||||
|
component: ForbiddenPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ForbiddenPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const goDashboard = () => {
|
||||||
|
void navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
flex={1}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{ minHeight: 0, width: "100%", padding: token.paddingXL }}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
// Avoid status="403": antd ignores `icon` for built-in illustrations; keep Lucide as the main icon.
|
||||||
|
icon={
|
||||||
|
<ShieldAlert size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
|
||||||
|
}
|
||||||
|
title="403"
|
||||||
|
subTitle={t`Sorry, you don't have permission to access this page.`}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
|
||||||
|
{t`Back to Home`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.dash-card-interactive.ant-card {
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-card-interactive.ant-card:hover {
|
||||||
|
background-color: var(--dash-card-hover-bg) !important;
|
||||||
|
border-color: var(--dash-card-hover-border) !important;
|
||||||
|
box-shadow: var(--dash-card-hover-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-recent-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-recent-row:hover {
|
||||||
|
background-color: var(--dash-recent-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-chart-placeholder {
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-chart-placeholder:hover {
|
||||||
|
border-color: var(--dash-chart-hover-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-skel-stat .ant-skeleton-input {
|
||||||
|
min-height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-skel-recent .ant-skeleton-input {
|
||||||
|
min-height: unset !important;
|
||||||
|
margin-block: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-skel-recent .ant-skeleton-avatar {
|
||||||
|
margin-inline-end: 0 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton } from "antd";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/dashboard/")({
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchDashboardShell() {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AntToken = ReturnType<typeof theme.useToken>["token"];
|
||||||
|
|
||||||
|
/** Matches loaded stat card body height: title row, value, description. */
|
||||||
|
function StatCardSkeleton({ token }: { token: AntToken }) {
|
||||||
|
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
|
||||||
|
/** Lucide default icon box 24px to align with the first row of real cards. */
|
||||||
|
const iconBox = 24;
|
||||||
|
const titleRowHeight = Math.max(titleLine, iconBox);
|
||||||
|
const valueLine = Math.round(24 * 1);
|
||||||
|
const descLine = Math.round(token.fontSizeSM * token.lineHeight);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card styles={{ body: { padding: token.paddingLG } }}>
|
||||||
|
<div className="dash-skel-stat">
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
style={{ marginBottom: token.marginXS, minHeight: titleRowHeight }}
|
||||||
|
>
|
||||||
|
<Skeleton.Input active size="small" style={{ width: "55%", height: titleLine }} />
|
||||||
|
<Skeleton.Avatar active size="small" style={{ width: iconBox, height: iconBox }} />
|
||||||
|
</Flex>
|
||||||
|
<Flex vertical gap={token.marginXS}>
|
||||||
|
<Skeleton.Input active style={{ width: "25%", height: valueLine }} />
|
||||||
|
<Skeleton.Input
|
||||||
|
active
|
||||||
|
size="small"
|
||||||
|
style={{ width: "75%", maxWidth: 200, height: descLine }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const body = { padding: token.paddingLG } as const;
|
||||||
|
const cardTitleSkel = (w: number) => (
|
||||||
|
<Skeleton.Input
|
||||||
|
active
|
||||||
|
style={{
|
||||||
|
width: w,
|
||||||
|
height: Math.round(token.fontSizeHeading5 * token.lineHeightHeading5),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={token.marginLG}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Col xs={24} sm={12} lg={6} key={i}>
|
||||||
|
<StatCardSkeleton token={token} />
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card title={cardTitleSkel(88)} styles={{ body: body }} style={{ height: "100%" }}>
|
||||||
|
<Skeleton.Node active style={{ width: "100%", height: 300 }}>
|
||||||
|
<div style={{ width: "100%", height: "100%" }} />
|
||||||
|
</Skeleton.Node>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title={cardTitleSkel(112)} styles={{ body: body }} style={{ height: "100%" }}>
|
||||||
|
<Flex vertical className="dash-skel-recent">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<Flex
|
||||||
|
key={i}
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
gap={token.marginSM}
|
||||||
|
style={{
|
||||||
|
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Skeleton.Avatar active size={40} shape="circle" />
|
||||||
|
<Flex vertical style={{ minWidth: 0, flex: 1 }} gap={2}>
|
||||||
|
<Skeleton.Input active size="small" style={{ width: "50%", height: 12 }} />
|
||||||
|
<Skeleton.Input active size="small" style={{ width: "85%", height: 12 }} />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { isPending } = useQuery({
|
||||||
|
queryKey: ["dashboard"],
|
||||||
|
queryFn: fetchDashboardShell,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
|
||||||
|
const cardHoverStyle = {
|
||||||
|
["--dash-card-hover-bg" as string]: token.colorBgContainer,
|
||||||
|
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
|
||||||
|
["--dash-card-hover-shadow" as string]: "none",
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t`Total Revenue`,
|
||||||
|
value: "$45,231.89",
|
||||||
|
description: t`+20.1% from last month`,
|
||||||
|
icon: <DollarSign style={{ color: token.colorTextSecondary }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Subscriptions`,
|
||||||
|
value: "+2350",
|
||||||
|
description: t`+180.1% from last month`,
|
||||||
|
icon: <Users style={{ color: token.colorTextSecondary }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Sales`,
|
||||||
|
value: "+12,234",
|
||||||
|
description: t`+19% from last month`,
|
||||||
|
icon: <CreditCard style={{ color: token.colorTextSecondary }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Active Now`,
|
||||||
|
value: "+573",
|
||||||
|
description: t`+201 since last hour`,
|
||||||
|
icon: <Activity style={{ color: token.colorTextSecondary }} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, token.colorTextSecondary],
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentSales = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "Olivia Martin",
|
||||||
|
email: "olivia.martin@email.com",
|
||||||
|
amount: "+$1,999.00",
|
||||||
|
initials: "OM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jackson Lee",
|
||||||
|
email: "jackson.lee@email.com",
|
||||||
|
amount: "+$39.00",
|
||||||
|
initials: "JL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Isabella Nguyen",
|
||||||
|
email: "isabella.nguyen@email.com",
|
||||||
|
amount: "+$299.00",
|
||||||
|
initials: "IN",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "William Kim",
|
||||||
|
email: "will@email.com",
|
||||||
|
amount: "+$99.00",
|
||||||
|
initials: "WK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sofia Davis",
|
||||||
|
email: "sofia.davis@email.com",
|
||||||
|
amount: "+$39.00",
|
||||||
|
initials: "SD",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={token.marginLG}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Col xs={24} sm={12} lg={6} key={stat.title}>
|
||||||
|
<Card
|
||||||
|
className="dash-card-interactive"
|
||||||
|
style={cardHoverStyle}
|
||||||
|
styles={{ body: { padding: token.paddingLG } }}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: token.marginXS }}>
|
||||||
|
<Text strong style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{stat.title}
|
||||||
|
</Text>
|
||||||
|
{stat.icon}
|
||||||
|
</Flex>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: "bold", marginBottom: 4 }}>{stat.value}</div>
|
||||||
|
<Text ellipsis type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{stat.description}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Card
|
||||||
|
className="dash-card-interactive"
|
||||||
|
style={{ ...cardHoverStyle, height: "100%" }}
|
||||||
|
title={<Title level={5} style={{ margin: 0 }}>{t`Overview`}</Title>}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
className="dash-chart-placeholder"
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: token.colorBgLayout,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
border: `1px dashed ${token.colorBorder}`,
|
||||||
|
["--dash-chart-hover-bg" as string]: token.colorBgLayout,
|
||||||
|
["--dash-chart-hover-border" as string]: token.colorTextTertiary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{t`Chart Placeholder`}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card
|
||||||
|
className="dash-card-interactive"
|
||||||
|
style={{ ...cardHoverStyle, height: "100%" }}
|
||||||
|
title={<Title level={5} style={{ margin: 0 }}>{t`Recent Sales`}</Title>}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
["--dash-recent-hover-bg" as string]: token.colorBgTextHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recentSales.map((item) => (
|
||||||
|
<Flex
|
||||||
|
key={item.email}
|
||||||
|
className="dash-recent-row"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
style={{
|
||||||
|
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
shape="circle"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: token.colorFillSecondary,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.initials}
|
||||||
|
</Avatar>
|
||||||
|
<Flex vertical style={{ minWidth: 0 }}>
|
||||||
|
<Text strong style={{ fontSize: token.fontSizeSM, lineHeight: 1 }}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }} ellipsis>
|
||||||
|
{item.email}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<div style={{ fontWeight: 500 }}>{item.amount}</div>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Form, Input, Select } from "antd";
|
||||||
|
import type { FormInstance } from "antd/es/form";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { CreateUserRequest, User } from "@/api/schemas";
|
||||||
|
import { BaseFormModal } from "@/components/FormModal";
|
||||||
|
|
||||||
|
export type FormModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
editingUser: User | null;
|
||||||
|
form: FormInstance<CreateUserRequest>;
|
||||||
|
confirmLoading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onFinish: (values: CreateUserRequest) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormModal({
|
||||||
|
open,
|
||||||
|
editingUser,
|
||||||
|
form,
|
||||||
|
confirmLoading,
|
||||||
|
onCancel,
|
||||||
|
onFinish,
|
||||||
|
}: FormModalProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseFormModal<CreateUserRequest>
|
||||||
|
open={open}
|
||||||
|
title={editingUser ? t`Edit User` : t`New User`}
|
||||||
|
okText={t`OK`}
|
||||||
|
cancelText={t`Cancel`}
|
||||||
|
form={form}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onFinish={onFinish}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={t`Username`}
|
||||||
|
rules={[{ required: true, message: t`Please enter username` }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="roles"
|
||||||
|
label={t`Roles`}
|
||||||
|
rules={[{ required: true, message: t`Please select roles` }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={[
|
||||||
|
{ label: t`Admin`, value: "admin" },
|
||||||
|
{ label: t`Editor`, value: "editor" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label={t`Email`}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</BaseFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Button, Input, Select, theme } from "antd";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Plus, UserRound } from "lucide-react";
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import { FilterToolbar } from "@/components/FilterToolbar";
|
||||||
|
|
||||||
|
/** Search + role slot `minWidth` for FilterToolbar collapse math */
|
||||||
|
const FILTER_CONTROL_WIDTH = 220;
|
||||||
|
|
||||||
|
export type ToolbarProps = {
|
||||||
|
keywordInput: string;
|
||||||
|
onKeywordChange: (value: string) => void;
|
||||||
|
onSearch: (keyword: string) => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
|
roleValue: string | undefined;
|
||||||
|
onRoleChange: (role: string) => void;
|
||||||
|
onCreateClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
|
||||||
|
{
|
||||||
|
keywordInput,
|
||||||
|
onKeywordChange,
|
||||||
|
onSearch,
|
||||||
|
onClearSearch,
|
||||||
|
roleValue,
|
||||||
|
onRoleChange,
|
||||||
|
onCreateClick,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const slots = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "keyword",
|
||||||
|
minWidth: FILTER_CONTROL_WIDTH,
|
||||||
|
children: (
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder={t`Search User`}
|
||||||
|
style={{ width: FILTER_CONTROL_WIDTH }}
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
|
onSearch={(v) => onSearch(v)}
|
||||||
|
onClear={onClearSearch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "role",
|
||||||
|
minWidth: FILTER_CONTROL_WIDTH,
|
||||||
|
children: (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder={t`Role`}
|
||||||
|
style={{ width: FILTER_CONTROL_WIDTH }}
|
||||||
|
prefix={<UserRound size={token.fontSize} />}
|
||||||
|
value={roleValue}
|
||||||
|
onChange={(v) => onRoleChange(v ?? "")}
|
||||||
|
options={[
|
||||||
|
{ label: t`Admin`, value: "admin" },
|
||||||
|
{ label: t`Editor`, value: "editor" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
keywordInput,
|
||||||
|
onClearSearch,
|
||||||
|
onKeywordChange,
|
||||||
|
onRoleChange,
|
||||||
|
onSearch,
|
||||||
|
roleValue,
|
||||||
|
t,
|
||||||
|
token.fontSize,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterToolbar
|
||||||
|
ref={ref}
|
||||||
|
slots={slots}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
|
||||||
|
{t`Create User`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
moreFiltersLabel={t`More filters`}
|
||||||
|
moreFiltersTitle={t`More filters`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
||||||
|
import type { TablePaginationConfig } from "antd/es/table/interface";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { httpClient } from "@/utils/http";
|
||||||
|
import { USER_ENDPOINTS } from "@/api/user";
|
||||||
|
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
|
||||||
|
import type { User, CreateUserRequest } from "@/api/schemas";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { DataTable } from "@/components/DataTable";
|
||||||
|
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
|
||||||
|
import { Toolbar } from "./-Toolbar";
|
||||||
|
import { FormModal } from "./-FormModal";
|
||||||
|
import { vercelAvatarUrl } from "@/utils/avatarUrl";
|
||||||
|
|
||||||
|
const UserSearchParamsSchema = z.object({
|
||||||
|
limit: z.number().int().positive().catch(100),
|
||||||
|
offset: z.number().int().nonnegative().catch(0),
|
||||||
|
sortField: z.string().nullable().catch(null),
|
||||||
|
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
|
||||||
|
keyword: z.string().catch(""),
|
||||||
|
role: z.string().catch(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/users/")({
|
||||||
|
validateSearch: (search) => UserSearchParamsSchema.parse(search),
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
|
||||||
|
|
||||||
|
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
|
||||||
|
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
|
||||||
|
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const pageShellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const middleSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tableFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tableAvailableRef = useRef(0);
|
||||||
|
/**
|
||||||
|
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
|
||||||
|
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
|
||||||
|
*/
|
||||||
|
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
||||||
|
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
||||||
|
);
|
||||||
|
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
||||||
|
return Math.max(120, maxH - 40);
|
||||||
|
});
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
||||||
|
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setKeywordInput(search.keyword);
|
||||||
|
}, [search.keyword]);
|
||||||
|
|
||||||
|
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
||||||
|
{ list: User[]; total: number },
|
||||||
|
CreateUserRequest,
|
||||||
|
CreateUserRequest & { id: string }
|
||||||
|
>({
|
||||||
|
queryKey: [
|
||||||
|
"users",
|
||||||
|
search.limit,
|
||||||
|
search.offset,
|
||||||
|
search.keyword,
|
||||||
|
search.role,
|
||||||
|
search.sortField,
|
||||||
|
search.sortOrder,
|
||||||
|
],
|
||||||
|
invalidateKey: ["users"],
|
||||||
|
queryFn: () =>
|
||||||
|
httpClient.get(USER_ENDPOINTS.list, {
|
||||||
|
params: {
|
||||||
|
limit: search.limit,
|
||||||
|
offset: search.offset,
|
||||||
|
keyword: search.keyword || undefined,
|
||||||
|
role: search.role || undefined,
|
||||||
|
sortField: search.sortField ?? undefined,
|
||||||
|
sortOrder: search.sortOrder ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
select: (raw) => paginatedUserSchema.shape.data.parse(raw),
|
||||||
|
createFn: (values) =>
|
||||||
|
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
|
||||||
|
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
|
||||||
|
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
|
||||||
|
createLifecycle: {
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({
|
||||||
|
content: t`Created successfully`,
|
||||||
|
key: MESSAGE_KEY_USER_CREATE,
|
||||||
|
});
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({
|
||||||
|
content: t`Create failed`,
|
||||||
|
key: MESSAGE_KEY_USER_CREATE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateLifecycle: {
|
||||||
|
onMutate: () => {
|
||||||
|
message.loading({
|
||||||
|
content: t`Updating…`,
|
||||||
|
key: MESSAGE_KEY_USER_UPDATE,
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({
|
||||||
|
content: t`Updated successfully`,
|
||||||
|
key: MESSAGE_KEY_USER_UPDATE,
|
||||||
|
});
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({
|
||||||
|
content: t`Update failed`,
|
||||||
|
key: MESSAGE_KEY_USER_UPDATE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deleteLifecycle: {
|
||||||
|
onMutate: () => {
|
||||||
|
message.loading({
|
||||||
|
content: t`Deleting…`,
|
||||||
|
key: MESSAGE_KEY_USER_DELETE,
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({
|
||||||
|
content: t`Deleted successfully`,
|
||||||
|
key: MESSAGE_KEY_USER_DELETE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({
|
||||||
|
content: t`Delete failed`,
|
||||||
|
key: MESSAGE_KEY_USER_DELETE,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = (record: User) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: t`Are you absolutely sure?`,
|
||||||
|
content: t`This action cannot be undone. This will permanently delete the user.`,
|
||||||
|
okText: t`Delete`,
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: t`Cancel`,
|
||||||
|
onOk: () => deleteMutation.mutate(record.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "ID",
|
||||||
|
dataIndex: "id",
|
||||||
|
key: "id",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: search.sortField === "id" ? search.sortOrder : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Username`,
|
||||||
|
dataIndex: "username",
|
||||||
|
key: "username",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: search.sortField === "username" ? search.sortOrder : null,
|
||||||
|
render: (_: unknown, record: User) => {
|
||||||
|
const src = record.avatar?.trim() || vercelAvatarUrl(record.username);
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
|
||||||
|
<Avatar size={24} src={src} shape="circle" style={{ flexShrink: 0 }}>
|
||||||
|
{record.username?.[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.username}
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Email`,
|
||||||
|
dataIndex: "email",
|
||||||
|
key: "email",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: search.sortField === "email" ? search.sortOrder : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Roles`,
|
||||||
|
dataIndex: "roles",
|
||||||
|
key: "roles",
|
||||||
|
sorter: true,
|
||||||
|
sortOrder: search.sortField === "roles" ? search.sortOrder : null,
|
||||||
|
render: (roles: string[]) => (
|
||||||
|
<Space wrap>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Tag
|
||||||
|
key={role}
|
||||||
|
variant="outlined"
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "transparent",
|
||||||
|
boxShadow: "none",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
key: "actions",
|
||||||
|
width: 60,
|
||||||
|
align: "right" as const,
|
||||||
|
sorter: false,
|
||||||
|
render: (_: unknown, record: User) => (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
icon: <Pencil size={token.fontSize} />,
|
||||||
|
label: t`Edit`,
|
||||||
|
onClick: () => {
|
||||||
|
setEditingUser(record);
|
||||||
|
form.setFieldsValue(record);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
icon: <Trash2 size={token.fontSize} />,
|
||||||
|
label: t`Delete`,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => confirmDelete(record),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const applySearch = (keyword: string) => {
|
||||||
|
void navigate({
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
keyword: keyword.trim(),
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPagination = (data?.total ?? 0) > search.limit;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const shell = pageShellRef.current;
|
||||||
|
const toolbarEl = toolbarRowRef.current;
|
||||||
|
const mid = middleSectionRef.current;
|
||||||
|
if (!shell || !toolbarEl || !mid) return;
|
||||||
|
|
||||||
|
/** Fixed small table header row height */
|
||||||
|
const headReserve = 40;
|
||||||
|
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
|
||||||
|
const rowEstimate = 44;
|
||||||
|
const paginationBlock = 56;
|
||||||
|
|
||||||
|
let rafRetry = 0;
|
||||||
|
const maxRafRetries = 8;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const toolbarRect = toolbarEl.getBoundingClientRect();
|
||||||
|
/**
|
||||||
|
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
|
||||||
|
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
|
||||||
|
*/
|
||||||
|
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
||||||
|
const mainRect = mainEl?.getBoundingClientRect();
|
||||||
|
const viewportBottom =
|
||||||
|
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
||||||
|
const clipBottom = mainRect
|
||||||
|
? mainRect.bottom
|
||||||
|
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
||||||
|
|
||||||
|
const maxMiddleFromShell = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
|
||||||
|
);
|
||||||
|
|
||||||
|
const midHRaw = mid.clientHeight;
|
||||||
|
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
||||||
|
|
||||||
|
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
||||||
|
rafRetry += 1;
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(measure));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bordered frame now includes Table built-in pagination */
|
||||||
|
const frameInner = Math.max(0, Math.floor(midH));
|
||||||
|
tableAvailableRef.current = frameInner;
|
||||||
|
|
||||||
|
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
||||||
|
|
||||||
|
const rowCount = data?.list?.length ?? 0;
|
||||||
|
const pagReserve = showPagination ? paginationBlock : 0;
|
||||||
|
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
||||||
|
|
||||||
|
if (isLoading && rowCount === 0) {
|
||||||
|
setTableScrollY(bodyMax);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowCount === 0 && !isLoading) {
|
||||||
|
setTableScrollY(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
||||||
|
const naturalTotalH = naturalBodyH + pagReserve;
|
||||||
|
const slack = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
|
||||||
|
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
|
||||||
|
*/
|
||||||
|
if (naturalTotalH + slack > frameInner) {
|
||||||
|
setTableScrollY(bodyMax);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableScrollY(undefined);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const frame = tableFrameRef.current;
|
||||||
|
const avail = tableAvailableRef.current;
|
||||||
|
const minFrame = headReserve + pagReserve;
|
||||||
|
if (!frame || avail <= minFrame) return;
|
||||||
|
if (frame.scrollHeight > frame.clientHeight + 1) {
|
||||||
|
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(shell);
|
||||||
|
ro.observe(mid);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
|
||||||
|
|
||||||
|
const tablePagination: false | TablePaginationConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
showPagination
|
||||||
|
? {
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: search.limit,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => t`${total} rows`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
void navigate({
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
limit: pageSize ?? search.limit,
|
||||||
|
offset: (page - 1) * (pageSize ?? search.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
[showPagination, data?.total, currentPage, search, navigate, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
ref={pageShellRef}
|
||||||
|
vertical
|
||||||
|
gap={token.marginMD}
|
||||||
|
style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
ref={toolbarRowRef}
|
||||||
|
keywordInput={keywordInput}
|
||||||
|
onKeywordChange={setKeywordInput}
|
||||||
|
onSearch={applySearch}
|
||||||
|
onClearSearch={() => {
|
||||||
|
setKeywordInput("");
|
||||||
|
applySearch("");
|
||||||
|
}}
|
||||||
|
roleValue={search.role || undefined}
|
||||||
|
onRoleChange={(role) =>
|
||||||
|
void navigate({
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
role,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCreateClick={() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable<User>
|
||||||
|
layoutRef={middleSectionRef}
|
||||||
|
frameRef={tableFrameRef}
|
||||||
|
lockScrollHeight={tableScrollY != null}
|
||||||
|
maxHeight={tableAreaMaxHeight}
|
||||||
|
frameHeight={
|
||||||
|
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
|
||||||
|
}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.list ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={tablePagination}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
|
||||||
|
onChange={(_pagination, _filters, sorter) => {
|
||||||
|
if (Array.isArray(sorter)) return;
|
||||||
|
const nextSortField = sorter.order ? String(sorter.field) : "username";
|
||||||
|
const nextSortOrder = sorter.order ? sorter.order : "descend";
|
||||||
|
void navigate({
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
sortField: nextSortField,
|
||||||
|
sortOrder: nextSortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
open={modalOpen}
|
||||||
|
editingUser={editingUser}
|
||||||
|
form={form}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onFinish={(values) => {
|
||||||
|
if (editingUser) {
|
||||||
|
updateMutation.mutate({ ...values, id: editingUser.id });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@supports not (backdrop-filter: blur(1px)) {
|
||||||
|
.login-page__card.ant-card {
|
||||||
|
background: var(--login-card-fallback-bg) !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { httpClient } from "@/utils/http";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||||
|
import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas";
|
||||||
|
import { fetchSessionAndApplyToStore } from "@/auth/session";
|
||||||
|
import type { LoginRequest } from "@/api/schemas";
|
||||||
|
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||||
|
import { Languages } from "lucide-react";
|
||||||
|
import { Theme } from "@/components/Icon";
|
||||||
|
import { AppFooter } from "@/components/Layout/AppFooter";
|
||||||
|
import { Aurora } from "@/components/Aurora";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/login/")({
|
||||||
|
beforeLoad: () => {
|
||||||
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
if (isAuthenticated) {
|
||||||
|
throw redirect({ to: "/dashboard" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: LoginPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const setTokens = useAuthStore((s) => s.setTokens);
|
||||||
|
const locale = useSettingsStore((s) => s.locale);
|
||||||
|
const setLocale = useSettingsStore((s) => s.setLocale);
|
||||||
|
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
|
||||||
|
const darkMode = useSettingsStore((s) => s.darkMode);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: async (values: LoginRequest) => {
|
||||||
|
const parsed = LoginRequestSchema.parse(values);
|
||||||
|
const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed);
|
||||||
|
const validTokens = AuthTokensSchema.parse(tokens);
|
||||||
|
setTokens(validTokens);
|
||||||
|
await fetchSessionAndApplyToStore();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t`Login successful`);
|
||||||
|
void navigate({ to: "/dashboard" });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
message.error(err instanceof Error ? err.message : t`Login failed`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLocale = () => {
|
||||||
|
setLocale(locale === "en" ? "zh" : "en");
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellStyle: CSSProperties = {
|
||||||
|
position: "relative",
|
||||||
|
isolation: "isolate",
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: token.colorBgLayout,
|
||||||
|
color: token.colorText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentStyle: CSSProperties = {
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
flex: "1 1 0%",
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const glassMix = darkMode ? "52%" : "42%";
|
||||||
|
const backdrop = darkMode ? "blur(22px) saturate(1.2)" : "blur(18px) saturate(1.35)";
|
||||||
|
|
||||||
|
const cardStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 384,
|
||||||
|
background: `color-mix(in srgb, ${token.colorBgContainer} ${glassMix}, transparent)`,
|
||||||
|
backdropFilter: backdrop,
|
||||||
|
WebkitBackdropFilter: backdrop,
|
||||||
|
borderColor: token.colorBorderSecondary,
|
||||||
|
boxShadow: token.boxShadow,
|
||||||
|
["--login-card-fallback-bg" as string]: token.colorBgElevated,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical style={shellStyle}>
|
||||||
|
<Aurora />
|
||||||
|
<Flex vertical style={contentStyle}>
|
||||||
|
<Flex
|
||||||
|
flex={1}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{ padding: token.padding, minHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="login-page__card"
|
||||||
|
style={cardStyle}
|
||||||
|
styles={{
|
||||||
|
body: { padding: token.paddingLG, background: "transparent" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap={token.margin}
|
||||||
|
wrap="wrap"
|
||||||
|
style={{ marginBottom: token.marginLG }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={APP_FAVICON_SRC}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
draggable={false}
|
||||||
|
style={{ display: "block", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography.Title
|
||||||
|
level={3}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: "-0.025em",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{APP_BRAND_NAME}
|
||||||
|
</Typography.Title>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(values) => {
|
||||||
|
loginMutation.mutate(LoginRequestSchema.parse(values));
|
||||||
|
}}
|
||||||
|
initialValues={{ username: "admin", password: "admin", remember: true }}
|
||||||
|
requiredMark={false}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{t`Username`}</span>}
|
||||||
|
rules={[{ required: true, message: t`Please enter username` }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="login-username"
|
||||||
|
aria-label={t`Username`}
|
||||||
|
placeholder="admin"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={<span style={{ fontWeight: 500 }}>{t`Password`}</span>}
|
||||||
|
rules={[{ required: true, message: t`Please enter password` }]}
|
||||||
|
style={{ marginBottom: token.marginLG }}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
id="login-password"
|
||||||
|
aria-label={t`Password`}
|
||||||
|
placeholder="admin"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
style={{ marginBottom: token.marginLG }}
|
||||||
|
wrap="wrap"
|
||||||
|
>
|
||||||
|
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>{t`Auto login`}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Typography.Link
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
style={{ fontSize: token.fontSizeSM }}
|
||||||
|
>
|
||||||
|
{t`Forgot password?`}
|
||||||
|
</Typography.Link>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loginMutation.isPending}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{t`Sign In`}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex justify="center" style={{ marginBottom: token.marginSM }}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleLocale}
|
||||||
|
icon={<Languages size={token.size} />}
|
||||||
|
aria-label={t`Switch language`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
icon={<Theme size={token.size} />}
|
||||||
|
aria-label={t`Toggle Theme`}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
<AppFooter />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { AuthTokens, User, MenuItem } from "@/api/schemas";
|
||||||
|
import { createPersistentStore } from "./createPersistentStore";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
tokens: AuthTokens | null;
|
||||||
|
user: User | null;
|
||||||
|
menus: MenuItem[];
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setTokens: (tokens: AuthTokens) => void;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
setMenus: (menus: MenuItem[]) => void;
|
||||||
|
hasPermission: (point: string) => boolean;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = createPersistentStore<AuthState>(
|
||||||
|
(set, get) => ({
|
||||||
|
tokens: null,
|
||||||
|
user: null,
|
||||||
|
menus: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setTokens: (tokens) => set({ tokens, isAuthenticated: true }),
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
setMenus: (menus) => set({ menus }),
|
||||||
|
|
||||||
|
hasPermission: (point) => {
|
||||||
|
const { user } = get();
|
||||||
|
return user?.permissions.includes(point) ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
set({
|
||||||
|
tokens: null,
|
||||||
|
user: null,
|
||||||
|
menus: [],
|
||||||
|
isAuthenticated: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "auth-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
tokens: state.tokens,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
const p = persistedState as Partial<Pick<AuthState, "tokens" | "isAuthenticated">> | null;
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
tokens: p?.tokens ?? currentState.tokens,
|
||||||
|
isAuthenticated: p?.isAuthenticated ?? currentState.isAuthenticated,
|
||||||
|
user: null,
|
||||||
|
menus: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { PersistOptions } from "zustand/middleware";
|
||||||
|
|
||||||
|
export interface PersistConfig<T> {
|
||||||
|
name: string;
|
||||||
|
version?: number;
|
||||||
|
partialize?: PersistOptions<T, Partial<T>>["partialize"];
|
||||||
|
merge?: PersistOptions<T, Partial<T>>["merge"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a Zustand store with persistence middleware
|
||||||
|
* Reduces boilerplate for stores that need automatic data persistence
|
||||||
|
*/
|
||||||
|
export function createPersistentStore<T extends object>(
|
||||||
|
initializer: StateCreator<T, [], []>,
|
||||||
|
config: PersistConfig<T>,
|
||||||
|
) {
|
||||||
|
const persistConfig: PersistOptions<T, Partial<T>> = {
|
||||||
|
name: config.name,
|
||||||
|
...(config.version !== undefined && { version: config.version }),
|
||||||
|
...(config.merge && { merge: config.merge }),
|
||||||
|
...(config.partialize && { partialize: config.partialize }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return create<T>()(persist(initializer, persistConfig));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createPersistentStore } from "./createPersistentStore";
|
||||||
|
|
||||||
|
export type Locale = "en" | "zh";
|
||||||
|
|
||||||
|
function getInitialDarkMode() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
darkMode: boolean;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
locale: Locale;
|
||||||
|
toggleDarkMode: () => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = createPersistentStore<SettingsState>(
|
||||||
|
(set) => ({
|
||||||
|
darkMode: getInitialDarkMode(),
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
locale: "en",
|
||||||
|
toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })),
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||||||
|
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||||
|
setLocale: (locale) => set({ locale }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "settings-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
darkMode: state.darkMode,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
locale: state.locale,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Theme entry: light/dark share radius, font, control tweaks; each sets primary/layout/border neutrals.
|
||||||
|
* @see https://ant.design/docs/react/customize-theme
|
||||||
|
*/
|
||||||
|
export { useAppTheme } from "./useAppTheme";
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { theme } from "antd";
|
||||||
|
import type { ConfigProviderProps, ThemeConfig } from "antd";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common theme token builders
|
||||||
|
* Reduces duplication between light and dark theme definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SHARED_DESIGN_TOKENS = {
|
||||||
|
fontFamily:
|
||||||
|
"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||||
|
|
||||||
|
borderRadius: 8,
|
||||||
|
borderRadiusSM: 6,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CONTROL_COMPONENTS = {
|
||||||
|
Button: {
|
||||||
|
primaryShadow: "none",
|
||||||
|
defaultShadow: "none",
|
||||||
|
dangerShadow: "none",
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
activeShadow: "none",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate table row selected color based on theme algorithm
|
||||||
|
*/
|
||||||
|
export function buildTableTokens(cfg: ThemeConfig) {
|
||||||
|
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
|
||||||
|
return {
|
||||||
|
rowSelectedBg,
|
||||||
|
rowSelectedHoverBg: rowSelectedBg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build menu tokens for light theme
|
||||||
|
*/
|
||||||
|
export const MENU_LIGHT = {
|
||||||
|
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
|
||||||
|
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
||||||
|
subMenuItemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
||||||
|
itemHoverBg: "rgba(0, 0, 0, 0.04)",
|
||||||
|
itemHoverColor: "rgba(0, 0, 0, 0.88)",
|
||||||
|
itemActiveBg: "rgba(0, 0, 0, 0.08)",
|
||||||
|
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
|
||||||
|
itemMarginInline: 8,
|
||||||
|
horizontalItemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
||||||
|
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build menu tokens for dark theme
|
||||||
|
*/
|
||||||
|
export const MENU_DARK = {
|
||||||
|
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||||
|
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
||||||
|
subMenuItemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
||||||
|
itemHoverBg: "rgba(255, 255, 255, 0.06)",
|
||||||
|
itemHoverColor: "rgba(255, 255, 255, 0.85)",
|
||||||
|
itemActiveBg: "rgba(255, 255, 255, 0.1)",
|
||||||
|
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
|
||||||
|
itemMarginInline: 8,
|
||||||
|
horizontalItemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
||||||
|
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build light theme config
|
||||||
|
*/
|
||||||
|
export function buildLightThemeConfig(): ConfigProviderProps {
|
||||||
|
const lightSeed: ThemeConfig["token"] = {
|
||||||
|
colorBgLayout: "#ffffff",
|
||||||
|
...SHARED_DESIGN_TOKENS,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: {
|
||||||
|
algorithm: theme.defaultAlgorithm,
|
||||||
|
token: lightSeed,
|
||||||
|
components: {
|
||||||
|
...CONTROL_COMPONENTS,
|
||||||
|
Table: buildTableTokens({
|
||||||
|
algorithm: theme.defaultAlgorithm,
|
||||||
|
token: lightSeed,
|
||||||
|
}),
|
||||||
|
Menu: MENU_LIGHT,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build dark theme config
|
||||||
|
*/
|
||||||
|
export function buildDarkThemeConfig(): ConfigProviderProps {
|
||||||
|
const darkSeed: ThemeConfig["token"] = {
|
||||||
|
...SHARED_DESIGN_TOKENS,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: {
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: darkSeed,
|
||||||
|
components: {
|
||||||
|
...CONTROL_COMPONENTS,
|
||||||
|
Table: buildTableTokens({
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: darkSeed,
|
||||||
|
}),
|
||||||
|
Menu: MENU_DARK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ConfigProviderProps } from "antd";
|
||||||
|
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
|
||||||
|
export function useAppTheme(): ConfigProviderProps {
|
||||||
|
const darkMode = useSettingsStore((s) => s.darkMode);
|
||||||
|
|
||||||
|
return useMemo(() => (darkMode ? buildDarkThemeConfig() : buildLightThemeConfig()), [darkMode]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { MenuItem } from "@/api/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
|
||||||
|
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
|
||||||
|
*/
|
||||||
|
export const APP_MENU_TREE: MenuItem[] = [
|
||||||
|
{
|
||||||
|
id: "g-platform",
|
||||||
|
kind: "group",
|
||||||
|
name: "Platform",
|
||||||
|
path: null,
|
||||||
|
icon: "IconLucideSparkles",
|
||||||
|
permissions: null,
|
||||||
|
sort: 0,
|
||||||
|
hidden: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
kind: "item",
|
||||||
|
name: "Dashboard",
|
||||||
|
path: "/dashboard",
|
||||||
|
icon: "IconLucideLayoutDashboard",
|
||||||
|
children: null,
|
||||||
|
permissions: null,
|
||||||
|
sort: 0,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
kind: "item",
|
||||||
|
name: "Users",
|
||||||
|
path: "/users",
|
||||||
|
icon: "IconLucideUsers",
|
||||||
|
children: null,
|
||||||
|
permissions: ["user:view"],
|
||||||
|
sort: 1,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "g-projects",
|
||||||
|
kind: "group",
|
||||||
|
name: "Projects",
|
||||||
|
path: null,
|
||||||
|
icon: "IconLucideFolderKanban",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
kind: "item",
|
||||||
|
name: "Design Engineering",
|
||||||
|
path: "/design-engineering",
|
||||||
|
icon: "IconLucideBookOpen",
|
||||||
|
children: null,
|
||||||
|
permissions: null,
|
||||||
|
sort: 0,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
kind: "item",
|
||||||
|
name: "Sales & Marketing",
|
||||||
|
path: "/sales-marketing",
|
||||||
|
icon: "IconLucideBriefcase",
|
||||||
|
children: null,
|
||||||
|
permissions: ["user:view"],
|
||||||
|
sort: 1,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: null,
|
||||||
|
sort: 1,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function hasRequiredPermissions(
|
||||||
|
required: string[] | null | undefined,
|
||||||
|
granted: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
if (!required || required.length === 0) return true;
|
||||||
|
return required.every((p) => granted.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMenuTreeByPermissions(
|
||||||
|
nodes: MenuItem[],
|
||||||
|
permissionList: string[],
|
||||||
|
): MenuItem[] {
|
||||||
|
const granted = new Set(permissionList);
|
||||||
|
|
||||||
|
const walk = (list: MenuItem[]): MenuItem[] =>
|
||||||
|
list
|
||||||
|
.map((node) => {
|
||||||
|
if (!hasRequiredPermissions(node.permissions ?? null, granted)) return null;
|
||||||
|
|
||||||
|
if (node.kind === "group") {
|
||||||
|
const children = walk(node.children);
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
return { ...node, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
const children = walk(node.children);
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
return { ...node, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
})
|
||||||
|
.filter((n): n is MenuItem => n != null);
|
||||||
|
|
||||||
|
return walk(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized pathname (no trailing slash except `/`) */
|
||||||
|
export function normalizeAppPath(pathname: string): string {
|
||||||
|
if (pathname === "/") return pathname;
|
||||||
|
return pathname.replace(/\/+$/, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route → permission required to open the page. `null` = no permission gate.
|
||||||
|
* Keep in sync with {@link APP_MENU_TREE} paths.
|
||||||
|
*/
|
||||||
|
export function requiredPermissionForPath(pathname: string): string | null {
|
||||||
|
const p = normalizeAppPath(pathname);
|
||||||
|
const map: Record<string, string | null> = {
|
||||||
|
"/dashboard": null,
|
||||||
|
"/users": "user:view",
|
||||||
|
"/design-engineering": null,
|
||||||
|
"/sales-marketing": "user:view",
|
||||||
|
"/403": null,
|
||||||
|
};
|
||||||
|
return map[p] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessPath(pathname: string, permissions: string[] | undefined): boolean {
|
||||||
|
const required = requiredPermissionForPath(pathname);
|
||||||
|
if (required == null) return true;
|
||||||
|
if (!permissions?.length) return false;
|
||||||
|
return permissions.includes(required);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Deterministic avatar image from username (Vercel avatar API). */
|
||||||
|
export function vercelAvatarUrl(username: string): string {
|
||||||
|
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
/** Favicon path under `public/` (Vite serves as site root). */
|
||||||
|
export const APP_FAVICON_SRC = "/favicon.svg";
|
||||||
|
|
||||||
|
/** Product / brand name (login header, sidebar logo text, etc.). */
|
||||||
|
export const APP_BRAND_NAME = "Antd Admin";
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { API_BASE_URL } from "./constants";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code: number;
|
||||||
|
constructor(code: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
|
||||||
|
params?: Record<string, string | number | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
||||||
|
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
|
||||||
|
if (!params) return base;
|
||||||
|
const url = new URL(base, window.location.origin);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null) url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("auth-storage");
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const token = parsed?.state?.tokens?.accessToken;
|
||||||
|
if (token) return { Authorization: `Bearer ${token}` };
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = buildUrl(path, options?.params);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
...(options?.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.code !== undefined && json.code !== 0) {
|
||||||
|
throw new ApiError(json.code, json.message ?? "Unknown error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.data !== undefined ? json.data : json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = {
|
||||||
|
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
||||||
|
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||||
|
request<T>("POST", path, body, options),
|
||||||
|
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||||
|
request<T>("PUT", path, body, options),
|
||||||
|
delete: <T>(path: string, options?: RequestOptions) =>
|
||||||
|
request<T>("DELETE", path, undefined, options),
|
||||||
|
};
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
|
import { lingui } from "@lingui/vite-plugin";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
tanstackRouter({
|
||||||
|
routesDirectory: "./src/routes",
|
||||||
|
generatedRouteTree: "./src/routeTree.gen.ts",
|
||||||
|
}),
|
||||||
|
react({
|
||||||
|
plugins: [["@lingui/swc-plugin", {}]],
|
||||||
|
}),
|
||||||
|
lingui(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: (id: string) => {
|
||||||
|
if (id.includes("node_modules/antd")) {
|
||||||
|
return "vendor-antd";
|
||||||
|
}
|
||||||
|
if (id.includes("@tanstack/react-router") || id.includes("@tanstack/react-query")) {
|
||||||
|
return "vendor-tanstack";
|
||||||
|
}
|
||||||
|
if (id.includes("lucide-react")) {
|
||||||
|
return "vendor-ui";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 1024,
|
||||||
|
},
|
||||||
|
staged: {
|
||||||
|
"*": "vp check --fix",
|
||||||
|
},
|
||||||
|
lint: { options: { typeAware: true, typeCheck: true } },
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "antd-admin",
|
||||||
|
"version": "6.0.0",
|
||||||
|
"description": "AI-friendly enterprise front-end best practices",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"turbo": "^2.9.6"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.15.6"
|
||||||
|
}
|
||||||
Generated
+4841
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turborepo.dev/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
"$TURBO_DEFAULT$",
|
||||||
|
".env*"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"dist/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lint": {},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user