mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-19 16:14:37 +00:00
feat(with-lingui): orders CRUD, shared hooks, auth refresh, and docs
- Add second sample resource (orders): API, schemas, MSW, routes, menu, Lingui, and E2E. - Introduce useCrudToasts, useUrlSearchState, useTableFitHeight; extend useResourceCRUD with optimistic updates; add Vitest coverage for hooks and createHandler. - Implement token refresh in http layer, RouteError, and new E2E specs (auth-refresh, rbac, url-state). - Replace gen-resource codegen with English add-resource.instructions.md; fix Copilot instruction YAML in with-lingui and basic. - Add with-lingui CI workflow, Cursor thin rules, optimization plan doc, and refresh README/AGENTS. - Bump packages/create and lockfile where applicable. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: All code comments must be written in English
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# English-only comments
|
||||||
|
|
||||||
|
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
|
||||||
|
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
|
||||||
|
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD — non-English comment
|
||||||
|
// Initialiser le cache (example: any non-English language)
|
||||||
|
|
||||||
|
// GOOD
|
||||||
|
// Initialize the cache from the last session snapshot.
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BAD
|
||||||
|
{/* Chargement… */}
|
||||||
|
|
||||||
|
// GOOD
|
||||||
|
{/* Loading spinner */}
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "Add a new CRUD resource / scaffold (with-lingui)."
|
||||||
|
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# with-lingui · Add resource (thin rule)
|
||||||
|
|
||||||
|
**Single source of truth:**
|
||||||
|
|
||||||
|
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
|
||||||
|
|
||||||
|
When adding a resource, follow that recipe (mirror the `users` / `orders` examples). On conflict, the `.instructions.md` file wins.
|
||||||
|
|
||||||
|
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
|
||||||
|
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# with-lingui · API(薄规则)
|
||||||
|
|
||||||
|
**唯一细则来源**:
|
||||||
|
|
||||||
|
`apps/with-lingui/.github/instructions/api.instructions.md`
|
||||||
|
|
||||||
|
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
|
||||||
|
|
||||||
|
`@apps/with-lingui/.github/instructions/api.instructions.md`
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
|
||||||
|
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# with-lingui · Frontend(薄规则)
|
||||||
|
|
||||||
|
**唯一细则来源**(勿在此文件重复维护长文):
|
||||||
|
|
||||||
|
`apps/with-lingui/.github/instructions/frontend.instructions.md`
|
||||||
|
|
||||||
|
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
|
||||||
|
|
||||||
|
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
|
||||||
|
globs: apps/with-lingui/src/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# with-lingui · Refactor(薄规则)
|
||||||
|
|
||||||
|
**唯一细则来源**:
|
||||||
|
|
||||||
|
`apps/with-lingui/.github/instructions/refactor.instructions.md`
|
||||||
|
|
||||||
|
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
|
||||||
|
|
||||||
|
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
|
||||||
|
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# with-lingui · Testing(薄规则)
|
||||||
|
|
||||||
|
**唯一细则来源**:
|
||||||
|
|
||||||
|
`apps/with-lingui/.github/instructions/testing.instructions.md`
|
||||||
|
|
||||||
|
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
|
||||||
|
|
||||||
|
`@apps/with-lingui/.github/instructions/testing.instructions.md`
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: with-lingui
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "apps/with-lingui/**"
|
||||||
|
- ".github/workflows/with-lingui-ci.yml"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "apps/with-lingui/**"
|
||||||
|
- ".github/workflows/with-lingui-ci.yml"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
working-directory: apps/with-lingui
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
- name: Typecheck and lint
|
||||||
|
working-directory: apps/with-lingui
|
||||||
|
run: pnpm exec vp check --no-fmt
|
||||||
|
|
||||||
|
- name: E2E (core)
|
||||||
|
working-directory: apps/with-lingui
|
||||||
|
run: pnpm run test:e2e:core
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: apps/with-lingui
|
||||||
|
run: pnpm run build
|
||||||
+5
-1
@@ -41,6 +41,10 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.cursor
|
|
||||||
|
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
|
||||||
|
.cursor/*
|
||||||
|
!.cursor/rules/
|
||||||
|
!.cursor/rules/**
|
||||||
|
|
||||||
./docs/superpowers
|
./docs/superpowers
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
|
|
||||||
|
## applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
|
||||||
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
|
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
|
||||||
---
|
|
||||||
|
|
||||||
# API Instructions
|
# API Instructions
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
applyTo: "src/**/*.{ts,tsx,css}"
|
|
||||||
|
## applyTo: "src/**/*.{ts,tsx,css}"
|
||||||
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
|
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
|
||||||
---
|
|
||||||
|
|
||||||
# Frontend Instructions
|
# Frontend Instructions
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## applyTo: "src/\*_/_.{ts,tsx}"
|
## applyTo: "src/*/*.{ts,tsx}"
|
||||||
|
|
||||||
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
|
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
|
## applyTo: "tests/**/.ts,tests/**/*.tsx,src/mocks//*.ts,playwright.config.ts"
|
||||||
|
|
||||||
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
|
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ This directory contains scoped instruction files used by AI coding agents.
|
|||||||
- `testing.instructions.md`: Playwright/MSW/test changes
|
- `testing.instructions.md`: Playwright/MSW/test changes
|
||||||
- `api.instructions.md`: API client/schema/handler changes
|
- `api.instructions.md`: API client/schema/handler changes
|
||||||
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
|
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
|
||||||
|
- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users` + `orders`)
|
||||||
|
|
||||||
## Authoring Rules
|
## Authoring Rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
applyTo: "src/api/**/*.ts,src/mocks/**/*.ts,src/routes/_auth/**/*.tsx,e2e/**/*.spec.ts"
|
||||||
|
description: "Use when adding a new CRUD resource (orders, roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Resource Recipe
|
||||||
|
|
||||||
|
Add a list CRUD flow in `apps/with-lingui` that mirrors **Users** (TanStack Router + Query + MSW + Lingui). There is **no** codegen script: **copy and adapt by hand** using the steps below, or have an Agent follow this checklist. Primary references: `src/routes/_auth/users/`, the sample `src/routes/_auth/orders/`, and the matching `e2e/*.spec.ts` files.
|
||||||
|
|
||||||
|
## 0. Verification commands (after your changes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/with-lingui
|
||||||
|
pnpm run build # refresh TanStack routeTree when routes change
|
||||||
|
pnpm exec vp check --no-fmt
|
||||||
|
pnpm run test:e2e -- e2e/<slug>.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Zod and types (`src/api/schemas.ts`)
|
||||||
|
|
||||||
|
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
|
||||||
|
- Reuse `PaginatedResponseSchema(<Entity>Schema)` for list responses.
|
||||||
|
- Export `type <Entity> = z.infer<typeof ...>`.
|
||||||
|
|
||||||
|
## 2. Endpoint constants (`src/api/<slug>.ts`)
|
||||||
|
|
||||||
|
- Export `<SLUG_UPPER>_ENDPOINTS`: `list` / `create` / `update(id)` / `delete(id)` with paths like `/api/<slug>`.
|
||||||
|
|
||||||
|
## 3. Mock data (`src/mocks/data.ts`)
|
||||||
|
|
||||||
|
- Append a `MOCK_<ENTITIES>` array. For **admin** users, `permissions` must include `<slug>:view` (and create/edit/delete if you add writes), aligned with `GET /api/auth/permissions`.
|
||||||
|
|
||||||
|
## 4. MSW (`src/mocks/handlers/<slug>.ts` + `handlers/index.ts`)
|
||||||
|
|
||||||
|
- Follow `handlers/user.ts` / `handlers/orders.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
|
||||||
|
- Register `...<slug>Handlers` in `handlers/index.ts`.
|
||||||
|
|
||||||
|
## 5. Route page (`src/routes/_auth/<slug>/`)
|
||||||
|
|
||||||
|
- `createFileRoute("/_auth/<slug>/")`, wiring `useResourceCRUD` (recommended: `optimistic: { update: true, delete: true }`), `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`).
|
||||||
|
- **Lingui**: run `pnpm run i18n:extract && pnpm run i18n:compile` for new copy (or `vp exec lingui …`).
|
||||||
|
|
||||||
|
## 6. Menu and permissions (`src/utils/appMenu.ts`)
|
||||||
|
|
||||||
|
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key such as `IconLucidePackage` (see existing `IconLucide*` entries in `Sidebar`).
|
||||||
|
- Keep behavior consistent with `canAccessPath` / 403.
|
||||||
|
|
||||||
|
## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`)
|
||||||
|
|
||||||
|
- In `MENU_LABELS`, add a Lingui `msg` entry for the menu item `name` (must match the `name` string from `appMenu`).
|
||||||
|
- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`.
|
||||||
|
- Then run `pnpm run i18n:extract && pnpm run i18n:compile` and fill in `zh` (and other locale) translations.
|
||||||
|
|
||||||
|
> In this template repo, the `orders` sample is already aligned with the Sidebar next to `appMenu`. When adding a resource from scratch, double-check this section.
|
||||||
|
|
||||||
|
## 7. E2E (`e2e/<slug>.spec.ts`)
|
||||||
|
|
||||||
|
- Copy structure from `users.spec.ts` / `orders.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
- `pnpm exec vp check --no-fmt`
|
||||||
|
- `pnpm run test:e2e:core` (if you add the new spec to core) or `pnpm exec playwright test e2e/<slug>.spec.ts`
|
||||||
|
- `pnpm run build` and watch chunk sizes in the log
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Before adding or renaming files, confirm nothing already exists at that path so you do not overwrite hand-written code.
|
||||||
|
- After manual schema edits, treat the diff as source of truth; there is no automatic merge.
|
||||||
@@ -14,6 +14,7 @@ Maintain strict API contracts and consistent server/client mock behavior.
|
|||||||
- Keep request/response shapes schema-driven and type-safe.
|
- Keep request/response shapes schema-driven and type-safe.
|
||||||
- Reuse endpoint constants and shared response helpers.
|
- Reuse endpoint constants and shared response helpers.
|
||||||
- Keep handler response envelope consistent (`code`, `data`, `message`).
|
- Keep handler response envelope consistent (`code`, `data`, `message`).
|
||||||
|
- MSW success payloads: use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` from `src/mocks/createHandler.ts` so responses match Zod contracts before `HttpResponse.json`.
|
||||||
- Ensure mock handlers reflect real API behavior and error semantics.
|
- Ensure mock handlers reflect real API behavior and error semantics.
|
||||||
- Keep naming stable for endpoint keys and mutation/query keys.
|
- Keep naming stable for endpoint keys and mutation/query keys.
|
||||||
|
|
||||||
@@ -26,4 +27,5 @@ Maintain strict API contracts and consistent server/client mock behavior.
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Verify TypeScript and schema parsing paths compile cleanly.
|
- Verify TypeScript and schema parsing paths compile cleanly.
|
||||||
- Run: `vp check --no-fmt`
|
- Run: `pnpm exec vp check --no-fmt`
|
||||||
|
- After changing `createHandler` or handler envelopes: `pnpm run test:unit` (includes `src/mocks/createHandler.test.ts`).
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Run: `vp check --no-fmt`
|
- Run: `pnpm exec vp check --no-fmt`
|
||||||
- For UI flow changes, run focused e2e where possible.
|
- For UI flow changes, run focused e2e where possible.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
---
|
---
|
||||||
|
applyTo: "src/**/*.{ts,tsx}"
|
||||||
## applyTo: "src/\*_/_.{ts,tsx}"
|
|
||||||
|
|
||||||
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
|
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
|
||||||
|
---
|
||||||
|
|
||||||
# Refactor Instructions
|
# Refactor Instructions
|
||||||
|
|
||||||
@@ -33,5 +32,5 @@ Reduce duplication while preserving behavior and public contracts.
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Confirm no new diagnostics in touched files.
|
- Confirm no new diagnostics in touched files.
|
||||||
- Run: `vp check --no-fmt`
|
- Run: `pnpm exec vp check --no-fmt`
|
||||||
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
|
- For significant changes, verify bundle size impact: `pnpm run build 2>&1 | grep "kB"`
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
---
|
---
|
||||||
|
applyTo: "e2e/**/*.ts,e2e/**/*.tsx,src/mocks/**/*.ts,playwright.config.ts"
|
||||||
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
|
|
||||||
|
|
||||||
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
|
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
|
||||||
|
---
|
||||||
|
|
||||||
# Testing Instructions
|
# Testing Instructions
|
||||||
|
|
||||||
@@ -27,4 +26,5 @@ Keep tests fast, deterministic, and aligned with core business flows.
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Run focused tests first, then broader checks if needed.
|
- Run focused tests first, then broader checks if needed.
|
||||||
- Run: `vp check --no-fmt`
|
- Run: `pnpm exec vp check --no-fmt`
|
||||||
|
- Unit / hook logic (Vitest): `pnpm run test:unit` — tests live under `src/**/*.test.ts`.
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ src/locales/*/messages.js
|
|||||||
|
|
||||||
.agent
|
.agent
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.cursor
|
|
||||||
.tanstack
|
.tanstack
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
|
|||||||
@@ -80,5 +80,6 @@ This repository also uses scoped AI instruction files in `.github/instructions/`
|
|||||||
- `testing.instructions.md` for Playwright/MSW/test updates
|
- `testing.instructions.md` for Playwright/MSW/test updates
|
||||||
- `api.instructions.md` for API/schema/handler changes
|
- `api.instructions.md` for API/schema/handler changes
|
||||||
- `refactor.instructions.md` for behavior-preserving cleanup
|
- `refactor.instructions.md` for behavior-preserving cleanup
|
||||||
|
- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users` / `orders`)
|
||||||
|
|
||||||
See `.github/instructions/README.md` for usage and authoring rules.
|
See `.github/instructions/README.md` for usage and authoring rules.
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
|
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
|
||||||
|
|
||||||
|
**AI rules (Cursor):** `.cursor/rules/with-lingui-*.mdc` at the repo root mirrors `apps/with-lingui/.github/instructions/*.instructions.md` (same intent; `globs` align with each file’s `applyTo`). Prefer updating **both** when you change guidance.
|
||||||
|
|
||||||
[](https://github.com/ant-design/ant-design)
|
[](https://github.com/ant-design/ant-design)
|
||||||
[](https://react.dev)
|
[](https://react.dev)
|
||||||
[](https://viteplus.dev)
|
[](https://viteplus.dev)
|
||||||
@@ -80,16 +82,18 @@ vp run test:e2e:core
|
|||||||
vp run test:e2e:ui # interactive UI mode
|
vp run test:e2e:ui # interactive UI mode
|
||||||
```
|
```
|
||||||
|
|
||||||
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
`test:e2e:core` runs the scaffold's highest-value flows: login, users CRUD, auth refresh, RBAC, URL state (see `package.json` script list).
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
.github/instructions/ # Scoped AI recipes (mirrored in ../../.cursor/rules/with-lingui-*.mdc)
|
||||||
src/
|
src/
|
||||||
├── api/ # Zod schemas, endpoint constants, type exports
|
├── api/ # Zod schemas, endpoint constants, type exports
|
||||||
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||||
│ ├── auth.ts # Auth endpoint constants
|
│ ├── auth.ts # Auth endpoint constants
|
||||||
│ └── user.ts # User CRUD endpoint constants
|
│ ├── user.ts # User CRUD endpoint constants
|
||||||
|
│ └── orders.ts # Example second resource API (mirror `user.ts`)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Aurora/ # Login background effect
|
│ ├── Aurora/ # Login background effect
|
||||||
│ ├── Auth/ # Auth (permission gate): index.tsx
|
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||||
@@ -119,12 +123,13 @@ src/
|
|||||||
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||||
│ ├── data.ts # Mock seed data (users, menus)
|
│ ├── data.ts # Mock seed data (users, menus)
|
||||||
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
|
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
|
||||||
│ └── handlers/ # Request handlers (auth, user CRUD)
|
│ └── handlers/ # Request handlers (auth, users, orders, …)
|
||||||
├── routes/ # TanStack Router file-based routes
|
├── routes/ # TanStack Router file-based routes
|
||||||
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
||||||
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||||
│ ├── _auth/dashboard/index.tsx
|
│ ├── _auth/dashboard/index.tsx
|
||||||
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||||
|
│ ├── _auth/orders/ # Example second resource (mirror users CRUD)
|
||||||
│ ├── _auth/403/index.tsx
|
│ ├── _auth/403/index.tsx
|
||||||
│ ├── login/index.tsx
|
│ ├── login/index.tsx
|
||||||
│ ├── 404/index.tsx
|
│ ├── 404/index.tsx
|
||||||
@@ -135,11 +140,12 @@ src/
|
|||||||
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
||||||
└── main.tsx # Entry point (MSW init → React render)
|
└── main.tsx # Entry point (MSW init → React render)
|
||||||
|
|
||||||
tests/
|
e2e/ # Playwright E2E tests
|
||||||
└── e2e/ # Playwright E2E tests
|
├── helpers.ts
|
||||||
├── helpers.ts
|
├── login.spec.ts
|
||||||
├── login.spec.ts
|
├── users.spec.ts
|
||||||
└── users.spec.ts
|
└── orders.spec.ts # Example resource smoke test
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
@@ -157,9 +163,16 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
|
|||||||
|
|
||||||
## Extending the template
|
## Extending the template
|
||||||
|
|
||||||
|
- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users` / `orders`), then `pnpm run i18n:extract && pnpm run i18n:compile`, wire Sidebar labels/icons if needed (§6b in that doc), and `pnpm exec vp check --no-fmt`.
|
||||||
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
||||||
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
|
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
|
||||||
|
|
||||||
|
### AI instruction files
|
||||||
|
|
||||||
|
**细则单源**:`apps/with-lingui/.github/instructions/`(`*.instructions.md`),人类与 Agent 以该目录为准维护、评审。
|
||||||
|
|
||||||
|
**Cursor**:仓库根 `.cursor/rules/with-lingui-*.mdc` 为**薄规则**(方案 A):只写范围与指向路径,`globs` 与上述文件的 `applyTo` 对齐;全文请在对话中 `@` 对应 `.instructions.md` 或自行打开该文件。
|
||||||
|
|
||||||
## Developer Notes
|
## Developer Notes
|
||||||
|
|
||||||
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
|
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
|
||||||
@@ -173,6 +186,7 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
|
|||||||
| `/login` | Login form with validation |
|
| `/login` | Login form with validation |
|
||||||
| `/dashboard` | Statistics overview cards |
|
| `/dashboard` | Statistics overview cards |
|
||||||
| `/users` | User CRUD table with search, pagination, create/edit/delete |
|
| `/users` | User CRUD table with search, pagination, create/edit/delete |
|
||||||
|
| `/orders` | Example orders list (title/status) — second CRUD sample alongside `users` |
|
||||||
| `/403` | Forbidden error page |
|
| `/403` | Forbidden error page |
|
||||||
| `/404` | Not found error page |
|
| `/404` | Not found error page |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginAsAdmin } from "./helpers";
|
||||||
|
|
||||||
|
const expiredAuthStorage = () =>
|
||||||
|
JSON.stringify({
|
||||||
|
state: {
|
||||||
|
tokens: {
|
||||||
|
accessToken: "__EXPIRED_ACCESS__",
|
||||||
|
refreshToken: "mock-refresh-token",
|
||||||
|
},
|
||||||
|
isAuthenticated: true,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Auth refresh", () => {
|
||||||
|
test.describe.configure({ retries: 2 });
|
||||||
|
|
||||||
|
test("401 on list triggers refresh then succeeds", async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
await page.evaluate((payload) => {
|
||||||
|
localStorage.setItem("auth-storage", payload);
|
||||||
|
}, expiredAuthStorage());
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "load" });
|
||||||
|
|
||||||
|
const usersListOk = page.waitForResponse(
|
||||||
|
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await page.goto("/users", { waitUntil: "domcontentloaded" });
|
||||||
|
await usersListOk;
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
|
||||||
|
const thead = page.locator(".ant-table-thead");
|
||||||
|
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
|
|||||||
await page.goto("/users");
|
await page.goto("/users");
|
||||||
await expect(page).toHaveURL(/users/);
|
await expect(page).toHaveURL(/users/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loginAsGuest(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel(/Username|用户名/).fill("guest");
|
||||||
|
await page.getByLabel(/Password|密码/).fill("guest");
|
||||||
|
await page.getByRole("button", { name: /Sign In|登录/ }).click();
|
||||||
|
await expect(page).toHaveURL(/dashboard/);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginAsAdmin } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("Order list", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto("/orders");
|
||||||
|
await expect(page.getByPlaceholder(/Search Order|搜索/)).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should display table", async ({ page }) => {
|
||||||
|
await expect(page.locator(".ant-table-thead").getByText(/Title|标题/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginAsGuest } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("RBAC", () => {
|
||||||
|
test("guest without user:view cannot open /users", async ({ page }) => {
|
||||||
|
await loginAsGuest(page);
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page).toHaveURL(/403/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { loginAsAdmin } from "./helpers";
|
||||||
|
|
||||||
|
test.describe("URL search state", () => {
|
||||||
|
test("persists keyword after reload", async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto("/users");
|
||||||
|
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
|
||||||
|
await searchInput.fill("zhao.ming");
|
||||||
|
await searchInput.press("Enter");
|
||||||
|
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||||
|
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
|
|||||||
await searchInput.fill("zhao.ming");
|
await searchInput.fill("zhao.ming");
|
||||||
await searchInput.press("Enter");
|
await searchInput.press("Enter");
|
||||||
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||||
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
|
|
||||||
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ test.describe("User Management", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
|
|
||||||
test.describe("User Management — role in URL", () => {
|
test.describe("User Management — role in URL", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
"fmt": "vp fmt",
|
"fmt": "vp fmt",
|
||||||
"lint": "vp lint",
|
"lint": "vp lint",
|
||||||
"check": "vp check --no-fmt",
|
"check": "vp check --no-fmt",
|
||||||
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:core": "playwright test tests/e2e/login.spec.ts tests/e2e/users.spec.ts",
|
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"i18n:extract": "vp exec lingui extract",
|
"i18n:extract": "vp exec lingui extract",
|
||||||
"i18n:compile": "vp exec lingui compile"
|
"i18n:compile": "vp exec lingui compile"
|
||||||
@@ -41,7 +42,8 @@
|
|||||||
"msw": "^2.12.12",
|
"msw": "^2.12.12",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||||
"vite-plus": "latest"
|
"vite-plus": "latest",
|
||||||
|
"vitest": "3.0.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.32.1",
|
"packageManager": "pnpm@10.32.1",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
|
|
||||||
declare const process: { env: { CI?: string } };
|
declare const process: { env: { CI?: string } };
|
||||||
|
|
||||||
const ci = Boolean(process.env.CI);
|
const ci = Boolean(process.env.CI);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./e2e",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: ci,
|
forbidOnly: ci,
|
||||||
retries: ci ? 2 : 0,
|
retries: ci ? 2 : 0,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { CreateOrderRequest, UpdateOrderRequest, Order } from "./schemas";
|
||||||
|
|
||||||
|
export const ORDER_ENDPOINTS = {
|
||||||
|
list: "/api/orders",
|
||||||
|
create: "/api/orders",
|
||||||
|
update: (id: string) => `/api/orders/${id}`,
|
||||||
|
delete: (id: string) => `/api/orders/${id}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type { CreateOrderRequest, UpdateOrderRequest, Order };
|
||||||
@@ -11,7 +11,6 @@ export const UserSchema = z.object({
|
|||||||
|
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|
||||||
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
|
|
||||||
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
||||||
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
||||||
|
|
||||||
@@ -22,6 +21,12 @@ export const AuthTokensSchema = z.object({
|
|||||||
|
|
||||||
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||||
|
|
||||||
|
export const RefreshTokenRequestSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
|
||||||
|
|
||||||
export const LoginRequestSchema = z.object({
|
export const LoginRequestSchema = z.object({
|
||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
@@ -129,3 +134,33 @@ export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
|
|||||||
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
|
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
|
||||||
|
|
||||||
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
|
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
|
||||||
|
|
||||||
|
export const CreateTestRequestSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
status: z.enum(["draft", "open", "done"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateTestRequest = z.infer<typeof CreateTestRequestSchema>;
|
||||||
|
|
||||||
|
export const UpdateTestRequestSchema = CreateTestRequestSchema.partial();
|
||||||
|
|
||||||
|
export type UpdateTestRequest = z.infer<typeof UpdateTestRequestSchema>;
|
||||||
|
|
||||||
|
export const OrderSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
status: z.enum(["draft", "open", "done"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Order = z.infer<typeof OrderSchema>;
|
||||||
|
|
||||||
|
export const CreateOrderRequestSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
status: z.enum(["draft", "open", "done"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOrderRequest = z.infer<typeof CreateOrderRequestSchema>;
|
||||||
|
|
||||||
|
export const UpdateOrderRequestSchema = CreateOrderRequestSchema.partial();
|
||||||
|
|
||||||
|
export type UpdateOrderRequest = z.infer<typeof UpdateOrderRequestSchema>;
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { useLingui } from "@lingui/react/macro";
|
|||||||
import { BarChart3 } from "lucide-react";
|
import { BarChart3 } from "lucide-react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
/**
|
|
||||||
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
|
|
||||||
*/
|
|
||||||
export function DataTableEmpty(): ReactElement {
|
export function DataTableEmpty(): ReactElement {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ export type DataTableSkeletonProps<RecordType extends object> = Pick<
|
|||||||
TableProps<RecordType>,
|
TableProps<RecordType>,
|
||||||
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
|
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
|
||||||
> & {
|
> & {
|
||||||
/**
|
|
||||||
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
|
|
||||||
* or from the table frame height so the body roughly fills the visible area.
|
|
||||||
*/
|
|
||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +50,6 @@ function isColumnGroup<RecordType extends object>(
|
|||||||
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
|
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ~ text icon button, capped small */
|
|
||||||
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
|
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
|
||||||
if (typeof col.width === "number") {
|
if (typeof col.width === "number") {
|
||||||
return Math.min(24, Math.max(20, col.width - 36));
|
return Math.min(24, Math.max(20, col.width - 36));
|
||||||
|
|||||||
@@ -6,24 +6,14 @@ import { DataTableEmpty } from "./DataTableEmpty";
|
|||||||
import { DataTableSkeleton } from "./DataTableSkeleton";
|
import { DataTableSkeleton } from "./DataTableSkeleton";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
/** Merged onto `Table` `rootClassName` with the flex layout class. */
|
|
||||||
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
|
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
|
||||||
|
|
||||||
export type DataTableProps<RecordType extends object = object> = {
|
export type DataTableProps<RecordType extends object = object> = {
|
||||||
/**
|
|
||||||
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
|
|
||||||
* and optional `frameHeight` can pin the outer height.
|
|
||||||
*/
|
|
||||||
lockScrollHeight?: boolean;
|
lockScrollHeight?: boolean;
|
||||||
/** Outer `max-height` (px) */
|
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
|
|
||||||
frameHeight?: number;
|
frameHeight?: number;
|
||||||
/** Flex column wrapping frame + optional `bottomExtra` */
|
|
||||||
layoutRef?: Ref<HTMLDivElement | null>;
|
layoutRef?: Ref<HTMLDivElement | null>;
|
||||||
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
|
|
||||||
frameRef?: Ref<HTMLDivElement | null>;
|
frameRef?: Ref<HTMLDivElement | null>;
|
||||||
/** Optional content below the bordered table frame */
|
|
||||||
bottomExtra?: ReactNode;
|
bottomExtra?: ReactNode;
|
||||||
} & TableProps<RecordType>;
|
} & TableProps<RecordType>;
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,14 @@ import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } f
|
|||||||
|
|
||||||
export type FilterToolbarSlot = {
|
export type FilterToolbarSlot = {
|
||||||
key: string;
|
key: string;
|
||||||
/** Used to decide how many filters fit in one row before the rest go into the popover */
|
|
||||||
minWidth: number;
|
minWidth: number;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterToolbarProps = {
|
export type FilterToolbarProps = {
|
||||||
slots: FilterToolbarSlot[];
|
slots: FilterToolbarSlot[];
|
||||||
/** Right side: primary actions (not collapsed) */
|
|
||||||
actions: React.ReactNode;
|
actions: React.ReactNode;
|
||||||
/** Reserve width for the “more filters” control when some slots are in the popover */
|
|
||||||
collapseTriggerReserve?: number;
|
collapseTriggerReserve?: number;
|
||||||
/** Accessible label + optional popover title */
|
|
||||||
moreFiltersLabel: React.ReactNode;
|
moreFiltersLabel: React.ReactNode;
|
||||||
moreFiltersTitle?: React.ReactNode;
|
moreFiltersTitle?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,10 +22,6 @@ function normalizePath(pathname: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HeaderProps = {
|
export type HeaderProps = {
|
||||||
/**
|
|
||||||
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
|
|
||||||
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
|
|
||||||
*/
|
|
||||||
showBreadcrumb?: boolean;
|
showBreadcrumb?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CircleDashed,
|
CircleDashed,
|
||||||
Folder,
|
Folder,
|
||||||
Home,
|
Home,
|
||||||
|
Package,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Star,
|
Star,
|
||||||
@@ -26,12 +27,12 @@ import { UserMenu } from "../UserMenu";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
/** API menu `name` → Lingui descriptors (extracted into catalogs). */
|
|
||||||
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
|
const MENU_LABELS: Record<string, ReturnType<typeof msg>> = {
|
||||||
Platform: msg`Platform`,
|
Platform: msg`Platform`,
|
||||||
Projects: msg`Projects`,
|
Projects: msg`Projects`,
|
||||||
Dashboard: msg`Dashboard`,
|
Dashboard: msg`Dashboard`,
|
||||||
Users: msg`Users`,
|
Users: msg`Users`,
|
||||||
|
Orders: msg`Orders`,
|
||||||
"Design Engineering": msg`Design Engineering`,
|
"Design Engineering": msg`Design Engineering`,
|
||||||
"Sales & Marketing": msg`Sales & Marketing`,
|
"Sales & Marketing": msg`Sales & Marketing`,
|
||||||
};
|
};
|
||||||
@@ -47,7 +48,6 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
IconLucideLayoutDashboard: Home,
|
IconLucideLayoutDashboard: Home,
|
||||||
IconLucideUsers: User,
|
IconLucideUsers: User,
|
||||||
IconLucideUserList: Users,
|
IconLucideUserList: Users,
|
||||||
/** Back-compat for older menu payloads still using IconLucideHistory */
|
|
||||||
IconLucideHistory: Users,
|
IconLucideHistory: Users,
|
||||||
IconLucideStar: Star,
|
IconLucideStar: Star,
|
||||||
IconLucideSettings: SlidersHorizontal,
|
IconLucideSettings: SlidersHorizontal,
|
||||||
@@ -55,6 +55,7 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
IconLucideBookOpen: Book,
|
IconLucideBookOpen: Book,
|
||||||
IconLucideFolderKanban: Folder,
|
IconLucideFolderKanban: Folder,
|
||||||
IconLucideSparkles: Zap,
|
IconLucideSparkles: Zap,
|
||||||
|
IconLucidePackage: Package,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderMenuIcon(icon: string | null, size = 16) {
|
function renderMenuIcon(icon: string | null, size = 16) {
|
||||||
@@ -179,7 +180,6 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [isMobile, setSidebarCollapsed]);
|
}, [isMobile, setSidebarCollapsed]);
|
||||||
|
|
||||||
/** Merge open keys required by the route with any submenu the user already expanded. */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpenKeys((prev) => {
|
setOpenKeys((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -219,7 +219,6 @@ export function Sidebar() {
|
|||||||
align={isCollapsed ? "center" : "stretch"}
|
align={isCollapsed ? "center" : "stretch"}
|
||||||
style={{
|
style={{
|
||||||
paddingBlock: token.paddingSM,
|
paddingBlock: token.paddingSM,
|
||||||
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
|
|
||||||
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
|
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
|
||||||
minHeight: 64,
|
minHeight: 64,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -237,7 +236,6 @@ export function Sidebar() {
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
|
|
||||||
paddingInline: isCollapsed ? 0 : token.paddingXS,
|
paddingInline: isCollapsed ? 0 : token.paddingXS,
|
||||||
minHeight: 40,
|
minHeight: 40,
|
||||||
justifyContent: isCollapsed ? "center" : "flex-start",
|
justifyContent: isCollapsed ? "center" : "flex-start",
|
||||||
@@ -348,7 +346,12 @@ export function Sidebar() {
|
|||||||
background: "transparent",
|
background: "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
|
<UserMenu
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
user={user}
|
||||||
|
userMenuItems={userMenuItems}
|
||||||
|
accountMenuTriggerAriaLabel={t`Open account menu`}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ interface UserMenuProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
userMenuItems: MenuProps["items"];
|
userMenuItems: MenuProps["items"];
|
||||||
|
accountMenuTriggerAriaLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
|
export function UserMenu({ collapsed, user, userMenuItems, accountMenuTriggerAriaLabel }: UserMenuProps) {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
const avatarSrc = (user?.avatar ?? "").trim() || undefined;
|
const avatarSrc = (user?.avatar ?? "").trim() || undefined;
|
||||||
@@ -33,7 +34,12 @@ export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const moreButton = (
|
const moreButton = (
|
||||||
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<MoreVertical size={token.fontSize} />}
|
||||||
|
aria-label={accountMenuTriggerAriaLabel}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLingui } from "@lingui/react/macro";
|
|||||||
import { ArrowLeft, Home, SearchX } from "lucide-react";
|
import { ArrowLeft, Home, SearchX } from "lucide-react";
|
||||||
import { Button, Flex, Result, Space, theme } from "antd";
|
import { Button, Flex, Result, Space, theme } from "antd";
|
||||||
|
|
||||||
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
|
|
||||||
export function NotFound() {
|
export function NotFound() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -25,7 +24,6 @@ export function NotFound() {
|
|||||||
<Flex
|
<Flex
|
||||||
vertical
|
vertical
|
||||||
style={{
|
style={{
|
||||||
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
|
|
||||||
height: "100dvh",
|
height: "100dvh",
|
||||||
maxHeight: "100dvh",
|
maxHeight: "100dvh",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Button, Result, theme } from "antd";
|
||||||
|
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
export function RouteError({ error }: ErrorComponentProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={t`Something went wrong`}
|
||||||
|
subTitle={detail || t`Please try again or return to the dashboard.`}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => void router.invalidate()}>
|
||||||
|
{t`Retry`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { theme } from "antd";
|
import { theme } from "antd";
|
||||||
import type { ConfigProviderProps, ThemeConfig } from "antd";
|
import type { ConfigProviderProps, ThemeConfig } from "antd";
|
||||||
|
|
||||||
/**
|
export function readBrandPrimary(): string {
|
||||||
* Common theme token builders
|
const raw = import.meta.env.VITE_BRAND_PRIMARY as string | undefined;
|
||||||
* Reduces duplication between light and dark theme definitions
|
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw.trim())) {
|
||||||
*/
|
return raw.trim();
|
||||||
|
}
|
||||||
|
return "#1677ff";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_PRIMARY = readBrandPrimary();
|
||||||
|
|
||||||
export const SHARED_DESIGN_TOKENS = {
|
export const SHARED_DESIGN_TOKENS = {
|
||||||
fontFamily:
|
fontFamily:
|
||||||
@@ -26,9 +31,6 @@ export const CONTROL_COMPONENTS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate table row selected color based on theme algorithm
|
|
||||||
*/
|
|
||||||
export function buildTableTokens(cfg: ThemeConfig) {
|
export function buildTableTokens(cfg: ThemeConfig) {
|
||||||
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
|
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
|
||||||
return {
|
return {
|
||||||
@@ -37,9 +39,6 @@ export function buildTableTokens(cfg: ThemeConfig) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build menu tokens for light theme
|
|
||||||
*/
|
|
||||||
export const MENU_LIGHT = {
|
export const MENU_LIGHT = {
|
||||||
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
|
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
|
||||||
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
|
||||||
@@ -53,9 +52,6 @@ export const MENU_LIGHT = {
|
|||||||
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
|
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Build menu tokens for dark theme
|
|
||||||
*/
|
|
||||||
export const MENU_DARK = {
|
export const MENU_DARK = {
|
||||||
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
|
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
|
||||||
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
|
||||||
@@ -69,12 +65,12 @@ export const MENU_DARK = {
|
|||||||
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
|
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Build light theme config
|
|
||||||
*/
|
|
||||||
export function buildLightThemeConfig(): ConfigProviderProps {
|
export function buildLightThemeConfig(): ConfigProviderProps {
|
||||||
const lightSeed: ThemeConfig["token"] = {
|
const lightSeed: ThemeConfig["token"] = {
|
||||||
colorBgLayout: "#ffffff",
|
colorBgLayout: "#ffffff",
|
||||||
|
colorPrimary: BRAND_PRIMARY,
|
||||||
|
colorLink: BRAND_PRIMARY,
|
||||||
|
colorInfo: BRAND_PRIMARY,
|
||||||
...SHARED_DESIGN_TOKENS,
|
...SHARED_DESIGN_TOKENS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,11 +90,11 @@ export function buildLightThemeConfig(): ConfigProviderProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build dark theme config
|
|
||||||
*/
|
|
||||||
export function buildDarkThemeConfig(): ConfigProviderProps {
|
export function buildDarkThemeConfig(): ConfigProviderProps {
|
||||||
const darkSeed: ThemeConfig["token"] = {
|
const darkSeed: ThemeConfig["token"] = {
|
||||||
|
colorPrimary: BRAND_PRIMARY,
|
||||||
|
colorLink: BRAND_PRIMARY,
|
||||||
|
colorInfo: BRAND_PRIMARY,
|
||||||
...SHARED_DESIGN_TOKENS,
|
...SHARED_DESIGN_TOKENS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type { ConfigProviderProps } from "antd";
|
|||||||
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
|
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme selection for ConfigProvider: light/dark share radius, font, control tweaks.
|
|
||||||
* @see https://ant.design/docs/react/customize-theme
|
|
||||||
*/
|
|
||||||
export function useAppTheme(): ConfigProviderProps {
|
export function useAppTheme(): ConfigProviderProps {
|
||||||
const darkMode = useSettingsStore((s) => s.darkMode);
|
const darkMode = useSettingsStore((s) => s.darkMode);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { MessageInstance } from "antd/es/message/interface";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
type MutationLifecycle<TValues> = {
|
||||||
|
onMutate?: (values: TValues) => void;
|
||||||
|
onSuccess?: (values: TValues) => void;
|
||||||
|
onError?: (values: TValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrudToastLifecycles<TCreate, TUpdate> = {
|
||||||
|
createLifecycle?: MutationLifecycle<TCreate>;
|
||||||
|
updateLifecycle?: MutationLifecycle<TUpdate>;
|
||||||
|
deleteLifecycle?: MutationLifecycle<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCrudToasts<TCreate, TUpdate>(options: {
|
||||||
|
message: MessageInstance;
|
||||||
|
resourceKey: string;
|
||||||
|
}): CrudToastLifecycles<TCreate, TUpdate> {
|
||||||
|
const { message, resourceKey } = options;
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const keys = useMemo(
|
||||||
|
() => ({
|
||||||
|
create: `${resourceKey}-mutation-create`,
|
||||||
|
update: `${resourceKey}-mutation-update`,
|
||||||
|
delete: `${resourceKey}-mutation-delete`,
|
||||||
|
}),
|
||||||
|
[resourceKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
createLifecycle: {
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({ content: t`Created successfully`, key: keys.create });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({ content: t`Create failed`, key: keys.create });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateLifecycle: {
|
||||||
|
onMutate: () => {
|
||||||
|
message.loading({ content: t`Updating…`, key: keys.update, duration: 0 });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({ content: t`Updated successfully`, key: keys.update });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({ content: t`Update failed`, key: keys.update });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deleteLifecycle: {
|
||||||
|
onMutate: () => {
|
||||||
|
message.loading({ content: t`Deleting…`, key: keys.delete, duration: 0 });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success({ content: t`Deleted successfully`, key: keys.delete });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error({ content: t`Delete failed`, key: keys.delete });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[keys.create, keys.delete, keys.update, message, t],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
|
||||||
|
|
||||||
|
type Row = { id: string; title: string; n: number };
|
||||||
|
|
||||||
|
describe("applyOptimisticListUpdate", () => {
|
||||||
|
it("merges patch into the matching row by id", () => {
|
||||||
|
const data: { list: Row[]; total: number } = {
|
||||||
|
list: [
|
||||||
|
{ id: "1", title: "A", n: 1 },
|
||||||
|
{ id: "2", title: "B", n: 2 },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
|
||||||
|
expect(next.list).toEqual([
|
||||||
|
{ id: "1", title: "A", n: 1 },
|
||||||
|
{ id: "2", title: "B2", n: 2 },
|
||||||
|
]);
|
||||||
|
expect(next.total).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyOptimisticListDelete", () => {
|
||||||
|
it("removes row and decrements total", () => {
|
||||||
|
const data: { list: Row[]; total: number } = {
|
||||||
|
list: [
|
||||||
|
{ id: "1", title: "A", n: 1 },
|
||||||
|
{ id: "2", title: "B", n: 2 },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
const next = applyOptimisticListDelete(data, "1");
|
||||||
|
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
|
||||||
|
expect(next.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take total below zero", () => {
|
||||||
|
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
|
||||||
|
const next = applyOptimisticListDelete(data, "1");
|
||||||
|
expect(next.list).toEqual([]);
|
||||||
|
expect(next.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rollback contract (QueryClient simulation)", () => {
|
||||||
|
it("restore snapshot after failed optimistic update", () => {
|
||||||
|
const key = ["items", 10, 0] as const;
|
||||||
|
const before = {
|
||||||
|
list: [
|
||||||
|
{ id: "1", title: "A", n: 1 },
|
||||||
|
{ id: "2", title: "B", n: 2 },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
};
|
||||||
|
const snapshot = structuredClone(before);
|
||||||
|
let cache: typeof before = structuredClone(before);
|
||||||
|
const setData = (next: typeof before) => {
|
||||||
|
cache = next;
|
||||||
|
};
|
||||||
|
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
|
||||||
|
expect(cache.list[1]?.title).toBe("BAD");
|
||||||
|
setData(snapshot);
|
||||||
|
expect(cache).toEqual(before);
|
||||||
|
expect(key[0]).toBe("items");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
type MutationLifecycle<TValues> = {
|
type MutationLifecycle<TValues> = {
|
||||||
onMutate?: (values: TValues) => void;
|
onMutate?: (values: TValues) => void;
|
||||||
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
|
|||||||
onError?: (values: TValues) => void;
|
onError?: (values: TValues) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
export type ResourceListData<TItem extends { id: string }> = {
|
||||||
|
list: readonly TItem[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptimisticFlags = {
|
||||||
|
update?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseResourceCRUDOptions<
|
||||||
|
TListData extends ResourceListData<{ id: string }>,
|
||||||
|
TCreateValues,
|
||||||
|
TUpdateValues extends { id: string },
|
||||||
|
> = {
|
||||||
queryKey: readonly unknown[];
|
queryKey: readonly unknown[];
|
||||||
invalidateKey: readonly unknown[];
|
invalidateKey?: readonly unknown[];
|
||||||
queryFn: () => Promise<unknown>;
|
queryFn: () => Promise<unknown>;
|
||||||
select: (raw: unknown) => TListData;
|
select: (raw: unknown) => TListData;
|
||||||
createFn: (values: TCreateValues) => Promise<unknown>;
|
createFn: (values: TCreateValues) => Promise<unknown>;
|
||||||
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
|
|||||||
createLifecycle?: MutationLifecycle<TCreateValues>;
|
createLifecycle?: MutationLifecycle<TCreateValues>;
|
||||||
updateLifecycle?: MutationLifecycle<TUpdateValues>;
|
updateLifecycle?: MutationLifecycle<TUpdateValues>;
|
||||||
deleteLifecycle?: MutationLifecycle<string>;
|
deleteLifecycle?: MutationLifecycle<string>;
|
||||||
|
optimistic?: OptimisticFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
|
||||||
|
data: TListData | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
|
||||||
|
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
|
||||||
|
deleteMutation: UseMutationResult<unknown, Error, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
|
||||||
|
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
|
||||||
|
TListData,
|
||||||
|
TCreateValues,
|
||||||
|
TUpdateValues
|
||||||
|
>;
|
||||||
|
|
||||||
|
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
|
||||||
|
|
||||||
|
export function applyOptimisticListUpdate<TItem extends { id: string }, TUpdate extends { id: string }>(
|
||||||
|
data: ResourceListData<TItem>,
|
||||||
|
values: TUpdate,
|
||||||
|
): { list: TItem[]; total: number } {
|
||||||
|
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
list: data.list.map((row) =>
|
||||||
|
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
|
||||||
|
),
|
||||||
|
total: data.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOptimisticListDelete<TItem extends { id: string }>(
|
||||||
|
data: ResourceListData<TItem>,
|
||||||
|
id: string,
|
||||||
|
): { list: TItem[]; total: number } {
|
||||||
|
return {
|
||||||
|
list: data.list.filter((row) => row.id !== id),
|
||||||
|
total: Math.max(0, data.total - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResourceCRUD<
|
||||||
|
TListData extends ResourceListData<{ id: string }>,
|
||||||
|
TCreateValues,
|
||||||
|
TUpdateValues extends { id: string },
|
||||||
|
>(
|
||||||
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
|
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
|
||||||
) {
|
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
|
||||||
|
const queryKey = options.queryKey;
|
||||||
|
const opt = options.optimistic;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: options.queryKey,
|
queryKey,
|
||||||
queryFn: options.queryFn,
|
queryFn: options.queryFn,
|
||||||
select: options.select,
|
select: options.select,
|
||||||
});
|
});
|
||||||
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
|||||||
options.createLifecycle?.onMutate?.(values);
|
options.createLifecycle?.onMutate?.(values);
|
||||||
},
|
},
|
||||||
onSuccess: (_data, values) => {
|
onSuccess: (_data, values) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||||
options.createLifecycle?.onSuccess?.(values);
|
options.createLifecycle?.onSuccess?.(values);
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values) => {
|
||||||
@@ -46,28 +109,59 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: options.updateFn,
|
mutationFn: options.updateFn,
|
||||||
onMutate: (values) => {
|
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<TListData>(queryKey);
|
||||||
|
const applied = Boolean(opt?.update && previous);
|
||||||
|
if (applied) {
|
||||||
|
queryClient.setQueryData<TListData>(
|
||||||
|
queryKey,
|
||||||
|
applyOptimisticListUpdate(
|
||||||
|
previous as ResourceListData<{ id: string }>,
|
||||||
|
values as { id: string } & Record<string, unknown>,
|
||||||
|
) as unknown as TListData,
|
||||||
|
);
|
||||||
|
}
|
||||||
options.updateLifecycle?.onMutate?.(values);
|
options.updateLifecycle?.onMutate?.(values);
|
||||||
|
return { snapshot: applied ? previous : undefined };
|
||||||
},
|
},
|
||||||
onSuccess: (_data, values) => {
|
onSuccess: (_data, values) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||||
options.updateLifecycle?.onSuccess?.(values);
|
options.updateLifecycle?.onSuccess?.(values);
|
||||||
},
|
},
|
||||||
onError: (_error, values) => {
|
onError: (_error, values, context) => {
|
||||||
|
const snap = context?.snapshot;
|
||||||
|
if (snap !== undefined) {
|
||||||
|
queryClient.setQueryData(queryKey, snap);
|
||||||
|
}
|
||||||
options.updateLifecycle?.onError?.(values);
|
options.updateLifecycle?.onError?.(values);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: options.deleteFn,
|
mutationFn: options.deleteFn,
|
||||||
onMutate: (id) => {
|
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<TListData>(queryKey);
|
||||||
|
const applied = Boolean(opt?.delete && previous);
|
||||||
|
if (applied) {
|
||||||
|
queryClient.setQueryData<TListData>(
|
||||||
|
queryKey,
|
||||||
|
applyOptimisticListDelete(previous as ResourceListData<{ id: string }>, id) as unknown as TListData,
|
||||||
|
);
|
||||||
|
}
|
||||||
options.deleteLifecycle?.onMutate?.(id);
|
options.deleteLifecycle?.onMutate?.(id);
|
||||||
|
return { snapshot: applied ? previous : undefined };
|
||||||
},
|
},
|
||||||
onSuccess: (_data, id) => {
|
onSuccess: (_data, id) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
|
void queryClient.invalidateQueries({ queryKey: invalidateKey });
|
||||||
options.deleteLifecycle?.onSuccess?.(id);
|
options.deleteLifecycle?.onSuccess?.(id);
|
||||||
},
|
},
|
||||||
onError: (_error, id) => {
|
onError: (_error, id, context) => {
|
||||||
|
const snap = context?.snapshot;
|
||||||
|
if (snap !== undefined) {
|
||||||
|
queryClient.setQueryData(queryKey, snap);
|
||||||
|
}
|
||||||
options.deleteLifecycle?.onError?.(id);
|
options.deleteLifecycle?.onError?.(id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
|
||||||
|
export type UseTableFitHeightArgs = {
|
||||||
|
pageShellRef: RefObject<HTMLDivElement | null>;
|
||||||
|
toolbarRef: RefObject<HTMLDivElement | null>;
|
||||||
|
middleRef: RefObject<HTMLDivElement | null>;
|
||||||
|
tableFrameRef: RefObject<HTMLDivElement | null>;
|
||||||
|
marginLG: number;
|
||||||
|
rowCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
showPagination: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTableFitHeight(args: UseTableFitHeightArgs) {
|
||||||
|
const { pageShellRef, toolbarRef, middleRef, tableFrameRef, marginLG, rowCount, isLoading, showPagination } =
|
||||||
|
args;
|
||||||
|
|
||||||
|
const tableAvailableRef = useRef(0);
|
||||||
|
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
||||||
|
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
||||||
|
);
|
||||||
|
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
||||||
|
return Math.max(120, maxH - 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const shell = pageShellRef.current;
|
||||||
|
const toolbarEl = toolbarRef.current;
|
||||||
|
const mid = middleRef.current;
|
||||||
|
if (!shell || !toolbarEl || !mid) return;
|
||||||
|
|
||||||
|
const headReserve = 40;
|
||||||
|
const rowEstimate = 44;
|
||||||
|
const paginationBlock = 56;
|
||||||
|
|
||||||
|
let rafRetry = 0;
|
||||||
|
const maxRafRetries = 8;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const toolbarRect = toolbarEl.getBoundingClientRect();
|
||||||
|
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
||||||
|
const mainRect = mainEl?.getBoundingClientRect();
|
||||||
|
const viewportBottom =
|
||||||
|
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
||||||
|
const clipBottom = mainRect
|
||||||
|
? mainRect.bottom
|
||||||
|
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
||||||
|
|
||||||
|
const maxMiddleFromShell = Math.max(0, Math.floor(clipBottom - toolbarRect.bottom - marginLG));
|
||||||
|
|
||||||
|
const midHRaw = mid.clientHeight;
|
||||||
|
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
||||||
|
|
||||||
|
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
||||||
|
rafRetry += 1;
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(measure));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameInner = Math.max(0, Math.floor(midH));
|
||||||
|
tableAvailableRef.current = frameInner;
|
||||||
|
|
||||||
|
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
||||||
|
|
||||||
|
const pagReserve = showPagination ? paginationBlock : 0;
|
||||||
|
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
||||||
|
|
||||||
|
if (isLoading && rowCount === 0) {
|
||||||
|
setTableScrollY(bodyMax);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowCount === 0 && !isLoading) {
|
||||||
|
setTableScrollY(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
||||||
|
const naturalTotalH = naturalBodyH + pagReserve;
|
||||||
|
const slack = 12;
|
||||||
|
|
||||||
|
if (naturalTotalH + slack > frameInner) {
|
||||||
|
setTableScrollY(bodyMax);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableScrollY(undefined);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const frame = tableFrameRef.current;
|
||||||
|
const avail = tableAvailableRef.current;
|
||||||
|
const minFrame = headReserve + pagReserve;
|
||||||
|
if (!frame || avail <= minFrame) return;
|
||||||
|
if (frame.scrollHeight > frame.clientHeight + 1) {
|
||||||
|
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(shell);
|
||||||
|
ro.observe(mid);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [showPagination, rowCount, isLoading, marginLG, pageShellRef, toolbarRef, middleRef, tableFrameRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tableAreaMaxHeight,
|
||||||
|
tableScrollY,
|
||||||
|
lockScrollHeight: tableScrollY != null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
|
||||||
|
search: TSearch;
|
||||||
|
setSearch: (next: TSearch) => void;
|
||||||
|
}) {
|
||||||
|
const { search, setSearch } = args;
|
||||||
|
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setKeywordInput(search.keyword);
|
||||||
|
}, [search.keyword]);
|
||||||
|
|
||||||
|
const commitKeyword = useCallback(() => {
|
||||||
|
const trimmed = keywordInput.trim();
|
||||||
|
setSearch({ ...search, keyword: trimmed, offset: 0 });
|
||||||
|
}, [keywordInput, search, setSearch]);
|
||||||
|
|
||||||
|
const applyKeyword = useCallback(
|
||||||
|
(kw: string) => {
|
||||||
|
const trimmed = kw.trim();
|
||||||
|
setKeywordInput(trimmed);
|
||||||
|
setSearch({ ...search, keyword: trimmed, offset: 0 });
|
||||||
|
},
|
||||||
|
[search, setSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
|
||||||
|
}
|
||||||
@@ -17,62 +17,65 @@ msgstr ""
|
|||||||
#~ msgid "{n} rows"
|
#~ msgid "{n} rows"
|
||||||
#~ msgstr "{n} rows"
|
#~ msgstr "{n} rows"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:386
|
#: src/routes/_auth/orders/index.tsx:186
|
||||||
|
#: src/routes/_auth/users/index.tsx:262
|
||||||
msgid "{total} rows"
|
msgid "{total} rows"
|
||||||
msgstr "{total} rows"
|
msgstr "{total} rows"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:35
|
#: src/routes/_auth/dashboard/index.tsx:144
|
||||||
msgid "+180.1% from last month"
|
msgid "+180.1% from last month"
|
||||||
msgstr "+180.1% from last month"
|
msgstr "+180.1% from last month"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:41
|
#: src/routes/_auth/dashboard/index.tsx:150
|
||||||
msgid "+19% from last month"
|
msgid "+19% from last month"
|
||||||
msgstr "+19% from last month"
|
msgstr "+19% from last month"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:29
|
#: src/routes/_auth/dashboard/index.tsx:138
|
||||||
msgid "+20.1% from last month"
|
msgid "+20.1% from last month"
|
||||||
msgstr "+20.1% from last month"
|
msgstr "+20.1% from last month"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:47
|
#: src/routes/_auth/dashboard/index.tsx:156
|
||||||
msgid "+201 since last hour"
|
msgid "+201 since last hour"
|
||||||
msgstr "+201 since last hour"
|
msgstr "+201 since last hour"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:78
|
#: src/components/Layout/Header/index.tsx:83
|
||||||
msgid "403"
|
msgid "403"
|
||||||
msgstr "403"
|
msgstr "403"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:45
|
#: src/routes/_auth/dashboard/index.tsx:154
|
||||||
msgid "Active Now"
|
msgid "Active Now"
|
||||||
msgstr "Active Now"
|
msgstr "Active Now"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:58
|
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:64
|
#: src/routes/_auth/users/-Toolbar.tsx:64
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:164
|
#: src/routes/_auth/orders/index.tsx:112
|
||||||
|
#: src/routes/_auth/users/index.tsx:124
|
||||||
msgid "Are you absolutely sure?"
|
msgid "Are you absolutely sure?"
|
||||||
msgstr "Are you absolutely sure?"
|
msgstr "Are you absolutely sure?"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:185
|
#: src/routes/login/index.tsx:181
|
||||||
msgid "Auto login"
|
msgid "Auto login"
|
||||||
msgstr "Auto login"
|
msgstr "Auto login"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:54
|
#: src/components/NotFound/index.tsx:59
|
||||||
#: src/routes/_auth/403/index.tsx:40
|
#: src/routes/_auth/403/index.tsx:35
|
||||||
msgid "Back to Home"
|
msgid "Back to Home"
|
||||||
msgstr "Back to Home"
|
msgstr "Back to Home"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:32
|
#: src/routes/_auth/orders/index.tsx:116
|
||||||
#: src/routes/_auth/users/index.tsx:168
|
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||||
|
#: src/routes/_auth/users/index.tsx:128
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:134
|
#: src/routes/_auth/dashboard/index.tsx:250
|
||||||
msgid "Chart Placeholder"
|
msgid "Chart Placeholder"
|
||||||
msgstr "Chart Placeholder"
|
msgstr "Chart Placeholder"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:106
|
#: src/hooks/useCrudToasts.ts:43
|
||||||
msgid "Create failed"
|
msgid "Create failed"
|
||||||
msgstr "Create failed"
|
msgstr "Create failed"
|
||||||
|
|
||||||
@@ -80,53 +83,68 @@ msgstr "Create failed"
|
|||||||
msgid "Create User"
|
msgid "Create User"
|
||||||
msgstr "Create User"
|
msgstr "Create User"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:100
|
#: src/hooks/useCrudToasts.ts:40
|
||||||
msgid "Created successfully"
|
msgid "Created successfully"
|
||||||
msgstr "Created successfully"
|
msgstr "Created successfully"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:74
|
#: src/components/Layout/Header/index.tsx:79
|
||||||
#: src/components/Layout/Header/index.tsx:87
|
#: src/components/Layout/Header/index.tsx:92
|
||||||
#: src/components/Layout/Header/index.tsx:91
|
#: src/components/Layout/Header/index.tsx:96
|
||||||
#: src/components/Layout/Sidebar/index.tsx:32
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "Dashboard"
|
msgstr "Dashboard"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:166
|
#: src/routes/_auth/orders/index.tsx:114
|
||||||
#: src/routes/_auth/users/index.tsx:257
|
#: src/routes/_auth/orders/index.tsx:152
|
||||||
|
#: src/routes/_auth/users/index.tsx:126
|
||||||
|
#: src/routes/_auth/users/index.tsx:223
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Delete"
|
msgstr "Delete"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:156
|
#: src/hooks/useCrudToasts.ts:65
|
||||||
msgid "Delete failed"
|
msgid "Delete failed"
|
||||||
msgstr "Delete failed"
|
msgstr "Delete failed"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:149
|
#: src/hooks/useCrudToasts.ts:62
|
||||||
msgid "Deleted successfully"
|
msgid "Deleted successfully"
|
||||||
msgstr "Deleted successfully"
|
msgstr "Deleted successfully"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:142
|
#: src/hooks/useCrudToasts.ts:59
|
||||||
msgid "Deleting…"
|
msgid "Deleting…"
|
||||||
msgstr "Deleting…"
|
msgstr "Deleting…"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:35
|
#: src/components/Layout/Sidebar/index.tsx:37
|
||||||
msgid "Design Engineering"
|
msgid "Design Engineering"
|
||||||
msgstr "Design Engineering"
|
msgstr "Design Engineering"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:247
|
#: src/routes/_auth/orders/-FormModal.tsx:36
|
||||||
|
msgid "Done"
|
||||||
|
msgstr "Done"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:34
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr "Draft"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/index.tsx:142
|
||||||
|
#: src/routes/_auth/users/index.tsx:213
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||||
|
msgid "Edit Order"
|
||||||
|
msgstr "Edit Order"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
msgid "Edit User"
|
msgid "Edit User"
|
||||||
msgstr "Edit User"
|
msgstr "Edit User"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:59
|
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:65
|
#: src/routes/_auth/users/-Toolbar.tsx:65
|
||||||
msgid "Editor"
|
msgid "Editor"
|
||||||
msgstr "Editor"
|
msgstr "Editor"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:63
|
#: src/routes/_auth/users/-FormModal.tsx:57
|
||||||
#: src/routes/_auth/users/index.tsx:202
|
#: src/routes/_auth/users/index.tsx:168
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "Email"
|
msgstr "Email"
|
||||||
|
|
||||||
@@ -134,27 +152,34 @@ msgstr "Email"
|
|||||||
#~ msgid "Filter by role"
|
#~ msgid "Filter by role"
|
||||||
#~ msgstr "Filter by role"
|
#~ msgstr "Filter by role"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:192
|
#: src/routes/login/index.tsx:188
|
||||||
msgid "Forgot password?"
|
msgid "Forgot password?"
|
||||||
msgstr "Forgot password?"
|
msgstr "Forgot password?"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:57
|
#: src/components/NotFound/index.tsx:62
|
||||||
msgid "Go back"
|
msgid "Go back"
|
||||||
msgstr "Go back"
|
msgstr "Go back"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:68
|
#: src/routes/login/index.tsx:54
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "Login failed"
|
msgstr "Login failed"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:64
|
#: src/routes/login/index.tsx:50
|
||||||
msgid "Login successful"
|
msgid "Login successful"
|
||||||
msgstr "Login successful"
|
msgstr "Login successful"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:54
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:55
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:92
|
#: src/routes/_auth/users/-Toolbar.tsx:92
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:93
|
#: src/routes/_auth/users/-Toolbar.tsx:93
|
||||||
msgid "More filters"
|
msgid "More filters"
|
||||||
msgstr "More filters"
|
msgstr "More filters"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:51
|
||||||
|
msgid "New Order"
|
||||||
|
msgstr "New Order"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
msgid "New User"
|
msgid "New User"
|
||||||
msgstr "New User"
|
msgstr "New User"
|
||||||
@@ -167,11 +192,23 @@ msgstr "No data"
|
|||||||
msgid "Nothing to show in this list yet"
|
msgid "Nothing to show in this list yet"
|
||||||
msgstr "Nothing to show in this list yet"
|
msgstr "Nothing to show in this list yet"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:31
|
#: src/routes/_auth/users/-FormModal.tsx:30
|
||||||
msgid "OK"
|
msgid "OK"
|
||||||
msgstr "OK"
|
msgstr "OK"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:115
|
#: src/routes/_auth/orders/-FormModal.tsx:35
|
||||||
|
msgid "Open"
|
||||||
|
msgstr "Open"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:358
|
||||||
|
msgid "Open account menu"
|
||||||
|
msgstr "Open account menu"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:36
|
||||||
|
msgid "Orders"
|
||||||
|
msgstr "Orders"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:233
|
||||||
msgid "Overview"
|
msgid "Overview"
|
||||||
msgstr "Overview"
|
msgstr "Overview"
|
||||||
|
|
||||||
@@ -179,56 +216,74 @@ msgstr "Overview"
|
|||||||
#~ msgid "Page not found"
|
#~ msgid "Page not found"
|
||||||
#~ msgstr "Page not found"
|
#~ msgstr "Page not found"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:171
|
#: src/routes/login/index.tsx:162
|
||||||
|
#: src/routes/login/index.tsx:168
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:30
|
#: src/components/Layout/Sidebar/index.tsx:32
|
||||||
msgid "Platform"
|
msgid "Platform"
|
||||||
msgstr "Platform"
|
msgstr "Platform"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:172
|
#: src/routes/login/index.tsx:163
|
||||||
msgid "Please enter password"
|
msgid "Please enter password"
|
||||||
msgstr "Please enter password"
|
msgstr "Please enter password"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
#: src/routes/_auth/users/-FormModal.tsx:40
|
||||||
#: src/routes/login/index.tsx:164
|
#: src/routes/login/index.tsx:150
|
||||||
msgid "Please enter username"
|
msgid "Please enter username"
|
||||||
msgstr "Please enter username"
|
msgstr "Please enter username"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:53
|
#: src/routes/_auth/users/-FormModal.tsx:47
|
||||||
msgid "Please select roles"
|
msgid "Please select roles"
|
||||||
msgstr "Please select roles"
|
msgstr "Please select roles"
|
||||||
|
|
||||||
#: src/components/Layout/AppFooter/index.tsx:36
|
#: src/components/RouteError.tsx:17
|
||||||
|
msgid "Please try again or return to the dashboard."
|
||||||
|
msgstr "Please try again or return to the dashboard."
|
||||||
|
|
||||||
|
#: src/components/Layout/AppFooter/index.tsx:23
|
||||||
msgid "Powered by"
|
msgid "Powered by"
|
||||||
msgstr "Powered by"
|
msgstr "Powered by"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:31
|
#: src/components/Layout/Sidebar/index.tsx:33
|
||||||
msgid "Projects"
|
msgid "Projects"
|
||||||
msgstr "Projects"
|
msgstr "Projects"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:142
|
#: src/routes/_auth/dashboard/index.tsx:259
|
||||||
msgid "Recent Sales"
|
msgid "Recent Sales"
|
||||||
msgstr "Recent Sales"
|
msgstr "Recent Sales"
|
||||||
|
|
||||||
|
#: src/components/RouteError.tsx:20
|
||||||
|
msgid "Retry"
|
||||||
|
msgstr "Retry"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:58
|
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Role"
|
msgstr "Role"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:52
|
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||||
#: src/routes/_auth/users/index.tsx:209
|
#: src/routes/_auth/users/index.tsx:175
|
||||||
msgid "Roles"
|
msgid "Roles"
|
||||||
msgstr "Roles"
|
msgstr "Roles"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:39
|
#: src/routes/_auth/orders/index.tsx:160
|
||||||
|
#: src/routes/_auth/users/index.tsx:234
|
||||||
|
msgid "Row actions"
|
||||||
|
msgstr "Row actions"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:148
|
||||||
msgid "Sales"
|
msgid "Sales"
|
||||||
msgstr "Sales"
|
msgstr "Sales"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:36
|
#: src/components/Layout/Sidebar/index.tsx:38
|
||||||
msgid "Sales & Marketing"
|
msgid "Sales & Marketing"
|
||||||
msgstr "Sales & Marketing"
|
msgstr "Sales & Marketing"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:32
|
||||||
|
msgid "Search Order"
|
||||||
|
msgstr "Search Order"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:43
|
#: src/routes/_auth/users/-Toolbar.tsx:43
|
||||||
msgid "Search User"
|
msgid "Search User"
|
||||||
msgstr "Search User"
|
msgstr "Search User"
|
||||||
@@ -237,7 +292,7 @@ msgstr "Search User"
|
|||||||
#~ msgid "Search username or email"
|
#~ msgid "Search username or email"
|
||||||
#~ msgstr "Search username or email"
|
#~ msgstr "Search username or email"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:204
|
#: src/routes/login/index.tsx:200
|
||||||
msgid "Sign In"
|
msgid "Sign In"
|
||||||
msgstr "Sign In"
|
msgstr "Sign In"
|
||||||
|
|
||||||
@@ -245,70 +300,89 @@ msgstr "Sign In"
|
|||||||
#~ msgid "Sign in with your credentials."
|
#~ msgid "Sign in with your credentials."
|
||||||
#~ msgstr "Sign in with your credentials."
|
#~ msgstr "Sign in with your credentials."
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:194
|
#: src/components/Layout/Sidebar/index.tsx:197
|
||||||
msgid "Sign Out"
|
msgid "Sign Out"
|
||||||
msgstr "Sign Out"
|
msgstr "Sign Out"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:50
|
#: src/components/RouteError.tsx:16
|
||||||
|
msgid "Something went wrong"
|
||||||
|
msgstr "Something went wrong"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:55
|
||||||
msgid "Sorry, the page you visited does not exist."
|
msgid "Sorry, the page you visited does not exist."
|
||||||
msgstr "Sorry, the page you visited does not exist."
|
msgstr "Sorry, the page you visited does not exist."
|
||||||
|
|
||||||
#: src/routes/_auth/403/index.tsx:37
|
#: src/routes/_auth/403/index.tsx:32
|
||||||
msgid "Sorry, you don't have permission to access this page."
|
msgid "Sorry, you don't have permission to access this page."
|
||||||
msgstr "Sorry, you don't have permission to access this page."
|
msgstr "Sorry, you don't have permission to access this page."
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:33
|
#: src/routes/_auth/orders/-FormModal.tsx:31
|
||||||
|
#: src/routes/_auth/orders/index.tsx:125
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "Status"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:142
|
||||||
msgid "Subscriptions"
|
msgid "Subscriptions"
|
||||||
msgstr "Subscriptions"
|
msgstr "Subscriptions"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:145
|
#: src/components/Layout/Header/index.tsx:153
|
||||||
#: src/routes/login/index.tsx:225
|
#: src/routes/login/index.tsx:221
|
||||||
msgid "Switch language"
|
msgid "Switch language"
|
||||||
msgstr "Switch language"
|
msgstr "Switch language"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:165
|
#: src/routes/_auth/orders/index.tsx:113
|
||||||
|
msgid "This action cannot be undone."
|
||||||
|
msgstr "This action cannot be undone."
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:125
|
||||||
msgid "This action cannot be undone. This will permanently delete the user."
|
msgid "This action cannot be undone. This will permanently delete the user."
|
||||||
msgstr "This action cannot be undone. This will permanently delete the user."
|
msgstr "This action cannot be undone. This will permanently delete the user."
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:133
|
#: src/routes/_auth/orders/-FormModal.tsx:28
|
||||||
#: src/components/Layout/Sidebar/index.tsx:263
|
#: src/routes/_auth/orders/index.tsx:123
|
||||||
#: src/components/Layout/Sidebar/index.tsx:311
|
msgid "Title"
|
||||||
|
msgstr "Title"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:138
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:271
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:319
|
||||||
msgid "Toggle sidebar"
|
msgid "Toggle sidebar"
|
||||||
msgstr "Toggle sidebar"
|
msgstr "Toggle sidebar"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:151
|
#: src/components/Layout/Header/index.tsx:159
|
||||||
#: src/routes/login/index.tsx:232
|
#: src/routes/login/index.tsx:228
|
||||||
msgid "Toggle Theme"
|
msgid "Toggle Theme"
|
||||||
msgstr "Toggle Theme"
|
msgstr "Toggle Theme"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:27
|
#: src/routes/_auth/dashboard/index.tsx:136
|
||||||
msgid "Total Revenue"
|
msgid "Total Revenue"
|
||||||
msgstr "Total Revenue"
|
msgstr "Total Revenue"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:132
|
#: src/hooks/useCrudToasts.ts:54
|
||||||
msgid "Update failed"
|
msgid "Update failed"
|
||||||
msgstr "Update failed"
|
msgstr "Update failed"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:122
|
#: src/hooks/useCrudToasts.ts:51
|
||||||
msgid "Updated successfully"
|
msgid "Updated successfully"
|
||||||
msgstr "Updated successfully"
|
msgstr "Updated successfully"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:115
|
#: src/hooks/useCrudToasts.ts:48
|
||||||
msgid "Updating…"
|
msgid "Updating…"
|
||||||
msgstr "Updating…"
|
msgstr "Updating…"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:34
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
msgid "User List"
|
#~ msgid "User List"
|
||||||
msgstr "User List"
|
#~ msgstr "User List"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:45
|
#: src/routes/_auth/users/-FormModal.tsx:39
|
||||||
#: src/routes/_auth/users/index.tsx:182
|
#: src/routes/_auth/users/index.tsx:142
|
||||||
#: src/routes/login/index.tsx:163
|
#: src/routes/login/index.tsx:149
|
||||||
|
#: src/routes/login/index.tsx:154
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:76
|
#: src/components/Layout/Header/index.tsx:81
|
||||||
#: src/components/Layout/Sidebar/index.tsx:33
|
#: src/components/Layout/Sidebar/index.tsx:35
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type { Locale } from "@/stores/settings";
|
|||||||
|
|
||||||
const catalogCache: Partial<Record<Locale, Messages>> = {};
|
const catalogCache: Partial<Record<Locale, Messages>> = {};
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically loads compiled Lingui messages for the given locale.
|
|
||||||
* Results are cached so repeated calls (e.g. main + root) do not refetch chunks.
|
|
||||||
*/
|
|
||||||
export async function loadLocaleCatalog(locale: Locale): Promise<Messages> {
|
export async function loadLocaleCatalog(locale: Locale): Promise<Messages> {
|
||||||
const hit = catalogCache[locale];
|
const hit = catalogCache[locale];
|
||||||
if (hit) {
|
if (hit) {
|
||||||
|
|||||||
@@ -17,62 +17,65 @@ msgstr ""
|
|||||||
#~ msgid "{n} rows"
|
#~ msgid "{n} rows"
|
||||||
#~ msgstr "{n} 行"
|
#~ msgstr "{n} 行"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:386
|
#: src/routes/_auth/orders/index.tsx:186
|
||||||
|
#: src/routes/_auth/users/index.tsx:262
|
||||||
msgid "{total} rows"
|
msgid "{total} rows"
|
||||||
msgstr "共 {total} 条"
|
msgstr "共 {total} 条"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:35
|
#: src/routes/_auth/dashboard/index.tsx:144
|
||||||
msgid "+180.1% from last month"
|
msgid "+180.1% from last month"
|
||||||
msgstr "较上月 +180.1%"
|
msgstr "较上月 +180.1%"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:41
|
#: src/routes/_auth/dashboard/index.tsx:150
|
||||||
msgid "+19% from last month"
|
msgid "+19% from last month"
|
||||||
msgstr "较上月 +19%"
|
msgstr "较上月 +19%"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:29
|
#: src/routes/_auth/dashboard/index.tsx:138
|
||||||
msgid "+20.1% from last month"
|
msgid "+20.1% from last month"
|
||||||
msgstr "较上月 +20.1%"
|
msgstr "较上月 +20.1%"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:47
|
#: src/routes/_auth/dashboard/index.tsx:156
|
||||||
msgid "+201 since last hour"
|
msgid "+201 since last hour"
|
||||||
msgstr "较上一小时 +201"
|
msgstr "较上一小时 +201"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:78
|
#: src/components/Layout/Header/index.tsx:83
|
||||||
msgid "403"
|
msgid "403"
|
||||||
msgstr "403"
|
msgstr "403"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:45
|
#: src/routes/_auth/dashboard/index.tsx:154
|
||||||
msgid "Active Now"
|
msgid "Active Now"
|
||||||
msgstr "当前在线"
|
msgstr "当前在线"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:58
|
#: src/routes/_auth/users/-FormModal.tsx:52
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:64
|
#: src/routes/_auth/users/-Toolbar.tsx:64
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "管理员"
|
msgstr "管理员"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:164
|
#: src/routes/_auth/orders/index.tsx:112
|
||||||
|
#: src/routes/_auth/users/index.tsx:124
|
||||||
msgid "Are you absolutely sure?"
|
msgid "Are you absolutely sure?"
|
||||||
msgstr "确定要执行此操作吗?"
|
msgstr "确定要执行此操作吗?"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:185
|
#: src/routes/login/index.tsx:181
|
||||||
msgid "Auto login"
|
msgid "Auto login"
|
||||||
msgstr "自动登录"
|
msgstr "自动登录"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:54
|
#: src/components/NotFound/index.tsx:59
|
||||||
#: src/routes/_auth/403/index.tsx:40
|
#: src/routes/_auth/403/index.tsx:35
|
||||||
msgid "Back to Home"
|
msgid "Back to Home"
|
||||||
msgstr "返回首页"
|
msgstr "返回首页"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:32
|
#: src/routes/_auth/orders/index.tsx:116
|
||||||
#: src/routes/_auth/users/index.tsx:168
|
#: src/routes/_auth/users/-FormModal.tsx:31
|
||||||
|
#: src/routes/_auth/users/index.tsx:128
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "取消"
|
msgstr "取消"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:134
|
#: src/routes/_auth/dashboard/index.tsx:250
|
||||||
msgid "Chart Placeholder"
|
msgid "Chart Placeholder"
|
||||||
msgstr "图表占位"
|
msgstr "图表占位"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:106
|
#: src/hooks/useCrudToasts.ts:43
|
||||||
msgid "Create failed"
|
msgid "Create failed"
|
||||||
msgstr "创建失败"
|
msgstr "创建失败"
|
||||||
|
|
||||||
@@ -80,53 +83,68 @@ msgstr "创建失败"
|
|||||||
msgid "Create User"
|
msgid "Create User"
|
||||||
msgstr "创建用户"
|
msgstr "创建用户"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:100
|
#: src/hooks/useCrudToasts.ts:40
|
||||||
msgid "Created successfully"
|
msgid "Created successfully"
|
||||||
msgstr "创建成功"
|
msgstr "创建成功"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:74
|
#: src/components/Layout/Header/index.tsx:79
|
||||||
#: src/components/Layout/Header/index.tsx:87
|
#: src/components/Layout/Header/index.tsx:92
|
||||||
#: src/components/Layout/Header/index.tsx:91
|
#: src/components/Layout/Header/index.tsx:96
|
||||||
#: src/components/Layout/Sidebar/index.tsx:32
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "仪表盘"
|
msgstr "仪表盘"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:166
|
#: src/routes/_auth/orders/index.tsx:114
|
||||||
#: src/routes/_auth/users/index.tsx:257
|
#: src/routes/_auth/orders/index.tsx:152
|
||||||
|
#: src/routes/_auth/users/index.tsx:126
|
||||||
|
#: src/routes/_auth/users/index.tsx:223
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "删除"
|
msgstr "删除"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:156
|
#: src/hooks/useCrudToasts.ts:65
|
||||||
msgid "Delete failed"
|
msgid "Delete failed"
|
||||||
msgstr "删除失败"
|
msgstr "删除失败"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:149
|
#: src/hooks/useCrudToasts.ts:62
|
||||||
msgid "Deleted successfully"
|
msgid "Deleted successfully"
|
||||||
msgstr "删除成功"
|
msgstr "删除成功"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:142
|
#: src/hooks/useCrudToasts.ts:59
|
||||||
msgid "Deleting…"
|
msgid "Deleting…"
|
||||||
msgstr "正在删除…"
|
msgstr "正在删除…"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:35
|
#: src/components/Layout/Sidebar/index.tsx:37
|
||||||
msgid "Design Engineering"
|
msgid "Design Engineering"
|
||||||
msgstr "设计工程"
|
msgstr "设计工程"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:247
|
#: src/routes/_auth/orders/-FormModal.tsx:36
|
||||||
|
msgid "Done"
|
||||||
|
msgstr "已完成"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:34
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr "草稿"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/index.tsx:142
|
||||||
|
#: src/routes/_auth/users/index.tsx:213
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "编辑"
|
msgstr "编辑"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||||
|
msgid "Edit Order"
|
||||||
|
msgstr "编辑订单"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
msgid "Edit User"
|
msgid "Edit User"
|
||||||
msgstr "编辑用户"
|
msgstr "编辑用户"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:59
|
#: src/routes/_auth/users/-FormModal.tsx:53
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:65
|
#: src/routes/_auth/users/-Toolbar.tsx:65
|
||||||
msgid "Editor"
|
msgid "Editor"
|
||||||
msgstr "编辑者"
|
msgstr "编辑者"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:63
|
#: src/routes/_auth/users/-FormModal.tsx:57
|
||||||
#: src/routes/_auth/users/index.tsx:202
|
#: src/routes/_auth/users/index.tsx:168
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "邮箱"
|
msgstr "邮箱"
|
||||||
|
|
||||||
@@ -134,27 +152,34 @@ msgstr "邮箱"
|
|||||||
#~ msgid "Filter by role"
|
#~ msgid "Filter by role"
|
||||||
#~ msgstr "按角色筛选"
|
#~ msgstr "按角色筛选"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:192
|
#: src/routes/login/index.tsx:188
|
||||||
msgid "Forgot password?"
|
msgid "Forgot password?"
|
||||||
msgstr "忘记密码?"
|
msgstr "忘记密码?"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:57
|
#: src/components/NotFound/index.tsx:62
|
||||||
msgid "Go back"
|
msgid "Go back"
|
||||||
msgstr "返回上一页"
|
msgstr "返回上一页"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:68
|
#: src/routes/login/index.tsx:54
|
||||||
msgid "Login failed"
|
msgid "Login failed"
|
||||||
msgstr "登录失败"
|
msgstr "登录失败"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:64
|
#: src/routes/login/index.tsx:50
|
||||||
msgid "Login successful"
|
msgid "Login successful"
|
||||||
msgstr "登录成功"
|
msgstr "登录成功"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:54
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:55
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:92
|
#: src/routes/_auth/users/-Toolbar.tsx:92
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:93
|
#: src/routes/_auth/users/-Toolbar.tsx:93
|
||||||
msgid "More filters"
|
msgid "More filters"
|
||||||
msgstr "更多筛选"
|
msgstr "更多筛选"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-FormModal.tsx:19
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:51
|
||||||
|
msgid "New Order"
|
||||||
|
msgstr "新建订单"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:29
|
#: src/routes/_auth/users/-FormModal.tsx:29
|
||||||
msgid "New User"
|
msgid "New User"
|
||||||
msgstr "新建用户"
|
msgstr "新建用户"
|
||||||
@@ -167,11 +192,23 @@ msgstr "暂无数据"
|
|||||||
msgid "Nothing to show in this list yet"
|
msgid "Nothing to show in this list yet"
|
||||||
msgstr "当前列表为空,暂无可展示的记录"
|
msgstr "当前列表为空,暂无可展示的记录"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:31
|
#: src/routes/_auth/users/-FormModal.tsx:30
|
||||||
msgid "OK"
|
msgid "OK"
|
||||||
msgstr "确定"
|
msgstr "确定"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:115
|
#: src/routes/_auth/orders/-FormModal.tsx:35
|
||||||
|
msgid "Open"
|
||||||
|
msgstr "进行中"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:358
|
||||||
|
msgid "Open account menu"
|
||||||
|
msgstr "打开账户菜单"
|
||||||
|
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:36
|
||||||
|
msgid "Orders"
|
||||||
|
msgstr "订单"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:233
|
||||||
msgid "Overview"
|
msgid "Overview"
|
||||||
msgstr "概览"
|
msgstr "概览"
|
||||||
|
|
||||||
@@ -179,56 +216,74 @@ msgstr "概览"
|
|||||||
#~ msgid "Page not found"
|
#~ msgid "Page not found"
|
||||||
#~ msgstr "页面不存在"
|
#~ msgstr "页面不存在"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:171
|
#: src/routes/login/index.tsx:162
|
||||||
|
#: src/routes/login/index.tsx:168
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "密码"
|
msgstr "密码"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:30
|
#: src/components/Layout/Sidebar/index.tsx:32
|
||||||
msgid "Platform"
|
msgid "Platform"
|
||||||
msgstr "平台"
|
msgstr "平台"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:172
|
#: src/routes/login/index.tsx:163
|
||||||
msgid "Please enter password"
|
msgid "Please enter password"
|
||||||
msgstr "请输入密码"
|
msgstr "请输入密码"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:46
|
#: src/routes/_auth/users/-FormModal.tsx:40
|
||||||
#: src/routes/login/index.tsx:164
|
#: src/routes/login/index.tsx:150
|
||||||
msgid "Please enter username"
|
msgid "Please enter username"
|
||||||
msgstr "请输入用户名"
|
msgstr "请输入用户名"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:53
|
#: src/routes/_auth/users/-FormModal.tsx:47
|
||||||
msgid "Please select roles"
|
msgid "Please select roles"
|
||||||
msgstr "请选择角色"
|
msgstr "请选择角色"
|
||||||
|
|
||||||
#: src/components/Layout/AppFooter/index.tsx:36
|
#: src/components/RouteError.tsx:17
|
||||||
|
msgid "Please try again or return to the dashboard."
|
||||||
|
msgstr "请重试或返回控制台。"
|
||||||
|
|
||||||
|
#: src/components/Layout/AppFooter/index.tsx:23
|
||||||
msgid "Powered by"
|
msgid "Powered by"
|
||||||
msgstr "由"
|
msgstr "由"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:31
|
#: src/components/Layout/Sidebar/index.tsx:33
|
||||||
msgid "Projects"
|
msgid "Projects"
|
||||||
msgstr "项目"
|
msgstr "项目"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:142
|
#: src/routes/_auth/dashboard/index.tsx:259
|
||||||
msgid "Recent Sales"
|
msgid "Recent Sales"
|
||||||
msgstr "近期销售"
|
msgstr "近期销售"
|
||||||
|
|
||||||
|
#: src/components/RouteError.tsx:20
|
||||||
|
msgid "Retry"
|
||||||
|
msgstr "重试"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:58
|
#: src/routes/_auth/users/-Toolbar.tsx:58
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "角色"
|
msgstr "角色"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:52
|
#: src/routes/_auth/users/-FormModal.tsx:46
|
||||||
#: src/routes/_auth/users/index.tsx:209
|
#: src/routes/_auth/users/index.tsx:175
|
||||||
msgid "Roles"
|
msgid "Roles"
|
||||||
msgstr "角色"
|
msgstr "角色"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:39
|
#: src/routes/_auth/orders/index.tsx:160
|
||||||
|
#: src/routes/_auth/users/index.tsx:234
|
||||||
|
msgid "Row actions"
|
||||||
|
msgstr "行操作"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:148
|
||||||
msgid "Sales"
|
msgid "Sales"
|
||||||
msgstr "销售额"
|
msgstr "销售额"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:36
|
#: src/components/Layout/Sidebar/index.tsx:38
|
||||||
msgid "Sales & Marketing"
|
msgid "Sales & Marketing"
|
||||||
msgstr "销售与市场"
|
msgstr "销售与市场"
|
||||||
|
|
||||||
|
#: src/routes/_auth/orders/-Toolbar.tsx:32
|
||||||
|
msgid "Search Order"
|
||||||
|
msgstr "搜索订单"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-Toolbar.tsx:43
|
#: src/routes/_auth/users/-Toolbar.tsx:43
|
||||||
msgid "Search User"
|
msgid "Search User"
|
||||||
msgstr "搜索用户"
|
msgstr "搜索用户"
|
||||||
@@ -237,7 +292,7 @@ msgstr "搜索用户"
|
|||||||
#~ msgid "Search username or email"
|
#~ msgid "Search username or email"
|
||||||
#~ msgstr "搜索用户名或邮箱"
|
#~ msgstr "搜索用户名或邮箱"
|
||||||
|
|
||||||
#: src/routes/login/index.tsx:204
|
#: src/routes/login/index.tsx:200
|
||||||
msgid "Sign In"
|
msgid "Sign In"
|
||||||
msgstr "登录"
|
msgstr "登录"
|
||||||
|
|
||||||
@@ -245,70 +300,89 @@ msgstr "登录"
|
|||||||
#~ msgid "Sign in with your credentials."
|
#~ msgid "Sign in with your credentials."
|
||||||
#~ msgstr "凭用户名与密码登录"
|
#~ msgstr "凭用户名与密码登录"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:194
|
#: src/components/Layout/Sidebar/index.tsx:197
|
||||||
msgid "Sign Out"
|
msgid "Sign Out"
|
||||||
msgstr "退出登录"
|
msgstr "退出登录"
|
||||||
|
|
||||||
#: src/components/NotFound/index.tsx:50
|
#: src/components/RouteError.tsx:16
|
||||||
|
msgid "Something went wrong"
|
||||||
|
msgstr "出了点问题"
|
||||||
|
|
||||||
|
#: src/components/NotFound/index.tsx:55
|
||||||
msgid "Sorry, the page you visited does not exist."
|
msgid "Sorry, the page you visited does not exist."
|
||||||
msgstr "抱歉,您访问的页面不存在。"
|
msgstr "抱歉,您访问的页面不存在。"
|
||||||
|
|
||||||
#: src/routes/_auth/403/index.tsx:37
|
#: src/routes/_auth/403/index.tsx:32
|
||||||
msgid "Sorry, you don't have permission to access this page."
|
msgid "Sorry, you don't have permission to access this page."
|
||||||
msgstr "抱歉,您没有权限访问此页面。"
|
msgstr "抱歉,您没有权限访问此页面。"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:33
|
#: src/routes/_auth/orders/-FormModal.tsx:31
|
||||||
|
#: src/routes/_auth/orders/index.tsx:125
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "状态"
|
||||||
|
|
||||||
|
#: src/routes/_auth/dashboard/index.tsx:142
|
||||||
msgid "Subscriptions"
|
msgid "Subscriptions"
|
||||||
msgstr "订阅"
|
msgstr "订阅"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:145
|
#: src/components/Layout/Header/index.tsx:153
|
||||||
#: src/routes/login/index.tsx:225
|
#: src/routes/login/index.tsx:221
|
||||||
msgid "Switch language"
|
msgid "Switch language"
|
||||||
msgstr "切换语言"
|
msgstr "切换语言"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:165
|
#: src/routes/_auth/orders/index.tsx:113
|
||||||
|
msgid "This action cannot be undone."
|
||||||
|
msgstr "此操作无法撤销。"
|
||||||
|
|
||||||
|
#: src/routes/_auth/users/index.tsx:125
|
||||||
msgid "This action cannot be undone. This will permanently delete the user."
|
msgid "This action cannot be undone. This will permanently delete the user."
|
||||||
msgstr "此操作无法撤销,将永久删除该用户。"
|
msgstr "此操作无法撤销,将永久删除该用户。"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:133
|
#: src/routes/_auth/orders/-FormModal.tsx:28
|
||||||
#: src/components/Layout/Sidebar/index.tsx:263
|
#: src/routes/_auth/orders/index.tsx:123
|
||||||
#: src/components/Layout/Sidebar/index.tsx:311
|
msgid "Title"
|
||||||
|
msgstr "标题"
|
||||||
|
|
||||||
|
#: src/components/Layout/Header/index.tsx:138
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:271
|
||||||
|
#: src/components/Layout/Sidebar/index.tsx:319
|
||||||
msgid "Toggle sidebar"
|
msgid "Toggle sidebar"
|
||||||
msgstr "折叠侧栏"
|
msgstr "折叠侧栏"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:151
|
#: src/components/Layout/Header/index.tsx:159
|
||||||
#: src/routes/login/index.tsx:232
|
#: src/routes/login/index.tsx:228
|
||||||
msgid "Toggle Theme"
|
msgid "Toggle Theme"
|
||||||
msgstr "切换主题"
|
msgstr "切换主题"
|
||||||
|
|
||||||
#: src/routes/_auth/dashboard/index.tsx:27
|
#: src/routes/_auth/dashboard/index.tsx:136
|
||||||
msgid "Total Revenue"
|
msgid "Total Revenue"
|
||||||
msgstr "总收入"
|
msgstr "总收入"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:132
|
#: src/hooks/useCrudToasts.ts:54
|
||||||
msgid "Update failed"
|
msgid "Update failed"
|
||||||
msgstr "更新失败"
|
msgstr "更新失败"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:122
|
#: src/hooks/useCrudToasts.ts:51
|
||||||
msgid "Updated successfully"
|
msgid "Updated successfully"
|
||||||
msgstr "更新成功"
|
msgstr "更新成功"
|
||||||
|
|
||||||
#: src/routes/_auth/users/index.tsx:115
|
#: src/hooks/useCrudToasts.ts:48
|
||||||
msgid "Updating…"
|
msgid "Updating…"
|
||||||
msgstr "正在更新…"
|
msgstr "正在更新…"
|
||||||
|
|
||||||
#: src/components/Layout/Sidebar/index.tsx:34
|
#: src/components/Layout/Sidebar/index.tsx:34
|
||||||
msgid "User List"
|
#~ msgid "User List"
|
||||||
msgstr "用户列表"
|
#~ msgstr "用户列表"
|
||||||
|
|
||||||
#: src/routes/_auth/users/-FormModal.tsx:45
|
#: src/routes/_auth/users/-FormModal.tsx:39
|
||||||
#: src/routes/_auth/users/index.tsx:182
|
#: src/routes/_auth/users/index.tsx:142
|
||||||
#: src/routes/login/index.tsx:163
|
#: src/routes/login/index.tsx:149
|
||||||
|
#: src/routes/login/index.tsx:154
|
||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "用户名"
|
msgstr "用户名"
|
||||||
|
|
||||||
#: src/components/Layout/Header/index.tsx:76
|
#: src/components/Layout/Header/index.tsx:81
|
||||||
#: src/components/Layout/Sidebar/index.tsx:33
|
#: src/components/Layout/Sidebar/index.tsx:35
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "用户"
|
msgstr "用户"
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { i18n } from "@lingui/core";
|
|||||||
import { loadLocaleCatalog } from "./locales/loadLocaleCatalog";
|
import { loadLocaleCatalog } from "./locales/loadLocaleCatalog";
|
||||||
import { useSettingsStore } from "./stores/settings";
|
import { useSettingsStore } from "./stores/settings";
|
||||||
import { useAuthStore } from "./stores/auth";
|
import { useAuthStore } from "./stores/auth";
|
||||||
import { fetchSessionAndApplyToStore } from "./utils/session";
|
import { installHttpRouter } from "./utils/http";
|
||||||
|
|
||||||
const router = createRouter({ routeTree });
|
const router = createRouter({ routeTree });
|
||||||
|
installHttpRouter(router);
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
@@ -25,22 +26,17 @@ async function enableMocking() {
|
|||||||
|
|
||||||
enableMocking()
|
enableMocking()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await useSettingsStore.persist.rehydrate();
|
await Promise.all([
|
||||||
|
useSettingsStore.persist.rehydrate(),
|
||||||
|
useAuthStore.persist.rehydrate(),
|
||||||
|
]);
|
||||||
|
|
||||||
const initialLocale = useSettingsStore.getState().locale;
|
const initialLocale = useSettingsStore.getState().locale;
|
||||||
const messages = await loadLocaleCatalog(initialLocale);
|
const messages = await loadLocaleCatalog(initialLocale);
|
||||||
i18n.load(initialLocale, messages);
|
i18n.load(initialLocale, messages);
|
||||||
i18n.activate(initialLocale);
|
i18n.activate(initialLocale);
|
||||||
document.documentElement.lang = initialLocale === "zh" ? "zh" : "en";
|
document.documentElement.lang = initialLocale === "zh" ? "zh" : "en";
|
||||||
|
document.documentElement.dir = "ltr";
|
||||||
await useAuthStore.persist.rehydrate();
|
|
||||||
const { isAuthenticated, tokens } = useAuthStore.getState();
|
|
||||||
if (isAuthenticated && tokens) {
|
|
||||||
try {
|
|
||||||
await fetchSessionAndApplyToStore();
|
|
||||||
} catch {
|
|
||||||
useAuthStore.getState().logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import {
|
||||||
|
paginatedListSchema,
|
||||||
|
paginatedWithSchema,
|
||||||
|
successWithNullBody,
|
||||||
|
successWithSchema,
|
||||||
|
} from "./createHandler";
|
||||||
|
import { UserSchema } from "@/api/schemas";
|
||||||
|
|
||||||
|
describe("successWithSchema", () => {
|
||||||
|
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
|
||||||
|
expect(() =>
|
||||||
|
successWithSchema(UserSchema, {
|
||||||
|
id: "1",
|
||||||
|
username: "x",
|
||||||
|
} as unknown),
|
||||||
|
).toThrow(z.ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns JSON body with parsed data when valid", async () => {
|
||||||
|
const row = {
|
||||||
|
id: "1",
|
||||||
|
username: "u",
|
||||||
|
avatar: null,
|
||||||
|
email: null,
|
||||||
|
roles: [] as string[],
|
||||||
|
permissions: [] as string[],
|
||||||
|
};
|
||||||
|
const res = successWithSchema(UserSchema, row);
|
||||||
|
const body = (await res.json()) as { code: number; data: typeof row };
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
expect(body.data.username).toBe("u");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("paginatedListSchema / paginatedWithSchema", () => {
|
||||||
|
it("rejects invalid list shape", () => {
|
||||||
|
const schema = paginatedListSchema(UserSchema);
|
||||||
|
expect(() =>
|
||||||
|
schema.parse({
|
||||||
|
list: [{ id: "1" }],
|
||||||
|
total: 1,
|
||||||
|
}),
|
||||||
|
).toThrow(z.ZodError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid paginated payload via paginatedWithSchema", async () => {
|
||||||
|
const row = {
|
||||||
|
id: "1",
|
||||||
|
username: "u",
|
||||||
|
avatar: null,
|
||||||
|
email: null,
|
||||||
|
roles: [] as string[],
|
||||||
|
permissions: [] as string[],
|
||||||
|
};
|
||||||
|
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
|
||||||
|
const body = (await res.json()) as { data: { list: typeof row[]; total: number } };
|
||||||
|
expect(body.data.total).toBe(1);
|
||||||
|
expect(body.data.list).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("successWithNullBody", () => {
|
||||||
|
it("wraps null data with schema validation", async () => {
|
||||||
|
const res = successWithNullBody();
|
||||||
|
const body = (await res.json()) as { code: number; data: null };
|
||||||
|
expect(body.data).toBeNull();
|
||||||
|
expect(body.code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
import { HttpResponse, delay as mswDelay } from "msw";
|
import { HttpResponse, delay as mswDelay } from "msw";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import type { ZodTypeAny } from "zod/v4";
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard API response format
|
|
||||||
*/
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
code: number;
|
code: number;
|
||||||
data: T;
|
data: T;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* MSW handler factory functions
|
|
||||||
* Provides consistent response wrapping and error handling
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a handler function with standard response format
|
|
||||||
* Automatically handles delay, error responses, and data wrapping
|
|
||||||
*/
|
|
||||||
export async function withDelay(ms: number = 200): Promise<void> {
|
export async function withDelay(ms: number = 200): Promise<void> {
|
||||||
await mswDelay(ms);
|
await mswDelay(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a success response
|
|
||||||
*/
|
|
||||||
export function successResponse<T>(data: T, message: string = "ok") {
|
export function successResponse<T>(data: T, message: string = "ok") {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
@@ -33,10 +20,42 @@ export function successResponse<T>(data: T, message: string = "ok") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
|
||||||
* Create an error response
|
export function successWithSchema<T extends ZodTypeAny>(
|
||||||
*/
|
schema: T,
|
||||||
export function errorResponse(code: number, message: string, data: unknown = null) {
|
data: unknown,
|
||||||
|
message: string = "ok",
|
||||||
|
) {
|
||||||
|
const parsed = schema.parse(data);
|
||||||
|
return successResponse(parsed, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
|
||||||
|
return z.object({
|
||||||
|
list: z.array(itemSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginatedWithSchema<T extends ZodTypeAny>(
|
||||||
|
itemSchema: T,
|
||||||
|
data: unknown,
|
||||||
|
message: string = "ok",
|
||||||
|
) {
|
||||||
|
return successWithSchema(paginatedListSchema(itemSchema), data, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NullBodySchema = z.null();
|
||||||
|
|
||||||
|
export function successWithNullBody(message: string = "ok") {
|
||||||
|
return successWithSchema(NullBodySchema, null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
data: unknown = null,
|
||||||
|
) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
code,
|
code,
|
||||||
data,
|
data,
|
||||||
@@ -44,9 +63,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Predefined error codes
|
|
||||||
*/
|
|
||||||
export const ERROR_CODES = {
|
export const ERROR_CODES = {
|
||||||
INVALID_CREDENTIALS: 1001,
|
INVALID_CREDENTIALS: 1001,
|
||||||
NOT_FOUND: 1002,
|
NOT_FOUND: 1002,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { User } from "@/api/schemas";
|
import type { User, Order } from "@/api/schemas";
|
||||||
import { vercelAvatarUrl } from "./utils";
|
import { vercelAvatarUrl } from "./utils";
|
||||||
|
|
||||||
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
|
|
||||||
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
||||||
["admin", "ops.admin@northstar.io"],
|
["admin", "ops.admin@northstar.io"],
|
||||||
["zhao.ming", "zhao.ming@northstar.io"],
|
["zhao.ming", "zhao.ming@northstar.io"],
|
||||||
@@ -16,11 +15,25 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
|
|||||||
["mia.silva", "mia.silva@northstar.io"],
|
["mia.silva", "mia.silva@northstar.io"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const GUEST_AUTH_USER_BODY = {
|
||||||
|
id: "99",
|
||||||
|
username: "guest",
|
||||||
|
avatar: null as null,
|
||||||
|
email: "guest@example.com",
|
||||||
|
roles: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
|
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
username,
|
username,
|
||||||
avatar: vercelAvatarUrl(username),
|
avatar: vercelAvatarUrl(username),
|
||||||
email,
|
email,
|
||||||
roles: i === 0 ? ["admin"] : ["editor"],
|
roles: i === 0 ? ["admin"] : ["editor"],
|
||||||
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
|
permissions: i === 0 ? ["orders:view","orders:create","orders:edit","orders:delete","user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const MOCK_ORDERS: Order[] = [
|
||||||
|
{ id: "1", title: "Alpha", status: "open" },
|
||||||
|
{ id: "2", title: "Beta", status: "draft" },
|
||||||
|
{ id: "3", title: "Gamma", status: "done" },
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,16 +1,48 @@
|
|||||||
import { http } from "msw";
|
import { http } from "msw";
|
||||||
import { MOCK_USERS } from "../data";
|
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
|
||||||
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
import {
|
||||||
|
withDelay,
|
||||||
|
errorResponse,
|
||||||
|
ERROR_CODES,
|
||||||
|
successWithSchema,
|
||||||
|
successWithNullBody,
|
||||||
|
} from "../createHandler";
|
||||||
|
import {
|
||||||
|
AuthTokensSchema,
|
||||||
|
AuthUserResponseSchema,
|
||||||
|
LoginRequestSchema,
|
||||||
|
PermissionsListSchema,
|
||||||
|
RefreshTokenRequestSchema,
|
||||||
|
} from "@/api/schemas";
|
||||||
|
|
||||||
|
const GUEST_REFRESH = "mock-guest-refresh";
|
||||||
|
|
||||||
|
function isGuestAuth(authorization: string | null): boolean {
|
||||||
|
return (authorization ?? "").includes("mock-guest-access");
|
||||||
|
}
|
||||||
|
|
||||||
export const authHandlers = [
|
export const authHandlers = [
|
||||||
http.post("/api/auth/login", async ({ request }) => {
|
http.post("/api/auth/login", async ({ request }) => {
|
||||||
await withDelay(300);
|
await withDelay(300);
|
||||||
const body = (await request.json()) as {
|
let json: unknown;
|
||||||
username: string;
|
try {
|
||||||
password: string;
|
json = await request.json();
|
||||||
};
|
} catch {
|
||||||
if (body.username === "admin" && body.password === "admin") {
|
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||||
return successResponse({
|
}
|
||||||
|
const parsed = LoginRequestSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
|
||||||
|
}
|
||||||
|
const { username, password } = parsed.data;
|
||||||
|
if (username === "guest" && password === "guest") {
|
||||||
|
return successWithSchema(AuthTokensSchema, {
|
||||||
|
accessToken: "mock-guest-access",
|
||||||
|
refreshToken: GUEST_REFRESH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (username === "admin" && password === "admin") {
|
||||||
|
return successWithSchema(AuthTokensSchema, {
|
||||||
accessToken: "mock-access-token",
|
accessToken: "mock-access-token",
|
||||||
refreshToken: "mock-refresh-token",
|
refreshToken: "mock-refresh-token",
|
||||||
});
|
});
|
||||||
@@ -18,20 +50,46 @@ export const authHandlers = [
|
|||||||
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
|
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.post("/api/auth/refresh", async () => {
|
http.post("/api/auth/refresh", async ({ request }) => {
|
||||||
await withDelay(100);
|
await withDelay(100);
|
||||||
return successResponse({
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||||
|
}
|
||||||
|
const parsed = RefreshTokenRequestSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
|
||||||
|
}
|
||||||
|
if (parsed.data.refreshToken === GUEST_REFRESH) {
|
||||||
|
return successWithSchema(AuthTokensSchema, {
|
||||||
|
accessToken: "mock-guest-access-refreshed",
|
||||||
|
refreshToken: "mock-guest-refresh-refreshed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return successWithSchema(AuthTokensSchema, {
|
||||||
accessToken: "mock-new-access-token",
|
accessToken: "mock-new-access-token",
|
||||||
refreshToken: "mock-new-refresh-token",
|
refreshToken: "mock-new-refresh-token",
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.post("/api/auth/logout", () => successResponse(null)),
|
http.post("/api/auth/logout", () => successWithNullBody()),
|
||||||
|
|
||||||
http.get("/api/auth/user", () => {
|
http.get("/api/auth/user", ({ request }) => {
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (isGuestAuth(auth)) {
|
||||||
|
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
|
||||||
|
}
|
||||||
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
|
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
|
||||||
return successResponse(userWithoutPermissions);
|
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
|
http.get("/api/auth/permissions", ({ request }) => {
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (isGuestAuth(auth)) {
|
||||||
|
return successWithSchema(PermissionsListSchema, [] as string[]);
|
||||||
|
}
|
||||||
|
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authHandlers } from "./auth";
|
import { authHandlers } from "./auth";
|
||||||
import { userHandlers } from "./user";
|
import { userHandlers } from "./user";
|
||||||
|
import { ordersHandlers } from "./orders";
|
||||||
|
|
||||||
export const handlers = [...authHandlers, ...userHandlers];
|
export const handlers = [...authHandlers, ...userHandlers, ...ordersHandlers];
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { http } from "msw";
|
||||||
|
import { OrderSchema } from "@/api/schemas";
|
||||||
|
import { MOCK_ORDERS } from "../data";
|
||||||
|
import { paginateList, parsePaginationParams } from "../utils";
|
||||||
|
import {
|
||||||
|
withDelay,
|
||||||
|
errorResponse,
|
||||||
|
ERROR_CODES,
|
||||||
|
paginatedWithSchema,
|
||||||
|
successWithSchema,
|
||||||
|
successWithNullBody,
|
||||||
|
} from "../createHandler";
|
||||||
|
|
||||||
|
let rows = [...MOCK_ORDERS];
|
||||||
|
|
||||||
|
function filterRows(items: typeof rows, keyword: string) {
|
||||||
|
if (!keyword.trim()) return items;
|
||||||
|
const k = keyword.toLowerCase();
|
||||||
|
return items.filter((r) => r.title.toLowerCase().includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ordersHandlers = [
|
||||||
|
http.get("/api/orders", async ({ request }) => {
|
||||||
|
await withDelay(150);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const { limit, offset } = parsePaginationParams(url.searchParams);
|
||||||
|
const keyword = url.searchParams.get("keyword") ?? "";
|
||||||
|
const filtered = filterRows(rows, keyword);
|
||||||
|
const list = paginateList(filtered, limit, offset);
|
||||||
|
return paginatedWithSchema(OrderSchema, { list, total: filtered.length });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post("/api/orders", async ({ request }) => {
|
||||||
|
await withDelay(150);
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const next: (typeof rows)[number] = {
|
||||||
|
id: String(rows.length + 1),
|
||||||
|
title: typeof body.title === "string" ? body.title : "",
|
||||||
|
status: (body.status as (typeof rows)[number]["status"]) ?? "draft",
|
||||||
|
};
|
||||||
|
rows.push(next);
|
||||||
|
return successWithSchema(OrderSchema, next);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.put("/api/orders/:id", async ({ params, request }) => {
|
||||||
|
await withDelay(150);
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const idx = rows.findIndex((r) => r.id === params.id);
|
||||||
|
if (idx === -1) {
|
||||||
|
return errorResponse(ERROR_CODES.NOT_FOUND, "Not found");
|
||||||
|
}
|
||||||
|
rows[idx] = { ...rows[idx], ...body } as (typeof rows)[number];
|
||||||
|
return successWithSchema(OrderSchema, rows[idx]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.delete("/api/orders/:id", async ({ params }) => {
|
||||||
|
await withDelay(150);
|
||||||
|
rows = rows.filter((r) => r.id !== params.id);
|
||||||
|
return successWithNullBody();
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
import { http } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { UserSchema } from "@/api/schemas";
|
||||||
import { MOCK_USERS } from "../data";
|
import { MOCK_USERS } from "../data";
|
||||||
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
|
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
|
||||||
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
import {
|
||||||
|
withDelay,
|
||||||
|
errorResponse,
|
||||||
|
ERROR_CODES,
|
||||||
|
paginatedWithSchema,
|
||||||
|
successWithSchema,
|
||||||
|
successWithNullBody,
|
||||||
|
} from "../createHandler";
|
||||||
|
|
||||||
let users = [...MOCK_USERS];
|
let users = [...MOCK_USERS];
|
||||||
|
|
||||||
|
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
|
||||||
|
|
||||||
export const userHandlers = [
|
export const userHandlers = [
|
||||||
http.get("/api/users", async ({ request }) => {
|
http.get("/api/users", async ({ request }) => {
|
||||||
await withDelay(200);
|
await withDelay(200);
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
|
||||||
|
return new HttpResponse(null, { status: 401 });
|
||||||
|
}
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const { limit, offset } = parsePaginationParams(url.searchParams);
|
const { limit, offset } = parsePaginationParams(url.searchParams);
|
||||||
const keyword = url.searchParams.get("keyword") ?? "";
|
const keyword = url.searchParams.get("keyword") ?? "";
|
||||||
@@ -16,7 +30,7 @@ export const userHandlers = [
|
|||||||
const filtered = filterUsers(users, { keyword, role });
|
const filtered = filterUsers(users, { keyword, role });
|
||||||
const list = paginateList(filtered, limit, offset);
|
const list = paginateList(filtered, limit, offset);
|
||||||
|
|
||||||
return successResponse({ list, total: filtered.length });
|
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.post("/api/users", async ({ request }) => {
|
http.post("/api/users", async ({ request }) => {
|
||||||
@@ -28,10 +42,10 @@ export const userHandlers = [
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
email: typeof body.email === "string" ? body.email : null,
|
email: typeof body.email === "string" ? body.email : null,
|
||||||
roles: (body.roles as string[]) ?? [],
|
roles: (body.roles as string[]) ?? [],
|
||||||
permissions: [],
|
permissions: [] as string[],
|
||||||
};
|
};
|
||||||
users.push(newUser);
|
users.push(newUser);
|
||||||
return successResponse(newUser);
|
return successWithSchema(UserSchema, newUser);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.put("/api/users/:id", async ({ params, request }) => {
|
http.put("/api/users/:id", async ({ params, request }) => {
|
||||||
@@ -41,13 +55,13 @@ export const userHandlers = [
|
|||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
|
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
|
||||||
}
|
}
|
||||||
users[idx] = { ...users[idx], ...body };
|
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
|
||||||
return successResponse(users[idx]);
|
return successWithSchema(UserSchema, users[idx]);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.delete("/api/users/:id", async ({ params }) => {
|
http.delete("/api/users/:id", async ({ params }) => {
|
||||||
await withDelay(200);
|
await withDelay(200);
|
||||||
users = users.filter((u) => u.id !== params.id);
|
users = users.filter((u) => u.id !== params.id);
|
||||||
return successResponse(null);
|
return successWithNullBody();
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
/**
|
|
||||||
* Mock-only helpers: demo avatars, list filtering, pagination.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Deterministic demo avatar URL from username (mock seed data only). */
|
|
||||||
export function vercelAvatarUrl(username: string): string {
|
export function vercelAvatarUrl(username: string): string {
|
||||||
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
|
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
import { Route as LoginIndexRouteImport } from './routes/login/index'
|
import { Route as LoginIndexRouteImport } from './routes/login/index'
|
||||||
import { Route as R404IndexRouteImport } from './routes/404/index'
|
import { Route as R404IndexRouteImport } from './routes/404/index'
|
||||||
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
|
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
|
||||||
|
import { Route as AuthOrdersIndexRouteImport } from './routes/_auth/orders/index'
|
||||||
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
||||||
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
|
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
|
||||||
|
|
||||||
@@ -41,6 +42,11 @@ const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
|
|||||||
path: '/users/',
|
path: '/users/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthOrdersIndexRoute = AuthOrdersIndexRouteImport.update({
|
||||||
|
id: '/orders/',
|
||||||
|
path: '/orders/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
||||||
id: '/dashboard/',
|
id: '/dashboard/',
|
||||||
path: '/dashboard/',
|
path: '/dashboard/',
|
||||||
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/login/': typeof LoginIndexRoute
|
'/login/': typeof LoginIndexRoute
|
||||||
'/403/': typeof Auth403IndexRoute
|
'/403/': typeof Auth403IndexRoute
|
||||||
'/dashboard/': typeof AuthDashboardIndexRoute
|
'/dashboard/': typeof AuthDashboardIndexRoute
|
||||||
|
'/orders/': typeof AuthOrdersIndexRoute
|
||||||
'/users/': typeof AuthUsersIndexRoute
|
'/users/': typeof AuthUsersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -66,6 +73,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginIndexRoute
|
'/login': typeof LoginIndexRoute
|
||||||
'/403': typeof Auth403IndexRoute
|
'/403': typeof Auth403IndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/dashboard': typeof AuthDashboardIndexRoute
|
||||||
|
'/orders': typeof AuthOrdersIndexRoute
|
||||||
'/users': typeof AuthUsersIndexRoute
|
'/users': typeof AuthUsersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
@@ -76,13 +84,21 @@ export interface FileRoutesById {
|
|||||||
'/login/': typeof LoginIndexRoute
|
'/login/': typeof LoginIndexRoute
|
||||||
'/_auth/403/': typeof Auth403IndexRoute
|
'/_auth/403/': typeof Auth403IndexRoute
|
||||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
||||||
|
'/_auth/orders/': typeof AuthOrdersIndexRoute
|
||||||
'/_auth/users/': typeof AuthUsersIndexRoute
|
'/_auth/users/': typeof AuthUsersIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/404/' | '/login/' | '/403/' | '/dashboard/' | '/users/'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/404/'
|
||||||
|
| '/login/'
|
||||||
|
| '/403/'
|
||||||
|
| '/dashboard/'
|
||||||
|
| '/orders/'
|
||||||
|
| '/users/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
|
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/orders' | '/users'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -91,6 +107,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login/'
|
| '/login/'
|
||||||
| '/_auth/403/'
|
| '/_auth/403/'
|
||||||
| '/_auth/dashboard/'
|
| '/_auth/dashboard/'
|
||||||
|
| '/_auth/orders/'
|
||||||
| '/_auth/users/'
|
| '/_auth/users/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
@@ -138,6 +155,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthUsersIndexRouteImport
|
preLoaderRoute: typeof AuthUsersIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/orders/': {
|
||||||
|
id: '/_auth/orders/'
|
||||||
|
path: '/orders'
|
||||||
|
fullPath: '/orders/'
|
||||||
|
preLoaderRoute: typeof AuthOrdersIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/dashboard/': {
|
'/_auth/dashboard/': {
|
||||||
id: '/_auth/dashboard/'
|
id: '/_auth/dashboard/'
|
||||||
path: '/dashboard'
|
path: '/dashboard'
|
||||||
@@ -158,12 +182,14 @@ declare module '@tanstack/react-router' {
|
|||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
Auth403IndexRoute: typeof Auth403IndexRoute
|
Auth403IndexRoute: typeof Auth403IndexRoute
|
||||||
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
||||||
|
AuthOrdersIndexRoute: typeof AuthOrdersIndexRoute
|
||||||
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
|
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
Auth403IndexRoute: Auth403IndexRoute,
|
Auth403IndexRoute: Auth403IndexRoute,
|
||||||
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
||||||
|
AuthOrdersIndexRoute: AuthOrdersIndexRoute,
|
||||||
AuthUsersIndexRoute: AuthUsersIndexRoute,
|
AuthUsersIndexRoute: AuthUsersIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { ConfigProvider, App } from "antd";
|
import { ConfigProvider, App } from "antd";
|
||||||
import enUS from "antd/locale/en_US";
|
import enUS from "antd/locale/en_US";
|
||||||
@@ -10,15 +10,31 @@ import { loadLocaleCatalog } from "@/locales/loadLocaleCatalog";
|
|||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
import { NotFound } from "@/components/NotFound";
|
import { NotFound } from "@/components/NotFound";
|
||||||
|
import { RouteError } from "@/components/RouteError";
|
||||||
|
import type { Locale } from "@/stores/settings";
|
||||||
import "@/index.css";
|
import "@/index.css";
|
||||||
|
|
||||||
const antdLocaleMap = { en: enUS, zh: zhCN } as const;
|
const antdLocaleMap = { en: enUS, zh: zhCN } as const;
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
function AppQueryBridge({ children }: { children: React.ReactNode }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const queryClient = useMemo(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||||
|
mutations: {
|
||||||
|
onError: (err) => {
|
||||||
|
message.error(err instanceof Error ? err.message : String(err));
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
const locale = useSettingsStore((s) => s.locale);
|
const locale = useSettingsStore((s) => s.locale);
|
||||||
@@ -39,6 +55,7 @@ function RootComponent() {
|
|||||||
i18n.load(locale, messages);
|
i18n.load(locale, messages);
|
||||||
i18n.activate(locale);
|
i18n.activate(locale);
|
||||||
document.documentElement.lang = locale === "zh" ? "zh" : "en";
|
document.documentElement.lang = locale === "zh" ? "zh" : "en";
|
||||||
|
document.documentElement.dir = "ltr";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -46,24 +63,48 @@ function RootComponent() {
|
|||||||
};
|
};
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const other: Locale = locale === "en" ? "zh" : "en";
|
||||||
|
let idleId: number | undefined;
|
||||||
|
let timeoutId: number | undefined;
|
||||||
|
if (typeof window.requestIdleCallback === "function") {
|
||||||
|
idleId = window.requestIdleCallback(() => {
|
||||||
|
void loadLocaleCatalog(other);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
void loadLocaleCatalog(other);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (idleId !== undefined && typeof window.cancelIdleCallback === "function") {
|
||||||
|
window.cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
|
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
|
||||||
}, [darkMode]);
|
}, [darkMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
|
<ConfigProvider {...configProviderProps} locale={antdLocaleMap[locale]}>
|
||||||
<App>
|
<App>
|
||||||
|
<AppQueryBridge>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</AppQueryBridge>
|
||||||
</App>
|
</App>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
notFoundComponent: NotFound,
|
notFoundComponent: NotFound,
|
||||||
|
errorComponent: RouteError,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { MainLayout } from "@/components/Layout/MainLayout";
|
import { MainLayout } from "@/components/Layout/MainLayout";
|
||||||
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
|
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
|
||||||
|
import { fetchSessionAndApplyToStore } from "@/utils/session";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth")({
|
export const Route = createFileRoute("/_auth")({
|
||||||
beforeLoad: ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
const { isAuthenticated, user } = useAuthStore.getState();
|
const { isAuthenticated, user, tokens } = useAuthStore.getState();
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tokens && !user) {
|
||||||
|
try {
|
||||||
|
await fetchSessionAndApplyToStore();
|
||||||
|
} catch {
|
||||||
|
useAuthStore.getState().logout();
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: nextUser } = useAuthStore.getState();
|
||||||
const path = normalizeAppPath(location.pathname);
|
const path = normalizeAppPath(location.pathname);
|
||||||
if (path === "/403") return;
|
if (path === "/403") return;
|
||||||
|
|
||||||
if (!canAccessPath(location.pathname, user?.permissions)) {
|
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
|
||||||
throw redirect({ to: "/403" });
|
throw redirect({ to: "/403" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ async function fetchDashboardShell() {
|
|||||||
|
|
||||||
type AntToken = ReturnType<typeof theme.useToken>["token"];
|
type AntToken = ReturnType<typeof theme.useToken>["token"];
|
||||||
|
|
||||||
/** Matches loaded stat card body height: title row, value, description. */
|
|
||||||
function StatCardSkeleton({ token }: { token: AntToken }) {
|
function StatCardSkeleton({ token }: { token: AntToken }) {
|
||||||
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
|
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
|
||||||
/** Lucide default icon box 24px to align with the first row of real cards. */
|
|
||||||
const iconBox = 24;
|
const iconBox = 24;
|
||||||
const titleRowHeight = Math.max(titleLine, iconBox);
|
const titleRowHeight = Math.max(titleLine, iconBox);
|
||||||
const valueLine = Math.round(24 * 1);
|
const valueLine = Math.round(24 * 1);
|
||||||
@@ -123,7 +121,6 @@ function DashboardPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
|
|
||||||
const cardHoverStyle = {
|
const cardHoverStyle = {
|
||||||
["--dash-card-hover-bg" as string]: token.colorBgContainer,
|
["--dash-card-hover-bg" as string]: token.colorBgContainer,
|
||||||
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
|
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Form, Input, Modal, Select } from "antd";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { Order, CreateOrderRequest } from "@/api/schemas";
|
||||||
|
|
||||||
|
export type FormModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
editing: Order | null;
|
||||||
|
form: ReturnType<typeof Form.useForm<CreateOrderRequest>>[0];
|
||||||
|
confirmLoading: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onFinish: (values: CreateOrderRequest) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FormModal({ open, editing, form, confirmLoading, onCancel, onFinish }: FormModalProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? t`Edit Order` : t`New Order`}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={() => {
|
||||||
|
form.submit();
|
||||||
|
}}
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form layout="vertical" form={form} onFinish={onFinish} requiredMark={false}>
|
||||||
|
<Form.Item name="title" label={t`Title`} rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label={t`Status`} rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: "draft", label: t`Draft` },
|
||||||
|
{ value: "open", label: t`Open` },
|
||||||
|
{ value: "done", label: t`Done` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button, Input, theme } from "antd";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import { FilterToolbar } from "@/components/FilterToolbar";
|
||||||
|
|
||||||
|
const FILTER_CONTROL_WIDTH = 220;
|
||||||
|
|
||||||
|
export type ToolbarProps = {
|
||||||
|
keywordInput: string;
|
||||||
|
onKeywordChange: (value: string) => void;
|
||||||
|
onSearch: (keyword: string) => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
|
onCreateClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
|
||||||
|
{ keywordInput, onKeywordChange, onSearch, onClearSearch, onCreateClick },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const slots = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: "keyword",
|
||||||
|
minWidth: FILTER_CONTROL_WIDTH,
|
||||||
|
children: (
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder={t`Search Order`}
|
||||||
|
style={{ width: FILTER_CONTROL_WIDTH }}
|
||||||
|
value={keywordInput}
|
||||||
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
|
onSearch={(v) => onSearch(v)}
|
||||||
|
onClear={onClearSearch}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[keywordInput, onClearSearch, onKeywordChange, onSearch, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterToolbar
|
||||||
|
ref={ref}
|
||||||
|
slots={slots}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
|
||||||
|
{t`New Order`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
moreFiltersLabel={t`More filters`}
|
||||||
|
moreFiltersTitle={t`More filters`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Button, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
||||||
|
import type { TablePaginationConfig } from "antd/es/table/interface";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { httpClient } from "@/utils/http";
|
||||||
|
import { ORDER_ENDPOINTS } from "@/api/orders";
|
||||||
|
import { PaginatedResponseSchema, OrderSchema, CreateOrderRequestSchema } from "@/api/schemas";
|
||||||
|
import type { Order, CreateOrderRequest } from "@/api/schemas";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { DataTable } from "@/components/DataTable";
|
||||||
|
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
|
||||||
|
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
|
||||||
|
import { useCrudToasts } from "@/hooks/useCrudToasts";
|
||||||
|
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
|
||||||
|
import { Toolbar } from "./-Toolbar";
|
||||||
|
import { FormModal } from "./-FormModal";
|
||||||
|
|
||||||
|
const SearchParamsSchema = z.object({
|
||||||
|
limit: z.number().int().positive().catch(100),
|
||||||
|
offset: z.number().int().nonnegative().catch(0),
|
||||||
|
sortField: z.string().nullable().catch(null),
|
||||||
|
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
|
||||||
|
keyword: z.string().catch(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RowSearch = z.infer<typeof SearchParamsSchema>;
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/orders/")({
|
||||||
|
validateSearch: (search) => SearchParamsSchema.parse(search),
|
||||||
|
component: OrderListPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedSchema = PaginatedResponseSchema(OrderSchema);
|
||||||
|
|
||||||
|
function OrderListPage() {
|
||||||
|
const search = Route.useSearch();
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const pageShellRef = useRef<HTMLDivElement>(null);
|
||||||
|
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const middleSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tableFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [editing, setEditing] = useState<Order | null>(null);
|
||||||
|
const [form] = Form.useForm<CreateOrderRequest>();
|
||||||
|
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
||||||
|
|
||||||
|
const setSearch = (next: RowSearch) => {
|
||||||
|
void navigate({ search: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const crudToasts = useCrudToasts<CreateOrderRequest, CreateOrderRequest & { id: string }>({
|
||||||
|
message,
|
||||||
|
resourceKey: "orders",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
||||||
|
{ list: Order[]; total: number },
|
||||||
|
CreateOrderRequest,
|
||||||
|
CreateOrderRequest & { id: string }
|
||||||
|
>({
|
||||||
|
queryKey: ["orders", search.limit, search.offset, search.keyword, search.sortField, search.sortOrder],
|
||||||
|
queryFn: () =>
|
||||||
|
httpClient.get(ORDER_ENDPOINTS.list, {
|
||||||
|
params: {
|
||||||
|
limit: search.limit,
|
||||||
|
offset: search.offset,
|
||||||
|
keyword: search.keyword || undefined,
|
||||||
|
sortField: search.sortField ?? undefined,
|
||||||
|
sortOrder: search.sortOrder ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
select: (raw) => paginatedSchema.shape.data.parse(raw),
|
||||||
|
createFn: (values) => httpClient.post(ORDER_ENDPOINTS.create, CreateOrderRequestSchema.parse(values)),
|
||||||
|
updateFn: ({ id, ...values }) => httpClient.put(ORDER_ENDPOINTS.update(id), values),
|
||||||
|
deleteFn: (id) => httpClient.delete(ORDER_ENDPOINTS.delete(id)),
|
||||||
|
optimistic: { update: true, delete: true },
|
||||||
|
createLifecycle: {
|
||||||
|
onSuccess: (values) => {
|
||||||
|
crudToasts.createLifecycle?.onSuccess?.(values);
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
onError: crudToasts.createLifecycle?.onError,
|
||||||
|
},
|
||||||
|
updateLifecycle: {
|
||||||
|
onMutate: crudToasts.updateLifecycle?.onMutate,
|
||||||
|
onSuccess: (values) => {
|
||||||
|
crudToasts.updateLifecycle?.onSuccess?.(values);
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
onError: crudToasts.updateLifecycle?.onError,
|
||||||
|
},
|
||||||
|
deleteLifecycle: {
|
||||||
|
onMutate: crudToasts.deleteLifecycle?.onMutate,
|
||||||
|
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
|
||||||
|
onError: crudToasts.deleteLifecycle?.onError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDelete = (record: Order) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: t`Are you absolutely sure?`,
|
||||||
|
content: t`This action cannot be undone.`,
|
||||||
|
okText: t`Delete`,
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: t`Cancel`,
|
||||||
|
onOk: () => deleteMutation.mutate(record.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "ID", dataIndex: "id", key: "id" },
|
||||||
|
{ title: t`Title`, dataIndex: "title", key: "title" },
|
||||||
|
{
|
||||||
|
title: t`Status`,
|
||||||
|
dataIndex: "status",
|
||||||
|
key: "status",
|
||||||
|
render: (s: Order["status"]) => <Tag>{s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
key: "actions",
|
||||||
|
width: 60,
|
||||||
|
align: "right" as const,
|
||||||
|
render: (_: unknown, record: Order) => (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
icon: <Pencil size={token.fontSize} />,
|
||||||
|
label: t`Edit`,
|
||||||
|
onClick: () => {
|
||||||
|
setEditing(record);
|
||||||
|
form.setFieldsValue(record);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
icon: <Trash2 size={token.fontSize} />,
|
||||||
|
label: t`Delete`,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => confirmDelete(record),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button type="text" icon={<MoreVertical size={token.fontSize} />} aria-label={t`Row actions`} />
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const showPagination = (data?.total ?? 0) > search.limit;
|
||||||
|
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
|
||||||
|
pageShellRef,
|
||||||
|
toolbarRef: toolbarRowRef,
|
||||||
|
middleRef: middleSectionRef,
|
||||||
|
tableFrameRef,
|
||||||
|
marginLG: token.marginLG,
|
||||||
|
rowCount: data?.list?.length ?? 0,
|
||||||
|
isLoading,
|
||||||
|
showPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tablePagination: false | TablePaginationConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
showPagination
|
||||||
|
? {
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
current: currentPage,
|
||||||
|
pageSize: search.limit,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => t`${total} rows`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
void navigate({
|
||||||
|
search: {
|
||||||
|
...search,
|
||||||
|
limit: pageSize ?? search.limit,
|
||||||
|
offset: (page - 1) * (pageSize ?? search.limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
[showPagination, data?.total, currentPage, search, navigate, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
ref={pageShellRef}
|
||||||
|
vertical
|
||||||
|
gap={token.marginMD}
|
||||||
|
style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
ref={toolbarRowRef}
|
||||||
|
keywordInput={keywordInput}
|
||||||
|
onKeywordChange={setKeywordInput}
|
||||||
|
onSearch={applyKeyword}
|
||||||
|
onClearSearch={() => {
|
||||||
|
setKeywordInput("");
|
||||||
|
setSearch({ ...search, keyword: "", offset: 0 });
|
||||||
|
}}
|
||||||
|
onCreateClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable<Order>
|
||||||
|
layoutRef={middleSectionRef}
|
||||||
|
frameRef={tableFrameRef}
|
||||||
|
lockScrollHeight={lockScrollHeight}
|
||||||
|
maxHeight={tableAreaMaxHeight}
|
||||||
|
frameHeight={tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.list ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={tablePagination}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
open={modalOpen}
|
||||||
|
editing={editing}
|
||||||
|
form={form}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onFinish={(values) => {
|
||||||
|
if (editing) {
|
||||||
|
updateMutation.mutate({ ...values, id: editing.id });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { Plus, UserRound } from "lucide-react";
|
|||||||
import { forwardRef, useMemo } from "react";
|
import { forwardRef, useMemo } from "react";
|
||||||
import { FilterToolbar } from "@/components/FilterToolbar";
|
import { FilterToolbar } from "@/components/FilterToolbar";
|
||||||
|
|
||||||
/** Search + role slot `minWidth` for FilterToolbar collapse math */
|
|
||||||
const FILTER_CONTROL_WIDTH = 220;
|
const FILTER_CONTROL_WIDTH = 220;
|
||||||
|
|
||||||
export type ToolbarProps = {
|
export type ToolbarProps = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|||||||
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
||||||
import type { TablePaginationConfig } from "antd/es/table/interface";
|
import type { TablePaginationConfig } from "antd/es/table/interface";
|
||||||
import { useLingui } from "@lingui/react/macro";
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { httpClient } from "@/utils/http";
|
import { httpClient } from "@/utils/http";
|
||||||
import { USER_ENDPOINTS } from "@/api/user";
|
import { USER_ENDPOINTS } from "@/api/user";
|
||||||
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
|
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
|
||||||
@@ -11,6 +11,9 @@ import { z } from "zod/v4";
|
|||||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
import { DataTable } from "@/components/DataTable";
|
import { DataTable } from "@/components/DataTable";
|
||||||
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
|
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
|
||||||
|
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
|
||||||
|
import { useCrudToasts } from "@/hooks/useCrudToasts";
|
||||||
|
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
|
||||||
import { Toolbar } from "./-Toolbar";
|
import { Toolbar } from "./-Toolbar";
|
||||||
import { FormModal } from "./-FormModal";
|
import { FormModal } from "./-FormModal";
|
||||||
|
|
||||||
@@ -23,6 +26,8 @@ const UserSearchParamsSchema = z.object({
|
|||||||
role: z.string().catch(""),
|
role: z.string().catch(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/users/")({
|
export const Route = createFileRoute("/_auth/users/")({
|
||||||
validateSearch: (search) => UserSearchParamsSchema.parse(search),
|
validateSearch: (search) => UserSearchParamsSchema.parse(search),
|
||||||
component: UsersPage,
|
component: UsersPage,
|
||||||
@@ -30,10 +35,6 @@ export const Route = createFileRoute("/_auth/users/")({
|
|||||||
|
|
||||||
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
|
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
|
||||||
|
|
||||||
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
|
|
||||||
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
|
|
||||||
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
|
|
||||||
|
|
||||||
function UsersPage() {
|
function UsersPage() {
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
@@ -45,27 +46,23 @@ function UsersPage() {
|
|||||||
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
||||||
const middleSectionRef = useRef<HTMLDivElement>(null);
|
const middleSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const tableFrameRef = useRef<HTMLDivElement>(null);
|
const tableFrameRef = useRef<HTMLDivElement>(null);
|
||||||
const tableAvailableRef = useRef(0);
|
|
||||||
/**
|
|
||||||
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
|
|
||||||
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
|
|
||||||
*/
|
|
||||||
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
|
||||||
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
|
||||||
);
|
|
||||||
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
|
||||||
if (typeof window === "undefined") return undefined;
|
|
||||||
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
|
||||||
return Math.max(120, maxH - 40);
|
|
||||||
});
|
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
|
||||||
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
||||||
|
|
||||||
useEffect(() => {
|
const setSearch = (next: UserSearch) => {
|
||||||
setKeywordInput(search.keyword);
|
void navigate({ search: next });
|
||||||
}, [search.keyword]);
|
};
|
||||||
|
|
||||||
|
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
|
||||||
|
message,
|
||||||
|
resourceKey: "users",
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
||||||
{ list: User[]; total: number },
|
{ list: User[]; total: number },
|
||||||
@@ -81,7 +78,6 @@ function UsersPage() {
|
|||||||
search.sortField,
|
search.sortField,
|
||||||
search.sortOrder,
|
search.sortOrder,
|
||||||
],
|
],
|
||||||
invalidateKey: ["users"],
|
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
httpClient.get(USER_ENDPOINTS.list, {
|
httpClient.get(USER_ENDPOINTS.list, {
|
||||||
params: {
|
params: {
|
||||||
@@ -98,66 +94,29 @@ function UsersPage() {
|
|||||||
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
|
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
|
||||||
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
|
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
|
||||||
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
|
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
|
||||||
|
optimistic: { update: true, delete: true },
|
||||||
createLifecycle: {
|
createLifecycle: {
|
||||||
onSuccess: () => {
|
onSuccess: (values) => {
|
||||||
message.success({
|
crudToasts.createLifecycle?.onSuccess?.(values);
|
||||||
content: t`Created successfully`,
|
|
||||||
key: MESSAGE_KEY_USER_CREATE,
|
|
||||||
});
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: crudToasts.createLifecycle?.onError,
|
||||||
message.error({
|
|
||||||
content: t`Create failed`,
|
|
||||||
key: MESSAGE_KEY_USER_CREATE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
updateLifecycle: {
|
updateLifecycle: {
|
||||||
onMutate: () => {
|
onMutate: crudToasts.updateLifecycle?.onMutate,
|
||||||
message.loading({
|
onSuccess: (values) => {
|
||||||
content: t`Updating…`,
|
crudToasts.updateLifecycle?.onSuccess?.(values);
|
||||||
key: MESSAGE_KEY_USER_UPDATE,
|
|
||||||
duration: 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success({
|
|
||||||
content: t`Updated successfully`,
|
|
||||||
key: MESSAGE_KEY_USER_UPDATE,
|
|
||||||
});
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: crudToasts.updateLifecycle?.onError,
|
||||||
message.error({
|
|
||||||
content: t`Update failed`,
|
|
||||||
key: MESSAGE_KEY_USER_UPDATE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
deleteLifecycle: {
|
deleteLifecycle: {
|
||||||
onMutate: () => {
|
onMutate: crudToasts.deleteLifecycle?.onMutate,
|
||||||
message.loading({
|
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
|
||||||
content: t`Deleting…`,
|
onError: crudToasts.deleteLifecycle?.onError,
|
||||||
key: MESSAGE_KEY_USER_DELETE,
|
|
||||||
duration: 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success({
|
|
||||||
content: t`Deleted successfully`,
|
|
||||||
key: MESSAGE_KEY_USER_DELETE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
message.error({
|
|
||||||
content: t`Delete failed`,
|
|
||||||
key: MESSAGE_KEY_USER_DELETE,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,118 +229,28 @@ function UsersPage() {
|
|||||||
}}
|
}}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MoreVertical size={token.fontSize} />}
|
||||||
|
aria-label={t`Row actions`}
|
||||||
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const applySearch = (keyword: string) => {
|
|
||||||
void navigate({
|
|
||||||
search: {
|
|
||||||
...search,
|
|
||||||
keyword: keyword.trim(),
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showPagination = (data?.total ?? 0) > search.limit;
|
const showPagination = (data?.total ?? 0) > search.limit;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
|
||||||
const shell = pageShellRef.current;
|
pageShellRef,
|
||||||
const toolbarEl = toolbarRowRef.current;
|
toolbarRef: toolbarRowRef,
|
||||||
const mid = middleSectionRef.current;
|
middleRef: middleSectionRef,
|
||||||
if (!shell || !toolbarEl || !mid) return;
|
tableFrameRef,
|
||||||
|
marginLG: token.marginLG,
|
||||||
/** Fixed small table header row height */
|
rowCount: data?.list?.length ?? 0,
|
||||||
const headReserve = 40;
|
isLoading,
|
||||||
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
|
showPagination,
|
||||||
const rowEstimate = 44;
|
|
||||||
const paginationBlock = 56;
|
|
||||||
|
|
||||||
let rafRetry = 0;
|
|
||||||
const maxRafRetries = 8;
|
|
||||||
|
|
||||||
const measure = () => {
|
|
||||||
const toolbarRect = toolbarEl.getBoundingClientRect();
|
|
||||||
/**
|
|
||||||
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
|
|
||||||
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
|
|
||||||
*/
|
|
||||||
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
|
||||||
const mainRect = mainEl?.getBoundingClientRect();
|
|
||||||
const viewportBottom =
|
|
||||||
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
|
||||||
const clipBottom = mainRect
|
|
||||||
? mainRect.bottom
|
|
||||||
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
|
||||||
|
|
||||||
const maxMiddleFromShell = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
|
|
||||||
);
|
|
||||||
|
|
||||||
const midHRaw = mid.clientHeight;
|
|
||||||
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
|
||||||
|
|
||||||
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
|
||||||
rafRetry += 1;
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(measure));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bordered frame now includes Table built-in pagination */
|
|
||||||
const frameInner = Math.max(0, Math.floor(midH));
|
|
||||||
tableAvailableRef.current = frameInner;
|
|
||||||
|
|
||||||
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
|
||||||
|
|
||||||
const rowCount = data?.list?.length ?? 0;
|
|
||||||
const pagReserve = showPagination ? paginationBlock : 0;
|
|
||||||
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
|
||||||
|
|
||||||
if (isLoading && rowCount === 0) {
|
|
||||||
setTableScrollY(bodyMax);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowCount === 0 && !isLoading) {
|
|
||||||
setTableScrollY(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
|
||||||
const naturalTotalH = naturalBodyH + pagReserve;
|
|
||||||
const slack = 12;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
|
|
||||||
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
|
|
||||||
*/
|
|
||||||
if (naturalTotalH + slack > frameInner) {
|
|
||||||
setTableScrollY(bodyMax);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTableScrollY(undefined);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const frame = tableFrameRef.current;
|
|
||||||
const avail = tableAvailableRef.current;
|
|
||||||
const minFrame = headReserve + pagReserve;
|
|
||||||
if (!frame || avail <= minFrame) return;
|
|
||||||
if (frame.scrollHeight > frame.clientHeight + 1) {
|
|
||||||
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
measure();
|
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(shell);
|
|
||||||
ro.observe(mid);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
|
|
||||||
|
|
||||||
const tablePagination: false | TablePaginationConfig = useMemo(
|
const tablePagination: false | TablePaginationConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -417,10 +286,10 @@ function UsersPage() {
|
|||||||
ref={toolbarRowRef}
|
ref={toolbarRowRef}
|
||||||
keywordInput={keywordInput}
|
keywordInput={keywordInput}
|
||||||
onKeywordChange={setKeywordInput}
|
onKeywordChange={setKeywordInput}
|
||||||
onSearch={applySearch}
|
onSearch={applyKeyword}
|
||||||
onClearSearch={() => {
|
onClearSearch={() => {
|
||||||
setKeywordInput("");
|
setKeywordInput("");
|
||||||
applySearch("");
|
setSearch({ ...search, keyword: "", offset: 0 });
|
||||||
}}
|
}}
|
||||||
roleValue={search.role || undefined}
|
roleValue={search.role || undefined}
|
||||||
onRoleChange={(role) =>
|
onRoleChange={(role) =>
|
||||||
@@ -442,7 +311,7 @@ function UsersPage() {
|
|||||||
<DataTable<User>
|
<DataTable<User>
|
||||||
layoutRef={middleSectionRef}
|
layoutRef={middleSectionRef}
|
||||||
frameRef={tableFrameRef}
|
frameRef={tableFrameRef}
|
||||||
lockScrollHeight={tableScrollY != null}
|
lockScrollHeight={lockScrollHeight}
|
||||||
maxHeight={tableAreaMaxHeight}
|
maxHeight={tableAreaMaxHeight}
|
||||||
frameHeight={
|
frameHeight={
|
||||||
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
|
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
|
||||||
|
|||||||
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function getAuthTokens(): AuthTokens | null {
|
||||||
|
return useAuthStore.getState().tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return useAuthStore.getState().tokens?.accessToken ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ export interface PersistConfig<T> {
|
|||||||
merge?: PersistOptions<T, Partial<T>>["merge"];
|
merge?: PersistOptions<T, Partial<T>>["merge"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create a Zustand store with persistence middleware
|
|
||||||
* Reduces boilerplate for stores that need automatic data persistence
|
|
||||||
*/
|
|
||||||
export function createPersistentStore<T extends object>(
|
export function createPersistentStore<T extends object>(
|
||||||
initializer: StateCreator<T, [], []>,
|
initializer: StateCreator<T, [], []>,
|
||||||
config: PersistConfig<T>,
|
config: PersistConfig<T>,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { MenuItem } from "@/api/schemas";
|
import type { MenuItem } from "@/api/schemas";
|
||||||
|
|
||||||
/**
|
|
||||||
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
|
|
||||||
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
|
|
||||||
*/
|
|
||||||
export const APP_MENU_TREE: MenuItem[] = [
|
export const APP_MENU_TREE: MenuItem[] = [
|
||||||
{
|
{
|
||||||
id: "g-platform",
|
id: "g-platform",
|
||||||
@@ -37,6 +33,17 @@ export const APP_MENU_TREE: MenuItem[] = [
|
|||||||
sort: 1,
|
sort: 1,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "orders-menu",
|
||||||
|
kind: "item",
|
||||||
|
name: "Orders",
|
||||||
|
path: "/orders",
|
||||||
|
icon: "IconLucidePackage",
|
||||||
|
children: null,
|
||||||
|
permissions: ["orders:view"],
|
||||||
|
sort: 2,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,16 +120,11 @@ export function filterMenuTreeByPermissions(
|
|||||||
return walk(nodes);
|
return walk(nodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalized pathname (no trailing slash except `/`) */
|
|
||||||
export function normalizeAppPath(pathname: string): string {
|
export function normalizeAppPath(pathname: string): string {
|
||||||
if (pathname === "/") return pathname;
|
if (pathname === "/") return pathname;
|
||||||
return pathname.replace(/\/+$/, "") || "/";
|
return pathname.replace(/\/+$/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Route → permission required to open the page. `null` = no permission gate.
|
|
||||||
* Keep in sync with {@link APP_MENU_TREE} paths.
|
|
||||||
*/
|
|
||||||
export function requiredPermissionForPath(pathname: string): string | null {
|
export function requiredPermissionForPath(pathname: string): string | null {
|
||||||
const p = normalizeAppPath(pathname);
|
const p = normalizeAppPath(pathname);
|
||||||
const map: Record<string, string | null> = {
|
const map: Record<string, string | null> = {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
/** Favicon path under `public/` (Vite serves as site root). */
|
|
||||||
export const APP_FAVICON_SRC = "/favicon.svg";
|
export const APP_FAVICON_SRC = "/favicon.svg";
|
||||||
|
|
||||||
/** Product / brand name (login header, sidebar logo text, etc.). */
|
|
||||||
export const APP_BRAND_NAME = "Antd Admin";
|
export const APP_BRAND_NAME = "Antd Admin";
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { API_BASE_URL } from "./constants";
|
import { API_BASE_URL } from "./constants";
|
||||||
|
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||||
|
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
|
||||||
|
import { getAccessToken, useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -22,6 +25,70 @@ type RequestOptions = Omit<RequestInit, "method" | "body"> & {
|
|||||||
params?: Record<string, string | number | null | undefined>;
|
params?: Record<string, string | number | null | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HttpRouter = {
|
||||||
|
navigate: (opts: { to: string }) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let httpRouter: HttpRouter | null = null;
|
||||||
|
|
||||||
|
export function installHttpRouter(router: HttpRouter): void {
|
||||||
|
httpRouter = router;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inflightRefresh: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function doRefreshSessionTokens(): Promise<boolean> {
|
||||||
|
const { tokens, setTokens, logout } = useAuthStore.getState();
|
||||||
|
const refreshToken = tokens?.refreshToken;
|
||||||
|
if (!refreshToken) {
|
||||||
|
logout();
|
||||||
|
navigateToLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = RefreshTokenRequestSchema.parse({ refreshToken });
|
||||||
|
const url = buildUrl(AUTH_ENDPOINTS.refresh);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logout();
|
||||||
|
navigateToLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: unknown = await res.json();
|
||||||
|
const envelope = json as { code?: number; data?: unknown; message?: string };
|
||||||
|
if (envelope.code !== undefined && envelope.code !== 0) {
|
||||||
|
logout();
|
||||||
|
navigateToLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
|
||||||
|
setTokens(next);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
logout();
|
||||||
|
navigateToLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSessionTokens(): Promise<boolean> {
|
||||||
|
if (inflightRefresh) return inflightRefresh;
|
||||||
|
|
||||||
|
inflightRefresh = doRefreshSessionTokens().finally(() => {
|
||||||
|
inflightRefresh = null;
|
||||||
|
});
|
||||||
|
return inflightRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
||||||
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
|
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
|
||||||
if (!params) return base;
|
if (!params) return base;
|
||||||
@@ -33,23 +100,25 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeaders(): Record<string, string> {
|
function getAuthHeaders(): Record<string, string> {
|
||||||
try {
|
const token = getAccessToken();
|
||||||
const raw = localStorage.getItem("auth-storage");
|
|
||||||
if (!raw) return {};
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
const token = parsed?.state?.tokens?.accessToken;
|
|
||||||
if (token) return { Authorization: `Bearer ${token}` };
|
if (token) return { Authorization: `Bearer ${token}` };
|
||||||
} catch {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateTo403(): void {
|
||||||
|
void httpRouter?.navigate({ to: "/403" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToLogin(): void {
|
||||||
|
void httpRouter?.navigate({ to: "/login" });
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
|
isAfterRefresh = false,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = buildUrl(path, options?.params);
|
const url = buildUrl(path, options?.params);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -61,29 +130,40 @@ async function request<T>(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
|
||||||
|
const refreshed = await refreshSessionTokens();
|
||||||
|
if (refreshed) {
|
||||||
|
return request<T>(method, path, body, options, true);
|
||||||
|
}
|
||||||
|
throw new HttpError(401, "Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
navigateTo403();
|
||||||
|
throw new HttpError(403, "Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
|
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const json: unknown = await res.json();
|
||||||
|
const envelope = json as { code?: number; data?: unknown; message?: string };
|
||||||
|
|
||||||
if (json.code !== undefined && json.code !== 0) {
|
if (envelope.code !== undefined && envelope.code !== 0) {
|
||||||
throw new ApiError(json.code, json.message ?? "Unknown error");
|
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.data !== undefined ? json.data : json;
|
return (envelope.data !== undefined ? envelope.data : json) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const httpClient = {
|
export const httpClient = {
|
||||||
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
||||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
post: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("POST", path, body, options),
|
||||||
request<T>("POST", path, body, options),
|
put: <T>(path: string, body?: unknown, options?: RequestOptions) => request<T>("PUT", path, body, options),
|
||||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
delete: <T>(path: string, options?: RequestOptions) => request<T>("DELETE", path, undefined, options),
|
||||||
request<T>("PUT", path, body, options),
|
|
||||||
delete: <T>(path: string, options?: RequestOptions) =>
|
|
||||||
request<T>("DELETE", path, undefined, options),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api
|
|||||||
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
|
|
||||||
export async function fetchSessionAndApplyToStore(): Promise<void> {
|
export async function fetchSessionAndApplyToStore(): Promise<void> {
|
||||||
const [userBase, permissions] = await Promise.all([
|
const [userBase, permissions] = await Promise.all([
|
||||||
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
|
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
|
||||||
|
|||||||
Vendored
+4
@@ -1 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BRAND_PRIMARY?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import react from "@vitejs/plugin-react-swc";
|
|||||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
import { lingui } from "@lingui/vite-plugin";
|
import { lingui } from "@lingui/vite-plugin";
|
||||||
|
|
||||||
|
const VENDOR_CHUNKS: Array<{ chunk: string; test: (id: string) => boolean }> = [
|
||||||
|
{
|
||||||
|
chunk: "vendor-antd",
|
||||||
|
test: (id) =>
|
||||||
|
id.includes("node_modules/antd") || id.includes("node_modules/rc-"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunk: "vendor-tanstack",
|
||||||
|
test: (id) => id.includes("node_modules/@tanstack/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunk: "vendor-ui",
|
||||||
|
test: (id) => id.includes("node_modules/lucide-react"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chunk: "vendor-i18n",
|
||||||
|
test: (id) =>
|
||||||
|
id.includes("node_modules/@lingui/") || id.includes("/@lingui/"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function manualChunkForId(id: string): string | undefined {
|
||||||
|
for (const { chunk, test } of VENDOR_CHUNKS) {
|
||||||
|
if (test(id)) return chunk;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
tanstackRouter({
|
tanstackRouter({
|
||||||
@@ -22,17 +50,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: (id: string) => {
|
manualChunks: (id: string) => manualChunkForId(id),
|
||||||
if (id.includes("node_modules/antd")) {
|
|
||||||
return "vendor-antd";
|
|
||||||
}
|
|
||||||
if (id.includes("@tanstack/react-router") || id.includes("@tanstack/react-query")) {
|
|
||||||
return "vendor-tanstack";
|
|
||||||
}
|
|
||||||
if (id.includes("lucide-react")) {
|
|
||||||
return "vendor-ui";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
chunkSizeWarningLimit: 1024,
|
chunkSizeWarningLimit: 1024,
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|
||||||
Generated
+288
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user