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

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

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

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

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

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

- Bump packages/create and lockfile where applicable.

Made-with: Cursor
This commit is contained in:
zuiidea
2026-04-27 14:47:55 +08:00
parent 2d46d7bf27
commit c5221c71be
81 changed files with 2624 additions and 737 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
@@ -0,0 +1,15 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` / `orders` examples). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
+15
View File
@@ -0,0 +1,15 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
+47
View File
@@ -0,0 +1,47 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e:core
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+5 -1
View File
@@ -41,6 +41,10 @@ yarn-error.log*
# Git worktrees
.worktrees/
.cursor
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
+2 -2
View File
@@ -1,7 +1,7 @@
---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
## applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions
+2 -2
View File
@@ -1,7 +1,7 @@
---
applyTo: "src/**/*.{ts,tsx,css}"
## applyTo: "src/**/*.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "src/\*_/_.{ts,tsx}"
## applyTo: "src/*/*.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
## applyTo: "tests/**/.ts,tests/**/*.tsx,src/mocks//*.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
+1
View File
@@ -8,6 +8,7 @@ This directory contains scoped instruction files used by AI coding agents.
- `testing.instructions.md`: Playwright/MSW/test changes
- `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.
+3 -1
View File
@@ -14,6 +14,7 @@ Maintain strict API contracts and consistent server/client mock behavior.
- Keep request/response shapes schema-driven and type-safe.
- 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`.
-1
View File
@@ -28,7 +28,6 @@ src/locales/*/messages.js
.agent
.pnpm-store
.cursor
.tanstack
# Playwright
+1
View File
@@ -80,5 +80,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
- `testing.instructions.md` for Playwright/MSW/test updates
- `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.
+20 -6
View File
@@ -10,6 +10,8 @@
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
**AI rules (Cursor):** `.cursor/rules/with-lingui-*.mdc` at the repo root mirrors `apps/with-lingui/.github/instructions/*.instructions.md` (same intent; `globs` align with each file’s `applyTo`). Prefer updating **both** when you change guidance.
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
[![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
@@ -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
e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
└── users.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 |
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+8
View File
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("Order list", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/orders");
await expect(page.getByPlaceholder(/Search Order|搜索/)).toBeVisible({ timeout: 15_000 });
});
test("should display table", async ({ page }) => {
await expect(page.locator(".ant-table-thead").getByText(/Title|标题/)).toBeVisible();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
-2
View File
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
await searchInput.fill("zhao.ming");
await searchInput.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);
+4 -2
View File
@@ -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 -2
View File
@@ -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,
+10
View File
@@ -0,0 +1,10 @@
import type { CreateOrderRequest, UpdateOrderRequest, Order } from "./schemas";
export const ORDER_ENDPOINTS = {
list: "/api/orders",
create: "/api/orders",
update: (id: string) => `/api/orders/${id}`,
delete: (id: string) => `/api/orders/${id}`,
} as const;
export type { CreateOrderRequest, UpdateOrderRequest, Order };
+36 -1
View File
@@ -11,7 +11,6 @@ export const UserSchema = z.object({
export type User = z.infer<typeof UserSchema>;
/** 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>
);
}
+15 -19
View File
@@ -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");
});
});
+106 -12
View File
@@ -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 };
}
+148 -74
View File
@@ -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) {
+148 -74
View File
@@ -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 "用户"
+8 -12
View File
@@ -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);
});
});
+38 -22
View File
@@ -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,
+16 -3
View File
@@ -1,7 +1,6 @@
import type { User } from "@/api/schemas";
import type { User, Order } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
/** 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" },
];
+72 -14
View File
@@ -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);
}),
];
+2 -1
View File
@@ -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();
}),
];
+22 -8
View File
@@ -1,13 +1,27 @@
import { http } from "msw";
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { 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();
}),
];
-5
View File
@@ -1,8 +1,3 @@
/**
* Mock-only helpers: demo avatars, list filtering, pagination.
*/
/** Deterministic demo avatar URL from username (mock seed data only). */
export function vercelAvatarUrl(username: string): string {
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
}
+28 -2
View File
@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as 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,
}
+46 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { 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({
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>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
</I18nProvider>
</QueryClientProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+14 -3
View File
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { 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 = {
+47 -178
View File
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import 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,118 +229,28 @@ 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));
}
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
@@ -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
+8
View File
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
},
},
);
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
@@ -10,10 +10,6 @@ export interface PersistConfig<T> {
merge?: PersistOptions<T, Partial<T>>["merge"];
}
/**
* 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>,
+11 -9
View File
@@ -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> = {
-2
View File
@@ -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";
+99 -19
View File
@@ -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;
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
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),
};
-1
View File
@@ -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)),
+4
View File
@@ -1 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BRAND_PRIMARY?: string;
}
+29 -11
View File
@@ -3,6 +3,34 @@ import react from "@vitejs/plugin-react-swc";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import { 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,
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
@@ -0,0 +1,207 @@
# apps/with-lingui 模板优化计划
> 状态:待评审
> 日期:2026-04-24
> 范围:`apps/with-lingui`(`apps/basic` 与主模板抽象稳定后再考虑同步,**当前不做回填**)
> 面向:人类贡献者 + AI Agent(结合 `.github/instructions/` 指令体系执行)
## 0. 背景
`apps/with-lingui` 作为仓库主模板,技术栈(React 19 / AntD 6 / TanStack Router & Query / Zod v4 / Lingui / MSW 2 / Vite+)已属第一梯队,AI 友好度(scoped instructions + 分包策略)领先同类开源 admin 模板。本计划聚焦**下一轮可见收益**,把页面级样板下沉到 hook/工具链,把 AI 指令从"规范"升级为"可执行食谱"。
详细评价参见内部讨论记录,本文只列执行项。
## 1. 目标与非目标
### 目标
- **安全性**:闭合 JWT 刷新流、移除 store/http 跨层耦合。
- **可维护性**:将 `routes/_auth/users/index.tsx`(491 行)中的通用能力下沉为 hook,使新增资源页 <200 行、<30 分钟。
- **可观测性**:RBAC / URL 状态等进入核心 E2E;体积以 `vp build` 日志与 Code review 把关(**不**在 CI 中跑自动化 bundle 上限工具)。
- **AI 协作**:补齐"加资源食谱"与 Cursor 规则镜像;**不**提供 codegen 脚本,以 **`add-resource.instructions.md`** 为唯一执行清单(对照 `users` / `orders`)。
### 非目标
- 不替换现有技术栈(Vite+ / Lingui / AntD 6 / TanStack 保持不变)。
- **`apps/basic` 回填**:抽象未稳定前**不做**;不与 `with-lingui` 强行双轨同步。
- 不引入新的 UI 或样式库(遵循 `frontend.instructions.md`)。
## 2. 执行项(按优先级)
### P0:正确性与启动性能
- **P0-1 解耦 http ↔ auth store**
- 现状:`apps/with-lingui/src/utils/http.ts` L35–46 直接 `localStorage.getItem("auth-storage")` 再 `JSON.parse`,与 `stores/auth.ts` 的 `partialize` 结构强耦合。
- 方案:
- 在 `stores/auth.ts` 导出 `getAccessToken()` / `getTokens()` 工具。
- `http.ts` 改为 `import { getAccessToken } from "@/stores/auth"`。
- `getAuthHeaders()` 保留但内部改调用工具。
- 验收:`vp check --no-fmt` + 登录 E2E 通过;搜索仓库不再出现 `"auth-storage"` 字面量(除 store 自身)。
- **P0-2 实现 401/403 拦截与 refresh token 流**
- 现状:`AuthTokensSchema` 已含 `refreshToken`,但 `request()` 未使用;403 由各页面自行处理。
- 方案:
- `utils/http.ts` 拦截 401:调 `POST /api/auth/refresh`(新增端点常量 + schema + MSW handler),并发去重(`inflightRefresh: Promise | null`),成功后回放原请求。
- 拦截 403:通过 `@tanstack/react-router` 的 `router.navigate({ to: "/403" })`;为此导出一个可注入 `router` 的 `installHttpInterceptors(router)`,在 `main.tsx` 调用。
- 刷新失败 → `useAuthStore.getState().logout()` + 导向 `/login`。
- 新增文件:
- `src/api/auth.ts` 增加 `REFRESH` 常量。
- `src/mocks/handlers/auth.ts` 增加 refresh handler(含错误分支模拟)。
- 验收:新增 E2E `e2e/auth-refresh.spec.ts` 覆盖:过期 access token → 自动刷新 → 请求成功;刷新失败 → 登出并跳登录。
- **P0-3 启动链并行化**
- 现状:`src/main.tsx` L26–49 串行 5 步:`enableMocking → rehydrate settings → loadLocale → rehydrate auth → fetchSession → render`。
- 方案:
- `loadLocaleCatalog` 与 `useAuthStore.persist.rehydrate()` 并行(`Promise.all`)。
- `fetchSessionAndApplyToStore()` 从 `main.tsx` 移除,改到 `routes/_auth.tsx` 的 `beforeLoad` 内按需触发(路由守卫已经会校验 `isAuthenticated`)。
- 保留渲染前仅做"i18n 激活 + 主题 hydrate"两个硬依赖。
- 验收:本地 DevTools Performance 首屏 TTI 改善 ≥ 150ms;刷新已登录页仍能正确拉取 session。
### P1:架构清晰度
- **P1-1 抽出 `useTableFitHeight` hook**
- 现状:`routes/_auth/users/index.tsx` L291–384 约 90 行 DOM 测量(`ResizeObserver` + rAF 重试 + 主布局 `clipBottom` 计算)写在页面内。
- 方案:
- 新文件 `src/hooks/useTableFitHeight.ts`,输入 `{ pageShellRef, toolbarRef, middleRef, tableFrameRef, deps }`,返回 `{ tableAreaMaxHeight, tableScrollY, lockScrollHeight }`。
- 页面只保留 ref 声明与调用。
- 验收:`users/index.tsx` 行数 < 260;暗色 / 不同分辨率下无双滚动条(以核心 E2E + 人工回归为准)。
- **P1-2 抽出 `useCrudToasts` hook**
- 现状:`users/index.tsx` L101–161 每个资源都要写 ~60 行 create/update/delete lifecycle toasts。
- 方案:
- 新文件 `src/hooks/useCrudToasts.ts`,接收 `{ resourceLabel: () => string }`(配合 Lingui macro),内部返回 `createLifecycle / updateLifecycle / deleteLifecycle` 三个对象,可直接传入 `useResourceCRUD`。
- 文案用 Lingui macro;允许覆盖单条文案。
- 验收:`users/index.tsx` 中不再出现 `MESSAGE_KEY_USER_`* 常量与裸 `t\`... successfully` 重复;至少一个新资源页引用并通过 E2E。
- **P1-3 抽出 `useUrlSearchState` hook**
- 现状:`users/index.tsx` 手写 `keywordInput` 本地态与 `search.keyword` 同步 + `applySearch`。
- 方案:
- 新文件 `src/hooks/useUrlSearchState.ts`,基于 `useNavigate` + `Route.useSearch()`,接收 zod schema,返回 `{ search, patch, keyword, setKeyword, commitKeyword }`。
- `users/index.tsx` 与未来资源页共享。
- 验收:`users.spec.ts` 新增"刷新保留分页/关键词/排序"用例通过。
- **P1-4 `useResourceCRUD` 人因优化**
- 现状:`src/hooks/useResourceCRUD.ts` 调用方需手写三个泛型 + `queryKey` 与 `invalidateKey` 两次。
- 方案:
- `invalidateKey` 默认取 `queryKey[0]`。
- 导出 `CrudResult<T>` 类型别名。
- 可选 `optimistic: { update?: boolean; delete?: boolean }`,内部 `onMutate` 里做乐观 `setQueryData`;失败自动回滚。
- 验收:用户列表删除 / 编辑在慢网(Playwright `route` 延时)下立即反馈;回滚路径有单测覆盖。
- **落地**:`optimistic: { update, delete }` + `cancelQueries` / 失败时 `setQueryData` 恢复快照;`pnpm run test:unit`(`src/hooks/useResourceCRUD.test.ts`)覆盖 merge/delete 与 rollback 契约;`users` / `orders` 已开启乐观更新。
- **P1-5 MSW handler 端也跑一次 schema 解析**
- 现状:mock 与契约漂移只能在前端 `Zod.parse` 抛错时发现,堆栈离源头远。
- 方案:
- `src/mocks/createHandler.ts`:`successWithSchema`;`paginatedListSchema` + `paginatedWithSchema(itemSchema, payload)`;`successWithNullBody` 用于 `data: null` 响应。
- `handlers/auth.ts` / `user.ts` / `orders.ts` 全部经 schema 出包;新增 handler 须沿用同一模式。
- 验收:故意改错 mock 字段名,dev 控制台首条报错来自 mock 层;`vp test` 下 handler 单元测试覆盖。
- **落地**:`src/mocks/createHandler.test.ts`(`pnpm run test:unit`)覆盖 `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` 的校验与 Zod 抛错路径。
### P2:体验与工程化
- **P2-1 统一错误兜底**
- 根路由 `__root.tsx` 追加 `errorComponent`;`QueryClient.defaultOptions.mutations.onError` 挂接全局 `App.useApp().message.error`。
- 全站 `message/modal/notification` 统一使用 `App.useApp()`(禁止静态方法),通过 oxlint 规则(或 code review checklist)约束。
- **P2-2 品牌 token 化**
- 在 `src/hooks/tokenBuilders.ts` 定义 `BRAND_TOKENS`(primary、link、info、success 等),深浅色分别给一份;替换默认 AntD 蓝。
- 允许通过 `VITE_BRAND_PRIMARY` 环境变量覆盖,便于二次分发。
- **P2-3 Bundle 体积(已决议:不再采用自动化 CI 预算)**
- **不再采用** `size-limit` / `@size-limit/file`(及同类在 CI 中卡阈值的方案);已从 `apps/with-lingui` 与 `with-lingui` workflow 移除。
- 仍通过 **`manualChunks` + `vp build` 输出** 观察各 chunk;重大依赖或分片变更在 PR 描述中注明体积影响即可。
- `refactor.instructions.md` 仅保留「分包与避免无谓大依赖」的叙述性建议,不写死 CI 阈值命令。
- **P2-4 i18n 预热**
- `__root.tsx` 在 `requestIdleCallback` 内预拉另一语言 catalog;切换瞬时完成。
- `document.documentElement` 预留 `dir` 属性钩子,为未来 RTL 留接口(不实现 RTL 样式)。
### P3:AI 友好度放大
- **P3-1 新增"加资源"食谱**
- 新文件 `apps/with-lingui/.github/instructions/add-resource.instructions.md`(英文正文),front-matter 指定触发关键词(add resource / CRUD / scaffold 等)。
- 内容为 6 步 checklist + 每步文件路径 + 验证命令,示例见下方"附录 A"。
- **P3-2 Cursor 薄规则(方案 A)**
- 细则**单源**:`apps/with-lingui/.github/instructions/*.instructions.md`。
- 仓库根 `.cursor/rules/with-lingui-*.mdc` 五份:仅 `description` + `globs` + 指向上述路径;**不**重复粘贴长文;`.gitignore` 放行 `.cursor/rules/` 以便进库。
- `apps/with-lingui/README.md` 与 `instructions/README.md` 说明用法(必要时 `@` 引用 `.instructions.md`)。
- **P3-3 加资源(无脚本 codegen)**
- **实现形态**:仅 `add-resource.instructions.md` 食谱 + `users` / `orders` 示例;由贡献者或 Agent 按步骤手工落地。
- 产出与原先 codegen 相当:`api/<name>.ts`、`schemas` 片段、mock、路由、`e2e`、菜单等。
- 验收:按食谱新增一资源后 `pnpm exec vp check --no-fmt && pnpm test:e2e:core` 通过(可将新 spec 纳入 core 或单跑)。
## 3. 落地节奏
| 阶段 | 包含项 | 预期工时 |
| -------- | --------------------------------------- | ----- |
| Sprint 1 | P0-1 / P0-2 / P0-3 / P1-1 | 2–3 天 |
| Sprint 2 | P1-2 / P1-3 / P1-4 / P1-5 / P3-1 | 2 天 |
| Sprint 3 | P2-1 ~ P2-4 + P3-2 + P3-3(食谱) | 2 天 |
| Sprint 4 | (预留)抽象稳定后再评估 `apps/basic` 与其它增强项 | 待定 |
每 Sprint 结束:`vp check --no-fmt && pnpm run test:e2e:core && pnpm run build`(在 `apps/with-lingui` 下),并关注构建日志中的 chunk 体积。
## 4. 风险与对策
| 风险 | 影响 | 对策 |
| -------------------------------------- | ----- | ------------------------------------------------------- |
| refresh token 并发去重实现错误,风暴请求 | 线上故障 | 用 `inflightRefresh`(单 Promise)串行化刷新,避免并发风暴 |
| 乐观更新回滚遗漏 | 数据不一致 | `useResourceCRUD` 回滚路径单测覆盖(`optimistic: true` 下 4xx 用例) |
| 手工加资源时覆盖已有文件 | 代码丢失 | 改前 grep / diff;食谱「注意」节强调不覆盖手写代码 |
| `apps/basic` 与 `apps/with-lingui` 抽象漂移 | 双倍维护 | **回填暂缓**;待 hook/契约稳定后再设固定回填节点;尽量参数化而非分叉 |
## 5. 验收标准总览
- `vp check --no-fmt` 全绿
- `pnpm test:e2e:core`(login / users / auth-refresh / rbac / url-state 等,以 `package.json` 脚本为准)全绿
- CI 含 `vp build`;bundle 不设自动化上限门禁(见 P2-3)
- `routes/_auth/users/index.tsx` 行数 ≤ 260
- 按 `add-resource.instructions.md` 能在约 30 分钟内产出可用 CRUD 页面(熟练后)
---
## 附录 A:`add-resource.instructions.md` 草稿
> **以仓库内正式文件为准**:`apps/with-lingui/.github/instructions/add-resource.instructions.md`;下文仅作计划期摘要。
```md
---
applyTo: "src/api/**/*.ts,src/mocks/handlers/**/*.ts,src/routes/_auth/**/*.tsx"
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page, 新资源."
---
# Add Resource Recipe
## Steps
1. `src/api/schemas.ts` — Add `<Name>Schema`, `Create<Name>RequestSchema`, `Update<Name>RequestSchema`. Reuse `PaginatedResponseSchema`.
2. `src/api/<name>.ts` — Export `<NAME>_ENDPOINTS = { list, create, update: (id) => ..., delete: (id) => ... }`.
3. `src/mocks/data.ts` — Append seed data.
4. `src/mocks/handlers/<name>.ts` — Mirror `handlers/user.ts`; wrap responses with `successWithSchema`.
5. `src/mocks/handlers/index.ts` — Register the new handlers array.
6. `src/routes/_auth/<name>/index.tsx` — Duplicate `users/index.tsx`, replace types, compose `useResourceCRUD + useCrudToasts + useUrlSearchState + useTableFitHeight`.
7. `e2e/<name>.spec.ts` — Copy `users.spec.ts`, adjust selectors.
## Validation
- `vp check --no-fmt`
- `pnpm test:e2e -- --grep <name>`
- `vp build` 后对照构建日志关注主 chunk / vendor 体积(无 CI 硬阈值)
```
## 附录 B:文件改动预估
| 文件 | 类型 |
| -------------------------------------------------------------------- | ------------------ |
| `apps/with-lingui/src/utils/http.ts` | 重构 |
| `apps/with-lingui/src/stores/auth.ts` | 增加导出 |
| `apps/with-lingui/src/main.tsx` | 简化 |
| `apps/with-lingui/src/routes/_auth.tsx` | 新增 `beforeLoad` |
| `apps/with-lingui/src/hooks/useTableFitHeight.ts` | 新增 |
| `apps/with-lingui/src/hooks/useCrudToasts.ts` | 新增 |
| `apps/with-lingui/src/hooks/useUrlSearchState.ts` | 新增 |
| `apps/with-lingui/src/hooks/useResourceCRUD.ts` | 增强 |
| `apps/with-lingui/src/mocks/createHandler.ts` | 增强 |
| `apps/with-lingui/src/mocks/handlers/auth.ts` | 补 refresh |
| `apps/with-lingui/src/routes/_auth/users/index.tsx` | 大幅精简 |
| `apps/with-lingui/.github/instructions/add-resource.instructions.md` | 新增 |
| `.cursor/rules/*.mdc` | 新增(5 份镜像) |
| `e2e/{auth-refresh,rbac,url-state,...}.spec.ts` | 新增 / 演进 |
| `.github/workflows/with-lingui-ci.yml` | 质量 + build(无 size-limit) |
+288 -144
View File
File diff suppressed because it is too large Load Diff