feat(with-lingui): orders CRUD, shared hooks, auth refresh, and docs

- Add second sample resource (orders): API, schemas, MSW, routes, menu, Lingui, and E2E.

- Introduce useCrudToasts, useUrlSearchState, useTableFitHeight; extend useResourceCRUD with optimistic updates; add Vitest coverage for hooks and createHandler.

- Implement token refresh in http layer, RouteError, and new E2E specs (auth-refresh, rbac, url-state).

- Replace gen-resource codegen with English add-resource.instructions.md; fix Copilot instruction YAML in with-lingui and basic.

- Add with-lingui CI workflow, Cursor thin rules, optimization plan doc, and refresh README/AGENTS.

- Bump packages/create and lockfile where applicable.

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-27 14:47:55 +08:00
parent 2d46d7bf27
commit c5221c71be
81 changed files with 2624 additions and 737 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
@@ -0,0 +1,15 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` / `orders` examples). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
+15
View File
@@ -0,0 +1,15 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
+47
View File
@@ -0,0 +1,47 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e:core
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+5 -1
View File
@@ -41,6 +41,10 @@ yarn-error.log*
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
.cursor
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers ./docs/superpowers
+3 -3
View File
@@ -1,7 +1,7 @@
--- ---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
## applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users." description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions # API Instructions
@@ -26,4 +26,4 @@ Maintain strict API contracts and consistent server/client mock behavior.
## Validation ## Validation
- Verify TypeScript and schema parsing paths compile cleanly. - Verify TypeScript and schema parsing paths compile cleanly.
- Run: `vp check --no-fmt` - Run: `vp check --no-fmt`
+3 -3
View File
@@ -1,7 +1,7 @@
--- ---
applyTo: "src/**/*.{ts,tsx,css}"
## applyTo: "src/**/*.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n." description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions # Frontend Instructions
@@ -28,4 +28,4 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
## Validation ## Validation
- Run: `vp check --no-fmt` - Run: `vp check --no-fmt`
- For UI flow changes, run focused e2e where possible. - For UI flow changes, run focused e2e where possible.
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
## applyTo: "src/\*_/_.{ts,tsx}" ## applyTo: "src/*/*.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability." description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
@@ -34,4 +34,4 @@ Reduce duplication while preserving behavior and public contracts.
- Confirm no new diagnostics in touched files. - Confirm no new diagnostics in touched files.
- Run: `vp check --no-fmt` - Run: `vp check --no-fmt`
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"` - For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts" ## applyTo: "tests/**/.ts,tests/**/*.tsx,src/mocks//*.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage." description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
@@ -27,4 +27,4 @@ Keep tests fast, deterministic, and aligned with core business flows.
## Validation ## Validation
- Run focused tests first, then broader checks if needed. - Run focused tests first, then broader checks if needed.
- Run: `vp check --no-fmt` - Run: `vp check --no-fmt`
+1 -1
View File
@@ -81,4 +81,4 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
- `api.instructions.md` for API/schema/handler changes - `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup - `refactor.instructions.md` for behavior-preserving cleanup
See `.github/instructions/README.md` for usage and authoring rules. See `.github/instructions/README.md` for usage and authoring rules.
+1
View File
@@ -8,6 +8,7 @@ This directory contains scoped instruction files used by AI coding agents.
- `testing.instructions.md`: Playwright/MSW/test changes - `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes - `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication - `refactor.instructions.md`: behavior-preserving cleanup and deduplication
- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users` + `orders`)
## Authoring Rules ## Authoring Rules
@@ -0,0 +1,69 @@
---
applyTo: "src/api/**/*.ts,src/mocks/**/*.ts,src/routes/_auth/**/*.tsx,e2e/**/*.spec.ts"
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page."
---
# Add Resource Recipe
Add a list CRUD flow in `apps/with-lingui` that mirrors **Users** (TanStack Router + Query + MSW + Lingui). There is **no** codegen script: **copy and adapt by hand** using the steps below, or have an Agent follow this checklist. Primary references: `src/routes/_auth/users/`, the sample `src/routes/_auth/orders/`, and the matching `e2e/*.spec.ts` files.
## 0. Verification commands (after your changes)
```bash
cd apps/with-lingui
pnpm run build # refresh TanStack routeTree when routes change
pnpm exec vp check --no-fmt
pnpm run test:e2e -- e2e/<slug>.spec.ts
```
## 1. Zod and types (`src/api/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- Reuse `PaginatedResponseSchema(<Entity>Schema)` for list responses.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Endpoint constants (`src/api/<slug>.ts`)
- Export `<SLUG_UPPER>_ENDPOINTS`: `list` / `create` / `update(id)` / `delete(id)` with paths like `/api/<slug>`.
## 3. Mock data (`src/mocks/data.ts`)
- Append a `MOCK_<ENTITIES>` array. For **admin** users, `permissions` must include `<slug>:view` (and create/edit/delete if you add writes), aligned with `GET /api/auth/permissions`.
## 4. MSW (`src/mocks/handlers/<slug>.ts` + `handlers/index.ts`)
- Follow `handlers/user.ts` / `handlers/orders.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
- Register `...<slug>Handlers` in `handlers/index.ts`.
## 5. Route page (`src/routes/_auth/<slug>/`)
- `createFileRoute("/_auth/<slug>/")`, wiring `useResourceCRUD` (recommended: `optimistic: { update: true, delete: true }`), `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`).
- **Lingui**: run `pnpm run i18n:extract && pnpm run i18n:compile` for new copy (or `vp exec lingui …`).
## 6. Menu and permissions (`src/utils/appMenu.ts`)
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key such as `IconLucidePackage` (see existing `IconLucide*` entries in `Sidebar`).
- Keep behavior consistent with `canAccessPath` / 403.
## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`)
- In `MENU_LABELS`, add a Lingui `msg` entry for the menu item `name` (must match the `name` string from `appMenu`).
- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`.
- Then run `pnpm run i18n:extract && pnpm run i18n:compile` and fill in `zh` (and other locale) translations.
> In this template repo, the `orders` sample is already aligned with the Sidebar next to `appMenu`. When adding a resource from scratch, double-check this section.
## 7. E2E (`e2e/<slug>.spec.ts`)
- Copy structure from `users.spec.ts` / `orders.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
## 8. Verification
- `pnpm exec vp check --no-fmt`
- `pnpm run test:e2e:core` (if you add the new spec to core) or `pnpm exec playwright test e2e/<slug>.spec.ts`
- `pnpm run build` and watch chunk sizes in the log
## Notes
- Before adding or renaming files, confirm nothing already exists at that path so you do not overwrite hand-written code.
- After manual schema edits, treat the diff as source of truth; there is no automatic merge.
+3 -1
View File
@@ -14,6 +14,7 @@ Maintain strict API contracts and consistent server/client mock behavior.
- Keep request/response shapes schema-driven and type-safe. - Keep request/response shapes schema-driven and type-safe.
- Reuse endpoint constants and shared response helpers. - Reuse endpoint constants and shared response helpers.
- Keep handler response envelope consistent (`code`, `data`, `message`). - Keep handler response envelope consistent (`code`, `data`, `message`).
- MSW success payloads: use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` from `src/mocks/createHandler.ts` so responses match Zod contracts before `HttpResponse.json`.
- Ensure mock handlers reflect real API behavior and error semantics. - Ensure mock handlers reflect real API behavior and error semantics.
- Keep naming stable for endpoint keys and mutation/query keys. - Keep naming stable for endpoint keys and mutation/query keys.
@@ -26,4 +27,5 @@ Maintain strict API contracts and consistent server/client mock behavior.
## Validation ## Validation
- Verify TypeScript and schema parsing paths compile cleanly. - Verify TypeScript and schema parsing paths compile cleanly.
- Run: `vp check --no-fmt` - Run: `pnpm exec vp check --no-fmt`
- After changing `createHandler` or handler envelopes: `pnpm run test:unit` (includes `src/mocks/createHandler.test.ts`).
@@ -27,5 +27,5 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
## Validation ## Validation
- Run: `vp check --no-fmt` - Run: `pnpm exec vp check --no-fmt`
- For UI flow changes, run focused e2e where possible. - For UI flow changes, run focused e2e where possible.
@@ -1,8 +1,7 @@
--- ---
applyTo: "src/**/*.{ts,tsx}"
## applyTo: "src/\*_/_.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability." description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
---
# Refactor Instructions # Refactor Instructions
@@ -33,5 +32,5 @@ Reduce duplication while preserving behavior and public contracts.
## Validation ## Validation
- Confirm no new diagnostics in touched files. - Confirm no new diagnostics in touched files.
- Run: `vp check --no-fmt` - Run: `pnpm exec vp check --no-fmt`
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"` - For significant changes, verify bundle size impact: `pnpm run build 2>&1 | grep "kB"`
@@ -1,8 +1,7 @@
--- ---
applyTo: "e2e/**/*.ts,e2e/**/*.tsx,src/mocks/**/*.ts,playwright.config.ts"
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage." description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
---
# Testing Instructions # Testing Instructions
@@ -27,4 +26,5 @@ Keep tests fast, deterministic, and aligned with core business flows.
## Validation ## Validation
- Run focused tests first, then broader checks if needed. - Run focused tests first, then broader checks if needed.
- Run: `vp check --no-fmt` - Run: `pnpm exec vp check --no-fmt`
- Unit / hook logic (Vitest): `pnpm run test:unit` — tests live under `src/**/*.test.ts`.
-1
View File
@@ -28,7 +28,6 @@ src/locales/*/messages.js
.agent .agent
.pnpm-store .pnpm-store
.cursor
.tanstack .tanstack
# Playwright # Playwright
+2 -1
View File
@@ -80,5 +80,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
- `testing.instructions.md` for Playwright/MSW/test updates - `testing.instructions.md` for Playwright/MSW/test updates
- `api.instructions.md` for API/schema/handler changes - `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup - `refactor.instructions.md` for behavior-preserving cleanup
- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users` / `orders`)
See `.github/instructions/README.md` for usage and authoring rules. See `.github/instructions/README.md` for usage and authoring rules.
+22 -8
View File
@@ -10,6 +10,8 @@
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. 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.
**AI rules (Cursor):** `.cursor/rules/with-lingui-*.mdc` at the repo root mirrors `apps/with-lingui/.github/instructions/*.instructions.md` (same intent; `globs` align with each file’s `applyTo`). Prefer updating **both** when you change guidance.
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design) [![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) [![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) [![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
@@ -80,16 +82,18 @@ vp run test:e2e:core
vp run test:e2e:ui # interactive UI mode vp run test:e2e:ui # interactive UI mode
``` ```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD. `test:e2e:core` runs the scaffold's highest-value flows: login, users CRUD, auth refresh, RBAC, URL state (see `package.json` script list).
## Project Structure ## Project Structure
``` ```
.github/instructions/ # Scoped AI recipes (mirrored in ../../.cursor/rules/with-lingui-*.mdc)
src/ src/
├── api/ # Zod schemas, endpoint constants, type exports ├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.) │ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants │ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants │ ├── user.ts # User CRUD endpoint constants
│ └── orders.ts # Example second resource API (mirror `user.ts`)
├── components/ ├── components/
│ ├── Aurora/ # Login background effect │ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx │ ├── Auth/ # Auth (permission gate): index.tsx
@@ -119,12 +123,13 @@ src/
│ ├── createHandler.ts # Shared MSW success/error/delay helpers │ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus) │ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs) │ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD) │ └── handlers/ # Request handlers (auth, users, orders, …)
├── routes/ # TanStack Router file-based routes ├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n) │ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login) │ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx │ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params │ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/orders/ # Example second resource (mirror users CRUD)
│ ├── _auth/403/index.tsx │ ├── _auth/403/index.tsx
│ ├── login/index.tsx │ ├── login/index.tsx
│ ├── 404/index.tsx │ ├── 404/index.tsx
@@ -135,11 +140,12 @@ src/
│ └── settings.ts # Settings store (darkMode, locale, sidebar) │ └── settings.ts # Settings store (darkMode, locale, sidebar)
└── main.tsx # Entry point (MSW init → React render) └── main.tsx # Entry point (MSW init → React render)
tests/ e2e/ # Playwright E2E tests
└── e2e/ # Playwright E2E tests ├── helpers.ts
├── helpers.ts ├── login.spec.ts
├── login.spec.ts ├── users.spec.ts
└── users.spec.ts └── orders.spec.ts # Example resource smoke test
``` ```
## Internationalization (i18n) ## Internationalization (i18n)
@@ -157,9 +163,16 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
## Extending the template ## Extending the template
- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users` / `orders`), then `pnpm run i18n:extract && pnpm run i18n:compile`, wire Sidebar labels/icons if needed (§6b in that doc), and `pnpm exec vp check --no-fmt`.
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks. - **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). - **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
### AI instruction files
**细则单源**:`apps/with-lingui/.github/instructions/`(`*.instructions.md`),人类与 Agent 以该目录为准维护、评审。
**Cursor**:仓库根 `.cursor/rules/with-lingui-*.mdc` 为**薄规则**(方案 A):只写范围与指向路径,`globs` 与上述文件的 `applyTo` 对齐;全文请在对话中 `@` 对应 `.instructions.md` 或自行打开该文件。
## Developer Notes ## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`. - Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
@@ -173,6 +186,7 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
| `/login` | Login form with validation | | `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards | | `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete | | `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/orders` | Example orders list (title/status) — second CRUD sample alongside `users` |
| `/403` | Forbidden error page | | `/403` | Forbidden error page |
| `/404` | Not found error page | | `/404` | Not found error page |
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+8
View File
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
await page.goto("/users"); await page.goto("/users");
await expect(page).toHaveURL(/users/); await expect(page).toHaveURL(/users/);
} }
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("Order list", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/orders");
await expect(page.getByPlaceholder(/Search Order|搜索/)).toBeVisible({ timeout: 15_000 });
});
test("should display table", async ({ page }) => {
await expect(page.locator(".ant-table-thead").getByText(/Title|标题/)).toBeVisible();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
-2
View File
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
await searchInput.fill("zhao.ming"); await searchInput.fill("zhao.ming");
await searchInput.press("Enter"); await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/); 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(); await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
}); });
@@ -28,7 +27,6 @@ test.describe("User Management", () => {
}); });
}); });
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
test.describe("User Management — role in URL", () => { test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginAsAdmin(page); await loginAsAdmin(page);
+4 -2
View File
@@ -11,8 +11,9 @@
"fmt": "vp fmt", "fmt": "vp fmt",
"lint": "vp lint", "lint": "vp lint",
"check": "vp check --no-fmt", "check": "vp check --no-fmt",
"test:unit": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:core": "playwright test tests/e2e/login.spec.ts tests/e2e/users.spec.ts", "test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"i18n:extract": "vp exec lingui extract", "i18n:extract": "vp exec lingui extract",
"i18n:compile": "vp exec lingui compile" "i18n:compile": "vp exec lingui compile"
@@ -41,7 +42,8 @@
"msw": "^2.12.12", "msw": "^2.12.12",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest", "vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest" "vite-plus": "latest",
"vitest": "3.0.5"
}, },
"packageManager": "pnpm@10.32.1", "packageManager": "pnpm@10.32.1",
"pnpm": { "pnpm": {
+1 -2
View File
@@ -1,12 +1,11 @@
import { defineConfig, devices } from "@playwright/test"; 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 } }; declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI); const ci = Boolean(process.env.CI);
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./e2e",
fullyParallel: true, fullyParallel: true,
forbidOnly: ci, forbidOnly: ci,
retries: ci ? 2 : 0, retries: ci ? 2 : 0,
+10
View File
@@ -0,0 +1,10 @@
import type { CreateOrderRequest, UpdateOrderRequest, Order } from "./schemas";
export const ORDER_ENDPOINTS = {
list: "/api/orders",
create: "/api/orders",
update: (id: string) => `/api/orders/${id}`,
delete: (id: string) => `/api/orders/${id}`,
} as const;
export type { CreateOrderRequest, UpdateOrderRequest, Order };
+36 -1
View File
@@ -11,7 +11,6 @@ export const UserSchema = z.object({
export type User = z.infer<typeof UserSchema>; 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 const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>; export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
@@ -22,6 +21,12 @@ export const AuthTokensSchema = z.object({
export type AuthTokens = z.infer<typeof AuthTokensSchema>; export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({ export const LoginRequestSchema = z.object({
username: z.string().min(1), username: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
@@ -129,3 +134,33 @@ export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>; export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
export const CreateTestRequestSchema = z.object({
title: z.string().min(1),
status: z.enum(["draft", "open", "done"]),
});
export type CreateTestRequest = z.infer<typeof CreateTestRequestSchema>;
export const UpdateTestRequestSchema = CreateTestRequestSchema.partial();
export type UpdateTestRequest = z.infer<typeof UpdateTestRequestSchema>;
export const OrderSchema = z.object({
id: z.string(),
title: z.string(),
status: z.enum(["draft", "open", "done"]),
});
export type Order = z.infer<typeof OrderSchema>;
export const CreateOrderRequestSchema = z.object({
title: z.string().min(1),
status: z.enum(["draft", "open", "done"]),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderRequestSchema>;
export const UpdateOrderRequestSchema = CreateOrderRequestSchema.partial();
export type UpdateOrderRequest = z.infer<typeof UpdateOrderRequestSchema>;
@@ -3,9 +3,6 @@ import { useLingui } from "@lingui/react/macro";
import { BarChart3 } from "lucide-react"; import { BarChart3 } from "lucide-react";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
/**
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
*/
export function DataTableEmpty(): ReactElement { export function DataTableEmpty(): ReactElement {
const { t } = useLingui(); const { t } = useLingui();
const { token } = theme.useToken(); const { token } = theme.useToken();
@@ -29,10 +29,6 @@ export type DataTableSkeletonProps<RecordType extends object> = Pick<
TableProps<RecordType>, TableProps<RecordType>,
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style" "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; rowCount?: number;
}; };
@@ -54,7 +50,6 @@ function isColumnGroup<RecordType extends object>(
return "children" in col && Array.isArray(col.children) && col.children.length > 0; 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 { function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
if (typeof col.width === "number") { if (typeof col.width === "number") {
return Math.min(24, Math.max(20, col.width - 36)); return Math.min(24, Math.max(20, col.width - 36));
@@ -6,24 +6,14 @@ import { DataTableEmpty } from "./DataTableEmpty";
import { DataTableSkeleton } from "./DataTableSkeleton"; import { DataTableSkeleton } from "./DataTableSkeleton";
import "./index.css"; import "./index.css";
/** Merged onto `Table` `rootClassName` with the flex layout class. */
export const DATA_TABLE_ROOT_CLASS = "data-table__table"; export const DATA_TABLE_ROOT_CLASS = "data-table__table";
export type DataTableProps<RecordType extends object = object> = { 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; lockScrollHeight?: boolean;
/** Outer `max-height` (px) */
maxHeight?: number; maxHeight?: number;
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
frameHeight?: number; frameHeight?: number;
/** Flex column wrapping frame + optional `bottomExtra` */
layoutRef?: Ref<HTMLDivElement | null>; layoutRef?: Ref<HTMLDivElement | null>;
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
frameRef?: Ref<HTMLDivElement | null>; frameRef?: Ref<HTMLDivElement | null>;
/** Optional content below the bordered table frame */
bottomExtra?: ReactNode; bottomExtra?: ReactNode;
} & TableProps<RecordType>; } & TableProps<RecordType>;
@@ -4,18 +4,14 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } f
export type FilterToolbarSlot = { export type FilterToolbarSlot = {
key: string; key: string;
/** Used to decide how many filters fit in one row before the rest go into the popover */
minWidth: number; minWidth: number;
children: React.ReactNode; children: React.ReactNode;
}; };
export type FilterToolbarProps = { export type FilterToolbarProps = {
slots: FilterToolbarSlot[]; slots: FilterToolbarSlot[];
/** Right side: primary actions (not collapsed) */
actions: React.ReactNode; actions: React.ReactNode;
/** Reserve width for the “more filters” control when some slots are in the popover */
collapseTriggerReserve?: number; collapseTriggerReserve?: number;
/** Accessible label + optional popover title */
moreFiltersLabel: React.ReactNode; moreFiltersLabel: React.ReactNode;
moreFiltersTitle?: React.ReactNode; moreFiltersTitle?: React.ReactNode;
}; };
@@ -22,10 +22,6 @@ function normalizePath(pathname: string): string {
} }
export type HeaderProps = { 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; showBreadcrumb?: boolean;
}; };
@@ -9,6 +9,7 @@ import {
CircleDashed, CircleDashed,
Folder, Folder,
Home, Home,
Package,
PanelLeft, PanelLeft,
SlidersHorizontal, SlidersHorizontal,
Star, Star,
@@ -26,12 +27,12 @@ import { UserMenu } from "../UserMenu";
import "./index.css"; import "./index.css";
const { Sider } = Layout; const { Sider } = Layout;
/** API menu `name` → Lingui descriptors (extracted into catalogs). */
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = { const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
Platform: msg`Platform`, Platform: msg`Platform`,
Projects: msg`Projects`, Projects: msg`Projects`,
Dashboard: msg`Dashboard`, Dashboard: msg`Dashboard`,
Users: msg`Users`, Users: msg`Users`,
Orders: msg`Orders`,
"Design Engineering": msg`Design Engineering`, "Design Engineering": msg`Design Engineering`,
"Sales & Marketing": msg`Sales & Marketing`, "Sales & Marketing": msg`Sales & Marketing`,
}; };
@@ -47,7 +48,6 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideLayoutDashboard: Home, IconLucideLayoutDashboard: Home,
IconLucideUsers: User, IconLucideUsers: User,
IconLucideUserList: Users, IconLucideUserList: Users,
/** Back-compat for older menu payloads still using IconLucideHistory */
IconLucideHistory: Users, IconLucideHistory: Users,
IconLucideStar: Star, IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal, IconLucideSettings: SlidersHorizontal,
@@ -55,6 +55,7 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideBookOpen: Book, IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder, IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap, IconLucideSparkles: Zap,
IconLucidePackage: Package,
}; };
function renderMenuIcon(icon: string | null, size = 16) { function renderMenuIcon(icon: string | null, size = 16) {
@@ -179,7 +180,6 @@ export function Sidebar() {
} }
}, [isMobile, setSidebarCollapsed]); }, [isMobile, setSidebarCollapsed]);
/** Merge open keys required by the route with any submenu the user already expanded. */
useEffect(() => { useEffect(() => {
setOpenKeys((prev) => { setOpenKeys((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -219,7 +219,6 @@ export function Sidebar() {
align={isCollapsed ? "center" : "stretch"} align={isCollapsed ? "center" : "stretch"}
style={{ style={{
paddingBlock: token.paddingSM, 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, paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
minHeight: 64, minHeight: 64,
flexShrink: 0, flexShrink: 0,
@@ -237,7 +236,6 @@ export function Sidebar() {
minWidth: 0, minWidth: 0,
maxWidth: "100%", maxWidth: "100%",
boxSizing: "border-box", boxSizing: "border-box",
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
paddingInline: isCollapsed ? 0 : token.paddingXS, paddingInline: isCollapsed ? 0 : token.paddingXS,
minHeight: 40, minHeight: 40,
justifyContent: isCollapsed ? "center" : "flex-start", justifyContent: isCollapsed ? "center" : "flex-start",
@@ -348,7 +346,12 @@ export function Sidebar() {
background: "transparent", background: "transparent",
}} }}
/> />
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} /> <UserMenu
collapsed={isCollapsed}
user={user}
userMenuItems={userMenuItems}
accountMenuTriggerAriaLabel={t`Open account menu`}
/>
</Flex> </Flex>
); );
@@ -10,9 +10,10 @@ interface UserMenuProps {
collapsed: boolean; collapsed: boolean;
user: User | null; user: User | null;
userMenuItems: MenuProps["items"]; userMenuItems: MenuProps["items"];
accountMenuTriggerAriaLabel: string;
} }
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) { export function UserMenu({ collapsed, user, userMenuItems, accountMenuTriggerAriaLabel }: UserMenuProps) {
const { token } = theme.useToken(); const { token } = theme.useToken();
const avatarSrc = (user?.avatar ?? "").trim() || undefined; const avatarSrc = (user?.avatar ?? "").trim() || undefined;
@@ -33,7 +34,12 @@ export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
); );
const moreButton = ( const moreButton = (
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} /> <Button
type="text"
size="small"
icon={<MoreVertical size={token.fontSize} />}
aria-label={accountMenuTriggerAriaLabel}
/>
); );
return ( return (
@@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro";
import { ArrowLeft, Home, SearchX } from "lucide-react"; import { ArrowLeft, Home, SearchX } from "lucide-react";
import { Button, Flex, Result, Space, theme } from "antd"; import { Button, Flex, Result, Space, theme } from "antd";
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
export function NotFound() { export function NotFound() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLingui(); const { t } = useLingui();
@@ -25,7 +24,6 @@ export function NotFound() {
<Flex <Flex
vertical vertical
style={{ style={{
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
height: "100dvh", height: "100dvh",
maxHeight: "100dvh", maxHeight: "100dvh",
overflow: "hidden", overflow: "hidden",
@@ -0,0 +1,26 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const { t } = useLingui();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title={t`Something went wrong`}
subTitle={detail || t`Please try again or return to the dashboard.`}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
{t`Retry`}
</Button>
}
/>
</div>
);
}
+15 -19
View File
@@ -1,10 +1,15 @@
import { theme } from "antd"; import { theme } from "antd";
import type { ConfigProviderProps, ThemeConfig } from "antd"; import type { ConfigProviderProps, ThemeConfig } from "antd";
/** export function readBrandPrimary(): string {
* Common theme token builders const raw = import.meta.env.VITE_BRAND_PRIMARY as string | undefined;
* Reduces duplication between light and dark theme definitions if (raw && /^#[0-9a-fA-F]{6}$/.test(raw.trim())) {
*/ return raw.trim();
}
return "#1677ff";
}
const BRAND_PRIMARY = readBrandPrimary();
export const SHARED_DESIGN_TOKENS = { export const SHARED_DESIGN_TOKENS = {
fontFamily: fontFamily:
@@ -26,9 +31,6 @@ export const CONTROL_COMPONENTS = {
}, },
} as const; } as const;
/**
* Calculate table row selected color based on theme algorithm
*/
export function buildTableTokens(cfg: ThemeConfig) { export function buildTableTokens(cfg: ThemeConfig) {
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter; const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
return { return {
@@ -37,9 +39,6 @@ export function buildTableTokens(cfg: ThemeConfig) {
}; };
} }
/**
* Build menu tokens for light theme
*/
export const MENU_LIGHT = { export const MENU_LIGHT = {
itemSelectedBg: "rgba(0, 0, 0, 0.06)", itemSelectedBg: "rgba(0, 0, 0, 0.06)",
itemSelectedColor: "rgba(0, 0, 0, 0.88)", itemSelectedColor: "rgba(0, 0, 0, 0.88)",
@@ -53,9 +52,6 @@ export const MENU_LIGHT = {
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)", horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
} as const; } as const;
/**
* Build menu tokens for dark theme
*/
export const MENU_DARK = { export const MENU_DARK = {
itemSelectedBg: "rgba(255, 255, 255, 0.08)", itemSelectedBg: "rgba(255, 255, 255, 0.08)",
itemSelectedColor: "rgba(255, 255, 255, 0.85)", itemSelectedColor: "rgba(255, 255, 255, 0.85)",
@@ -69,12 +65,12 @@ export const MENU_DARK = {
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)", horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
} as const; } as const;
/**
* Build light theme config
*/
export function buildLightThemeConfig(): ConfigProviderProps { export function buildLightThemeConfig(): ConfigProviderProps {
const lightSeed: ThemeConfig["token"] = { const lightSeed: ThemeConfig["token"] = {
colorBgLayout: "#ffffff", colorBgLayout: "#ffffff",
colorPrimary: BRAND_PRIMARY,
colorLink: BRAND_PRIMARY,
colorInfo: BRAND_PRIMARY,
...SHARED_DESIGN_TOKENS, ...SHARED_DESIGN_TOKENS,
}; };
@@ -94,11 +90,11 @@ export function buildLightThemeConfig(): ConfigProviderProps {
}; };
} }
/**
* Build dark theme config
*/
export function buildDarkThemeConfig(): ConfigProviderProps { export function buildDarkThemeConfig(): ConfigProviderProps {
const darkSeed: ThemeConfig["token"] = { const darkSeed: ThemeConfig["token"] = {
colorPrimary: BRAND_PRIMARY,
colorLink: BRAND_PRIMARY,
colorInfo: BRAND_PRIMARY,
...SHARED_DESIGN_TOKENS, ...SHARED_DESIGN_TOKENS,
}; };
@@ -3,10 +3,6 @@ import type { ConfigProviderProps } from "antd";
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders"; import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
/**
* Theme selection for ConfigProvider: light/dark share radius, font, control tweaks.
* @see https://ant.design/docs/react/customize-theme
*/
export function useAppTheme(): ConfigProviderProps { export function useAppTheme(): ConfigProviderProps {
const darkMode = useSettingsStore((s) => s.darkMode); const darkMode = useSettingsStore((s) => s.darkMode);
@@ -0,0 +1,68 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
import { useLingui } from "@lingui/react/macro";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const { t } = useLingui();
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: t`Created successfully`, key: keys.create });
},
onError: () => {
message.error({ content: t`Create failed`, key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: t`Updating…`, key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: t`Updated successfully`, key: keys.update });
},
onError: () => {
message.error({ content: t`Update failed`, key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: t`Deleting…`, key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: t`Deleted successfully`, key: keys.delete });
},
onError: () => {
message.error({ content: t`Delete failed`, key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message, t],
);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
+106 -12
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = { type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void; onMutate?: (values: TValues) => void;
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
onError?: (values: TValues) => void; onError?: (values: TValues) => void;
}; };
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = { export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
queryKey: readonly unknown[]; queryKey: readonly unknown[];
invalidateKey: readonly unknown[]; invalidateKey?: readonly unknown[];
queryFn: () => Promise<unknown>; queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData; select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>; createFn: (values: TCreateValues) => Promise<unknown>;
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
createLifecycle?: MutationLifecycle<TCreateValues>; createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>; updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>; deleteLifecycle?: MutationLifecycle<string>;
optimistic?: OptimisticFlags;
}; };
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>( export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
data: TListData | undefined;
isLoading: boolean;
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
deleteMutation: UseMutationResult<unknown, Error, string>;
};
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
TListData,
TCreateValues,
TUpdateValues
>;
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
export function applyOptimisticListUpdate<TItem extends { id: string }, TUpdate extends { id: string }>(
data: ResourceListData<TItem>,
values: TUpdate,
): { list: TItem[]; total: number } {
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
return {
list: data.list.map((row) =>
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
),
total: data.total,
};
}
export function applyOptimisticListDelete<TItem extends { id: string }>(
data: ResourceListData<TItem>,
id: string,
): { list: TItem[]; total: number } {
return {
list: data.list.filter((row) => row.id !== id),
total: Math.max(0, data.total - 1),
};
}
export function useResourceCRUD<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>, options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
) { ): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
const queryKey = options.queryKey;
const opt = options.optimistic;
const query = useQuery({ const query = useQuery({
queryKey: options.queryKey, queryKey,
queryFn: options.queryFn, queryFn: options.queryFn,
select: options.select, select: options.select,
}); });
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
options.createLifecycle?.onMutate?.(values); options.createLifecycle?.onMutate?.(values);
}, },
onSuccess: (_data, values) => { onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.createLifecycle?.onSuccess?.(values); options.createLifecycle?.onSuccess?.(values);
}, },
onError: (_error, values) => { onError: (_error, values) => {
@@ -46,28 +109,59 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: options.updateFn, mutationFn: options.updateFn,
onMutate: (values) => { onMutate: async (values): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.update && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListUpdate(
previous as ResourceListData<{ id: string }>,
values as { id: string } & Record<string, unknown>,
) as unknown as TListData,
);
}
options.updateLifecycle?.onMutate?.(values); options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
}, },
onSuccess: (_data, values) => { onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.updateLifecycle?.onSuccess?.(values); options.updateLifecycle?.onSuccess?.(values);
}, },
onError: (_error, values) => { onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values); options.updateLifecycle?.onError?.(values);
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: options.deleteFn, mutationFn: options.deleteFn,
onMutate: (id) => { onMutate: async (id): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.delete && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListDelete(previous as ResourceListData<{ id: string }>, id) as unknown as TListData,
);
}
options.deleteLifecycle?.onMutate?.(id); options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
}, },
onSuccess: (_data, id) => { onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey }); void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.deleteLifecycle?.onSuccess?.(id); options.deleteLifecycle?.onSuccess?.(id);
}, },
onError: (_error, id) => { onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id); options.deleteLifecycle?.onError?.(id);
}, },
}); });
@@ -0,0 +1,113 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const { pageShellRef, toolbarRef, middleRef, tableFrameRef, marginLG, rowCount, isLoading, showPagination } =
args;
const tableAvailableRef = useRef(0);
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);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
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 - 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;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
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;
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, rowCount, isLoading, marginLG, pageShellRef, toolbarRef, middleRef, tableFrameRef]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
+148 -74
View File
@@ -17,62 +17,65 @@ msgstr ""
#~ msgid "{n} rows" #~ msgid "{n} rows"
#~ msgstr "{n} rows" #~ msgstr "{n} rows"
#: src/routes/_auth/users/index.tsx:386 #: src/routes/_auth/orders/index.tsx:186
#: src/routes/_auth/users/index.tsx:262
msgid "{total} rows" msgid "{total} rows"
msgstr "{total} rows" msgstr "{total} rows"
#: src/routes/_auth/dashboard/index.tsx:35 #: src/routes/_auth/dashboard/index.tsx:144
msgid "+180.1% from last month" msgid "+180.1% from last month"
msgstr "+180.1% from last month" msgstr "+180.1% from last month"
#: src/routes/_auth/dashboard/index.tsx:41 #: src/routes/_auth/dashboard/index.tsx:150
msgid "+19% from last month" msgid "+19% from last month"
msgstr "+19% from last month" msgstr "+19% from last month"
#: src/routes/_auth/dashboard/index.tsx:29 #: src/routes/_auth/dashboard/index.tsx:138
msgid "+20.1% from last month" msgid "+20.1% from last month"
msgstr "+20.1% from last month" msgstr "+20.1% from last month"
#: src/routes/_auth/dashboard/index.tsx:47 #: src/routes/_auth/dashboard/index.tsx:156
msgid "+201 since last hour" msgid "+201 since last hour"
msgstr "+201 since last hour" msgstr "+201 since last hour"
#: src/components/Layout/Header/index.tsx:78 #: src/components/Layout/Header/index.tsx:83
msgid "403" msgid "403"
msgstr "403" msgstr "403"
#: src/routes/_auth/dashboard/index.tsx:45 #: src/routes/_auth/dashboard/index.tsx:154
msgid "Active Now" msgid "Active Now"
msgstr "Active Now" msgstr "Active Now"
#: src/routes/_auth/users/-FormModal.tsx:58 #: src/routes/_auth/users/-FormModal.tsx:52
#: src/routes/_auth/users/-Toolbar.tsx:64 #: src/routes/_auth/users/-Toolbar.tsx:64
msgid "Admin" msgid "Admin"
msgstr "Admin" msgstr "Admin"
#: src/routes/_auth/users/index.tsx:164 #: src/routes/_auth/orders/index.tsx:112
#: src/routes/_auth/users/index.tsx:124
msgid "Are you absolutely sure?" msgid "Are you absolutely sure?"
msgstr "Are you absolutely sure?" msgstr "Are you absolutely sure?"
#: src/routes/login/index.tsx:185 #: src/routes/login/index.tsx:181
msgid "Auto login" msgid "Auto login"
msgstr "Auto login" msgstr "Auto login"
#: src/components/NotFound/index.tsx:54 #: src/components/NotFound/index.tsx:59
#: src/routes/_auth/403/index.tsx:40 #: src/routes/_auth/403/index.tsx:35
msgid "Back to Home" msgid "Back to Home"
msgstr "Back to Home" msgstr "Back to Home"
#: src/routes/_auth/users/-FormModal.tsx:32 #: src/routes/_auth/orders/index.tsx:116
#: src/routes/_auth/users/index.tsx:168 #: src/routes/_auth/users/-FormModal.tsx:31
#: src/routes/_auth/users/index.tsx:128
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
#: src/routes/_auth/dashboard/index.tsx:134 #: src/routes/_auth/dashboard/index.tsx:250
msgid "Chart Placeholder" msgid "Chart Placeholder"
msgstr "Chart Placeholder" msgstr "Chart Placeholder"
#: src/routes/_auth/users/index.tsx:106 #: src/hooks/useCrudToasts.ts:43
msgid "Create failed" msgid "Create failed"
msgstr "Create failed" msgstr "Create failed"
@@ -80,53 +83,68 @@ msgstr "Create failed"
msgid "Create User" msgid "Create User"
msgstr "Create User" msgstr "Create User"
#: src/routes/_auth/users/index.tsx:100 #: src/hooks/useCrudToasts.ts:40
msgid "Created successfully" msgid "Created successfully"
msgstr "Created successfully" msgstr "Created successfully"
#: src/components/Layout/Header/index.tsx:74 #: src/components/Layout/Header/index.tsx:79
#: src/components/Layout/Header/index.tsx:87 #: src/components/Layout/Header/index.tsx:92
#: src/components/Layout/Header/index.tsx:91 #: src/components/Layout/Header/index.tsx:96
#: src/components/Layout/Sidebar/index.tsx:32 #: src/components/Layout/Sidebar/index.tsx:34
msgid "Dashboard" msgid "Dashboard"
msgstr "Dashboard" msgstr "Dashboard"
#: src/routes/_auth/users/index.tsx:166 #: src/routes/_auth/orders/index.tsx:114
#: src/routes/_auth/users/index.tsx:257 #: src/routes/_auth/orders/index.tsx:152
#: src/routes/_auth/users/index.tsx:126
#: src/routes/_auth/users/index.tsx:223
msgid "Delete" msgid "Delete"
msgstr "Delete" msgstr "Delete"
#: src/routes/_auth/users/index.tsx:156 #: src/hooks/useCrudToasts.ts:65
msgid "Delete failed" msgid "Delete failed"
msgstr "Delete failed" msgstr "Delete failed"
#: src/routes/_auth/users/index.tsx:149 #: src/hooks/useCrudToasts.ts:62
msgid "Deleted successfully" msgid "Deleted successfully"
msgstr "Deleted successfully" msgstr "Deleted successfully"
#: src/routes/_auth/users/index.tsx:142 #: src/hooks/useCrudToasts.ts:59
msgid "Deleting…" msgid "Deleting…"
msgstr "Deleting…" msgstr "Deleting…"
#: src/components/Layout/Sidebar/index.tsx:35 #: src/components/Layout/Sidebar/index.tsx:37
msgid "Design Engineering" msgid "Design Engineering"
msgstr "Design Engineering" msgstr "Design Engineering"
#: src/routes/_auth/users/index.tsx:247 #: src/routes/_auth/orders/-FormModal.tsx:36
msgid "Done"
msgstr "Done"
#: src/routes/_auth/orders/-FormModal.tsx:34
msgid "Draft"
msgstr "Draft"
#: src/routes/_auth/orders/index.tsx:142
#: src/routes/_auth/users/index.tsx:213
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
#: src/routes/_auth/orders/-FormModal.tsx:19
msgid "Edit Order"
msgstr "Edit Order"
#: src/routes/_auth/users/-FormModal.tsx:29 #: src/routes/_auth/users/-FormModal.tsx:29
msgid "Edit User" msgid "Edit User"
msgstr "Edit User" msgstr "Edit User"
#: src/routes/_auth/users/-FormModal.tsx:59 #: src/routes/_auth/users/-FormModal.tsx:53
#: src/routes/_auth/users/-Toolbar.tsx:65 #: src/routes/_auth/users/-Toolbar.tsx:65
msgid "Editor" msgid "Editor"
msgstr "Editor" msgstr "Editor"
#: src/routes/_auth/users/-FormModal.tsx:63 #: src/routes/_auth/users/-FormModal.tsx:57
#: src/routes/_auth/users/index.tsx:202 #: src/routes/_auth/users/index.tsx:168
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
@@ -134,27 +152,34 @@ msgstr "Email"
#~ msgid "Filter by role" #~ msgid "Filter by role"
#~ msgstr "Filter by role" #~ msgstr "Filter by role"
#: src/routes/login/index.tsx:192 #: src/routes/login/index.tsx:188
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Forgot password?" msgstr "Forgot password?"
#: src/components/NotFound/index.tsx:57 #: src/components/NotFound/index.tsx:62
msgid "Go back" msgid "Go back"
msgstr "Go back" msgstr "Go back"
#: src/routes/login/index.tsx:68 #: src/routes/login/index.tsx:54
msgid "Login failed" msgid "Login failed"
msgstr "Login failed" msgstr "Login failed"
#: src/routes/login/index.tsx:64 #: src/routes/login/index.tsx:50
msgid "Login successful" msgid "Login successful"
msgstr "Login successful" msgstr "Login successful"
#: src/routes/_auth/orders/-Toolbar.tsx:54
#: src/routes/_auth/orders/-Toolbar.tsx:55
#: src/routes/_auth/users/-Toolbar.tsx:92 #: src/routes/_auth/users/-Toolbar.tsx:92
#: src/routes/_auth/users/-Toolbar.tsx:93 #: src/routes/_auth/users/-Toolbar.tsx:93
msgid "More filters" msgid "More filters"
msgstr "More filters" msgstr "More filters"
#: src/routes/_auth/orders/-FormModal.tsx:19
#: src/routes/_auth/orders/-Toolbar.tsx:51
msgid "New Order"
msgstr "New Order"
#: src/routes/_auth/users/-FormModal.tsx:29 #: src/routes/_auth/users/-FormModal.tsx:29
msgid "New User" msgid "New User"
msgstr "New User" msgstr "New User"
@@ -167,11 +192,23 @@ msgstr "No data"
msgid "Nothing to show in this list yet" msgid "Nothing to show in this list yet"
msgstr "Nothing to show in this list yet" msgstr "Nothing to show in this list yet"
#: src/routes/_auth/users/-FormModal.tsx:31 #: src/routes/_auth/users/-FormModal.tsx:30
msgid "OK" msgid "OK"
msgstr "OK" msgstr "OK"
#: src/routes/_auth/dashboard/index.tsx:115 #: src/routes/_auth/orders/-FormModal.tsx:35
msgid "Open"
msgstr "Open"
#: src/components/Layout/Sidebar/index.tsx:358
msgid "Open account menu"
msgstr "Open account menu"
#: src/components/Layout/Sidebar/index.tsx:36
msgid "Orders"
msgstr "Orders"
#: src/routes/_auth/dashboard/index.tsx:233
msgid "Overview" msgid "Overview"
msgstr "Overview" msgstr "Overview"
@@ -179,56 +216,74 @@ msgstr "Overview"
#~ msgid "Page not found" #~ msgid "Page not found"
#~ msgstr "Page not found" #~ msgstr "Page not found"
#: src/routes/login/index.tsx:171 #: src/routes/login/index.tsx:162
#: src/routes/login/index.tsx:168
msgid "Password" msgid "Password"
msgstr "Password" msgstr "Password"
#: src/components/Layout/Sidebar/index.tsx:30 #: src/components/Layout/Sidebar/index.tsx:32
msgid "Platform" msgid "Platform"
msgstr "Platform" msgstr "Platform"
#: src/routes/login/index.tsx:172 #: src/routes/login/index.tsx:163
msgid "Please enter password" msgid "Please enter password"
msgstr "Please enter password" msgstr "Please enter password"
#: src/routes/_auth/users/-FormModal.tsx:46 #: src/routes/_auth/users/-FormModal.tsx:40
#: src/routes/login/index.tsx:164 #: src/routes/login/index.tsx:150
msgid "Please enter username" msgid "Please enter username"
msgstr "Please enter username" msgstr "Please enter username"
#: src/routes/_auth/users/-FormModal.tsx:53 #: src/routes/_auth/users/-FormModal.tsx:47
msgid "Please select roles" msgid "Please select roles"
msgstr "Please select roles" msgstr "Please select roles"
#: src/components/Layout/AppFooter/index.tsx:36 #: src/components/RouteError.tsx:17
msgid "Please try again or return to the dashboard."
msgstr "Please try again or return to the dashboard."
#: src/components/Layout/AppFooter/index.tsx:23
msgid "Powered by" msgid "Powered by"
msgstr "Powered by" msgstr "Powered by"
#: src/components/Layout/Sidebar/index.tsx:31 #: src/components/Layout/Sidebar/index.tsx:33
msgid "Projects" msgid "Projects"
msgstr "Projects" msgstr "Projects"
#: src/routes/_auth/dashboard/index.tsx:142 #: src/routes/_auth/dashboard/index.tsx:259
msgid "Recent Sales" msgid "Recent Sales"
msgstr "Recent Sales" msgstr "Recent Sales"
#: src/components/RouteError.tsx:20
msgid "Retry"
msgstr "Retry"
#: src/routes/_auth/users/-Toolbar.tsx:58 #: src/routes/_auth/users/-Toolbar.tsx:58
msgid "Role" msgid "Role"
msgstr "Role" msgstr "Role"
#: src/routes/_auth/users/-FormModal.tsx:52 #: src/routes/_auth/users/-FormModal.tsx:46
#: src/routes/_auth/users/index.tsx:209 #: src/routes/_auth/users/index.tsx:175
msgid "Roles" msgid "Roles"
msgstr "Roles" msgstr "Roles"
#: src/routes/_auth/dashboard/index.tsx:39 #: src/routes/_auth/orders/index.tsx:160
#: src/routes/_auth/users/index.tsx:234
msgid "Row actions"
msgstr "Row actions"
#: src/routes/_auth/dashboard/index.tsx:148
msgid "Sales" msgid "Sales"
msgstr "Sales" msgstr "Sales"
#: src/components/Layout/Sidebar/index.tsx:36 #: src/components/Layout/Sidebar/index.tsx:38
msgid "Sales & Marketing" msgid "Sales & Marketing"
msgstr "Sales & Marketing" msgstr "Sales & Marketing"
#: src/routes/_auth/orders/-Toolbar.tsx:32
msgid "Search Order"
msgstr "Search Order"
#: src/routes/_auth/users/-Toolbar.tsx:43 #: src/routes/_auth/users/-Toolbar.tsx:43
msgid "Search User" msgid "Search User"
msgstr "Search User" msgstr "Search User"
@@ -237,7 +292,7 @@ msgstr "Search User"
#~ msgid "Search username or email" #~ msgid "Search username or email"
#~ msgstr "Search username or email" #~ msgstr "Search username or email"
#: src/routes/login/index.tsx:204 #: src/routes/login/index.tsx:200
msgid "Sign In" msgid "Sign In"
msgstr "Sign In" msgstr "Sign In"
@@ -245,70 +300,89 @@ msgstr "Sign In"
#~ msgid "Sign in with your credentials." #~ msgid "Sign in with your credentials."
#~ msgstr "Sign in with your credentials." #~ msgstr "Sign in with your credentials."
#: src/components/Layout/Sidebar/index.tsx:194 #: src/components/Layout/Sidebar/index.tsx:197
msgid "Sign Out" msgid "Sign Out"
msgstr "Sign Out" msgstr "Sign Out"
#: src/components/NotFound/index.tsx:50 #: src/components/RouteError.tsx:16
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/components/NotFound/index.tsx:55
msgid "Sorry, the page you visited does not exist." msgid "Sorry, the page you visited does not exist."
msgstr "Sorry, the page you visited does not exist." msgstr "Sorry, the page you visited does not exist."
#: src/routes/_auth/403/index.tsx:37 #: src/routes/_auth/403/index.tsx:32
msgid "Sorry, you don't have permission to access this page." msgid "Sorry, you don't have permission to access this page."
msgstr "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 #: src/routes/_auth/orders/-FormModal.tsx:31
#: src/routes/_auth/orders/index.tsx:125
msgid "Status"
msgstr "Status"
#: src/routes/_auth/dashboard/index.tsx:142
msgid "Subscriptions" msgid "Subscriptions"
msgstr "Subscriptions" msgstr "Subscriptions"
#: src/components/Layout/Header/index.tsx:145 #: src/components/Layout/Header/index.tsx:153
#: src/routes/login/index.tsx:225 #: src/routes/login/index.tsx:221
msgid "Switch language" msgid "Switch language"
msgstr "Switch language" msgstr "Switch language"
#: src/routes/_auth/users/index.tsx:165 #: src/routes/_auth/orders/index.tsx:113
msgid "This action cannot be undone."
msgstr "This action cannot be undone."
#: src/routes/_auth/users/index.tsx:125
msgid "This action cannot be undone. This will permanently delete the user." msgid "This action cannot be undone. This will permanently delete the user."
msgstr "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/routes/_auth/orders/-FormModal.tsx:28
#: src/components/Layout/Sidebar/index.tsx:263 #: src/routes/_auth/orders/index.tsx:123
#: src/components/Layout/Sidebar/index.tsx:311 msgid "Title"
msgstr "Title"
#: src/components/Layout/Header/index.tsx:138
#: src/components/Layout/Sidebar/index.tsx:271
#: src/components/Layout/Sidebar/index.tsx:319
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "Toggle sidebar" msgstr "Toggle sidebar"
#: src/components/Layout/Header/index.tsx:151 #: src/components/Layout/Header/index.tsx:159
#: src/routes/login/index.tsx:232 #: src/routes/login/index.tsx:228
msgid "Toggle Theme" msgid "Toggle Theme"
msgstr "Toggle Theme" msgstr "Toggle Theme"
#: src/routes/_auth/dashboard/index.tsx:27 #: src/routes/_auth/dashboard/index.tsx:136
msgid "Total Revenue" msgid "Total Revenue"
msgstr "Total Revenue" msgstr "Total Revenue"
#: src/routes/_auth/users/index.tsx:132 #: src/hooks/useCrudToasts.ts:54
msgid "Update failed" msgid "Update failed"
msgstr "Update failed" msgstr "Update failed"
#: src/routes/_auth/users/index.tsx:122 #: src/hooks/useCrudToasts.ts:51
msgid "Updated successfully" msgid "Updated successfully"
msgstr "Updated successfully" msgstr "Updated successfully"
#: src/routes/_auth/users/index.tsx:115 #: src/hooks/useCrudToasts.ts:48
msgid "Updating…" msgid "Updating…"
msgstr "Updating…" msgstr "Updating…"
#: src/components/Layout/Sidebar/index.tsx:34 #: src/components/Layout/Sidebar/index.tsx:34
msgid "User List" #~ msgid "User List"
msgstr "User List" #~ msgstr "User List"
#: src/routes/_auth/users/-FormModal.tsx:45 #: src/routes/_auth/users/-FormModal.tsx:39
#: src/routes/_auth/users/index.tsx:182 #: src/routes/_auth/users/index.tsx:142
#: src/routes/login/index.tsx:163 #: src/routes/login/index.tsx:149
#: src/routes/login/index.tsx:154
msgid "Username" msgid "Username"
msgstr "Username" msgstr "Username"
#: src/components/Layout/Header/index.tsx:76 #: src/components/Layout/Header/index.tsx:81
#: src/components/Layout/Sidebar/index.tsx:33 #: src/components/Layout/Sidebar/index.tsx:35
msgid "Users" msgid "Users"
msgstr "Users" msgstr "Users"
@@ -3,10 +3,6 @@ import type { Locale } from "@/stores/settings";
const catalogCache: Partial<Record<Locale, Messages>> = {}; const catalogCache: Partial<Record<Locale, Messages>> = {};
/**
* Dynamically loads compiled Lingui messages for the given locale.
* Results are cached so repeated calls (e.g. main + root) do not refetch chunks.
*/
export async function loadLocaleCatalog(locale: Locale): Promise<Messages> { export async function loadLocaleCatalog(locale: Locale): Promise<Messages> {
const hit = catalogCache[locale]; const hit = catalogCache[locale];
if (hit) { if (hit) {
+148 -74
View File
@@ -17,62 +17,65 @@ msgstr ""
#~ msgid "{n} rows" #~ msgid "{n} rows"
#~ msgstr "{n} 行" #~ msgstr "{n} 行"
#: src/routes/_auth/users/index.tsx:386 #: src/routes/_auth/orders/index.tsx:186
#: src/routes/_auth/users/index.tsx:262
msgid "{total} rows" msgid "{total} rows"
msgstr "共 {total} 条" msgstr "共 {total} 条"
#: src/routes/_auth/dashboard/index.tsx:35 #: src/routes/_auth/dashboard/index.tsx:144
msgid "+180.1% from last month" msgid "+180.1% from last month"
msgstr "较上月 +180.1%" msgstr "较上月 +180.1%"
#: src/routes/_auth/dashboard/index.tsx:41 #: src/routes/_auth/dashboard/index.tsx:150
msgid "+19% from last month" msgid "+19% from last month"
msgstr "较上月 +19%" msgstr "较上月 +19%"
#: src/routes/_auth/dashboard/index.tsx:29 #: src/routes/_auth/dashboard/index.tsx:138
msgid "+20.1% from last month" msgid "+20.1% from last month"
msgstr "较上月 +20.1%" msgstr "较上月 +20.1%"
#: src/routes/_auth/dashboard/index.tsx:47 #: src/routes/_auth/dashboard/index.tsx:156
msgid "+201 since last hour" msgid "+201 since last hour"
msgstr "较上一小时 +201" msgstr "较上一小时 +201"
#: src/components/Layout/Header/index.tsx:78 #: src/components/Layout/Header/index.tsx:83
msgid "403" msgid "403"
msgstr "403" msgstr "403"
#: src/routes/_auth/dashboard/index.tsx:45 #: src/routes/_auth/dashboard/index.tsx:154
msgid "Active Now" msgid "Active Now"
msgstr "当前在线" msgstr "当前在线"
#: src/routes/_auth/users/-FormModal.tsx:58 #: src/routes/_auth/users/-FormModal.tsx:52
#: src/routes/_auth/users/-Toolbar.tsx:64 #: src/routes/_auth/users/-Toolbar.tsx:64
msgid "Admin" msgid "Admin"
msgstr "管理员" msgstr "管理员"
#: src/routes/_auth/users/index.tsx:164 #: src/routes/_auth/orders/index.tsx:112
#: src/routes/_auth/users/index.tsx:124
msgid "Are you absolutely sure?" msgid "Are you absolutely sure?"
msgstr "确定要执行此操作吗?" msgstr "确定要执行此操作吗?"
#: src/routes/login/index.tsx:185 #: src/routes/login/index.tsx:181
msgid "Auto login" msgid "Auto login"
msgstr "自动登录" msgstr "自动登录"
#: src/components/NotFound/index.tsx:54 #: src/components/NotFound/index.tsx:59
#: src/routes/_auth/403/index.tsx:40 #: src/routes/_auth/403/index.tsx:35
msgid "Back to Home" msgid "Back to Home"
msgstr "返回首页" msgstr "返回首页"
#: src/routes/_auth/users/-FormModal.tsx:32 #: src/routes/_auth/orders/index.tsx:116
#: src/routes/_auth/users/index.tsx:168 #: src/routes/_auth/users/-FormModal.tsx:31
#: src/routes/_auth/users/index.tsx:128
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
#: src/routes/_auth/dashboard/index.tsx:134 #: src/routes/_auth/dashboard/index.tsx:250
msgid "Chart Placeholder" msgid "Chart Placeholder"
msgstr "图表占位" msgstr "图表占位"
#: src/routes/_auth/users/index.tsx:106 #: src/hooks/useCrudToasts.ts:43
msgid "Create failed" msgid "Create failed"
msgstr "创建失败" msgstr "创建失败"
@@ -80,53 +83,68 @@ msgstr "创建失败"
msgid "Create User" msgid "Create User"
msgstr "创建用户" msgstr "创建用户"
#: src/routes/_auth/users/index.tsx:100 #: src/hooks/useCrudToasts.ts:40
msgid "Created successfully" msgid "Created successfully"
msgstr "创建成功" msgstr "创建成功"
#: src/components/Layout/Header/index.tsx:74 #: src/components/Layout/Header/index.tsx:79
#: src/components/Layout/Header/index.tsx:87 #: src/components/Layout/Header/index.tsx:92
#: src/components/Layout/Header/index.tsx:91 #: src/components/Layout/Header/index.tsx:96
#: src/components/Layout/Sidebar/index.tsx:32 #: src/components/Layout/Sidebar/index.tsx:34
msgid "Dashboard" msgid "Dashboard"
msgstr "仪表盘" msgstr "仪表盘"
#: src/routes/_auth/users/index.tsx:166 #: src/routes/_auth/orders/index.tsx:114
#: src/routes/_auth/users/index.tsx:257 #: src/routes/_auth/orders/index.tsx:152
#: src/routes/_auth/users/index.tsx:126
#: src/routes/_auth/users/index.tsx:223
msgid "Delete" msgid "Delete"
msgstr "删除" msgstr "删除"
#: src/routes/_auth/users/index.tsx:156 #: src/hooks/useCrudToasts.ts:65
msgid "Delete failed" msgid "Delete failed"
msgstr "删除失败" msgstr "删除失败"
#: src/routes/_auth/users/index.tsx:149 #: src/hooks/useCrudToasts.ts:62
msgid "Deleted successfully" msgid "Deleted successfully"
msgstr "删除成功" msgstr "删除成功"
#: src/routes/_auth/users/index.tsx:142 #: src/hooks/useCrudToasts.ts:59
msgid "Deleting…" msgid "Deleting…"
msgstr "正在删除…" msgstr "正在删除…"
#: src/components/Layout/Sidebar/index.tsx:35 #: src/components/Layout/Sidebar/index.tsx:37
msgid "Design Engineering" msgid "Design Engineering"
msgstr "设计工程" msgstr "设计工程"
#: src/routes/_auth/users/index.tsx:247 #: src/routes/_auth/orders/-FormModal.tsx:36
msgid "Done"
msgstr "已完成"
#: src/routes/_auth/orders/-FormModal.tsx:34
msgid "Draft"
msgstr "草稿"
#: src/routes/_auth/orders/index.tsx:142
#: src/routes/_auth/users/index.tsx:213
msgid "Edit" msgid "Edit"
msgstr "编辑" msgstr "编辑"
#: src/routes/_auth/orders/-FormModal.tsx:19
msgid "Edit Order"
msgstr "编辑订单"
#: src/routes/_auth/users/-FormModal.tsx:29 #: src/routes/_auth/users/-FormModal.tsx:29
msgid "Edit User" msgid "Edit User"
msgstr "编辑用户" msgstr "编辑用户"
#: src/routes/_auth/users/-FormModal.tsx:59 #: src/routes/_auth/users/-FormModal.tsx:53
#: src/routes/_auth/users/-Toolbar.tsx:65 #: src/routes/_auth/users/-Toolbar.tsx:65
msgid "Editor" msgid "Editor"
msgstr "编辑者" msgstr "编辑者"
#: src/routes/_auth/users/-FormModal.tsx:63 #: src/routes/_auth/users/-FormModal.tsx:57
#: src/routes/_auth/users/index.tsx:202 #: src/routes/_auth/users/index.tsx:168
msgid "Email" msgid "Email"
msgstr "邮箱" msgstr "邮箱"
@@ -134,27 +152,34 @@ msgstr "邮箱"
#~ msgid "Filter by role" #~ msgid "Filter by role"
#~ msgstr "按角色筛选" #~ msgstr "按角色筛选"
#: src/routes/login/index.tsx:192 #: src/routes/login/index.tsx:188
msgid "Forgot password?" msgid "Forgot password?"
msgstr "忘记密码?" msgstr "忘记密码?"
#: src/components/NotFound/index.tsx:57 #: src/components/NotFound/index.tsx:62
msgid "Go back" msgid "Go back"
msgstr "返回上一页" msgstr "返回上一页"
#: src/routes/login/index.tsx:68 #: src/routes/login/index.tsx:54
msgid "Login failed" msgid "Login failed"
msgstr "登录失败" msgstr "登录失败"
#: src/routes/login/index.tsx:64 #: src/routes/login/index.tsx:50
msgid "Login successful" msgid "Login successful"
msgstr "登录成功" msgstr "登录成功"
#: src/routes/_auth/orders/-Toolbar.tsx:54
#: src/routes/_auth/orders/-Toolbar.tsx:55
#: src/routes/_auth/users/-Toolbar.tsx:92 #: src/routes/_auth/users/-Toolbar.tsx:92
#: src/routes/_auth/users/-Toolbar.tsx:93 #: src/routes/_auth/users/-Toolbar.tsx:93
msgid "More filters" msgid "More filters"
msgstr "更多筛选" msgstr "更多筛选"
#: src/routes/_auth/orders/-FormModal.tsx:19
#: src/routes/_auth/orders/-Toolbar.tsx:51
msgid "New Order"
msgstr "新建订单"
#: src/routes/_auth/users/-FormModal.tsx:29 #: src/routes/_auth/users/-FormModal.tsx:29
msgid "New User" msgid "New User"
msgstr "新建用户" msgstr "新建用户"
@@ -167,11 +192,23 @@ msgstr "暂无数据"
msgid "Nothing to show in this list yet" msgid "Nothing to show in this list yet"
msgstr "当前列表为空,暂无可展示的记录" msgstr "当前列表为空,暂无可展示的记录"
#: src/routes/_auth/users/-FormModal.tsx:31 #: src/routes/_auth/users/-FormModal.tsx:30
msgid "OK" msgid "OK"
msgstr "确定" msgstr "确定"
#: src/routes/_auth/dashboard/index.tsx:115 #: src/routes/_auth/orders/-FormModal.tsx:35
msgid "Open"
msgstr "进行中"
#: src/components/Layout/Sidebar/index.tsx:358
msgid "Open account menu"
msgstr "打开账户菜单"
#: src/components/Layout/Sidebar/index.tsx:36
msgid "Orders"
msgstr "订单"
#: src/routes/_auth/dashboard/index.tsx:233
msgid "Overview" msgid "Overview"
msgstr "概览" msgstr "概览"
@@ -179,56 +216,74 @@ msgstr "概览"
#~ msgid "Page not found" #~ msgid "Page not found"
#~ msgstr "页面不存在" #~ msgstr "页面不存在"
#: src/routes/login/index.tsx:171 #: src/routes/login/index.tsx:162
#: src/routes/login/index.tsx:168
msgid "Password" msgid "Password"
msgstr "密码" msgstr "密码"
#: src/components/Layout/Sidebar/index.tsx:30 #: src/components/Layout/Sidebar/index.tsx:32
msgid "Platform" msgid "Platform"
msgstr "平台" msgstr "平台"
#: src/routes/login/index.tsx:172 #: src/routes/login/index.tsx:163
msgid "Please enter password" msgid "Please enter password"
msgstr "请输入密码" msgstr "请输入密码"
#: src/routes/_auth/users/-FormModal.tsx:46 #: src/routes/_auth/users/-FormModal.tsx:40
#: src/routes/login/index.tsx:164 #: src/routes/login/index.tsx:150
msgid "Please enter username" msgid "Please enter username"
msgstr "请输入用户名" msgstr "请输入用户名"
#: src/routes/_auth/users/-FormModal.tsx:53 #: src/routes/_auth/users/-FormModal.tsx:47
msgid "Please select roles" msgid "Please select roles"
msgstr "请选择角色" msgstr "请选择角色"
#: src/components/Layout/AppFooter/index.tsx:36 #: src/components/RouteError.tsx:17
msgid "Please try again or return to the dashboard."
msgstr "请重试或返回控制台。"
#: src/components/Layout/AppFooter/index.tsx:23
msgid "Powered by" msgid "Powered by"
msgstr "由" msgstr "由"
#: src/components/Layout/Sidebar/index.tsx:31 #: src/components/Layout/Sidebar/index.tsx:33
msgid "Projects" msgid "Projects"
msgstr "项目" msgstr "项目"
#: src/routes/_auth/dashboard/index.tsx:142 #: src/routes/_auth/dashboard/index.tsx:259
msgid "Recent Sales" msgid "Recent Sales"
msgstr "近期销售" msgstr "近期销售"
#: src/components/RouteError.tsx:20
msgid "Retry"
msgstr "重试"
#: src/routes/_auth/users/-Toolbar.tsx:58 #: src/routes/_auth/users/-Toolbar.tsx:58
msgid "Role" msgid "Role"
msgstr "角色" msgstr "角色"
#: src/routes/_auth/users/-FormModal.tsx:52 #: src/routes/_auth/users/-FormModal.tsx:46
#: src/routes/_auth/users/index.tsx:209 #: src/routes/_auth/users/index.tsx:175
msgid "Roles" msgid "Roles"
msgstr "角色" msgstr "角色"
#: src/routes/_auth/dashboard/index.tsx:39 #: src/routes/_auth/orders/index.tsx:160
#: src/routes/_auth/users/index.tsx:234
msgid "Row actions"
msgstr "行操作"
#: src/routes/_auth/dashboard/index.tsx:148
msgid "Sales" msgid "Sales"
msgstr "销售额" msgstr "销售额"
#: src/components/Layout/Sidebar/index.tsx:36 #: src/components/Layout/Sidebar/index.tsx:38
msgid "Sales & Marketing" msgid "Sales & Marketing"
msgstr "销售与市场" msgstr "销售与市场"
#: src/routes/_auth/orders/-Toolbar.tsx:32
msgid "Search Order"
msgstr "搜索订单"
#: src/routes/_auth/users/-Toolbar.tsx:43 #: src/routes/_auth/users/-Toolbar.tsx:43
msgid "Search User" msgid "Search User"
msgstr "搜索用户" msgstr "搜索用户"
@@ -237,7 +292,7 @@ msgstr "搜索用户"
#~ msgid "Search username or email" #~ msgid "Search username or email"
#~ msgstr "搜索用户名或邮箱" #~ msgstr "搜索用户名或邮箱"
#: src/routes/login/index.tsx:204 #: src/routes/login/index.tsx:200
msgid "Sign In" msgid "Sign In"
msgstr "登录" msgstr "登录"
@@ -245,70 +300,89 @@ msgstr "登录"
#~ msgid "Sign in with your credentials." #~ msgid "Sign in with your credentials."
#~ msgstr "凭用户名与密码登录" #~ msgstr "凭用户名与密码登录"
#: src/components/Layout/Sidebar/index.tsx:194 #: src/components/Layout/Sidebar/index.tsx:197
msgid "Sign Out" msgid "Sign Out"
msgstr "退出登录" msgstr "退出登录"
#: src/components/NotFound/index.tsx:50 #: src/components/RouteError.tsx:16
msgid "Something went wrong"
msgstr "出了点问题"
#: src/components/NotFound/index.tsx:55
msgid "Sorry, the page you visited does not exist." msgid "Sorry, the page you visited does not exist."
msgstr "抱歉,您访问的页面不存在。" msgstr "抱歉,您访问的页面不存在。"
#: src/routes/_auth/403/index.tsx:37 #: src/routes/_auth/403/index.tsx:32
msgid "Sorry, you don't have permission to access this page." msgid "Sorry, you don't have permission to access this page."
msgstr "抱歉,您没有权限访问此页面。" msgstr "抱歉,您没有权限访问此页面。"
#: src/routes/_auth/dashboard/index.tsx:33 #: src/routes/_auth/orders/-FormModal.tsx:31
#: src/routes/_auth/orders/index.tsx:125
msgid "Status"
msgstr "状态"
#: src/routes/_auth/dashboard/index.tsx:142
msgid "Subscriptions" msgid "Subscriptions"
msgstr "订阅" msgstr "订阅"
#: src/components/Layout/Header/index.tsx:145 #: src/components/Layout/Header/index.tsx:153
#: src/routes/login/index.tsx:225 #: src/routes/login/index.tsx:221
msgid "Switch language" msgid "Switch language"
msgstr "切换语言" msgstr "切换语言"
#: src/routes/_auth/users/index.tsx:165 #: src/routes/_auth/orders/index.tsx:113
msgid "This action cannot be undone."
msgstr "此操作无法撤销。"
#: src/routes/_auth/users/index.tsx:125
msgid "This action cannot be undone. This will permanently delete the user." msgid "This action cannot be undone. This will permanently delete the user."
msgstr "此操作无法撤销,将永久删除该用户。" msgstr "此操作无法撤销,将永久删除该用户。"
#: src/components/Layout/Header/index.tsx:133 #: src/routes/_auth/orders/-FormModal.tsx:28
#: src/components/Layout/Sidebar/index.tsx:263 #: src/routes/_auth/orders/index.tsx:123
#: src/components/Layout/Sidebar/index.tsx:311 msgid "Title"
msgstr "标题"
#: src/components/Layout/Header/index.tsx:138
#: src/components/Layout/Sidebar/index.tsx:271
#: src/components/Layout/Sidebar/index.tsx:319
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "折叠侧栏" msgstr "折叠侧栏"
#: src/components/Layout/Header/index.tsx:151 #: src/components/Layout/Header/index.tsx:159
#: src/routes/login/index.tsx:232 #: src/routes/login/index.tsx:228
msgid "Toggle Theme" msgid "Toggle Theme"
msgstr "切换主题" msgstr "切换主题"
#: src/routes/_auth/dashboard/index.tsx:27 #: src/routes/_auth/dashboard/index.tsx:136
msgid "Total Revenue" msgid "Total Revenue"
msgstr "总收入" msgstr "总收入"
#: src/routes/_auth/users/index.tsx:132 #: src/hooks/useCrudToasts.ts:54
msgid "Update failed" msgid "Update failed"
msgstr "更新失败" msgstr "更新失败"
#: src/routes/_auth/users/index.tsx:122 #: src/hooks/useCrudToasts.ts:51
msgid "Updated successfully" msgid "Updated successfully"
msgstr "更新成功" msgstr "更新成功"
#: src/routes/_auth/users/index.tsx:115 #: src/hooks/useCrudToasts.ts:48
msgid "Updating…" msgid "Updating…"
msgstr "正在更新…" msgstr "正在更新…"
#: src/components/Layout/Sidebar/index.tsx:34 #: src/components/Layout/Sidebar/index.tsx:34
msgid "User List" #~ msgid "User List"
msgstr "用户列表" #~ msgstr "用户列表"
#: src/routes/_auth/users/-FormModal.tsx:45 #: src/routes/_auth/users/-FormModal.tsx:39
#: src/routes/_auth/users/index.tsx:182 #: src/routes/_auth/users/index.tsx:142
#: src/routes/login/index.tsx:163 #: src/routes/login/index.tsx:149
#: src/routes/login/index.tsx:154
msgid "Username" msgid "Username"
msgstr "用户名" msgstr "用户名"
#: src/components/Layout/Header/index.tsx:76 #: src/components/Layout/Header/index.tsx:81
#: src/components/Layout/Sidebar/index.tsx:33 #: src/components/Layout/Sidebar/index.tsx:35
msgid "Users" msgid "Users"
msgstr "用户" msgstr "用户"
+8 -12
View File
@@ -6,9 +6,10 @@ import { i18n } from "@lingui/core";
import { loadLocaleCatalog } from "./locales/loadLocaleCatalog"; import { loadLocaleCatalog } from "./locales/loadLocaleCatalog";
import { useSettingsStore } from "./stores/settings"; import { useSettingsStore } from "./stores/settings";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
import { fetchSessionAndApplyToStore } from "./utils/session"; import { installHttpRouter } from "./utils/http";
const router = createRouter({ routeTree }); const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
@@ -25,22 +26,17 @@ async function enableMocking() {
enableMocking() enableMocking()
.then(async () => { .then(async () => {
await useSettingsStore.persist.rehydrate(); await Promise.all([
useSettingsStore.persist.rehydrate(),
useAuthStore.persist.rehydrate(),
]);
const initialLocale = useSettingsStore.getState().locale; const initialLocale = useSettingsStore.getState().locale;
const messages = await loadLocaleCatalog(initialLocale); const messages = await loadLocaleCatalog(initialLocale);
i18n.load(initialLocale, messages); i18n.load(initialLocale, messages);
i18n.activate(initialLocale); i18n.activate(initialLocale);
document.documentElement.lang = initialLocale === "zh" ? "zh" : "en"; document.documentElement.lang = initialLocale === "zh" ? "zh" : "en";
document.documentElement.dir = "ltr";
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( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: typeof row[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
+38 -22
View File
@@ -1,30 +1,17 @@
import { HttpResponse, delay as mswDelay } from "msw"; import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
/**
* Standard API response format
*/
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number; code: number;
data: T; data: T;
message: string; 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> { export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms); await mswDelay(ms);
} }
/**
* Create a success response
*/
export function successResponse<T>(data: T, message: string = "ok") { export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({ return HttpResponse.json({
code: 0, code: 0,
@@ -33,10 +20,42 @@ export function successResponse<T>(data: T, message: string = "ok") {
}); });
} }
/** /** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
* Create an error response export function successWithSchema<T extends ZodTypeAny>(
*/ schema: T,
export function errorResponse(code: number, message: string, data: unknown = null) { data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(
code: number,
message: string,
data: unknown = null,
) {
return HttpResponse.json({ return HttpResponse.json({
code, code,
data, data,
@@ -44,9 +63,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
}); });
} }
/**
* Predefined error codes
*/
export const ERROR_CODES = { export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001, INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002, NOT_FOUND: 1002,
+16 -3
View File
@@ -1,7 +1,6 @@
import type { User } from "@/api/schemas"; import type { User, Order } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils"; import { vercelAvatarUrl } from "./utils";
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"], ["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"], ["zhao.ming", "zhao.ming@northstar.io"],
@@ -16,11 +15,25 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["mia.silva", "mia.silva@northstar.io"], ["mia.silva", "mia.silva@northstar.io"],
]; ];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({ export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1), id: String(i + 1),
username, username,
avatar: vercelAvatarUrl(username), avatar: vercelAvatarUrl(username),
email, email,
roles: i === 0 ? ["admin"] : ["editor"], roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"], permissions: i === 0 ? ["orders:view","orders:create","orders:edit","orders:delete","user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
})); }));
export const MOCK_ORDERS: Order[] = [
{ id: "1", title: "Alpha", status: "open" },
{ id: "2", title: "Beta", status: "draft" },
{ id: "3", title: "Gamma", status: "done" },
];
+72 -14
View File
@@ -1,16 +1,48 @@
import { http } from "msw"; import { http } from "msw";
import { MOCK_USERS } from "../data"; import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler"; import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
} from "@/api/schemas";
const GUEST_REFRESH = "mock-guest-refresh";
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
export const authHandlers = [ export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => { http.post("/api/auth/login", async ({ request }) => {
await withDelay(300); await withDelay(300);
const body = (await request.json()) as { let json: unknown;
username: string; try {
password: string; json = await request.json();
}; } catch {
if (body.username === "admin" && body.password === "admin") { return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
return successResponse({ }
const parsed = LoginRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
}
const { username, password } = parsed.data;
if (username === "guest" && password === "guest") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access",
refreshToken: GUEST_REFRESH,
});
}
if (username === "admin" && password === "admin") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-access-token", accessToken: "mock-access-token",
refreshToken: "mock-refresh-token", refreshToken: "mock-refresh-token",
}); });
@@ -18,20 +50,46 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password"); return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}), }),
http.post("/api/auth/refresh", async () => { http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100); await withDelay(100);
return successResponse({ let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token", accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token", refreshToken: "mock-new-refresh-token",
}); });
}), }),
http.post("/api/auth/logout", () => successResponse(null)), http.post("/api/auth/logout", () => successWithNullBody()),
http.get("/api/auth/user", () => { http.get("/api/auth/user", ({ request }) => {
const auth = request.headers.get("authorization");
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!; const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successResponse(userWithoutPermissions); return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}), }),
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)), http.get("/api/auth/permissions", ({ request }) => {
const auth = request.headers.get("authorization");
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
]; ];
+2 -1
View File
@@ -1,4 +1,5 @@
import { authHandlers } from "./auth"; import { authHandlers } from "./auth";
import { userHandlers } from "./user"; import { userHandlers } from "./user";
import { ordersHandlers } from "./orders";
export const handlers = [...authHandlers, ...userHandlers]; export const handlers = [...authHandlers, ...userHandlers, ...ordersHandlers];
@@ -0,0 +1,61 @@
import { http } from "msw";
import { OrderSchema } from "@/api/schemas";
import { MOCK_ORDERS } from "../data";
import { paginateList, parsePaginationParams } from "../utils";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let rows = [...MOCK_ORDERS];
function filterRows(items: typeof rows, keyword: string) {
if (!keyword.trim()) return items;
const k = keyword.toLowerCase();
return items.filter((r) => r.title.toLowerCase().includes(k));
}
export const ordersHandlers = [
http.get("/api/orders", async ({ request }) => {
await withDelay(150);
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const filtered = filterRows(rows, keyword);
const list = paginateList(filtered, limit, offset);
return paginatedWithSchema(OrderSchema, { list, total: filtered.length });
}),
http.post("/api/orders", async ({ request }) => {
await withDelay(150);
const body = (await request.json()) as Record<string, unknown>;
const next: (typeof rows)[number] = {
id: String(rows.length + 1),
title: typeof body.title === "string" ? body.title : "",
status: (body.status as (typeof rows)[number]["status"]) ?? "draft",
};
rows.push(next);
return successWithSchema(OrderSchema, next);
}),
http.put("/api/orders/:id", async ({ params, request }) => {
await withDelay(150);
const body = (await request.json()) as Record<string, unknown>;
const idx = rows.findIndex((r) => r.id === params.id);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Not found");
}
rows[idx] = { ...rows[idx], ...body } as (typeof rows)[number];
return successWithSchema(OrderSchema, rows[idx]);
}),
http.delete("/api/orders/:id", async ({ params }) => {
await withDelay(150);
rows = rows.filter((r) => r.id !== params.id);
return successWithNullBody();
}),
];
+22 -8
View File
@@ -1,13 +1,27 @@
import { http } from "msw"; import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data"; import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils"; import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler"; import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let users = [...MOCK_USERS]; let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [ export const userHandlers = [
http.get("/api/users", async ({ request }) => { http.get("/api/users", async ({ request }) => {
await withDelay(200); await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
}
const url = new URL(request.url); const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams); const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? ""; const keyword = url.searchParams.get("keyword") ?? "";
@@ -16,7 +30,7 @@ export const userHandlers = [
const filtered = filterUsers(users, { keyword, role }); const filtered = filterUsers(users, { keyword, role });
const list = paginateList(filtered, limit, offset); const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length }); return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}), }),
http.post("/api/users", async ({ request }) => { http.post("/api/users", async ({ request }) => {
@@ -28,10 +42,10 @@ export const userHandlers = [
avatar: null, avatar: null,
email: typeof body.email === "string" ? body.email : null, email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [], roles: (body.roles as string[]) ?? [],
permissions: [], permissions: [] as string[],
}; };
users.push(newUser); users.push(newUser);
return successResponse(newUser); return successWithSchema(UserSchema, newUser);
}), }),
http.put("/api/users/:id", async ({ params, request }) => { http.put("/api/users/:id", async ({ params, request }) => {
@@ -41,13 +55,13 @@ export const userHandlers = [
if (idx === -1) { if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found"); return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
} }
users[idx] = { ...users[idx], ...body }; users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successResponse(users[idx]); return successWithSchema(UserSchema, users[idx]);
}), }),
http.delete("/api/users/:id", async ({ params }) => { http.delete("/api/users/:id", async ({ params }) => {
await withDelay(200); await withDelay(200);
users = users.filter((u) => u.id !== params.id); users = users.filter((u) => u.id !== params.id);
return successResponse(null); return successWithNullBody();
}), }),
]; ];
-5
View File
@@ -1,8 +1,3 @@
/**
* Mock-only helpers: demo avatars, list filtering, pagination.
*/
/** Deterministic demo avatar URL from username (mock seed data only). */
export function vercelAvatarUrl(username: string): string { export function vercelAvatarUrl(username: string): string {
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`; return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
} }
+28 -2
View File
@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as LoginIndexRouteImport } from './routes/login/index' import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as R404IndexRouteImport } from './routes/404/index' import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index' import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
import { Route as AuthOrdersIndexRouteImport } from './routes/_auth/orders/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index' import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index' import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
@@ -41,6 +42,11 @@ const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
path: '/users/', path: '/users/',
getParentRoute: () => AuthRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
const AuthOrdersIndexRoute = AuthOrdersIndexRouteImport.update({
id: '/orders/',
path: '/orders/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({ const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/', id: '/dashboard/',
path: '/dashboard/', path: '/dashboard/',
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
'/login/': typeof LoginIndexRoute '/login/': typeof LoginIndexRoute
'/403/': typeof Auth403IndexRoute '/403/': typeof Auth403IndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute '/dashboard/': typeof AuthDashboardIndexRoute
'/orders/': typeof AuthOrdersIndexRoute
'/users/': typeof AuthUsersIndexRoute '/users/': typeof AuthUsersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -66,6 +73,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginIndexRoute '/login': typeof LoginIndexRoute
'/403': typeof Auth403IndexRoute '/403': typeof Auth403IndexRoute
'/dashboard': typeof AuthDashboardIndexRoute '/dashboard': typeof AuthDashboardIndexRoute
'/orders': typeof AuthOrdersIndexRoute
'/users': typeof AuthUsersIndexRoute '/users': typeof AuthUsersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -76,13 +84,21 @@ export interface FileRoutesById {
'/login/': typeof LoginIndexRoute '/login/': typeof LoginIndexRoute
'/_auth/403/': typeof Auth403IndexRoute '/_auth/403/': typeof Auth403IndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute '/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/orders/': typeof AuthOrdersIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute '/_auth/users/': typeof AuthUsersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/' fullPaths:
| '/'
| '/404/'
| '/login/'
| '/403/'
| '/dashboard/'
| '/orders/'
| '/users/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users' to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/orders' | '/users'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -91,6 +107,7 @@ export interface FileRouteTypes {
| '/login/' | '/login/'
| '/_auth/403/' | '/_auth/403/'
| '/_auth/dashboard/' | '/_auth/dashboard/'
| '/_auth/orders/'
| '/_auth/users/' | '/_auth/users/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -138,6 +155,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUsersIndexRouteImport preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute parentRoute: typeof AuthRoute
} }
'/_auth/orders/': {
id: '/_auth/orders/'
path: '/orders'
fullPath: '/orders/'
preLoaderRoute: typeof AuthOrdersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': { '/_auth/dashboard/': {
id: '/_auth/dashboard/' id: '/_auth/dashboard/'
path: '/dashboard' path: '/dashboard'
@@ -158,12 +182,14 @@ declare module '@tanstack/react-router' {
interface AuthRouteChildren { interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute Auth403IndexRoute: typeof Auth403IndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthOrdersIndexRoute: typeof AuthOrdersIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute AuthUsersIndexRoute: typeof AuthUsersIndexRoute
} }
const AuthRouteChildren: AuthRouteChildren = { const AuthRouteChildren: AuthRouteChildren = {
Auth403IndexRoute: Auth403IndexRoute, Auth403IndexRoute: Auth403IndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute, AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthOrdersIndexRoute: AuthOrdersIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute, AuthUsersIndexRoute: AuthUsersIndexRoute,
} }
+55 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router"; import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd"; import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US"; import enUS from "antd/locale/en_US";
@@ -10,15 +10,31 @@ import { loadLocaleCatalog } from "@/locales/loadLocaleCatalog";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound"; import { NotFound } from "@/components/NotFound";
import { RouteError } from "@/components/RouteError";
import type { Locale } from "@/stores/settings";
import "@/index.css"; import "@/index.css";
const antdLocaleMap = { en: enUS, zh: zhCN } as const; const antdLocaleMap = { en: enUS, zh: zhCN } as const;
const queryClient = new QueryClient({ function AppQueryBridge({ children }: { children: React.ReactNode }) {
defaultOptions: { const { message } = App.useApp();
queries: { retry: 1, refetchOnWindowFocus: false }, const queryClient = useMemo(
}, () =>
}); new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: {
onError: (err) => {
message.error(err instanceof Error ? err.message : String(err));
},
},
},
}),
[message],
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function RootComponent() { function RootComponent() {
const locale = useSettingsStore((s) => s.locale); const locale = useSettingsStore((s) => s.locale);
@@ -39,6 +55,7 @@ function RootComponent() {
i18n.load(locale, messages); i18n.load(locale, messages);
i18n.activate(locale); i18n.activate(locale);
document.documentElement.lang = locale === "zh" ? "zh" : "en"; document.documentElement.lang = locale === "zh" ? "zh" : "en";
document.documentElement.dir = "ltr";
})(); })();
return () => { return () => {
@@ -46,24 +63,48 @@ function RootComponent() {
}; };
}, [locale]); }, [locale]);
useEffect(() => {
const other: Locale = locale === "en" ? "zh" : "en";
let idleId: number | undefined;
let timeoutId: number | undefined;
if (typeof window.requestIdleCallback === "function") {
idleId = window.requestIdleCallback(() => {
void loadLocaleCatalog(other);
});
} else {
timeoutId = window.setTimeout(() => {
void loadLocaleCatalog(other);
}, 200);
}
return () => {
if (idleId !== undefined && typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(idleId);
}
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
}, [locale]);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light"); document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
}, [darkMode]); }, [darkMode]);
return ( return (
<QueryClientProvider client={queryClient}> <I18nProvider i18n={i18n}>
<I18nProvider i18n={i18n}> <ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}> <App>
<App> <AppQueryBridge>
<Outlet /> <Outlet />
</App> </AppQueryBridge>
</ConfigProvider> </App>
</I18nProvider> </ConfigProvider>
</QueryClientProvider> </I18nProvider>
); );
} }
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootComponent, component: RootComponent,
notFoundComponent: NotFound, notFoundComponent: NotFound,
errorComponent: RouteError,
}); });
+14 -3
View File
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout"; import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu"; import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { fetchSessionAndApplyToStore } from "@/utils/session";
export const Route = createFileRoute("/_auth")({ export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => { beforeLoad: async ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState(); const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) { if (!isAuthenticated) {
throw redirect({ to: "/login" }); throw redirect({ to: "/login" });
} }
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname); const path = normalizeAppPath(location.pathname);
if (path === "/403") return; if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) { if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" }); throw redirect({ to: "/403" });
} }
}, },
@@ -20,10 +20,8 @@ async function fetchDashboardShell() {
type AntToken = ReturnType<typeof theme.useToken>["token"]; type AntToken = ReturnType<typeof theme.useToken>["token"];
/** Matches loaded stat card body height: title row, value, description. */
function StatCardSkeleton({ token }: { token: AntToken }) { function StatCardSkeleton({ token }: { token: AntToken }) {
const titleLine = Math.round(token.fontSizeSM * token.lineHeight); 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 iconBox = 24;
const titleRowHeight = Math.max(titleLine, iconBox); const titleRowHeight = Math.max(titleLine, iconBox);
const valueLine = Math.round(24 * 1); const valueLine = Math.round(24 * 1);
@@ -123,7 +121,6 @@ function DashboardPage() {
staleTime: 60_000, staleTime: 60_000,
}); });
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
const cardHoverStyle = { const cardHoverStyle = {
["--dash-card-hover-bg" as string]: token.colorBgContainer, ["--dash-card-hover-bg" as string]: token.colorBgContainer,
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover, ["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
@@ -0,0 +1,43 @@
import { Form, Input, Modal, Select } from "antd";
import { useLingui } from "@lingui/react/macro";
import type { Order, CreateOrderRequest } from "@/api/schemas";
export type FormModalProps = {
open: boolean;
editing: Order | null;
form: ReturnType<typeof Form.useForm<CreateOrderRequest>>[0];
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: CreateOrderRequest) => void;
};
export function FormModal({ open, editing, form, confirmLoading, onCancel, onFinish }: FormModalProps) {
const { t } = useLingui();
return (
<Modal
open={open}
title={editing ? t`Edit Order` : t`New Order`}
onCancel={onCancel}
onOk={() => {
form.submit();
}}
confirmLoading={confirmLoading}
destroyOnHidden
>
<Form layout="vertical" form={form} onFinish={onFinish} requiredMark={false}>
<Form.Item name="title" label={t`Title`} rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="status" label={t`Status`} rules={[{ required: true }]}>
<Select
options={[
{ value: "draft", label: t`Draft` },
{ value: "open", label: t`Open` },
{ value: "done", label: t`Done` },
]}
/>
</Form.Item>
</Form>
</Modal>
);
}
@@ -0,0 +1,58 @@
import { Button, Input, theme } from "antd";
import { useLingui } from "@lingui/react/macro";
import { Plus } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ keywordInput, onKeywordChange, onSearch, onClearSearch, 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 Order`}
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onSearch, t],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
{t`New Order`}
</Button>
}
moreFiltersLabel={t`More filters`}
moreFiltersTitle={t`More filters`}
/>
);
});
@@ -0,0 +1,262 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useLingui } from "@lingui/react/macro";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { ORDER_ENDPOINTS } from "@/api/orders";
import { PaginatedResponseSchema, OrderSchema, CreateOrderRequestSchema } from "@/api/schemas";
import type { Order, CreateOrderRequest } 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 { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const SearchParamsSchema = 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(""),
});
type RowSearch = z.infer<typeof SearchParamsSchema>;
export const Route = createFileRoute("/_auth/orders/")({
validateSearch: (search) => SearchParamsSchema.parse(search),
component: OrderListPage,
});
const paginatedSchema = PaginatedResponseSchema(OrderSchema);
function OrderListPage() {
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 [editing, setEditing] = useState<Order | null>(null);
const [form] = Form.useForm<CreateOrderRequest>();
const currentPage = Math.floor(search.offset / search.limit) + 1;
const setSearch = (next: RowSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateOrderRequest, CreateOrderRequest & { id: string }>({
message,
resourceKey: "orders",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: Order[]; total: number },
CreateOrderRequest,
CreateOrderRequest & { id: string }
>({
queryKey: ["orders", search.limit, search.offset, search.keyword, search.sortField, search.sortOrder],
queryFn: () =>
httpClient.get(ORDER_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
},
}),
select: (raw) => paginatedSchema.shape.data.parse(raw),
createFn: (values) => httpClient.post(ORDER_ENDPOINTS.create, CreateOrderRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(ORDER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(ORDER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditing(null);
form.resetFields();
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
const confirmDelete = (record: Order) => {
modal.confirm({
title: t`Are you absolutely sure?`,
content: t`This action cannot be undone.`,
okText: t`Delete`,
okType: "danger",
cancelText: t`Cancel`,
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = [
{ title: "ID", dataIndex: "id", key: "id" },
{ title: t`Title`, dataIndex: "title", key: "title" },
{
title: t`Status`,
dataIndex: "status",
key: "status",
render: (s: Order["status"]) => <Tag>{s}</Tag>,
},
{
title: "Actions",
key: "actions",
width: 60,
align: "right" as const,
render: (_: unknown, record: Order) => (
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: t`Edit`,
onClick: () => {
setEditing(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} />} aria-label={t`Row actions`} />
</Dropdown>
),
},
];
const showPagination = (data?.total ?? 0) > search.limit;
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
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={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
onCreateClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
/>
<DataTable<Order>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={lockScrollHeight}
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" }}
/>
<FormModal
open={modalOpen}
editing={editing}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditing(null);
form.resetFields();
}}
onFinish={(values) => {
if (editing) {
updateMutation.mutate({ ...values, id: editing.id });
} else {
createMutation.mutate(values);
}
}}
/>
</Flex>
);
}
@@ -4,7 +4,6 @@ import { Plus, UserRound } from "lucide-react";
import { forwardRef, useMemo } from "react"; import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar"; import { FilterToolbar } from "@/components/FilterToolbar";
/** Search + role slot `minWidth` for FilterToolbar collapse math */
const FILTER_CONTROL_WIDTH = 220; const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = { export type ToolbarProps = {
+63 -194
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd"; import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface"; import type { TablePaginationConfig } from "antd/es/table/interface";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http"; import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user"; import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas"; import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
@@ -11,6 +11,9 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable"; import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD"; import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar"; import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal"; import { FormModal } from "./-FormModal";
@@ -23,6 +26,8 @@ const UserSearchParamsSchema = z.object({
role: z.string().catch(""), role: z.string().catch(""),
}); });
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({ export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search), validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage, component: UsersPage,
@@ -30,10 +35,6 @@ export const Route = createFileRoute("/_auth/users/")({
const paginatedUserSchema = PaginatedResponseSchema(UserSchema); 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() { function UsersPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
@@ -45,27 +46,23 @@ function UsersPage() {
const toolbarRowRef = useRef<HTMLDivElement>(null); const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null); const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = 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 [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1; const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => { const setSearch = (next: UserSearch) => {
setKeywordInput(search.keyword); void navigate({ search: next });
}, [search.keyword]); };
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD< const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number }, { list: User[]; total: number },
@@ -81,7 +78,6 @@ function UsersPage() {
search.sortField, search.sortField,
search.sortOrder, search.sortOrder,
], ],
invalidateKey: ["users"],
queryFn: () => queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, { httpClient.get(USER_ENDPOINTS.list, {
params: { params: {
@@ -98,66 +94,29 @@ function UsersPage() {
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)), httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values), updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)), deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: { createLifecycle: {
onSuccess: () => { onSuccess: (values) => {
message.success({ crudToasts.createLifecycle?.onSuccess?.(values);
content: t`Created successfully`,
key: MESSAGE_KEY_USER_CREATE,
});
setModalOpen(false); setModalOpen(false);
form.resetFields(); form.resetFields();
}, },
onError: () => { onError: crudToasts.createLifecycle?.onError,
message.error({
content: t`Create failed`,
key: MESSAGE_KEY_USER_CREATE,
});
},
}, },
updateLifecycle: { updateLifecycle: {
onMutate: () => { onMutate: crudToasts.updateLifecycle?.onMutate,
message.loading({ onSuccess: (values) => {
content: t`Updating…`, crudToasts.updateLifecycle?.onSuccess?.(values);
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: t`Updated successfully`,
key: MESSAGE_KEY_USER_UPDATE,
});
setModalOpen(false); setModalOpen(false);
setEditingUser(null); setEditingUser(null);
form.resetFields(); form.resetFields();
}, },
onError: () => { onError: crudToasts.updateLifecycle?.onError,
message.error({
content: t`Update failed`,
key: MESSAGE_KEY_USER_UPDATE,
});
},
}, },
deleteLifecycle: { deleteLifecycle: {
onMutate: () => { onMutate: crudToasts.deleteLifecycle?.onMutate,
message.loading({ onSuccess: crudToasts.deleteLifecycle?.onSuccess,
content: t`Deleting…`, onError: crudToasts.deleteLifecycle?.onError,
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,
});
},
}, },
}); });
@@ -270,138 +229,48 @@ function UsersPage() {
}} }}
placement="bottomRight" placement="bottomRight"
> >
<Button type="text" icon={<MoreVertical size={token.fontSize} />} /> <Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label={t`Row actions`}
/>
</Dropdown> </Dropdown>
), ),
}, },
]; ];
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const showPagination = (data?.total ?? 0) > search.limit; const showPagination = (data?.total ?? 0) > search.limit;
useLayoutEffect(() => { const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
const shell = pageShellRef.current; pageShellRef,
const toolbarEl = toolbarRowRef.current; toolbarRef: toolbarRowRef,
const mid = middleSectionRef.current; middleRef: middleSectionRef,
if (!shell || !toolbarEl || !mid) return; tableFrameRef,
marginLG: token.marginLG,
/** Fixed small table header row height */ rowCount: data?.list?.length ?? 0,
const headReserve = 40; isLoading,
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */ showPagination,
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( const tablePagination: false | TablePaginationConfig = useMemo(
() => () =>
showPagination showPagination
? { ? {
total: data?.total ?? 0, total: data?.total ?? 0,
current: currentPage, current: currentPage,
pageSize: search.limit, pageSize: search.limit,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => t`${total} rows`, showTotal: (total) => t`${total} rows`,
onChange: (page, pageSize) => { onChange: (page, pageSize) => {
void navigate({ void navigate({
search: { search: {
...search, ...search,
limit: pageSize ?? search.limit, limit: pageSize ?? search.limit,
offset: (page - 1) * (pageSize ?? search.limit), offset: (page - 1) * (pageSize ?? search.limit),
}, },
}); });
}, },
} }
: false, : false,
[showPagination, data?.total, currentPage, search, navigate, t], [showPagination, data?.total, currentPage, search, navigate, t],
); );
@@ -417,10 +286,10 @@ function UsersPage() {
ref={toolbarRowRef} ref={toolbarRowRef}
keywordInput={keywordInput} keywordInput={keywordInput}
onKeywordChange={setKeywordInput} onKeywordChange={setKeywordInput}
onSearch={applySearch} onSearch={applyKeyword}
onClearSearch={() => { onClearSearch={() => {
setKeywordInput(""); setKeywordInput("");
applySearch(""); setSearch({ ...search, keyword: "", offset: 0 });
}} }}
roleValue={search.role || undefined} roleValue={search.role || undefined}
onRoleChange={(role) => onRoleChange={(role) =>
@@ -442,7 +311,7 @@ function UsersPage() {
<DataTable<User> <DataTable<User>
layoutRef={middleSectionRef} layoutRef={middleSectionRef}
frameRef={tableFrameRef} frameRef={tableFrameRef}
lockScrollHeight={tableScrollY != null} lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight} maxHeight={tableAreaMaxHeight}
frameHeight={ frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
+8
View File
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
}, },
}, },
); );
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
@@ -10,10 +10,6 @@ export interface PersistConfig<T> {
merge?: PersistOptions<T, Partial<T>>["merge"]; 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>( export function createPersistentStore<T extends object>(
initializer: StateCreator<T, [], []>, initializer: StateCreator<T, [], []>,
config: PersistConfig<T>, config: PersistConfig<T>,
+11 -9
View File
@@ -1,9 +1,5 @@
import type { MenuItem } from "@/api/schemas"; 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[] = [ export const APP_MENU_TREE: MenuItem[] = [
{ {
id: "g-platform", id: "g-platform",
@@ -37,6 +33,17 @@ export const APP_MENU_TREE: MenuItem[] = [
sort: 1, sort: 1,
hidden: false, hidden: false,
}, },
{
id: "orders-menu",
kind: "item",
name: "Orders",
path: "/orders",
icon: "IconLucidePackage",
children: null,
permissions: ["orders:view"],
sort: 2,
hidden: false,
},
], ],
}, },
{ {
@@ -113,16 +120,11 @@ export function filterMenuTreeByPermissions(
return walk(nodes); return walk(nodes);
} }
/** Normalized pathname (no trailing slash except `/`) */
export function normalizeAppPath(pathname: string): string { export function normalizeAppPath(pathname: string): string {
if (pathname === "/") return pathname; if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/"; 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 { export function requiredPermissionForPath(pathname: string): string | null {
const p = normalizeAppPath(pathname); const p = normalizeAppPath(pathname);
const map: Record<string, string | null> = { const map: Record<string, string | null> = {
-2
View File
@@ -1,7 +1,5 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; 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"; export const APP_FAVICON_SRC = "/favicon.svg";
/** Product / brand name (login header, sidebar logo text, etc.). */
export const APP_BRAND_NAME = "Antd Admin"; export const APP_BRAND_NAME = "Antd Admin";
+100 -20
View File
@@ -1,4 +1,7 @@
import { API_BASE_URL } from "./constants"; import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error { export class ApiError extends Error {
code: number; code: number;
@@ -22,6 +25,70 @@ type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>; params?: Record<string, string | number | null | undefined>;
}; };
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string { function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`; const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base; if (!params) return base;
@@ -33,23 +100,25 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
} }
function getAuthHeaders(): Record<string, string> { function getAuthHeaders(): Record<string, string> {
try { const token = getAccessToken();
const raw = localStorage.getItem("auth-storage"); if (token) return { Authorization: `Bearer ${token}` };
if (!raw) return {};
const parsed = JSON.parse(raw);
const token = parsed?.state?.tokens?.accessToken;
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
return {}; return {};
} }
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
async function request<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
body?: unknown, body?: unknown,
options?: RequestOptions, options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> { ): Promise<T> {
const url = buildUrl(path, options?.params); const url = buildUrl(path, options?.params);
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -61,29 +130,40 @@ async function request<T>(
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers, headers,
body: body ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
...options, ...options,
}); });
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
throw new HttpError(401, "Unauthorized");
}
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) { if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`); throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
} }
const json = await res.json(); const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (json.code !== undefined && json.code !== 0) { if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error"); throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
} }
return json.data !== undefined ? json.data : json; return (envelope.data !== undefined ? envelope.data : json) as T;
} }
export const httpClient = { export const httpClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options), get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) => post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, options),
request<T>("POST", path, body, options), put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) => delete: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, options),
request<T>("PUT", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
}; };
-1
View File
@@ -4,7 +4,6 @@ import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth"; 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> { export async function fetchSessionAndApplyToStore(): Promise<void> {
const [userBase, permissions] = await Promise.all([ const [userBase, permissions] = await Promise.all([
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)), httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
+4
View File
@@ -1 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BRAND_PRIMARY?: string;
}
+29 -11
View File
@@ -3,6 +3,34 @@ import react from "@vitejs/plugin-react-swc";
import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { lingui } from "@lingui/vite-plugin"; import { lingui } from "@lingui/vite-plugin";
const VENDOR_CHUNKS: Array<{ chunk: string; test: (id: string) => boolean }> = [
{
chunk: "vendor-antd",
test: (id) =>
id.includes("node_modules/antd") || id.includes("node_modules/rc-"),
},
{
chunk: "vendor-tanstack",
test: (id) => id.includes("node_modules/@tanstack/"),
},
{
chunk: "vendor-ui",
test: (id) => id.includes("node_modules/lucide-react"),
},
{
chunk: "vendor-i18n",
test: (id) =>
id.includes("node_modules/@lingui/") || id.includes("/@lingui/"),
},
];
function manualChunkForId(id: string): string | undefined {
for (const { chunk, test } of VENDOR_CHUNKS) {
if (test(id)) return chunk;
}
return undefined;
}
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tanstackRouter({ tanstackRouter({
@@ -22,17 +50,7 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: (id: string) => { manualChunks: (id: string) => manualChunkForId(id),
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, chunkSizeWarningLimit: 1024,
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
@@ -0,0 +1,207 @@
# apps/with-lingui 模板优化计划
> 状态:待评审
> 日期:2026-04-24
> 范围:`apps/with-lingui`(`apps/basic` 与主模板抽象稳定后再考虑同步,**当前不做回填**)
> 面向:人类贡献者 + AI Agent(结合 `.github/instructions/` 指令体系执行)
## 0. 背景
`apps/with-lingui` 作为仓库主模板,技术栈(React 19 / AntD 6 / TanStack Router & Query / Zod v4 / Lingui / MSW 2 / Vite+)已属第一梯队,AI 友好度(scoped instructions + 分包策略)领先同类开源 admin 模板。本计划聚焦**下一轮可见收益**,把页面级样板下沉到 hook/工具链,把 AI 指令从"规范"升级为"可执行食谱"。
详细评价参见内部讨论记录,本文只列执行项。
## 1. 目标与非目标
### 目标
- **安全性**:闭合 JWT 刷新流、移除 store/http 跨层耦合。
- **可维护性**:将 `routes/_auth/users/index.tsx`(491 行)中的通用能力下沉为 hook,使新增资源页 <200 行、<30 分钟。
- **可观测性**:RBAC / URL 状态等进入核心 E2E;体积以 `vp build` 日志与 Code review 把关(**不**在 CI 中跑自动化 bundle 上限工具)。
- **AI 协作**:补齐"加资源食谱"与 Cursor 规则镜像;**不**提供 codegen 脚本,以 **`add-resource.instructions.md`** 为唯一执行清单(对照 `users` / `orders`)。
### 非目标
- 不替换现有技术栈(Vite+ / Lingui / AntD 6 / TanStack 保持不变)。
- **`apps/basic` 回填**:抽象未稳定前**不做**;不与 `with-lingui` 强行双轨同步。
- 不引入新的 UI 或样式库(遵循 `frontend.instructions.md`)。
## 2. 执行项(按优先级)
### P0:正确性与启动性能
- **P0-1 解耦 http ↔ auth store**
- 现状:`apps/with-lingui/src/utils/http.ts` L35–46 直接 `localStorage.getItem("auth-storage")` 再 `JSON.parse`,与 `stores/auth.ts` 的 `partialize` 结构强耦合。
- 方案:
- 在 `stores/auth.ts` 导出 `getAccessToken()` / `getTokens()` 工具。
- `http.ts` 改为 `import { getAccessToken } from "@/stores/auth"`。
- `getAuthHeaders()` 保留但内部改调用工具。
- 验收:`vp check --no-fmt` + 登录 E2E 通过;搜索仓库不再出现 `"auth-storage"` 字面量(除 store 自身)。
- **P0-2 实现 401/403 拦截与 refresh token 流**
- 现状:`AuthTokensSchema` 已含 `refreshToken`,但 `request()` 未使用;403 由各页面自行处理。
- 方案:
- `utils/http.ts` 拦截 401:调 `POST /api/auth/refresh`(新增端点常量 + schema + MSW handler),并发去重(`inflightRefresh: Promise | null`),成功后回放原请求。
- 拦截 403:通过 `@tanstack/react-router` 的 `router.navigate({ to: "/403" })`;为此导出一个可注入 `router` 的 `installHttpInterceptors(router)`,在 `main.tsx` 调用。
- 刷新失败 → `useAuthStore.getState().logout()` + 导向 `/login`。
- 新增文件:
- `src/api/auth.ts` 增加 `REFRESH` 常量。
- `src/mocks/handlers/auth.ts` 增加 refresh handler(含错误分支模拟)。
- 验收:新增 E2E `e2e/auth-refresh.spec.ts` 覆盖:过期 access token → 自动刷新 → 请求成功;刷新失败 → 登出并跳登录。
- **P0-3 启动链并行化**
- 现状:`src/main.tsx` L26–49 串行 5 步:`enableMocking → rehydrate settings → loadLocale → rehydrate auth → fetchSession → render`。
- 方案:
- `loadLocaleCatalog` 与 `useAuthStore.persist.rehydrate()` 并行(`Promise.all`)。
- `fetchSessionAndApplyToStore()` 从 `main.tsx` 移除,改到 `routes/_auth.tsx` 的 `beforeLoad` 内按需触发(路由守卫已经会校验 `isAuthenticated`)。
- 保留渲染前仅做"i18n 激活 + 主题 hydrate"两个硬依赖。
- 验收:本地 DevTools Performance 首屏 TTI 改善 ≥ 150ms;刷新已登录页仍能正确拉取 session。
### P1:架构清晰度
- **P1-1 抽出 `useTableFitHeight` hook**
- 现状:`routes/_auth/users/index.tsx` L291–384 约 90 行 DOM 测量(`ResizeObserver` + rAF 重试 + 主布局 `clipBottom` 计算)写在页面内。
- 方案:
- 新文件 `src/hooks/useTableFitHeight.ts`,输入 `{ pageShellRef, toolbarRef, middleRef, tableFrameRef, deps }`,返回 `{ tableAreaMaxHeight, tableScrollY, lockScrollHeight }`。
- 页面只保留 ref 声明与调用。
- 验收:`users/index.tsx` 行数 < 260;暗色 / 不同分辨率下无双滚动条(以核心 E2E + 人工回归为准)。
- **P1-2 抽出 `useCrudToasts` hook**
- 现状:`users/index.tsx` L101–161 每个资源都要写 ~60 行 create/update/delete lifecycle toasts。
- 方案:
- 新文件 `src/hooks/useCrudToasts.ts`,接收 `{ resourceLabel: () => string }`(配合 Lingui macro),内部返回 `createLifecycle / updateLifecycle / deleteLifecycle` 三个对象,可直接传入 `useResourceCRUD`。
- 文案用 Lingui macro;允许覆盖单条文案。
- 验收:`users/index.tsx` 中不再出现 `MESSAGE_KEY_USER_`* 常量与裸 `t\`... successfully` 重复;至少一个新资源页引用并通过 E2E。
- **P1-3 抽出 `useUrlSearchState` hook**
- 现状:`users/index.tsx` 手写 `keywordInput` 本地态与 `search.keyword` 同步 + `applySearch`。
- 方案:
- 新文件 `src/hooks/useUrlSearchState.ts`,基于 `useNavigate` + `Route.useSearch()`,接收 zod schema,返回 `{ search, patch, keyword, setKeyword, commitKeyword }`。
- `users/index.tsx` 与未来资源页共享。
- 验收:`users.spec.ts` 新增"刷新保留分页/关键词/排序"用例通过。
- **P1-4 `useResourceCRUD` 人因优化**
- 现状:`src/hooks/useResourceCRUD.ts` 调用方需手写三个泛型 + `queryKey` 与 `invalidateKey` 两次。
- 方案:
- `invalidateKey` 默认取 `queryKey[0]`。
- 导出 `CrudResult<T>` 类型别名。
- 可选 `optimistic: { update?: boolean; delete?: boolean }`,内部 `onMutate` 里做乐观 `setQueryData`;失败自动回滚。
- 验收:用户列表删除 / 编辑在慢网(Playwright `route` 延时)下立即反馈;回滚路径有单测覆盖。
- **落地**:`optimistic: { update, delete }` + `cancelQueries` / 失败时 `setQueryData` 恢复快照;`pnpm run test:unit`(`src/hooks/useResourceCRUD.test.ts`)覆盖 merge/delete 与 rollback 契约;`users` / `orders` 已开启乐观更新。
- **P1-5 MSW handler 端也跑一次 schema 解析**
- 现状:mock 与契约漂移只能在前端 `Zod.parse` 抛错时发现,堆栈离源头远。
- 方案:
- `src/mocks/createHandler.ts`:`successWithSchema`;`paginatedListSchema` + `paginatedWithSchema(itemSchema, payload)`;`successWithNullBody` 用于 `data: null` 响应。
- `handlers/auth.ts` / `user.ts` / `orders.ts` 全部经 schema 出包;新增 handler 须沿用同一模式。
- 验收:故意改错 mock 字段名,dev 控制台首条报错来自 mock 层;`vp test` 下 handler 单元测试覆盖。
- **落地**:`src/mocks/createHandler.test.ts`(`pnpm run test:unit`)覆盖 `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` 的校验与 Zod 抛错路径。
### P2:体验与工程化
- **P2-1 统一错误兜底**
- 根路由 `__root.tsx` 追加 `errorComponent`;`QueryClient.defaultOptions.mutations.onError` 挂接全局 `App.useApp().message.error`。
- 全站 `message/modal/notification` 统一使用 `App.useApp()`(禁止静态方法),通过 oxlint 规则(或 code review checklist)约束。
- **P2-2 品牌 token 化**
- 在 `src/hooks/tokenBuilders.ts` 定义 `BRAND_TOKENS`(primary、link、info、success 等),深浅色分别给一份;替换默认 AntD 蓝。
- 允许通过 `VITE_BRAND_PRIMARY` 环境变量覆盖,便于二次分发。
- **P2-3 Bundle 体积(已决议:不再采用自动化 CI 预算)**
- **不再采用** `size-limit` / `@size-limit/file`(及同类在 CI 中卡阈值的方案);已从 `apps/with-lingui` 与 `with-lingui` workflow 移除。
- 仍通过 **`manualChunks` + `vp build` 输出** 观察各 chunk;重大依赖或分片变更在 PR 描述中注明体积影响即可。
- `refactor.instructions.md` 仅保留「分包与避免无谓大依赖」的叙述性建议,不写死 CI 阈值命令。
- **P2-4 i18n 预热**
- `__root.tsx` 在 `requestIdleCallback` 内预拉另一语言 catalog;切换瞬时完成。
- `document.documentElement` 预留 `dir` 属性钩子,为未来 RTL 留接口(不实现 RTL 样式)。
### P3:AI 友好度放大
- **P3-1 新增"加资源"食谱**
- 新文件 `apps/with-lingui/.github/instructions/add-resource.instructions.md`(英文正文),front-matter 指定触发关键词(add resource / CRUD / scaffold 等)。
- 内容为 6 步 checklist + 每步文件路径 + 验证命令,示例见下方"附录 A"。
- **P3-2 Cursor 薄规则(方案 A)**
- 细则**单源**:`apps/with-lingui/.github/instructions/*.instructions.md`。
- 仓库根 `.cursor/rules/with-lingui-*.mdc` 五份:仅 `description` + `globs` + 指向上述路径;**不**重复粘贴长文;`.gitignore` 放行 `.cursor/rules/` 以便进库。
- `apps/with-lingui/README.md` 与 `instructions/README.md` 说明用法(必要时 `@` 引用 `.instructions.md`)。
- **P3-3 加资源(无脚本 codegen)**
- **实现形态**:仅 `add-resource.instructions.md` 食谱 + `users` / `orders` 示例;由贡献者或 Agent 按步骤手工落地。
- 产出与原先 codegen 相当:`api/<name>.ts`、`schemas` 片段、mock、路由、`e2e`、菜单等。
- 验收:按食谱新增一资源后 `pnpm exec vp check --no-fmt && pnpm test:e2e:core` 通过(可将新 spec 纳入 core 或单跑)。
## 3. 落地节奏
| 阶段 | 包含项 | 预期工时 |
| -------- | --------------------------------------- | ----- |
| Sprint 1 | P0-1 / P0-2 / P0-3 / P1-1 | 2–3 天 |
| Sprint 2 | P1-2 / P1-3 / P1-4 / P1-5 / P3-1 | 2 天 |
| Sprint 3 | P2-1 ~ P2-4 + P3-2 + P3-3(食谱) | 2 天 |
| Sprint 4 | (预留)抽象稳定后再评估 `apps/basic` 与其它增强项 | 待定 |
每 Sprint 结束:`vp check --no-fmt && pnpm run test:e2e:core && pnpm run build`(在 `apps/with-lingui` 下),并关注构建日志中的 chunk 体积。
## 4. 风险与对策
| 风险 | 影响 | 对策 |
| -------------------------------------- | ----- | ------------------------------------------------------- |
| refresh token 并发去重实现错误,风暴请求 | 线上故障 | 用 `inflightRefresh`(单 Promise)串行化刷新,避免并发风暴 |
| 乐观更新回滚遗漏 | 数据不一致 | `useResourceCRUD` 回滚路径单测覆盖(`optimistic: true` 下 4xx 用例) |
| 手工加资源时覆盖已有文件 | 代码丢失 | 改前 grep / diff;食谱「注意」节强调不覆盖手写代码 |
| `apps/basic` 与 `apps/with-lingui` 抽象漂移 | 双倍维护 | **回填暂缓**;待 hook/契约稳定后再设固定回填节点;尽量参数化而非分叉 |
## 5. 验收标准总览
- `vp check --no-fmt` 全绿
- `pnpm test:e2e:core`(login / users / auth-refresh / rbac / url-state 等,以 `package.json` 脚本为准)全绿
- CI 含 `vp build`;bundle 不设自动化上限门禁(见 P2-3)
- `routes/_auth/users/index.tsx` 行数 ≤ 260
- 按 `add-resource.instructions.md` 能在约 30 分钟内产出可用 CRUD 页面(熟练后)
---
## 附录 A:`add-resource.instructions.md` 草稿
> **以仓库内正式文件为准**:`apps/with-lingui/.github/instructions/add-resource.instructions.md`;下文仅作计划期摘要。
```md
---
applyTo: "src/api/**/*.ts,src/mocks/handlers/**/*.ts,src/routes/_auth/**/*.tsx"
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page, 新资源."
---
# Add Resource Recipe
## Steps
1. `src/api/schemas.ts` — Add `<Name>Schema`, `Create<Name>RequestSchema`, `Update<Name>RequestSchema`. Reuse `PaginatedResponseSchema`.
2. `src/api/<name>.ts` — Export `<NAME>_ENDPOINTS = { list, create, update: (id) => ..., delete: (id) => ... }`.
3. `src/mocks/data.ts` — Append seed data.
4. `src/mocks/handlers/<name>.ts` — Mirror `handlers/user.ts`; wrap responses with `successWithSchema`.
5. `src/mocks/handlers/index.ts` — Register the new handlers array.
6. `src/routes/_auth/<name>/index.tsx` — Duplicate `users/index.tsx`, replace types, compose `useResourceCRUD + useCrudToasts + useUrlSearchState + useTableFitHeight`.
7. `e2e/<name>.spec.ts` — Copy `users.spec.ts`, adjust selectors.
## Validation
- `vp check --no-fmt`
- `pnpm test:e2e -- --grep <name>`
- `vp build` 后对照构建日志关注主 chunk / vendor 体积(无 CI 硬阈值)
```
## 附录 B:文件改动预估
| 文件 | 类型 |
| -------------------------------------------------------------------- | ------------------ |
| `apps/with-lingui/src/utils/http.ts` | 重构 |
| `apps/with-lingui/src/stores/auth.ts` | 增加导出 |
| `apps/with-lingui/src/main.tsx` | 简化 |
| `apps/with-lingui/src/routes/_auth.tsx` | 新增 `beforeLoad` |
| `apps/with-lingui/src/hooks/useTableFitHeight.ts` | 新增 |
| `apps/with-lingui/src/hooks/useCrudToasts.ts` | 新增 |
| `apps/with-lingui/src/hooks/useUrlSearchState.ts` | 新增 |
| `apps/with-lingui/src/hooks/useResourceCRUD.ts` | 增强 |
| `apps/with-lingui/src/mocks/createHandler.ts` | 增强 |
| `apps/with-lingui/src/mocks/handlers/auth.ts` | 补 refresh |
| `apps/with-lingui/src/routes/_auth/users/index.tsx` | 大幅精简 |
| `apps/with-lingui/.github/instructions/add-resource.instructions.md` | 新增 |
| `.cursor/rules/*.mdc` | 新增(5 份镜像) |
| `e2e/{auth-refresh,rbac,url-state,...}.spec.ts` | 新增 / 演进 |
| `.github/workflows/with-lingui-ci.yml` | 质量 + build(无 size-limit) |
+1 -1
View File
@@ -37,4 +37,4 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vitest": "^3.0.5" "vitest": "^3.0.5"
} }
} }
+288 -144
View File
File diff suppressed because it is too large Load Diff