mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-19 08:13:38 +00:00
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:
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`。
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
@@ -41,6 +41,10 @@ yarn-error.log*
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
.cursor
|
||||
|
||||
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
|
||||
./docs/superpowers
|
||||
|
||||
+3
-3
@@ -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."
|
||||
---
|
||||
|
||||
# API Instructions
|
||||
|
||||
@@ -26,4 +26,4 @@ Maintain strict API contracts and consistent server/client mock behavior.
|
||||
## Validation
|
||||
|
||||
- Verify TypeScript and schema parsing paths compile cleanly.
|
||||
- Run: `vp check --no-fmt`
|
||||
- Run: `vp check --no-fmt`
|
||||
@@ -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."
|
||||
---
|
||||
|
||||
# Frontend Instructions
|
||||
|
||||
@@ -28,4 +28,4 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
|
||||
## Validation
|
||||
|
||||
- Run: `vp check --no-fmt`
|
||||
- For UI flow changes, run focused e2e where possible.
|
||||
- For UI flow changes, run focused e2e where possible.
|
||||
@@ -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."
|
||||
|
||||
@@ -34,4 +34,4 @@ Reduce duplication while preserving behavior and public contracts.
|
||||
|
||||
- Confirm no new diagnostics in touched files.
|
||||
- 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
@@ -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."
|
||||
|
||||
@@ -27,4 +27,4 @@ Keep tests fast, deterministic, and aligned with core business flows.
|
||||
## Validation
|
||||
|
||||
- Run focused tests first, then broader checks if needed.
|
||||
- Run: `vp check --no-fmt`
|
||||
- Run: `vp check --no-fmt`
|
||||
@@ -81,4 +81,4 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
|
||||
- `api.instructions.md` for API/schema/handler changes
|
||||
- `refactor.instructions.md` for behavior-preserving cleanup
|
||||
|
||||
See `.github/instructions/README.md` for usage and authoring rules.
|
||||
See `.github/instructions/README.md` for usage and authoring rules.
|
||||
@@ -8,6 +8,7 @@ This directory contains scoped instruction files used by AI coding agents.
|
||||
- `testing.instructions.md`: Playwright/MSW/test changes
|
||||
- `api.instructions.md`: API client/schema/handler changes
|
||||
- `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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -14,6 +14,7 @@ Maintain strict API contracts and consistent server/client mock behavior.
|
||||
- Keep request/response shapes schema-driven and type-safe.
|
||||
- Reuse endpoint constants and shared response helpers.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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
|
||||
|
||||
- Run: `vp check --no-fmt`
|
||||
- Run: `pnpm exec vp check --no-fmt`
|
||||
- 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."
|
||||
---
|
||||
|
||||
# Refactor Instructions
|
||||
|
||||
@@ -33,5 +32,5 @@ Reduce duplication while preserving behavior and public contracts.
|
||||
## Validation
|
||||
|
||||
- Confirm no new diagnostics in touched files.
|
||||
- Run: `vp check --no-fmt`
|
||||
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
|
||||
- Run: `pnpm exec vp check --no-fmt`
|
||||
- For significant changes, verify bundle size impact: `pnpm run build 2>&1 | grep "kB"`
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
|
||||
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
|
||||
|
||||
applyTo: "e2e/**/*.ts,e2e/**/*.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."
|
||||
---
|
||||
|
||||
# Testing Instructions
|
||||
|
||||
@@ -27,4 +26,5 @@ Keep tests fast, deterministic, and aligned with core business flows.
|
||||
## Validation
|
||||
|
||||
- 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`.
|
||||
|
||||
@@ -28,7 +28,6 @@ src/locales/*/messages.js
|
||||
|
||||
.agent
|
||||
.pnpm-store
|
||||
.cursor
|
||||
.tanstack
|
||||
|
||||
# Playwright
|
||||
|
||||
@@ -80,5 +80,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
|
||||
- `testing.instructions.md` for Playwright/MSW/test updates
|
||||
- `api.instructions.md` for API/schema/handler changes
|
||||
- `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.
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
[](https://github.com/ant-design/ant-design)
|
||||
[](https://react.dev)
|
||||
[](https://viteplus.dev)
|
||||
@@ -80,16 +82,18 @@ vp run test:e2e:core
|
||||
vp run test:e2e:ui # interactive UI mode
|
||||
```
|
||||
|
||||
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
||||
`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
|
||||
|
||||
```
|
||||
.github/instructions/ # Scoped AI recipes (mirrored in ../../.cursor/rules/with-lingui-*.mdc)
|
||||
src/
|
||||
├── api/ # Zod schemas, endpoint constants, type exports
|
||||
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||
│ ├── auth.ts # Auth endpoint constants
|
||||
│ └── user.ts # User CRUD endpoint constants
|
||||
│ ├── user.ts # User CRUD endpoint constants
|
||||
│ └── orders.ts # Example second resource API (mirror `user.ts`)
|
||||
├── components/
|
||||
│ ├── Aurora/ # Login background effect
|
||||
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||
@@ -119,12 +123,13 @@ src/
|
||||
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||
│ ├── data.ts # Mock seed data (users, menus)
|
||||
│ ├── 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
|
||||
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
||||
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||
│ ├── _auth/dashboard/index.tsx
|
||||
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||
│ ├── _auth/orders/ # Example second resource (mirror users CRUD)
|
||||
│ ├── _auth/403/index.tsx
|
||||
│ ├── login/index.tsx
|
||||
│ ├── 404/index.tsx
|
||||
@@ -135,11 +140,12 @@ src/
|
||||
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
||||
└── main.tsx # Entry point (MSW init → React render)
|
||||
|
||||
tests/
|
||||
└── e2e/ # Playwright E2E tests
|
||||
├── helpers.ts
|
||||
├── login.spec.ts
|
||||
└── users.spec.ts
|
||||
e2e/ # Playwright E2E tests
|
||||
├── helpers.ts
|
||||
├── login.spec.ts
|
||||
├── users.spec.ts
|
||||
└── orders.spec.ts # Example resource smoke test
|
||||
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
@@ -157,9 +163,16 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
|
||||
|
||||
## 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.
|
||||
- **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
|
||||
|
||||
- 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 |
|
||||
| `/dashboard` | Statistics overview cards |
|
||||
| `/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 |
|
||||
| `/404` | Not found error page |
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
|
||||
await page.goto("/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/);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
|
||||
await searchInput.fill("zhao.ming");
|
||||
await searchInput.press("Enter");
|
||||
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
|
||||
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -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.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
"fmt": "vp fmt",
|
||||
"lint": "vp lint",
|
||||
"check": "vp check --no-fmt",
|
||||
"test:unit": "vitest run",
|
||||
"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",
|
||||
"i18n:extract": "vp exec lingui extract",
|
||||
"i18n:compile": "vp exec lingui compile"
|
||||
@@ -41,7 +42,8 @@
|
||||
"msw": "^2.12.12",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vite-plus": "latest"
|
||||
"vite-plus": "latest",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
|
||||
declare const process: { env: { CI?: string } };
|
||||
|
||||
const ci = Boolean(process.env.CI);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: ci,
|
||||
retries: ci ? 2 : 0,
|
||||
|
||||
@@ -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 };
|
||||
@@ -11,7 +11,6 @@ export const UserSchema = z.object({
|
||||
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
|
||||
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
||||
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
||||
|
||||
@@ -22,6 +21,12 @@ export const AuthTokensSchema = z.object({
|
||||
|
||||
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({
|
||||
username: 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 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 type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
|
||||
*/
|
||||
export function DataTableEmpty(): ReactElement {
|
||||
const { t } = useLingui();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
@@ -29,10 +29,6 @@ export type DataTableSkeletonProps<RecordType extends object> = Pick<
|
||||
TableProps<RecordType>,
|
||||
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
|
||||
> & {
|
||||
/**
|
||||
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
|
||||
* or from the table frame height so the body roughly fills the visible area.
|
||||
*/
|
||||
rowCount?: number;
|
||||
};
|
||||
|
||||
@@ -54,7 +50,6 @@ function isColumnGroup<RecordType extends object>(
|
||||
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
|
||||
}
|
||||
|
||||
/** ~ text icon button, capped small */
|
||||
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
|
||||
if (typeof col.width === "number") {
|
||||
return Math.min(24, Math.max(20, col.width - 36));
|
||||
|
||||
@@ -6,24 +6,14 @@ import { DataTableEmpty } from "./DataTableEmpty";
|
||||
import { DataTableSkeleton } from "./DataTableSkeleton";
|
||||
import "./index.css";
|
||||
|
||||
/** Merged onto `Table` `rootClassName` with the flex layout class. */
|
||||
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
|
||||
|
||||
export type DataTableProps<RecordType extends object = object> = {
|
||||
/**
|
||||
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
|
||||
* and optional `frameHeight` can pin the outer height.
|
||||
*/
|
||||
lockScrollHeight?: boolean;
|
||||
/** Outer `max-height` (px) */
|
||||
maxHeight?: number;
|
||||
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
|
||||
frameHeight?: number;
|
||||
/** Flex column wrapping frame + optional `bottomExtra` */
|
||||
layoutRef?: Ref<HTMLDivElement | null>;
|
||||
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
|
||||
frameRef?: Ref<HTMLDivElement | null>;
|
||||
/** Optional content below the bordered table frame */
|
||||
bottomExtra?: ReactNode;
|
||||
} & TableProps<RecordType>;
|
||||
|
||||
|
||||
@@ -4,18 +4,14 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } f
|
||||
|
||||
export type FilterToolbarSlot = {
|
||||
key: string;
|
||||
/** Used to decide how many filters fit in one row before the rest go into the popover */
|
||||
minWidth: number;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export type FilterToolbarProps = {
|
||||
slots: FilterToolbarSlot[];
|
||||
/** Right side: primary actions (not collapsed) */
|
||||
actions: React.ReactNode;
|
||||
/** Reserve width for the “more filters” control when some slots are in the popover */
|
||||
collapseTriggerReserve?: number;
|
||||
/** Accessible label + optional popover title */
|
||||
moreFiltersLabel: React.ReactNode;
|
||||
moreFiltersTitle?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -22,10 +22,6 @@ function normalizePath(pathname: string): string {
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CircleDashed,
|
||||
Folder,
|
||||
Home,
|
||||
Package,
|
||||
PanelLeft,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
@@ -26,12 +27,12 @@ import { UserMenu } from "../UserMenu";
|
||||
import "./index.css";
|
||||
|
||||
const { Sider } = Layout;
|
||||
/** API menu `name` → Lingui descriptors (extracted into catalogs). */
|
||||
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
|
||||
Platform: msg`Platform`,
|
||||
Projects: msg`Projects`,
|
||||
Dashboard: msg`Dashboard`,
|
||||
Users: msg`Users`,
|
||||
Orders: msg`Orders`,
|
||||
"Design Engineering": msg`Design Engineering`,
|
||||
"Sales & Marketing": msg`Sales & Marketing`,
|
||||
};
|
||||
@@ -47,7 +48,6 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
||||
IconLucideLayoutDashboard: Home,
|
||||
IconLucideUsers: User,
|
||||
IconLucideUserList: Users,
|
||||
/** Back-compat for older menu payloads still using IconLucideHistory */
|
||||
IconLucideHistory: Users,
|
||||
IconLucideStar: Star,
|
||||
IconLucideSettings: SlidersHorizontal,
|
||||
@@ -55,6 +55,7 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
||||
IconLucideBookOpen: Book,
|
||||
IconLucideFolderKanban: Folder,
|
||||
IconLucideSparkles: Zap,
|
||||
IconLucidePackage: Package,
|
||||
};
|
||||
|
||||
function renderMenuIcon(icon: string | null, size = 16) {
|
||||
@@ -179,7 +180,6 @@ export function Sidebar() {
|
||||
}
|
||||
}, [isMobile, setSidebarCollapsed]);
|
||||
|
||||
/** Merge open keys required by the route with any submenu the user already expanded. */
|
||||
useEffect(() => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -219,7 +219,6 @@ export function Sidebar() {
|
||||
align={isCollapsed ? "center" : "stretch"}
|
||||
style={{
|
||||
paddingBlock: token.paddingSM,
|
||||
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
|
||||
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
|
||||
minHeight: 64,
|
||||
flexShrink: 0,
|
||||
@@ -237,7 +236,6 @@ export function Sidebar() {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
boxSizing: "border-box",
|
||||
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
|
||||
paddingInline: isCollapsed ? 0 : token.paddingXS,
|
||||
minHeight: 40,
|
||||
justifyContent: isCollapsed ? "center" : "flex-start",
|
||||
@@ -348,7 +346,12 @@ export function Sidebar() {
|
||||
background: "transparent",
|
||||
}}
|
||||
/>
|
||||
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
|
||||
<UserMenu
|
||||
collapsed={isCollapsed}
|
||||
user={user}
|
||||
userMenuItems={userMenuItems}
|
||||
accountMenuTriggerAriaLabel={t`Open account menu`}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ interface UserMenuProps {
|
||||
collapsed: boolean;
|
||||
user: User | null;
|
||||
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 avatarSrc = (user?.avatar ?? "").trim() || undefined;
|
||||
@@ -33,7 +34,12 @@ export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
|
||||
);
|
||||
|
||||
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 (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro";
|
||||
import { ArrowLeft, Home, SearchX } from "lucide-react";
|
||||
import { Button, Flex, Result, Space, theme } from "antd";
|
||||
|
||||
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
|
||||
export function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLingui();
|
||||
@@ -25,7 +24,6 @@ export function NotFound() {
|
||||
<Flex
|
||||
vertical
|
||||
style={{
|
||||
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
|
||||
height: "100dvh",
|
||||
maxHeight: "100dvh",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { theme } from "antd";
|
||||
import type { ConfigProviderProps, ThemeConfig } from "antd";
|
||||
|
||||
/**
|
||||
* Common theme token builders
|
||||
* Reduces duplication between light and dark theme definitions
|
||||
*/
|
||||
export function readBrandPrimary(): string {
|
||||
const raw = import.meta.env.VITE_BRAND_PRIMARY as string | undefined;
|
||||
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw.trim())) {
|
||||
return raw.trim();
|
||||
}
|
||||
return "#1677ff";
|
||||
}
|
||||
|
||||
const BRAND_PRIMARY = readBrandPrimary();
|
||||
|
||||
export const SHARED_DESIGN_TOKENS = {
|
||||
fontFamily:
|
||||
@@ -26,9 +31,6 @@ export const CONTROL_COMPONENTS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Calculate table row selected color based on theme algorithm
|
||||
*/
|
||||
export function buildTableTokens(cfg: ThemeConfig) {
|
||||
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
|
||||
return {
|
||||
@@ -37,9 +39,6 @@ export function buildTableTokens(cfg: ThemeConfig) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu tokens for light theme
|
||||
*/
|
||||
export const MENU_LIGHT = {
|
||||
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
|
||||
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
||||
@@ -53,9 +52,6 @@ export const MENU_LIGHT = {
|
||||
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build menu tokens for dark theme
|
||||
*/
|
||||
export const MENU_DARK = {
|
||||
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
||||
@@ -69,12 +65,12 @@ export const MENU_DARK = {
|
||||
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build light theme config
|
||||
*/
|
||||
export function buildLightThemeConfig(): ConfigProviderProps {
|
||||
const lightSeed: ThemeConfig["token"] = {
|
||||
colorBgLayout: "#ffffff",
|
||||
colorPrimary: BRAND_PRIMARY,
|
||||
colorLink: BRAND_PRIMARY,
|
||||
colorInfo: BRAND_PRIMARY,
|
||||
...SHARED_DESIGN_TOKENS,
|
||||
};
|
||||
|
||||
@@ -94,11 +90,11 @@ export function buildLightThemeConfig(): ConfigProviderProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dark theme config
|
||||
*/
|
||||
export function buildDarkThemeConfig(): ConfigProviderProps {
|
||||
const darkSeed: ThemeConfig["token"] = {
|
||||
colorPrimary: BRAND_PRIMARY,
|
||||
colorLink: BRAND_PRIMARY,
|
||||
colorInfo: BRAND_PRIMARY,
|
||||
...SHARED_DESIGN_TOKENS,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import type { ConfigProviderProps } from "antd";
|
||||
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
|
||||
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 {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
|
||||
type MutationLifecycle<TValues> = {
|
||||
onMutate?: (values: TValues) => void;
|
||||
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
|
||||
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[];
|
||||
invalidateKey: readonly unknown[];
|
||||
invalidateKey?: readonly unknown[];
|
||||
queryFn: () => Promise<unknown>;
|
||||
select: (raw: unknown) => TListData;
|
||||
createFn: (values: TCreateValues) => Promise<unknown>;
|
||||
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
||||
createLifecycle?: MutationLifecycle<TCreateValues>;
|
||||
updateLifecycle?: MutationLifecycle<TUpdateValues>;
|
||||
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>,
|
||||
) {
|
||||
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
|
||||
const queryKey = options.queryKey;
|
||||
const opt = options.optimistic;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: options.queryKey,
|
||||
queryKey,
|
||||
queryFn: options.queryFn,
|
||||
select: options.select,
|
||||
});
|
||||
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||
options.createLifecycle?.onMutate?.(values);
|
||||
},
|
||||
onSuccess: (_data, values) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
options.createLifecycle?.onSuccess?.(values);
|
||||
},
|
||||
onError: (_error, values) => {
|
||||
@@ -46,28 +109,59 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
||||
|
||||
const updateMutation = useMutation({
|
||||
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);
|
||||
return { snapshot: applied ? previous : undefined };
|
||||
},
|
||||
onSuccess: (_data, values) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
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);
|
||||
return { snapshot: applied ? previous : undefined };
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
||||
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -17,62 +17,65 @@ msgstr ""
|
||||
#~ msgid "{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"
|
||||
msgstr "{total} rows"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:35
|
||||
#: src/routes/_auth/dashboard/index.tsx:144
|
||||
msgid "+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"
|
||||
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"
|
||||
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"
|
||||
msgstr "+201 since last hour"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:78
|
||||
#: src/components/Layout/Header/index.tsx:83
|
||||
msgid "403"
|
||||
msgstr "403"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:45
|
||||
#: src/routes/_auth/dashboard/index.tsx:154
|
||||
msgid "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
|
||||
msgid "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?"
|
||||
msgstr "Are you absolutely sure?"
|
||||
|
||||
#: src/routes/login/index.tsx:185
|
||||
#: src/routes/login/index.tsx:181
|
||||
msgid "Auto login"
|
||||
msgstr "Auto login"
|
||||
|
||||
#: src/components/NotFound/index.tsx:54
|
||||
#: src/routes/_auth/403/index.tsx:40
|
||||
#: src/components/NotFound/index.tsx:59
|
||||
#: src/routes/_auth/403/index.tsx:35
|
||||
msgid "Back to Home"
|
||||
msgstr "Back to Home"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:32
|
||||
#: src/routes/_auth/users/index.tsx:168
|
||||
#: src/routes/_auth/orders/index.tsx:116
|
||||
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||
#: src/routes/_auth/users/index.tsx:128
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:134
|
||||
#: src/routes/_auth/dashboard/index.tsx:250
|
||||
msgid "Chart Placeholder"
|
||||
msgstr "Chart Placeholder"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:106
|
||||
#: src/hooks/useCrudToasts.ts:43
|
||||
msgid "Create failed"
|
||||
msgstr "Create failed"
|
||||
|
||||
@@ -80,53 +83,68 @@ msgstr "Create failed"
|
||||
msgid "Create User"
|
||||
msgstr "Create User"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:100
|
||||
#: src/hooks/useCrudToasts.ts:40
|
||||
msgid "Created successfully"
|
||||
msgstr "Created successfully"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:74
|
||||
#: src/components/Layout/Header/index.tsx:87
|
||||
#: src/components/Layout/Header/index.tsx:91
|
||||
#: src/components/Layout/Sidebar/index.tsx:32
|
||||
#: src/components/Layout/Header/index.tsx:79
|
||||
#: src/components/Layout/Header/index.tsx:92
|
||||
#: src/components/Layout/Header/index.tsx:96
|
||||
#: src/components/Layout/Sidebar/index.tsx:34
|
||||
msgid "Dashboard"
|
||||
msgstr "Dashboard"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:166
|
||||
#: src/routes/_auth/users/index.tsx:257
|
||||
#: src/routes/_auth/orders/index.tsx:114
|
||||
#: src/routes/_auth/orders/index.tsx:152
|
||||
#: src/routes/_auth/users/index.tsx:126
|
||||
#: src/routes/_auth/users/index.tsx:223
|
||||
msgid "Delete"
|
||||
msgstr "Delete"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:156
|
||||
#: src/hooks/useCrudToasts.ts:65
|
||||
msgid "Delete failed"
|
||||
msgstr "Delete failed"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:149
|
||||
#: src/hooks/useCrudToasts.ts:62
|
||||
msgid "Deleted successfully"
|
||||
msgstr "Deleted successfully"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:142
|
||||
#: src/hooks/useCrudToasts.ts:59
|
||||
msgid "Deleting…"
|
||||
msgstr "Deleting…"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:35
|
||||
#: src/components/Layout/Sidebar/index.tsx:37
|
||||
msgid "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"
|
||||
msgstr "Edit"
|
||||
|
||||
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||
msgid "Edit Order"
|
||||
msgstr "Edit Order"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||
msgid "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
|
||||
msgid "Editor"
|
||||
msgstr "Editor"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:63
|
||||
#: src/routes/_auth/users/index.tsx:202
|
||||
#: src/routes/_auth/users/-FormModal.tsx:57
|
||||
#: src/routes/_auth/users/index.tsx:168
|
||||
msgid "Email"
|
||||
msgstr "Email"
|
||||
|
||||
@@ -134,27 +152,34 @@ msgstr "Email"
|
||||
#~ msgid "Filter by role"
|
||||
#~ msgstr "Filter by role"
|
||||
|
||||
#: src/routes/login/index.tsx:192
|
||||
#: src/routes/login/index.tsx:188
|
||||
msgid "Forgot password?"
|
||||
msgstr "Forgot password?"
|
||||
|
||||
#: src/components/NotFound/index.tsx:57
|
||||
#: src/components/NotFound/index.tsx:62
|
||||
msgid "Go back"
|
||||
msgstr "Go back"
|
||||
|
||||
#: src/routes/login/index.tsx:68
|
||||
#: src/routes/login/index.tsx:54
|
||||
msgid "Login failed"
|
||||
msgstr "Login failed"
|
||||
|
||||
#: src/routes/login/index.tsx:64
|
||||
#: src/routes/login/index.tsx:50
|
||||
msgid "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:93
|
||||
msgid "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
|
||||
msgid "New User"
|
||||
msgstr "New User"
|
||||
@@ -167,11 +192,23 @@ msgstr "No data"
|
||||
msgid "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"
|
||||
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"
|
||||
msgstr "Overview"
|
||||
|
||||
@@ -179,56 +216,74 @@ msgstr "Overview"
|
||||
#~ msgid "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"
|
||||
msgstr "Password"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:30
|
||||
#: src/components/Layout/Sidebar/index.tsx:32
|
||||
msgid "Platform"
|
||||
msgstr "Platform"
|
||||
|
||||
#: src/routes/login/index.tsx:172
|
||||
#: src/routes/login/index.tsx:163
|
||||
msgid "Please enter password"
|
||||
msgstr "Please enter password"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||
#: src/routes/login/index.tsx:164
|
||||
#: src/routes/_auth/users/-FormModal.tsx:40
|
||||
#: src/routes/login/index.tsx:150
|
||||
msgid "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"
|
||||
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"
|
||||
msgstr "Powered by"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:31
|
||||
#: src/components/Layout/Sidebar/index.tsx:33
|
||||
msgid "Projects"
|
||||
msgstr "Projects"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:142
|
||||
#: src/routes/_auth/dashboard/index.tsx:259
|
||||
msgid "Recent Sales"
|
||||
msgstr "Recent Sales"
|
||||
|
||||
#: src/components/RouteError.tsx:20
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
|
||||
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||
msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||
#: src/routes/_auth/users/index.tsx:209
|
||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||
#: src/routes/_auth/users/index.tsx:175
|
||||
msgid "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"
|
||||
msgstr "Sales"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:36
|
||||
#: src/components/Layout/Sidebar/index.tsx:38
|
||||
msgid "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
|
||||
msgid "Search User"
|
||||
msgstr "Search User"
|
||||
@@ -237,7 +292,7 @@ msgstr "Search User"
|
||||
#~ msgid "Search username or email"
|
||||
#~ msgstr "Search username or email"
|
||||
|
||||
#: src/routes/login/index.tsx:204
|
||||
#: src/routes/login/index.tsx:200
|
||||
msgid "Sign In"
|
||||
msgstr "Sign In"
|
||||
|
||||
@@ -245,70 +300,89 @@ msgstr "Sign In"
|
||||
#~ msgid "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"
|
||||
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."
|
||||
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."
|
||||
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"
|
||||
msgstr "Subscriptions"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:145
|
||||
#: src/routes/login/index.tsx:225
|
||||
#: src/components/Layout/Header/index.tsx:153
|
||||
#: src/routes/login/index.tsx:221
|
||||
msgid "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."
|
||||
msgstr "This action cannot be undone. This will permanently delete the user."
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:133
|
||||
#: src/components/Layout/Sidebar/index.tsx:263
|
||||
#: src/components/Layout/Sidebar/index.tsx:311
|
||||
#: src/routes/_auth/orders/-FormModal.tsx:28
|
||||
#: src/routes/_auth/orders/index.tsx:123
|
||||
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"
|
||||
msgstr "Toggle sidebar"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:151
|
||||
#: src/routes/login/index.tsx:232
|
||||
#: src/components/Layout/Header/index.tsx:159
|
||||
#: src/routes/login/index.tsx:228
|
||||
msgid "Toggle Theme"
|
||||
msgstr "Toggle Theme"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:27
|
||||
#: src/routes/_auth/dashboard/index.tsx:136
|
||||
msgid "Total Revenue"
|
||||
msgstr "Total Revenue"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:132
|
||||
#: src/hooks/useCrudToasts.ts:54
|
||||
msgid "Update failed"
|
||||
msgstr "Update failed"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:122
|
||||
#: src/hooks/useCrudToasts.ts:51
|
||||
msgid "Updated successfully"
|
||||
msgstr "Updated successfully"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:115
|
||||
#: src/hooks/useCrudToasts.ts:48
|
||||
msgid "Updating…"
|
||||
msgstr "Updating…"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:34
|
||||
msgid "User List"
|
||||
msgstr "User List"
|
||||
#~ msgid "User List"
|
||||
#~ msgstr "User List"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:45
|
||||
#: src/routes/_auth/users/index.tsx:182
|
||||
#: src/routes/login/index.tsx:163
|
||||
#: src/routes/_auth/users/-FormModal.tsx:39
|
||||
#: src/routes/_auth/users/index.tsx:142
|
||||
#: src/routes/login/index.tsx:149
|
||||
#: src/routes/login/index.tsx:154
|
||||
msgid "Username"
|
||||
msgstr "Username"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:76
|
||||
#: src/components/Layout/Sidebar/index.tsx:33
|
||||
#: src/components/Layout/Header/index.tsx:81
|
||||
#: src/components/Layout/Sidebar/index.tsx:35
|
||||
msgid "Users"
|
||||
msgstr "Users"
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ import type { Locale } from "@/stores/settings";
|
||||
|
||||
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> {
|
||||
const hit = catalogCache[locale];
|
||||
if (hit) {
|
||||
|
||||
@@ -17,62 +17,65 @@ msgstr ""
|
||||
#~ msgid "{n} rows"
|
||||
#~ 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"
|
||||
msgstr "共 {total} 条"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:35
|
||||
#: src/routes/_auth/dashboard/index.tsx:144
|
||||
msgid "+180.1% from last month"
|
||||
msgstr "较上月 +180.1%"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:41
|
||||
#: src/routes/_auth/dashboard/index.tsx:150
|
||||
msgid "+19% from last month"
|
||||
msgstr "较上月 +19%"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:29
|
||||
#: src/routes/_auth/dashboard/index.tsx:138
|
||||
msgid "+20.1% from last month"
|
||||
msgstr "较上月 +20.1%"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:47
|
||||
#: src/routes/_auth/dashboard/index.tsx:156
|
||||
msgid "+201 since last hour"
|
||||
msgstr "较上一小时 +201"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:78
|
||||
#: src/components/Layout/Header/index.tsx:83
|
||||
msgid "403"
|
||||
msgstr "403"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:45
|
||||
#: src/routes/_auth/dashboard/index.tsx:154
|
||||
msgid "Active Now"
|
||||
msgstr "当前在线"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:58
|
||||
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||
#: src/routes/_auth/users/-Toolbar.tsx:64
|
||||
msgid "Admin"
|
||||
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?"
|
||||
msgstr "确定要执行此操作吗?"
|
||||
|
||||
#: src/routes/login/index.tsx:185
|
||||
#: src/routes/login/index.tsx:181
|
||||
msgid "Auto login"
|
||||
msgstr "自动登录"
|
||||
|
||||
#: src/components/NotFound/index.tsx:54
|
||||
#: src/routes/_auth/403/index.tsx:40
|
||||
#: src/components/NotFound/index.tsx:59
|
||||
#: src/routes/_auth/403/index.tsx:35
|
||||
msgid "Back to Home"
|
||||
msgstr "返回首页"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:32
|
||||
#: src/routes/_auth/users/index.tsx:168
|
||||
#: src/routes/_auth/orders/index.tsx:116
|
||||
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||
#: src/routes/_auth/users/index.tsx:128
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:134
|
||||
#: src/routes/_auth/dashboard/index.tsx:250
|
||||
msgid "Chart Placeholder"
|
||||
msgstr "图表占位"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:106
|
||||
#: src/hooks/useCrudToasts.ts:43
|
||||
msgid "Create failed"
|
||||
msgstr "创建失败"
|
||||
|
||||
@@ -80,53 +83,68 @@ msgstr "创建失败"
|
||||
msgid "Create User"
|
||||
msgstr "创建用户"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:100
|
||||
#: src/hooks/useCrudToasts.ts:40
|
||||
msgid "Created successfully"
|
||||
msgstr "创建成功"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:74
|
||||
#: src/components/Layout/Header/index.tsx:87
|
||||
#: src/components/Layout/Header/index.tsx:91
|
||||
#: src/components/Layout/Sidebar/index.tsx:32
|
||||
#: src/components/Layout/Header/index.tsx:79
|
||||
#: src/components/Layout/Header/index.tsx:92
|
||||
#: src/components/Layout/Header/index.tsx:96
|
||||
#: src/components/Layout/Sidebar/index.tsx:34
|
||||
msgid "Dashboard"
|
||||
msgstr "仪表盘"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:166
|
||||
#: src/routes/_auth/users/index.tsx:257
|
||||
#: src/routes/_auth/orders/index.tsx:114
|
||||
#: src/routes/_auth/orders/index.tsx:152
|
||||
#: src/routes/_auth/users/index.tsx:126
|
||||
#: src/routes/_auth/users/index.tsx:223
|
||||
msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:156
|
||||
#: src/hooks/useCrudToasts.ts:65
|
||||
msgid "Delete failed"
|
||||
msgstr "删除失败"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:149
|
||||
#: src/hooks/useCrudToasts.ts:62
|
||||
msgid "Deleted successfully"
|
||||
msgstr "删除成功"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:142
|
||||
#: src/hooks/useCrudToasts.ts:59
|
||||
msgid "Deleting…"
|
||||
msgstr "正在删除…"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:35
|
||||
#: src/components/Layout/Sidebar/index.tsx:37
|
||||
msgid "Design Engineering"
|
||||
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"
|
||||
msgstr "编辑"
|
||||
|
||||
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||
msgid "Edit Order"
|
||||
msgstr "编辑订单"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||
msgid "Edit User"
|
||||
msgstr "编辑用户"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:59
|
||||
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||
#: src/routes/_auth/users/-Toolbar.tsx:65
|
||||
msgid "Editor"
|
||||
msgstr "编辑者"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:63
|
||||
#: src/routes/_auth/users/index.tsx:202
|
||||
#: src/routes/_auth/users/-FormModal.tsx:57
|
||||
#: src/routes/_auth/users/index.tsx:168
|
||||
msgid "Email"
|
||||
msgstr "邮箱"
|
||||
|
||||
@@ -134,27 +152,34 @@ msgstr "邮箱"
|
||||
#~ msgid "Filter by role"
|
||||
#~ msgstr "按角色筛选"
|
||||
|
||||
#: src/routes/login/index.tsx:192
|
||||
#: src/routes/login/index.tsx:188
|
||||
msgid "Forgot password?"
|
||||
msgstr "忘记密码?"
|
||||
|
||||
#: src/components/NotFound/index.tsx:57
|
||||
#: src/components/NotFound/index.tsx:62
|
||||
msgid "Go back"
|
||||
msgstr "返回上一页"
|
||||
|
||||
#: src/routes/login/index.tsx:68
|
||||
#: src/routes/login/index.tsx:54
|
||||
msgid "Login failed"
|
||||
msgstr "登录失败"
|
||||
|
||||
#: src/routes/login/index.tsx:64
|
||||
#: src/routes/login/index.tsx:50
|
||||
msgid "Login successful"
|
||||
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:93
|
||||
msgid "More filters"
|
||||
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
|
||||
msgid "New User"
|
||||
msgstr "新建用户"
|
||||
@@ -167,11 +192,23 @@ msgstr "暂无数据"
|
||||
msgid "Nothing to show in this list yet"
|
||||
msgstr "当前列表为空,暂无可展示的记录"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||
#: src/routes/_auth/users/-FormModal.tsx:30
|
||||
msgid "OK"
|
||||
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"
|
||||
msgstr "概览"
|
||||
|
||||
@@ -179,56 +216,74 @@ msgstr "概览"
|
||||
#~ msgid "Page not found"
|
||||
#~ msgstr "页面不存在"
|
||||
|
||||
#: src/routes/login/index.tsx:171
|
||||
#: src/routes/login/index.tsx:162
|
||||
#: src/routes/login/index.tsx:168
|
||||
msgid "Password"
|
||||
msgstr "密码"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:30
|
||||
#: src/components/Layout/Sidebar/index.tsx:32
|
||||
msgid "Platform"
|
||||
msgstr "平台"
|
||||
|
||||
#: src/routes/login/index.tsx:172
|
||||
#: src/routes/login/index.tsx:163
|
||||
msgid "Please enter password"
|
||||
msgstr "请输入密码"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||
#: src/routes/login/index.tsx:164
|
||||
#: src/routes/_auth/users/-FormModal.tsx:40
|
||||
#: src/routes/login/index.tsx:150
|
||||
msgid "Please enter username"
|
||||
msgstr "请输入用户名"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||
#: src/routes/_auth/users/-FormModal.tsx:47
|
||||
msgid "Please select roles"
|
||||
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"
|
||||
msgstr "由"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:31
|
||||
#: src/components/Layout/Sidebar/index.tsx:33
|
||||
msgid "Projects"
|
||||
msgstr "项目"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:142
|
||||
#: src/routes/_auth/dashboard/index.tsx:259
|
||||
msgid "Recent Sales"
|
||||
msgstr "近期销售"
|
||||
|
||||
#: src/components/RouteError.tsx:20
|
||||
msgid "Retry"
|
||||
msgstr "重试"
|
||||
|
||||
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||
msgid "Role"
|
||||
msgstr "角色"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||
#: src/routes/_auth/users/index.tsx:209
|
||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||
#: src/routes/_auth/users/index.tsx:175
|
||||
msgid "Roles"
|
||||
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"
|
||||
msgstr "销售额"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:36
|
||||
#: src/components/Layout/Sidebar/index.tsx:38
|
||||
msgid "Sales & Marketing"
|
||||
msgstr "销售与市场"
|
||||
|
||||
#: src/routes/_auth/orders/-Toolbar.tsx:32
|
||||
msgid "Search Order"
|
||||
msgstr "搜索订单"
|
||||
|
||||
#: src/routes/_auth/users/-Toolbar.tsx:43
|
||||
msgid "Search User"
|
||||
msgstr "搜索用户"
|
||||
@@ -237,7 +292,7 @@ msgstr "搜索用户"
|
||||
#~ msgid "Search username or email"
|
||||
#~ msgstr "搜索用户名或邮箱"
|
||||
|
||||
#: src/routes/login/index.tsx:204
|
||||
#: src/routes/login/index.tsx:200
|
||||
msgid "Sign In"
|
||||
msgstr "登录"
|
||||
|
||||
@@ -245,70 +300,89 @@ msgstr "登录"
|
||||
#~ msgid "Sign in with your credentials."
|
||||
#~ msgstr "凭用户名与密码登录"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:194
|
||||
#: src/components/Layout/Sidebar/index.tsx:197
|
||||
msgid "Sign Out"
|
||||
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."
|
||||
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."
|
||||
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"
|
||||
msgstr "订阅"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:145
|
||||
#: src/routes/login/index.tsx:225
|
||||
#: src/components/Layout/Header/index.tsx:153
|
||||
#: src/routes/login/index.tsx:221
|
||||
msgid "Switch language"
|
||||
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."
|
||||
msgstr "此操作无法撤销,将永久删除该用户。"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:133
|
||||
#: src/components/Layout/Sidebar/index.tsx:263
|
||||
#: src/components/Layout/Sidebar/index.tsx:311
|
||||
#: src/routes/_auth/orders/-FormModal.tsx:28
|
||||
#: src/routes/_auth/orders/index.tsx:123
|
||||
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"
|
||||
msgstr "折叠侧栏"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:151
|
||||
#: src/routes/login/index.tsx:232
|
||||
#: src/components/Layout/Header/index.tsx:159
|
||||
#: src/routes/login/index.tsx:228
|
||||
msgid "Toggle Theme"
|
||||
msgstr "切换主题"
|
||||
|
||||
#: src/routes/_auth/dashboard/index.tsx:27
|
||||
#: src/routes/_auth/dashboard/index.tsx:136
|
||||
msgid "Total Revenue"
|
||||
msgstr "总收入"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:132
|
||||
#: src/hooks/useCrudToasts.ts:54
|
||||
msgid "Update failed"
|
||||
msgstr "更新失败"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:122
|
||||
#: src/hooks/useCrudToasts.ts:51
|
||||
msgid "Updated successfully"
|
||||
msgstr "更新成功"
|
||||
|
||||
#: src/routes/_auth/users/index.tsx:115
|
||||
#: src/hooks/useCrudToasts.ts:48
|
||||
msgid "Updating…"
|
||||
msgstr "正在更新…"
|
||||
|
||||
#: src/components/Layout/Sidebar/index.tsx:34
|
||||
msgid "User List"
|
||||
msgstr "用户列表"
|
||||
#~ msgid "User List"
|
||||
#~ msgstr "用户列表"
|
||||
|
||||
#: src/routes/_auth/users/-FormModal.tsx:45
|
||||
#: src/routes/_auth/users/index.tsx:182
|
||||
#: src/routes/login/index.tsx:163
|
||||
#: src/routes/_auth/users/-FormModal.tsx:39
|
||||
#: src/routes/_auth/users/index.tsx:142
|
||||
#: src/routes/login/index.tsx:149
|
||||
#: src/routes/login/index.tsx:154
|
||||
msgid "Username"
|
||||
msgstr "用户名"
|
||||
|
||||
#: src/components/Layout/Header/index.tsx:76
|
||||
#: src/components/Layout/Sidebar/index.tsx:33
|
||||
#: src/components/Layout/Header/index.tsx:81
|
||||
#: src/components/Layout/Sidebar/index.tsx:35
|
||||
msgid "Users"
|
||||
msgstr "用户"
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import { i18n } from "@lingui/core";
|
||||
import { loadLocaleCatalog } from "./locales/loadLocaleCatalog";
|
||||
import { useSettingsStore } from "./stores/settings";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { fetchSessionAndApplyToStore } from "./utils/session";
|
||||
import { installHttpRouter } from "./utils/http";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
installHttpRouter(router);
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
@@ -25,22 +26,17 @@ async function enableMocking() {
|
||||
|
||||
enableMocking()
|
||||
.then(async () => {
|
||||
await useSettingsStore.persist.rehydrate();
|
||||
await Promise.all([
|
||||
useSettingsStore.persist.rehydrate(),
|
||||
useAuthStore.persist.rehydrate(),
|
||||
]);
|
||||
|
||||
const initialLocale = useSettingsStore.getState().locale;
|
||||
const messages = await loadLocaleCatalog(initialLocale);
|
||||
i18n.load(initialLocale, messages);
|
||||
i18n.activate(initialLocale);
|
||||
document.documentElement.lang = initialLocale === "zh" ? "zh" : "en";
|
||||
|
||||
await useAuthStore.persist.rehydrate();
|
||||
const { isAuthenticated, tokens } = useAuthStore.getState();
|
||||
if (isAuthenticated && tokens) {
|
||||
try {
|
||||
await fetchSessionAndApplyToStore();
|
||||
} catch {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
}
|
||||
document.documentElement.dir = "ltr";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<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);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,17 @@
|
||||
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> {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MSW handler factory functions
|
||||
* Provides consistent response wrapping and error handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wrap a handler function with standard response format
|
||||
* Automatically handles delay, error responses, and data wrapping
|
||||
*/
|
||||
export async function withDelay(ms: number = 200): Promise<void> {
|
||||
await mswDelay(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response
|
||||
*/
|
||||
export function successResponse<T>(data: T, message: string = "ok") {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
@@ -33,10 +20,42 @@ export function successResponse<T>(data: T, message: string = "ok") {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
*/
|
||||
export function errorResponse(code: number, message: string, data: unknown = null) {
|
||||
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
|
||||
export function successWithSchema<T extends ZodTypeAny>(
|
||||
schema: T,
|
||||
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({
|
||||
code,
|
||||
data,
|
||||
@@ -44,9 +63,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined error codes
|
||||
*/
|
||||
export const ERROR_CODES = {
|
||||
INVALID_CREDENTIALS: 1001,
|
||||
NOT_FOUND: 1002,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { User } from "@/api/schemas";
|
||||
import type { User, Order } from "@/api/schemas";
|
||||
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]> = [
|
||||
["admin", "ops.admin@northstar.io"],
|
||||
["zhao.ming", "zhao.ming@northstar.io"],
|
||||
@@ -16,11 +15,25 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
||||
["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) => ({
|
||||
id: String(i + 1),
|
||||
username,
|
||||
avatar: vercelAvatarUrl(username),
|
||||
email,
|
||||
roles: i === 0 ? ["admin"] : ["editor"],
|
||||
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
|
||||
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" },
|
||||
];
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import { http } from "msw";
|
||||
import { MOCK_USERS } from "../data";
|
||||
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
||||
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
|
||||
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 = [
|
||||
http.post("/api/auth/login", async ({ request }) => {
|
||||
await withDelay(300);
|
||||
const body = (await request.json()) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
if (body.username === "admin" && body.password === "admin") {
|
||||
return successResponse({
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await request.json();
|
||||
} catch {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
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",
|
||||
refreshToken: "mock-refresh-token",
|
||||
});
|
||||
@@ -18,20 +50,46 @@ export const authHandlers = [
|
||||
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);
|
||||
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",
|
||||
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]!;
|
||||
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);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authHandlers } from "./auth";
|
||||
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();
|
||||
}),
|
||||
];
|
||||
@@ -1,13 +1,27 @@
|
||||
import { http } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { UserSchema } from "@/api/schemas";
|
||||
import { MOCK_USERS } from "../data";
|
||||
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];
|
||||
|
||||
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
|
||||
|
||||
export const userHandlers = [
|
||||
http.get("/api/users", async ({ request }) => {
|
||||
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 { limit, offset } = parsePaginationParams(url.searchParams);
|
||||
const keyword = url.searchParams.get("keyword") ?? "";
|
||||
@@ -16,7 +30,7 @@ export const userHandlers = [
|
||||
const filtered = filterUsers(users, { keyword, role });
|
||||
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 }) => {
|
||||
@@ -28,10 +42,10 @@ export const userHandlers = [
|
||||
avatar: null,
|
||||
email: typeof body.email === "string" ? body.email : null,
|
||||
roles: (body.roles as string[]) ?? [],
|
||||
permissions: [],
|
||||
permissions: [] as string[],
|
||||
};
|
||||
users.push(newUser);
|
||||
return successResponse(newUser);
|
||||
return successWithSchema(UserSchema, newUser);
|
||||
}),
|
||||
|
||||
http.put("/api/users/:id", async ({ params, request }) => {
|
||||
@@ -41,13 +55,13 @@ export const userHandlers = [
|
||||
if (idx === -1) {
|
||||
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
|
||||
}
|
||||
users[idx] = { ...users[idx], ...body };
|
||||
return successResponse(users[idx]);
|
||||
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
|
||||
return successWithSchema(UserSchema, users[idx]);
|
||||
}),
|
||||
|
||||
http.delete("/api/users/:id", async ({ params }) => {
|
||||
await withDelay(200);
|
||||
users = users.filter((u) => u.id !== params.id);
|
||||
return successResponse(null);
|
||||
return successWithNullBody();
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as LoginIndexRouteImport } from './routes/login/index'
|
||||
import { Route as R404IndexRouteImport } from './routes/404/index'
|
||||
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
|
||||
import { Route as AuthOrdersIndexRouteImport } from './routes/_auth/orders/index'
|
||||
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
||||
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
|
||||
|
||||
@@ -41,6 +42,11 @@ const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
|
||||
path: '/users/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthOrdersIndexRoute = AuthOrdersIndexRouteImport.update({
|
||||
id: '/orders/',
|
||||
path: '/orders/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
||||
id: '/dashboard/',
|
||||
path: '/dashboard/',
|
||||
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
|
||||
'/login/': typeof LoginIndexRoute
|
||||
'/403/': typeof Auth403IndexRoute
|
||||
'/dashboard/': typeof AuthDashboardIndexRoute
|
||||
'/orders/': typeof AuthOrdersIndexRoute
|
||||
'/users/': typeof AuthUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -66,6 +73,7 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginIndexRoute
|
||||
'/403': typeof Auth403IndexRoute
|
||||
'/dashboard': typeof AuthDashboardIndexRoute
|
||||
'/orders': typeof AuthOrdersIndexRoute
|
||||
'/users': typeof AuthUsersIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -76,13 +84,21 @@ export interface FileRoutesById {
|
||||
'/login/': typeof LoginIndexRoute
|
||||
'/_auth/403/': typeof Auth403IndexRoute
|
||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
||||
'/_auth/orders/': typeof AuthOrdersIndexRoute
|
||||
'/_auth/users/': typeof AuthUsersIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/404/'
|
||||
| '/login/'
|
||||
| '/403/'
|
||||
| '/dashboard/'
|
||||
| '/orders/'
|
||||
| '/users/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
|
||||
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/orders' | '/users'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -91,6 +107,7 @@ export interface FileRouteTypes {
|
||||
| '/login/'
|
||||
| '/_auth/403/'
|
||||
| '/_auth/dashboard/'
|
||||
| '/_auth/orders/'
|
||||
| '/_auth/users/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -138,6 +155,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthUsersIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/orders/': {
|
||||
id: '/_auth/orders/'
|
||||
path: '/orders'
|
||||
fullPath: '/orders/'
|
||||
preLoaderRoute: typeof AuthOrdersIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/dashboard/': {
|
||||
id: '/_auth/dashboard/'
|
||||
path: '/dashboard'
|
||||
@@ -158,12 +182,14 @@ declare module '@tanstack/react-router' {
|
||||
interface AuthRouteChildren {
|
||||
Auth403IndexRoute: typeof Auth403IndexRoute
|
||||
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
||||
AuthOrdersIndexRoute: typeof AuthOrdersIndexRoute
|
||||
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
Auth403IndexRoute: Auth403IndexRoute,
|
||||
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
||||
AuthOrdersIndexRoute: AuthOrdersIndexRoute,
|
||||
AuthUsersIndexRoute: AuthUsersIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { ConfigProvider, App } from "antd";
|
||||
import enUS from "antd/locale/en_US";
|
||||
@@ -10,15 +10,31 @@ import { loadLocaleCatalog } from "@/locales/loadLocaleCatalog";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { NotFound } from "@/components/NotFound";
|
||||
import { RouteError } from "@/components/RouteError";
|
||||
import type { Locale } from "@/stores/settings";
|
||||
import "@/index.css";
|
||||
|
||||
const antdLocaleMap = { en: enUS, zh: zhCN } as const;
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
function AppQueryBridge({ children }: { children: React.ReactNode }) {
|
||||
const { message } = App.useApp();
|
||||
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() {
|
||||
const locale = useSettingsStore((s) => s.locale);
|
||||
@@ -39,6 +55,7 @@ function RootComponent() {
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
document.documentElement.lang = locale === "zh" ? "zh" : "en";
|
||||
document.documentElement.dir = "ltr";
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -46,24 +63,48 @@ function RootComponent() {
|
||||
};
|
||||
}, [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(() => {
|
||||
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
|
||||
<App>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
|
||||
<App>
|
||||
<AppQueryBridge>
|
||||
<Outlet />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</AppQueryBridge>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
notFoundComponent: NotFound,
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { MainLayout } from "@/components/Layout/MainLayout";
|
||||
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
|
||||
import { fetchSessionAndApplyToStore } from "@/utils/session";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
beforeLoad: ({ location }) => {
|
||||
const { isAuthenticated, user } = useAuthStore.getState();
|
||||
beforeLoad: async ({ location }) => {
|
||||
const { isAuthenticated, user, tokens } = useAuthStore.getState();
|
||||
if (!isAuthenticated) {
|
||||
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);
|
||||
if (path === "/403") return;
|
||||
|
||||
if (!canAccessPath(location.pathname, user?.permissions)) {
|
||||
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
|
||||
throw redirect({ to: "/403" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,10 +20,8 @@ async function fetchDashboardShell() {
|
||||
|
||||
type AntToken = ReturnType<typeof theme.useToken>["token"];
|
||||
|
||||
/** Matches loaded stat card body height: title row, value, description. */
|
||||
function StatCardSkeleton({ token }: { token: AntToken }) {
|
||||
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
|
||||
/** Lucide default icon box 24px to align with the first row of real cards. */
|
||||
const iconBox = 24;
|
||||
const titleRowHeight = Math.max(titleLine, iconBox);
|
||||
const valueLine = Math.round(24 * 1);
|
||||
@@ -123,7 +121,6 @@ function DashboardPage() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
|
||||
const cardHoverStyle = {
|
||||
["--dash-card-hover-bg" as string]: token.colorBgContainer,
|
||||
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
|
||||
|
||||
@@ -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 { FilterToolbar } from "@/components/FilterToolbar";
|
||||
|
||||
/** Search + role slot `minWidth` for FilterToolbar collapse math */
|
||||
const FILTER_CONTROL_WIDTH = 220;
|
||||
|
||||
export type ToolbarProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
||||
import type { TablePaginationConfig } from "antd/es/table/interface";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { USER_ENDPOINTS } from "@/api/user";
|
||||
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
|
||||
@@ -11,6 +11,9 @@ 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";
|
||||
|
||||
@@ -23,6 +26,8 @@ const UserSearchParamsSchema = z.object({
|
||||
role: z.string().catch(""),
|
||||
});
|
||||
|
||||
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
|
||||
|
||||
export const Route = createFileRoute("/_auth/users/")({
|
||||
validateSearch: (search) => UserSearchParamsSchema.parse(search),
|
||||
component: UsersPage,
|
||||
@@ -30,10 +35,6 @@ export const Route = createFileRoute("/_auth/users/")({
|
||||
|
||||
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
|
||||
|
||||
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
|
||||
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
|
||||
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
|
||||
|
||||
function UsersPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
@@ -45,27 +46,23 @@ function UsersPage() {
|
||||
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
||||
const middleSectionRef = useRef<HTMLDivElement>(null);
|
||||
const tableFrameRef = useRef<HTMLDivElement>(null);
|
||||
const tableAvailableRef = useRef(0);
|
||||
/**
|
||||
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
|
||||
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
|
||||
*/
|
||||
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
||||
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
||||
);
|
||||
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
||||
return Math.max(120, maxH - 40);
|
||||
});
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
||||
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
||||
|
||||
useEffect(() => {
|
||||
setKeywordInput(search.keyword);
|
||||
}, [search.keyword]);
|
||||
const setSearch = (next: UserSearch) => {
|
||||
void navigate({ search: next });
|
||||
};
|
||||
|
||||
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
|
||||
search,
|
||||
setSearch,
|
||||
});
|
||||
|
||||
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
|
||||
message,
|
||||
resourceKey: "users",
|
||||
});
|
||||
|
||||
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
||||
{ list: User[]; total: number },
|
||||
@@ -81,7 +78,6 @@ function UsersPage() {
|
||||
search.sortField,
|
||||
search.sortOrder,
|
||||
],
|
||||
invalidateKey: ["users"],
|
||||
queryFn: () =>
|
||||
httpClient.get(USER_ENDPOINTS.list, {
|
||||
params: {
|
||||
@@ -98,66 +94,29 @@ function UsersPage() {
|
||||
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
|
||||
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
|
||||
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
|
||||
optimistic: { update: true, delete: true },
|
||||
createLifecycle: {
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: t`Created successfully`,
|
||||
key: MESSAGE_KEY_USER_CREATE,
|
||||
});
|
||||
onSuccess: (values) => {
|
||||
crudToasts.createLifecycle?.onSuccess?.(values);
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: t`Create failed`,
|
||||
key: MESSAGE_KEY_USER_CREATE,
|
||||
});
|
||||
},
|
||||
onError: crudToasts.createLifecycle?.onError,
|
||||
},
|
||||
updateLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({
|
||||
content: t`Updating…`,
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: t`Updated successfully`,
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
});
|
||||
onMutate: crudToasts.updateLifecycle?.onMutate,
|
||||
onSuccess: (values) => {
|
||||
crudToasts.updateLifecycle?.onSuccess?.(values);
|
||||
setModalOpen(false);
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: t`Update failed`,
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
});
|
||||
},
|
||||
onError: crudToasts.updateLifecycle?.onError,
|
||||
},
|
||||
deleteLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({
|
||||
content: t`Deleting…`,
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: t`Deleted successfully`,
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: t`Delete failed`,
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
});
|
||||
},
|
||||
onMutate: crudToasts.deleteLifecycle?.onMutate,
|
||||
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
|
||||
onError: crudToasts.deleteLifecycle?.onError,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -270,138 +229,48 @@ function UsersPage() {
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreVertical size={token.fontSize} />}
|
||||
aria-label={t`Row actions`}
|
||||
/>
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const applySearch = (keyword: string) => {
|
||||
void navigate({
|
||||
search: {
|
||||
...search,
|
||||
keyword: keyword.trim(),
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showPagination = (data?.total ?? 0) > search.limit;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shell = pageShellRef.current;
|
||||
const toolbarEl = toolbarRowRef.current;
|
||||
const mid = middleSectionRef.current;
|
||||
if (!shell || !toolbarEl || !mid) return;
|
||||
|
||||
/** Fixed small table header row height */
|
||||
const headReserve = 40;
|
||||
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
|
||||
const rowEstimate = 44;
|
||||
const paginationBlock = 56;
|
||||
|
||||
let rafRetry = 0;
|
||||
const maxRafRetries = 8;
|
||||
|
||||
const measure = () => {
|
||||
const toolbarRect = toolbarEl.getBoundingClientRect();
|
||||
/**
|
||||
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
|
||||
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
|
||||
*/
|
||||
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
||||
const mainRect = mainEl?.getBoundingClientRect();
|
||||
const viewportBottom =
|
||||
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
||||
const clipBottom = mainRect
|
||||
? mainRect.bottom
|
||||
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
||||
|
||||
const maxMiddleFromShell = Math.max(
|
||||
0,
|
||||
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
|
||||
);
|
||||
|
||||
const midHRaw = mid.clientHeight;
|
||||
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
||||
|
||||
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
||||
rafRetry += 1;
|
||||
requestAnimationFrame(() => requestAnimationFrame(measure));
|
||||
return;
|
||||
}
|
||||
|
||||
/** Bordered frame now includes Table built-in pagination */
|
||||
const frameInner = Math.max(0, Math.floor(midH));
|
||||
tableAvailableRef.current = frameInner;
|
||||
|
||||
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
||||
|
||||
const rowCount = data?.list?.length ?? 0;
|
||||
const pagReserve = showPagination ? paginationBlock : 0;
|
||||
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
||||
|
||||
if (isLoading && rowCount === 0) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowCount === 0 && !isLoading) {
|
||||
setTableScrollY(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
||||
const naturalTotalH = naturalBodyH + pagReserve;
|
||||
const slack = 12;
|
||||
|
||||
/**
|
||||
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
|
||||
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
|
||||
*/
|
||||
if (naturalTotalH + slack > frameInner) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
setTableScrollY(undefined);
|
||||
requestAnimationFrame(() => {
|
||||
const frame = tableFrameRef.current;
|
||||
const avail = tableAvailableRef.current;
|
||||
const minFrame = headReserve + pagReserve;
|
||||
if (!frame || avail <= minFrame) return;
|
||||
if (frame.scrollHeight > frame.clientHeight + 1) {
|
||||
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(shell);
|
||||
ro.observe(mid);
|
||||
return () => ro.disconnect();
|
||||
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
|
||||
const { 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),
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
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],
|
||||
);
|
||||
@@ -417,10 +286,10 @@ function UsersPage() {
|
||||
ref={toolbarRowRef}
|
||||
keywordInput={keywordInput}
|
||||
onKeywordChange={setKeywordInput}
|
||||
onSearch={applySearch}
|
||||
onSearch={applyKeyword}
|
||||
onClearSearch={() => {
|
||||
setKeywordInput("");
|
||||
applySearch("");
|
||||
setSearch({ ...search, keyword: "", offset: 0 });
|
||||
}}
|
||||
roleValue={search.role || undefined}
|
||||
onRoleChange={(role) =>
|
||||
@@ -442,7 +311,7 @@ function UsersPage() {
|
||||
<DataTable<User>
|
||||
layoutRef={middleSectionRef}
|
||||
frameRef={tableFrameRef}
|
||||
lockScrollHeight={tableScrollY != null}
|
||||
lockScrollHeight={lockScrollHeight}
|
||||
maxHeight={tableAreaMaxHeight}
|
||||
frameHeight={
|
||||
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a Zustand store with persistence middleware
|
||||
* Reduces boilerplate for stores that need automatic data persistence
|
||||
*/
|
||||
export function createPersistentStore<T extends object>(
|
||||
initializer: StateCreator<T, [], []>,
|
||||
config: PersistConfig<T>,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { MenuItem } from "@/api/schemas";
|
||||
|
||||
/**
|
||||
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
|
||||
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
|
||||
*/
|
||||
export const APP_MENU_TREE: MenuItem[] = [
|
||||
{
|
||||
id: "g-platform",
|
||||
@@ -37,6 +33,17 @@ export const APP_MENU_TREE: MenuItem[] = [
|
||||
sort: 1,
|
||||
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);
|
||||
}
|
||||
|
||||
/** Normalized pathname (no trailing slash except `/`) */
|
||||
export function normalizeAppPath(pathname: string): string {
|
||||
if (pathname === "/") return pathname;
|
||||
return pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Route → permission required to open the page. `null` = no permission gate.
|
||||
* Keep in sync with {@link APP_MENU_TREE} paths.
|
||||
*/
|
||||
export function requiredPermissionForPath(pathname: string): string | null {
|
||||
const p = normalizeAppPath(pathname);
|
||||
const map: Record<string, string | null> = {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
/** Favicon path under `public/` (Vite serves as site root). */
|
||||
export const APP_FAVICON_SRC = "/favicon.svg";
|
||||
|
||||
/** Product / brand name (login header, sidebar logo text, etc.). */
|
||||
export const APP_BRAND_NAME = "Antd Admin";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 {
|
||||
code: number;
|
||||
@@ -22,6 +25,70 @@ type RequestOptions = Omit<RequestInit, "method" | "body"> & {
|
||||
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 {
|
||||
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
|
||||
if (!params) return base;
|
||||
@@ -33,23 +100,25 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem("auth-storage");
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
const token = parsed?.state?.tokens?.accessToken;
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
const token = getAccessToken();
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
return {};
|
||||
}
|
||||
|
||||
function navigateTo403(): void {
|
||||
void httpRouter?.navigate({ to: "/403" });
|
||||
}
|
||||
|
||||
function navigateToLogin(): void {
|
||||
void httpRouter?.navigate({ to: "/login" });
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
isAfterRefresh = false,
|
||||
): Promise<T> {
|
||||
const url = buildUrl(path, options?.params);
|
||||
const headers: Record<string, string> = {
|
||||
@@ -61,29 +130,40 @@ async function request<T>(
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
...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) {
|
||||
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) {
|
||||
throw new ApiError(json.code, json.message ?? "Unknown error");
|
||||
if (envelope.code !== undefined && envelope.code !== 0) {
|
||||
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 = {
|
||||
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("POST", path, body, options),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("PUT", path, body, options),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>("DELETE", path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, options),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, options),
|
||||
delete: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, options),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api
|
||||
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
|
||||
export async function fetchSessionAndApplyToStore(): Promise<void> {
|
||||
const [userBase, permissions] = await Promise.all([
|
||||
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
|
||||
|
||||
Vendored
+4
@@ -1 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BRAND_PRIMARY?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,34 @@ import react from "@vitejs/plugin-react-swc";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
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({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
@@ -22,17 +50,7 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id: string) => {
|
||||
if (id.includes("node_modules/antd")) {
|
||||
return "vendor-antd";
|
||||
}
|
||||
if (id.includes("@tanstack/react-router") || id.includes("@tanstack/react-query")) {
|
||||
return "vendor-tanstack";
|
||||
}
|
||||
if (id.includes("lucide-react")) {
|
||||
return "vendor-ui";
|
||||
}
|
||||
},
|
||||
manualChunks: (id: string) => manualChunkForId(id),
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1024,
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -37,4 +37,4 @@
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+288
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user