chore: initial import

This commit is contained in:
zuiidea
2026-04-14 18:04:41 +08:00
parent 458d63c020
commit 426518c70a
85 changed files with 16024 additions and 0 deletions
+44
View File
@@ -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
+182
View File
@@ -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.
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
[![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![GitHub stars](https://img.shields.io/github/stars/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/stargazers)
[![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](http://opensource.org/licenses/MIT)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](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
+36
View File
@@ -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
+25
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
vp staged
+84
View File
@@ -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.
+182
View File
@@ -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.
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
[![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![GitHub stars](https://img.shields.io/github/stars/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/stargazers)
[![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](http://opensource.org/licenses/MIT)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](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
+16
View File
@@ -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/);
}
+27
View File
@@ -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();
});
});
+43
View File
@@ -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();
});
});
+20
View File
@@ -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>
+13
View File
@@ -0,0 +1,13 @@
const config = {
locales: ["en", "zh"],
sourceLocale: "en",
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
},
],
format: "po",
};
export default config;
+62
View File
@@ -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"
]
}
}
+30
View File
@@ -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,
},
});
+4876
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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,
};
}
+11
View File
@@ -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 };
+131
View File
@@ -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>;
+10
View File
@@ -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 };
+18
View File
@@ -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,
};
}
+44
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.po" {
import type { Messages } from "@lingui/core";
export const messages: Messages;
}
+317
View File
@@ -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"
+317
View File
@@ -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 "欢迎回来"
+50
View File
@@ -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);
});
+4
View File
@@ -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;
+26
View File
@@ -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"],
}));
+47
View File
@@ -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);
}),
];
+180
View File
@@ -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,
});
+52
View File
@@ -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,
});
+21
View File
@@ -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>
);
}
+7
View File
@@ -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;
}
}
+237
View File
@@ -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>
);
}
+57
View File
@@ -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));
}
+40
View File
@@ -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,
}),
},
);
+5
View File
@@ -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";
+119
View File
@@ -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,
},
},
};
}
+10
View File
@@ -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]);
}
+143
View File
@@ -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);
}
+4
View File
@@ -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)}`;
}
+7
View File
@@ -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";
+89
View File
@@ -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),
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+32
View File
@@ -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"]
}
+44
View File
@@ -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 } },
});
+18
View File
@@ -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"
}
+4841
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+22
View File
@@ -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
}
}
}