Compare commits

...
Author SHA1 Message Date
zuiideaandCursor b8bb7713b3 docs: align site with modular src/api layout
Document API_PREFIX, folder structure, templates, and E2E script usage in en/zh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:17:47 +08:00
zuiideaandCursor bb141469e1 docs: document modular API layer in README files
Expand template READMEs with src/api layout, fix CLI table and E2E script references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:14:30 +08:00
zuiideaandCursor c02f6dc2f2 refactor(api): split modules under shared/ with API_PREFIX
Move auth and users into folder modules, centralize path constants, and align MSW with endpoint paths in both template apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:12:02 +08:00
zuiideaandCursor ee87a66a84 chore(basic): add add-resource skill and portable app-root paths
Align basic with with-lingui for CRUD scaffolding via AGENTS.md and .agents/skills, and drop hardcoded monorepo paths in agent docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 18:54:40 +08:00
zuiideaandCursor a72fde0739 chore: consolidate agent guidance into AGENTS.md
Merge scoped instructions into per-app AGENTS.md, add monorepo AGENTS.md and add-resource skill, and remove template .github Copilot files plus the Cursor app router rule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 18:48:05 +08:00
zuiideaandCursor a2a84e2c04 chore: drop root eslint/prettier and bump turbo to 2.11.9
Root formatting and lint stay on turbo fmt/lint via Vite+ in workspace apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 18:15:17 +08:00
zuiideaandCursor 44155206c0 chore: add root turbo fmt and format app sources
Expose fmt via turbo at the monorepo root, apply Oxfmt in basic/with-lingui, and run vp staged through pnpm exec in pre-commit so hooks work without a global vp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 18:09:25 +08:00
zuiideaandCursor 2614328537 chore: unify Cursor rules, fix monorepo install hooks, refresh basic docs
Add root preinstall to set githooks and disable parallel vp config git lock races; merge per-app Cursor thin rules into app.mdx; keep template READMEs free of monorepo-only paths and describe basic as default English rather than English-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 17:51:49 +08:00
Cursor Agentandzuiidea 8ab7ade30b refactor(api): introduce defineApi + axios request layer
Replace ad-hoc fetch httpClient with api/request.ts (axios interceptors,
envelope unwrap, auth refresh) and defineApi-based resource modules.
Move session hydration into api/auth, sync both templates, and update docs.

Co-authored-by: zuiidea <zuiiidea@gmail.com>
2026-10-18 03:48:28 +00:00
cursor[bot] 60ef2fe7f8 Merge pull request #1179 from zuiidea/cursor/upgrade-basic-lingui-deps-dc29
chore(deps): upgrade basic and with-lingui within current majors
2026-10-17 08:37:57 +00:00
Cursor Agentandzuiidea 3d17e6d636 chore(deps): upgrade basic and with-lingui within current majors
Align shared app dependencies, bump Lingui to 6.10, and move Vite+ to 0.3.3.
Drop the removed vite-plus-test override, import tests from vite-plus/test, and
fix TypeScript/lint issues surfaced by antd and oxlint.

Co-authored-by: zuiidea <zuiiidea@gmail.com>
2026-10-17 07:22:28 +00:00
zuiidea f28bae8f3e Feature/upgrade (#1170)
* chore: align workspace tooling and refresh docs

Unify pnpm workspace configuration and CI versioning, upgrade create package dependencies, and update README guidance for basic and with-lingui templates.

Made-with: Cursor

* chore: regenerate route tree files

Update generated route tree outputs after recent formatting and script changes.

Made-with: Cursor

* ci: fix with-lingui e2e script name

Use the existing test:e2e script in CI to avoid missing-script failures.

Made-with: Cursor
2026-04-28 18:52:45 +08:00
119 changed files with 15272 additions and 14086 deletions
@@ -1,15 +0,0 @@
---
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` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
-15
View File
@@ -1,15 +0,0 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
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`
+12 -8
View File
@@ -1,26 +1,30 @@
# Git hooks(仓库根)
# Git hooks (repository root)
本目录为 **monorepo 根级** Git 钩子。提交时根据暂存文件路径,在对应子项目目录执行检查(例如 `apps/with-lingui` 下执行 `vp staged`,以便正确读取该目录的 `vite.config.ts`)。
This directory holds **monorepo root-level** Git hooks. On commit, checks run in the matching subproject based on staged file paths (for example, `vp staged` under `apps/with-lingui` so that directory’s `vite.config.ts` is loaded correctly).
模板应用目录内的 `.vite-hooks/`(如 `apps/with-lingui/.vite-hooks/`)留给 **模板单独拷贝出去使用**;在本仓库开发时请使用根级钩子,避免在仓库根执行 `vp staged` 找不到 `vite.config.ts`。
Per-app `.vite-hooks/` under template apps (e.g. `apps/with-lingui/.vite-hooks/`) are for **copying a template out on its own**. When developing in this repo, use the root hooks so `vp staged` at the repo root does not fail to find `vite.config.ts`.
## 启用方式(一次性)
## Monorepo and `vp config`
在仓库根目录执行:
The root `package.json` `preinstall` sets `core.hooksPath=.githooks` before dependencies install and writes `vp.hooks.disabled=true` in **this clone** so parallel `prepare` scripts (`vp config`) from multiple workspaces do not fight over `.git/config` and cause lock conflicts. Template apps still use `"prepare": "vp config"`; when copied out alone they do not get the root `preinstall`.
## Enabling (one-time)
From the repository root:
```bash
git config core.hooksPath .githooks
```
或使用:
Or:
```bash
pnpm run setup:git-hooks
```
验证:
Verify:
```bash
git config core.hooksPath
# 应输出:.githooks
# Expected: .githooks
```
+2 -2
View File
@@ -16,11 +16,11 @@ run_in_dir() {
}
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
run_in_dir apps/with-lingui vp staged
run_in_dir apps/with-lingui pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic vp staged
run_in_dir apps/basic pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^packages/create/'; then
@@ -40,7 +40,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.6
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v4
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@v4
with:
version: 10
version: 10.33.2
- uses: actions/setup-node@v4
with:
@@ -44,7 +44,7 @@ jobs:
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e:core
run: pnpm run test:e2e
- name: Build
working-directory: apps/with-lingui
+1 -1
View File
@@ -42,7 +42,7 @@ yarn-error.log*
# Git worktrees
.worktrees/
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
# Cursor: share project rules only; ignore other local Cursor data (agent rules: each app’s AGENTS.md)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
+23
View File
@@ -0,0 +1,23 @@
# antd-admin (monorepo)
Enterprise admin templates and docs. **AI and coding rules live in each app’s `AGENTS.md`**, not here.
## Layout
| Path | Role |
| --- | --- |
| `apps/basic` | English Vite+ template — see `apps/basic/AGENTS.md` |
| `apps/with-lingui` | Lingui i18n template — see `apps/with-lingui/AGENTS.md` |
| `apps/docs` | Nextra docs site |
| `packages/create` | `init-antd-admin` CLI |
## Commands (repo root)
- `pnpm install` — sets `core.hooksPath=.githooks` via `preinstall`
- `pnpm dev` / `pnpm build` / `pnpm fmt` / `pnpm lint` — turbo across workspaces
**Template app work:** `cd apps/basic` or `cd apps/with-lingui`, then use **`vp`** (`vp install`, `vp check`, …). Do not run app `vp staged` from root without that `cd`.
## Git hooks
`.githooks/pre-commit` runs checks by staged paths: `pnpm exec vp staged` in `apps/basic` / `apps/with-lingui`, typecheck for `packages/create`, lint for `apps/docs`.
+21 -12
View File
@@ -5,10 +5,12 @@
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
Available templates:
- `basic`: English-only setup
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
- `basic`: Admin template with plain-string UI copy (default English; no bundled Lingui)
- `with-lingui`: Lingui i18n workflow (`en` + `zh`) with locale switching
Scaffolding CLI:
- `init-antd-admin` (from `packages/create`)
## Key Features
@@ -18,6 +20,7 @@ Scaffolding CLI:
- URL-synced table state (pagination, sorting, search)
- Dark mode theme switching
- Mock-first local development (no backend required)
- Modular API layer (`defineApi` per domain, Zod schemas, shared `API_PREFIX`)
- Typed API boundaries and reusable CRUD patterns
- React 19 + Ant Design 6 + TanStack Router/Query + Zustand + Zod
- Optional Lingui-based i18n workflow (`with-lingui`)
@@ -45,17 +48,23 @@ pnpm dlx init-antd-admin@latest my-app --example with-lingui --skip-install
### Common options
| Option | Description |
| --- | --- |
| `[project-directory]` | Target folder |
| `-e, --example <name|url>` | Template source |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
For detailed CLI behavior, see [`packages/create/README.md`](./packages/create/README.md).
| Option | Description |
| ---------------------------- | ------------------------------------------ |
| `[project-directory]` | Target folder |
| `-e, --example <name\|url>` | Template name (`basic`, `with-lingui`) or URL |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
For detailed CLI behavior, see `[packages/create/README.md](./packages/create/README.md)`.
### AI / agent guidance
Each template app keeps **`AGENTS.md`** as the single source of truth (Cursor, Claude Code, Codex, etc.). Monorepo overview: [AGENTS.md](./AGENTS.md) at the repo root.
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
@@ -0,0 +1,71 @@
---
name: add-resource
description: Add a new CRUD resource in antd-admin-basic (mirror users). Use when scaffolding roles, tenants, or any list+form resource with MSW, routes, menu, and e2e.
---
# Add Resource Recipe
Add a list CRUD flow that mirrors **Users** (TanStack Router + Query + MSW). **No codegen** — copy and adapt by hand. References: `src/routes/_auth/users/` and matching `e2e/*.spec.ts`. UI copy is **English plain strings** (no i18n).
## 0. Verification commands (after your changes)
Run from the **app root** (this project directory — where `package.json` and `vite.config.ts` live):
```bash
pnpm run build # refresh TanStack routeTree when routes change
vp check --no-fmt
pnpm run test:e2e -- e2e/<slug>.spec.ts
```
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Resource API module (`src/api/<slug>/index.ts`)
- Export path constants if useful for MSW/docs.
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
- `method` defaults to `GET`; mutations set `POST` / `PUT` / `DELETE`.
- Use segmented `params` / `query` / `body` + `output`; do not call axios from routes.
## 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`: 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` with resource functions (`listX`, `createX`, …) and `queryKeys` from `@/api/<slug>` (recommended: `optimistic: { update: true, delete: true }`), plus `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`).
- Use English string literals for labels, placeholders, and messages.
## 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 (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 an English label 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`.
## 7. E2E (`e2e/<slug>.spec.ts`)
- Copy structure from `users.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
## 8. Verification
- `vp check --no-fmt`
- `pnpm run test:e2e` (focused spec) or full suite as needed
- `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.
-17
View File
@@ -1,17 +0,0 @@
# AI Instruction Files
This directory contains scoped instruction files used by AI coding agents.
## Files
- `frontend.instructions.md`: UI/routes/components/style changes
- `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
## Authoring Rules
- Keep each file focused on one domain.
- Put trigger phrases in `description` so agents can discover the file.
- Use narrow `applyTo` patterns to avoid unnecessary context load.
- Keep instructions executable: short Do/Do Not/Validation sections.
-30
View File
@@ -1,30 +0,0 @@
---
## applyTo: "src/api/**/\*.ts,src/utils/http.ts,src/mocks/handlers/**/\*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
# API Instructions
## Purpose
Maintain strict API contracts and consistent server/client mock behavior.
## Do
- Keep request/response shapes schema-driven and type-safe.
- Reuse endpoint constants and shared response helpers.
- Keep handler response envelope consistent (`code`, `data`, `message`).
- Ensure mock handlers reflect real API behavior and error semantics.
- Keep naming stable for endpoint keys and mutation/query keys.
## Do Not
- Do not return ad-hoc response shapes for one-off convenience.
- Do not bypass schema parsing in new client mutations/queries.
- Do not mix unrelated domain endpoints in the same module.
## Validation
- Verify TypeScript and schema parsing paths compile cleanly.
- Run: `vp check --no-fmt`
@@ -1,32 +0,0 @@
---
## applyTo: "src/\*_/_.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
# Frontend Instructions
## Purpose
Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
## Do
- Preserve existing route contracts and UI behavior unless explicitly asked.
- Prefer reusable components and hooks over copy-paste logic.
- Keep styles token-driven using existing theme/store patterns.
- For Ant Design styling, follow this priority: component native API (`variant`/`type`/`size`/`styles`/`classNames`) -> `theme.useToken()` with inline `style` -> `className` + external CSS.
- Keep translations compatible with Lingui macros and catalogs.
- Follow current folder conventions under src/components and src/routes.
## Do Not
- Do not introduce new UI libraries when Ant Design already covers the need.
- Do not bypass existing theme/store abstractions with hardcoded global state.
- Do not write CSS overrides first when props or semantic `styles` can express the same UI.
- Do not refactor unrelated files in the same change.
## Validation
- Run: `vp check --no-fmt`
- For UI flow changes, run focused e2e where possible.
@@ -1,37 +0,0 @@
---
## applyTo: "src/_/_.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
# Refactor Instructions
## Purpose
Reduce duplication while preserving behavior and public contracts.
## Do
- Keep refactors incremental and scoped to one concern per change.
- Prefer extracting shared utilities/hooks/components over large rewrites.
- Preserve existing external behavior and route/store contracts.
- Keep diffs reviewable with minimal unrelated formatting churn.
- Add or update lightweight comments only when logic is non-obvious.
## Do Not
- Do not change runtime behavior unless explicitly requested.
- Do not rename broadly across the codebase without clear benefit.
- Do not delete tests that still cover unique critical scenarios.
- Do not introduce large dependencies for minor convenience; prefer composition or small utilities.
## Considerations
- Bundle: Main app chunk is ~135KB (gzip ~41KB). Ant Design (vendor-antd) and TanStack (vendor-tanstack) are in separate chunks for parallel loading. Avoid adding large UI libraries when Ant Design covers the need.
- Type safety: Keep validations at API boundaries (Zod schemas) rather than adding more runtime checks throughout the app.
## Validation
- Confirm no new diagnostics in touched files.
- Run: `vp check --no-fmt`
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
-30
View File
@@ -1,30 +0,0 @@
---
## 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."
# Testing Instructions
## Purpose
Keep tests fast, deterministic, and aligned with core business flows.
## Do
- Prioritize core flows first (auth, users CRUD, permission gates).
- Keep assertions user-visible and behavior-focused.
- Reuse existing helper patterns in tests before adding new helpers.
- Keep mock handlers and mock data synchronized with API schemas.
- Prefer focused test runs over full-suite runs during iteration.
## Do Not
- Do not add brittle selectors tied to unstable DOM internals.
- Do not expand fixture data without a clear scenario need.
- Do not duplicate scenarios already covered by existing tests.
## Validation
- Run focused tests first, then broader checks if needed.
- Run: `vp check --no-fmt`
+51 -59
View File
@@ -1,84 +1,76 @@
# Using Vite+, the Unified Toolchain for the Web
# antd-admin-basic
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
Vite+ template app (React 19, Ant Design 6, TanStack Router/Query, MSW). Default UI copy is English (no i18n). CLI details: [viteplus.dev](https://viteplus.dev).
## Vite+ Workflow
## Commands
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
Run from **this app root** (directory with `package.json` / `vite.config.ts`):
### Start
| Task | Command |
| ------------------------- | ---------------------------------------------------- |
| Install deps | `vp install` (after every pull) |
| Dev / build / preview | `vp dev`, `vp build`, `vp preview` |
| Format / lint / typecheck | `vp check`, `vp check --no-fmt`, `vp lint`, `vp fmt` |
| Unit tests | `vp test run` |
| E2E | `pnpm run test:e2e` (or focused spec via Playwright) |
| Add/remove deps | `vp add`, `vp remove` — not raw `pnpm`/`npm`/`yarn` |
- create - Create a new project from a template
- migrate - Migrate an existing project to Vite+
- config - Configure hooks and agent integration
- staged - Run linters on staged files
- install (`i`) - Install dependencies
- env - Manage Node.js versions
**Pitfalls:** Do not install Vitest/Oxlint/Oxfmt/tsdown directly; use `vp`. Do not run `vp vitest` — use `vp test`. If a `package.json` script name conflicts with a built-in `vp` command, use `vp run <script>`. Import toolchain modules from `vite-plus` (e.g. `defineConfig`, test utils), not standalone `vite`/`vitest` packages.
### Develop
**Before finishing:** `vp check` and `vp test run`; run focused e2e when UI flows change.
- dev - Run the development server
- check - Run format, lint, and TypeScript type checks
- lint - Lint code
- fmt - Format code
- test - Run tests
---
### Execute
## Frontend
- run - Run monorepo tasks
- exec - Execute a command from local `node_modules/.bin`
- dlx - Execute a package binary without installing it as a dependency
- cache - Manage the task cache
**Purpose:** Predictable, minimal UI changes under `src/`.
### Build
**Do:** Preserve route/UI contracts unless asked. Reuse components/hooks. Token-driven styles via theme/store. Ant Design styling order: component API → `theme.useToken()` + inline `style` → `className` + CSS. English plain string literals. Follow `src/components` and `src/routes` layout.
- build - Build for production
- pack - Build libraries
- preview - Preview production build
**Do not:** New UI libs when Ant Design suffices; bypass theme/store; CSS-first overrides when props/`styles` work; unrelated refactors in the same change.
### Manage Dependencies
**Validation:** `vp check --no-fmt`; focused e2e when flows change.
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
---
- add - Add packages to dependencies
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
- update (`up`) - Update packages to latest versions
- dedupe - Deduplicate dependencies
- outdated - Check for outdated packages
- list (`ls`) - List installed packages
- why (`explain`) - Show why a package is installed
- info (`view`, `show`) - View package information from the registry
- link (`ln`) / unlink - Manage local package links
- pm - Forward a command to the package manager
## API
### Maintain
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
- upgrade - Update `vp` itself to the latest version
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
## Common Pitfalls
**Validation:** `vp check --no-fmt`.
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vp test` command, run it using `vp run test`.
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
---
## Review Checklist for Agents
## Testing
- Run `vp install` after pulling remote changes and before getting started.
- Run `vp check` and `vp test` to validate changes.
**Purpose:** Fast, deterministic tests for core flows (auth, users CRUD, permissions).
## AI Instruction Files
**Do:** User-visible assertions; reuse test helpers; keep mocks/schemas in sync; focused runs while iterating.
This repository also uses scoped AI instruction files in `.github/instructions/`.
**Do not:** Brittle DOM-internal selectors; bloated fixtures; duplicate scenarios.
- `frontend.instructions.md` for UI/routes/components/style work
- `testing.instructions.md` for Playwright/MSW/test updates
- `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup
**Validation:** Focused tests first; `vp check --no-fmt`.
See `.github/instructions/README.md` for usage and authoring rules.
---
## Refactor
**Purpose:** Less duplication, same behavior and public contracts.
**Do:** One concern per change; extract shared utils/hooks/components; reviewable diffs; comments only when non-obvious.
**Do not:** Behavior changes unless requested; broad renames; drop unique test coverage; heavy deps for tiny wins.
**Considerations:** Main chunk ~135KB gzip ~41KB; vendor splits for antd/tanstack — avoid large extra UI libs. Validate at API boundaries (Zod), not app-wide runtime checks.
**Validation:** `vp check --no-fmt`; significant changes → `vp build` and watch chunk sizes in log.
---
## Add CRUD resource
Multi-step checklist (mirror **users**): use skill **`.agents/skills/add-resource/SKILL.md`** (invoke in Copilot/Codex/Claude or `@` that path in chat). After routes change, run `pnpm run build` to refresh `routeTree.gen.ts`.
+101 -63
View File
@@ -8,7 +8,7 @@
<div align="center">
English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. For Lingui-based i18n (en + zh), use the sibling app [`apps/with-lingui`](../with-lingui/).
Admin scaffold: 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, **`AGENTS.md`** agent rules, and type-safe contracts at every boundary. Default UI language is English; for Lingui i18n (en + zh) out of the box, see [`apps/with-lingui`](../with-lingui/).
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
@@ -31,19 +31,20 @@ English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and
| Async State | [TanStack Query v5](https://tanstack.com/query) |
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
| UI language | English strings in source (no i18n framework) |
| UI copy | Plain strings in source (default English); no bundled i18n stack |
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript 5.9 (strict mode) |
| Language | TypeScript (strict mode) |
## Features
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **English-only** — No Lingui or locale catalogs; Ant Design uses a fixed `en_US` locale. See [`apps/with-lingui`](../with-lingui/) for bilingual LinguiJS.
- **Default locale** — UI copy in plain strings (English by default); Ant Design `ConfigProvider` uses `en_US`.
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Modular API layer** — `defineApi` modules under `src/api/<domain>/`, shared Zod helpers, single `API_PREFIX`
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
@@ -56,12 +57,9 @@ English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and
### Install & Run
From the monorepo root, or after `cd apps/basic`:
```bash
cd apps/basic
vp install
vp dev
pnpm install
pnpm run dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
@@ -71,87 +69,127 @@ Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to
### Build
```bash
vp build
vp preview
pnpm run build
pnpm run preview
```
### Project Setup
```bash
pnpm run prepare
```
### Code Quality
```bash
pnpm run fmt
pnpm run lint
pnpm run check
```
### Unit Tests
```bash
pnpm run test:unit
```
### E2E Tests
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # core flows only
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
```text
AGENTS.md # Agent rules (frontend/api/testing/refactor)
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, en_US)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
├── api/ # HTTP + contracts (see “API layer” below)
│ ├── request.ts # axios, defineApi, token refresh, envelope unwrap
│ ├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
│ ├── shared/ # API_PREFIX, pagination, params, menu, envelope helpers
│ ├── auth/ # paths, schemas, login/register/hydrateSession
│ └── users/ # schemas, listUsers CRUD, userQueryKeys, path constants
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
│ ├── FormModal/ # Generic modal form wrapper
│ ├── FilterToolbar/ # Reusable list page toolbar
│ ├── Auth/ # Permission gate component
│ ├── Aurora/ # Login page background effect
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
│ ├── NotFound/ # Not-found visual component
│ └── RouteError.tsx # Route-level error boundary UI
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
│ ├── useAppTheme.ts
│ ├── usePermission.ts
│ ├── useResourceCRUD.ts
│ ├── useCrudToasts.ts
│ ├── useUrlSearchState.ts
│ ├── useTableFitHeight.ts
│ └── tokenBuilders.ts
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
│ ├── browser.ts
│ ├── createHandler.ts
│ ├── createHandler.test.ts
│ ├── data.ts
│ ├── utils.ts
│ └── handlers/
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # QueryClient + ConfigProvider (en_US) wiring
│ ├── _auth.tsx # Protected route layout
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/users/index.tsx
│ ├── _auth/users/-Toolbar.tsx
│ ├── _auth/users/-FormModal.tsx
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── register/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, sidebar)
└── main.tsx # Entry point (MSW init → React render)
│ └── index.tsx
├── stores/ # Persisted auth/settings stores
├── utils/ # constants, app menu helpers
├── main.tsx # App bootstrap (MSW init + React render)
└── routeTree.gen.ts # Generated TanStack route tree
e2e/ # Playwright E2E tests
e2e/ # Playwright E2E suites
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
├── users.spec.ts
├── auth-refresh.spec.ts
├── rbac.spec.ts
└── url-state.spec.ts
```
## Language & i18n
## API layer
This template is **English-only**: user-facing copy lives in components and routes as plain strings; there is no Lingui catalog or language switcher.
Data flow: **Zod schemas → `defineApi` in `src/api/<module>/index.ts` → routes/components call resource functions** (never axios or `@/api/request` directly).
For **LinguiJS** (English + Chinese, `.po` extract/compile, and Ant Design locale switching), use [`apps/with-lingui`](../with-lingui/).
| Piece | Role |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `request.ts` | Shared HTTP client, `{ code, data, message }` handling, 401 refresh |
| `shared/` | `API_PREFIX` (`/api`), pagination/query helpers, shared menu types |
| `auth/`, `users/` | Domain schemas + `defineApi` exports; MSW handlers import the same path constants |
| `schemas.ts` | Compatibility re-exports; new code should import from `@/api/shared` and `@/api/<module>/schemas` |
Change the REST prefix in one place: `src/api/shared/prefix.ts` (`API_PREFIX`).
## Language
User-facing copy lives in components and routes as plain strings. **Default language is English**; Ant Design is wired to `en_US` in `__root.tsx`. This template does not include a locale switcher or translation catalogs—you can add your own i18n library or start from the [`with-lingui`](../with-lingui/) template for LinguiJS (en + zh).
## Extending the template
- **New CRUD resource:** Follow `.agents/skills/add-resource/SKILL.md` (mirror `users`), then `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.
- **Add i18n:** Start from [`apps/with-lingui`](../with-lingui/) or port Lingui setup from that app; this `basic` tree intentionally omits it.
- **Add i18n:** Integrate your preferred stack, or copy patterns from [`apps/with-lingui`](../with-lingui/).
### AI guidance
**Single source of truth:** **`AGENTS.md`** in this app. New CRUD flows: `@.agents/skills/add-resource/SKILL.md`.
## Developer Notes
+20 -31
View File
@@ -11,44 +11,33 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vitest run",
"test:unit": "vp test run",
"test:e2e": "playwright test",
"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"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"antd": "^6.3.3",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"@tanstack/react-query": "^5.104.3",
"@tanstack/react-router": "^1.170.45",
"antd": "^6.6.6",
"axios": "^1.20.0",
"lucide-react": "^1.59.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.166.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.12.12",
"typescript": "~5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest",
"vitest": "3.0.5"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
"@playwright/test": "^1.64.1",
"@tanstack/router-plugin": "^1.168.47",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react-swc": "^4.3.3",
"msw": "^2.15.0",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
"vite-plus": "0.3.3"
},
"packageManager": "pnpm@10.33.2",
"msw": {
"workerDirectory": [
"public"
+1878 -2507
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.12.12";
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
const PACKAGE_VERSION = "2.15.0";
const INTEGRITY_CHECKSUM = "03cb67ac84128e63d7cd722a6e5b7f1e";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
@@ -129,8 +129,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get("content-type")
?.toLowerCase()
.startsWith("text/event-stream");
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
const responseClone = isEventStreamResponse ? null : response.clone();
sendToClient(
client,
@@ -143,15 +153,15 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
-19
View File
@@ -1,19 +0,0 @@
import type {
AuthTokens,
LoginRequest,
User,
MenuItem,
PermissionsList,
RegisterRequest,
} from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest };
+63
View File
@@ -0,0 +1,63 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RegisterRequestSchema,
} from "@/api/auth/schemas";
import type {
AuthTokens,
LoginRequest,
PermissionsList,
RegisterRequest,
} from "@/api/auth/schemas";
import { UserSchema, type User } from "@/api/users/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
export const login = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.login,
body: LoginRequestSchema,
output: AuthTokensSchema,
});
export const register = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.register,
body: RegisterRequestSchema,
output: AuthTokensSchema,
});
export const logout = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.logout,
output: z.null(),
});
export const getAuthUser = defineApi({
path: AUTH_ENDPOINTS.user,
output: AuthUserResponseSchema,
});
export const getPermissions = defineApi({
path: AUTH_ENDPOINTS.permissions,
output: PermissionsListSchema,
});
/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */
export async function hydrateSession(): Promise<void> {
const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
}
export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest };
+12
View File
@@ -0,0 +1,12 @@
import { API_PREFIX } from "@/api/shared/prefix";
const AUTH_BASE = `${API_PREFIX}/auth`;
export const AUTH_ENDPOINTS = {
login: `${AUTH_BASE}/login`,
register: `${AUTH_BASE}/register`,
refresh: `${AUTH_BASE}/refresh`,
logout: `${AUTH_BASE}/logout`,
user: `${AUTH_BASE}/user`,
permissions: `${AUTH_BASE}/permissions`,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod/v4";
import { UserSchema } from "@/api/users/schemas";
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
+302
View File
@@ -0,0 +1,302 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { z } from "zod/v4";
import { API_BASE_URL } from "@/utils/constants";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
type RequestConfig = InternalAxiosRequestConfig & {
skipAuthRefresh?: boolean;
_retry?: boolean;
};
export type CallOptions = {
signal?: AbortSignal;
};
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Envelope = {
code?: number;
data?: unknown;
message?: string;
};
function cleanParams(params?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!params) return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value != null) out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function unwrapEnvelope(json: unknown): unknown {
if (json == null || typeof json !== "object") return json;
const envelope = json as Envelope;
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
if (envelope.code !== undefined && envelope.data !== undefined) {
return envelope.data;
}
return json;
}
const client = axios.create({
baseURL: API_BASE_URL,
timeout: 15_000,
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
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 });
try {
const res = await client.post(REFRESH_PATH, body, {
skipAuthRefresh: true,
} as RequestConfig);
const next = AuthTokensSchema.parse(res.data);
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;
}
client.interceptors.response.use(
(response) => {
response.data = unwrapEnvelope(response.data);
return response;
},
async (error: AxiosError) => {
const status = error.response?.status;
const config = error.config as RequestConfig | undefined;
if (
status === 401 &&
config &&
!config._retry &&
!config.skipAuthRefresh &&
config.url !== REFRESH_PATH &&
!config.url?.endsWith(REFRESH_PATH)
) {
config._retry = true;
const refreshed = await refreshSessionTokens();
if (refreshed) {
return client.request(config);
}
throw new HttpError(401, "Unauthorized");
}
if (status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (status != null) {
throw new HttpError(status, `HTTP ${status}: ${error.response?.statusText ?? "Error"}`);
}
throw error;
},
);
async function transportRequest<T>(opts: {
method: HttpMethod;
url: string;
params?: Record<string, unknown>;
body?: unknown;
signal?: AbortSignal;
skipAuthRefresh?: boolean;
}): Promise<T> {
const res = await client.request<T>({
method: opts.method,
url: opts.url,
params: cleanParams(opts.params),
data: opts.body,
signal: opts.signal,
skipAuthRefresh: opts.skipAuthRefresh,
} as RequestConfig);
return res.data;
}
type ApiDefConfig<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
TOut extends z.ZodType,
> = {
method?: HttpMethod;
path: string;
params?: TParams;
query?: TQuery;
body?: TBody;
output: TOut;
};
type CallArgs<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
> = TParams extends z.ZodType
? TQuery extends z.ZodType
? TBody extends z.ZodType
? { params: z.infer<TParams>; query: z.infer<TQuery>; body: z.infer<TBody> }
: { params: z.infer<TParams>; query: z.infer<TQuery> }
: TBody extends z.ZodType
? { params: z.infer<TParams>; body: z.infer<TBody> }
: z.infer<TParams>
: TQuery extends z.ZodType
? TBody extends z.ZodType
? { query: z.infer<TQuery>; body: z.infer<TBody> }
: z.infer<TQuery>
: TBody extends z.ZodType
? z.infer<TBody>
: void;
function renderPath(path: string, params?: Record<string, unknown>): string {
if (!params) return path;
let url = path;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
}
return url;
}
export function defineApi<
TParams extends z.ZodType | undefined = undefined,
TQuery extends z.ZodType | undefined = undefined,
TBody extends z.ZodType | undefined = undefined,
TOut extends z.ZodType = z.ZodType,
>(
def: ApiDefConfig<TParams, TQuery, TBody, TOut>,
): (
...args: CallArgs<TParams, TQuery, TBody> extends void
? [options?: CallOptions]
: [args: CallArgs<TParams, TQuery, TBody>, options?: CallOptions]
) => Promise<z.infer<TOut>> {
const method: HttpMethod = def.method ?? "GET";
const segmentCount = (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0);
return async (...allArgs: unknown[]): Promise<z.infer<TOut>> => {
let callArgs: unknown;
let options: CallOptions | undefined;
if (segmentCount === 0) {
options = allArgs[0] as CallOptions | undefined;
} else {
callArgs = allArgs[0];
options = allArgs[1] as CallOptions | undefined;
}
let params: Record<string, unknown> | undefined;
let query: Record<string, unknown> | undefined;
let body: unknown;
if (segmentCount === 0) {
// no input segments
} else if (segmentCount === 1) {
if (def.params) {
params = def.params.parse(callArgs) as Record<string, unknown>;
} else if (def.query) {
query = def.query.parse(callArgs) as Record<string, unknown>;
} else if (def.body) {
body = def.body.parse(callArgs);
}
} else {
const shaped = (callArgs ?? {}) as {
params?: unknown;
query?: unknown;
body?: unknown;
};
if (def.params) {
params = def.params.parse(shaped.params) as Record<string, unknown>;
}
if (def.query) {
query = def.query.parse(shaped.query) as Record<string, unknown>;
}
if (def.body) {
body = def.body.parse(shaped.body);
}
}
const data = await transportRequest<unknown>({
method,
url: renderPath(def.path, params),
params: query,
body: def.body ? body : undefined,
signal: options?.signal,
});
return def.output.parse(data) as z.infer<TOut>;
};
}
+4 -155
View File
@@ -1,155 +1,4 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
export type PaginatedData<T> = {
list: T[];
total: number;
};
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
export * from "@/api/shared";
export * from "@/api/users/schemas";
export * from "@/api/auth/schemas";
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./envelope";
export * from "./menu";
export * from "./pagination";
export * from "./params";
export * from "./prefix";
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod/v4";
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
+2
View File
@@ -0,0 +1,2 @@
/** REST API path prefix (no trailing slash). */
export const API_PREFIX = "/api" as const;
-10
View File
@@ -1,10 +0,0 @@
import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas";
export const USER_ENDPOINTS = {
list: "/api/users",
create: "/api/users",
update: (id: string) => `/api/users/${id}`,
delete: (id: string) => `/api/users/${id}`,
} as const;
export type { CreateUserRequest, UpdateUserRequest, User };
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import { IdParamsSchema } from "@/api/shared/params";
import { PaginatedDataSchema } from "@/api/shared/pagination";
import {
CreateUserRequestSchema,
ListUsersQuerySchema,
UpdateUserRequestSchema,
UserSchema,
} from "@/api/users/schemas";
import type {
CreateUserRequest,
ListUsersQuery,
UpdateUserRequest,
User,
} from "@/api/users/schemas";
import { API_PREFIX } from "@/api/shared/prefix";
export const USERS_PATH = `${API_PREFIX}/users`;
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
export const userQueryKeys = {
all: ["users"] as const,
list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const,
};
export const listUsers = defineApi({
path: USERS_PATH,
query: ListUsersQuerySchema,
output: PaginatedDataSchema(UserSchema),
});
export const createUser = defineApi({
method: "POST",
path: USERS_PATH,
body: CreateUserRequestSchema,
output: UserSchema,
});
export const updateUser = defineApi({
method: "PUT",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
body: UpdateUserRequestSchema,
output: UserSchema,
});
export const deleteUser = defineApi({
method: "DELETE",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
output: z.null(),
});
export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User };
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
@@ -108,7 +108,7 @@ function mapSkeletonColumn<RecordType extends object>(
...c,
sorter: false,
sortOrder: undefined,
render: (_: unknown, __: RecordType) => {
render: () => {
if (isActionColumn(c)) {
const w = actionSkeletonDiameter(c);
return (
@@ -1,4 +1,4 @@
import { Flex, Table, theme } from "antd";
import { Table, theme } from "antd";
import type { TableProps } from "antd";
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
import { useMemo } from "react";
@@ -20,9 +20,9 @@ export type DataTableProps<RecordType extends object = object> = {
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
frameHeight?: number;
/** Flex column wrapping frame + optional `bottomExtra` */
layoutRef?: Ref<HTMLDivElement | null>;
layoutRef?: Ref<HTMLDivElement>;
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
frameRef?: Ref<HTMLDivElement | null>;
frameRef?: Ref<HTMLDivElement>;
/** Optional content below the bordered table frame */
bottomExtra?: ReactNode;
} & TableProps<RecordType>;
@@ -77,14 +77,15 @@ export function DataTable<RecordType extends object = object>(
);
return (
<Flex
<div
ref={layoutRef}
vertical
gap={token.marginSM}
style={{
flex: "1 1 0%",
minHeight: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
gap: token.marginSM,
}}
>
<div ref={frameRef} style={outerStyle}>
@@ -120,6 +121,6 @@ export function DataTable<RecordType extends object = object>(
</div>
</div>
{bottomExtra}
</Flex>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vite-plus/test";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
+3 -3
View File
@@ -4,8 +4,8 @@ import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installHttpRouter } from "./utils/http";
import { hydrateSession } from "./api/auth";
import { installHttpRouter } from "./api/request";
const router = createRouter({ routeTree });
installHttpRouter(router);
@@ -29,7 +29,7 @@ enableMocking()
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
await fetchSessionAndApplyToStore();
await hydrateSession();
} catch {
useAuthStore.getState().logout();
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vite-plus/test";
import { z } from "zod/v4";
import {
paginatedListSchema,
+7 -6
View File
@@ -15,6 +15,7 @@ import {
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
@@ -32,7 +33,7 @@ function isRegisteredAuth(authorization: string | null): boolean {
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -60,7 +61,7 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
@@ -90,7 +91,7 @@ export const authHandlers = [
});
}),
http.post("/api/auth/refresh", async ({ request }) => {
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
@@ -123,9 +124,9 @@ export const authHandlers = [
});
}),
http.post("/api/auth/logout", () => successWithNullBody()),
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
http.get("/api/auth/user", ({ request }) => {
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
@@ -146,7 +147,7 @@ export const authHandlers = [
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
+5 -4
View File
@@ -10,13 +10,14 @@ import {
successWithSchema,
successWithNullBody,
} from "../createHandler";
import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
http.get(USERS_PATH, async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
@@ -33,7 +34,7 @@ export const userHandlers = [
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
http.post(USERS_PATH, async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
@@ -48,7 +49,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
http.put(USERS_MEMBER_PATH, async ({ params, request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
@@ -59,7 +60,7 @@ export const userHandlers = [
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
+154 -147
View File
@@ -8,184 +8,191 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as AuthRouteImport } from "./routes/_auth";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as RegisterIndexRouteImport } from "./routes/register/index";
import { Route as LoginIndexRouteImport } from "./routes/login/index";
import { Route as R404IndexRouteImport } from "./routes/404/index";
import { Route as AuthUsersIndexRouteImport } from "./routes/_auth/users/index";
import { Route as AuthDashboardIndexRouteImport } from "./routes/_auth/dashboard/index";
import { Route as Auth403IndexRouteImport } from "./routes/_auth/403/index";
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as RegisterIndexRouteImport } from './routes/register/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
const AuthRoute = AuthRouteImport.update({
id: "/_auth",
getParentRoute: () => rootRouteImport,
} as any);
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: "/register/",
path: "/register/",
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any);
const LoginIndexRoute = LoginIndexRouteImport.update({
id: "/login/",
path: "/login/",
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const R404IndexRoute = R404IndexRouteImport.update({
id: "/404/",
path: "/404/",
id: '/404/',
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any);
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: "/users/",
path: "/users/",
getParentRoute: () => AuthRoute,
} as any);
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: "/dashboard/",
path: "/dashboard/",
getParentRoute: () => AuthRoute,
} as any);
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: "/403/",
path: "/403/",
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any);
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/403/": typeof Auth403IndexRoute;
"/dashboard/": typeof AuthDashboardIndexRoute;
"/users/": typeof AuthUsersIndexRoute;
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/403/': typeof Auth403IndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/users/': typeof AuthUsersIndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/404": typeof R404IndexRoute;
"/login": typeof LoginIndexRoute;
"/register": typeof RegisterIndexRoute;
"/403": typeof Auth403IndexRoute;
"/dashboard": typeof AuthDashboardIndexRoute;
"/users": typeof AuthUsersIndexRoute;
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/403': typeof Auth403IndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/users': typeof AuthUsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/_auth": typeof AuthRouteWithChildren;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/_auth/403/": typeof Auth403IndexRoute;
"/_auth/dashboard/": typeof AuthDashboardIndexRoute;
"/_auth/users/": typeof AuthUsersIndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/404/" | "/login/" | "/register/" | "/403/" | "/dashboard/" | "/users/";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/404" | "/login" | "/register" | "/403" | "/dashboard" | "/users";
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/register/'
| '/403/'
| '/dashboard/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/register' | '/403' | '/dashboard' | '/users'
id:
| "__root__"
| "/"
| "/_auth"
| "/404/"
| "/login/"
| "/register/"
| "/_auth/403/"
| "/_auth/dashboard/"
| "/_auth/users/";
fileRoutesById: FileRoutesById;
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/register/'
| '/_auth/403/'
| '/_auth/dashboard/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
AuthRoute: typeof AuthRouteWithChildren;
R404IndexRoute: typeof R404IndexRoute;
LoginIndexRoute: typeof LoginIndexRoute;
RegisterIndexRoute: typeof RegisterIndexRoute;
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/_auth": {
id: "/_auth";
path: "";
fullPath: "/";
preLoaderRoute: typeof AuthRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/register/": {
id: "/register/";
path: "/register";
fullPath: "/register/";
preLoaderRoute: typeof RegisterIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/login/": {
id: "/login/";
path: "/login";
fullPath: "/login/";
preLoaderRoute: typeof LoginIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/404/": {
id: "/404/";
path: "/404";
fullPath: "/404/";
preLoaderRoute: typeof R404IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/_auth/users/": {
id: "/_auth/users/";
path: "/users";
fullPath: "/users/";
preLoaderRoute: typeof AuthUsersIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/dashboard/": {
id: "/_auth/dashboard/";
path: "/dashboard";
fullPath: "/dashboard/";
preLoaderRoute: typeof AuthDashboardIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/403/": {
id: "/_auth/403/";
path: "/403";
fullPath: "/403/";
preLoaderRoute: typeof Auth403IndexRouteImport;
parentRoute: typeof AuthRoute;
};
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
id: '/404/'
path: '/404'
fullPath: '/404/'
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute;
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute;
AuthUsersIndexRoute: typeof AuthUsersIndexRoute;
Auth403IndexRoute: typeof Auth403IndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
Auth403IndexRoute: Auth403IndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
};
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren);
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
@@ -193,7 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
};
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileTypes<FileRouteTypes>()
+2 -2
View File
@@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { hydrateSession } from "@/api/auth";
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ location }) => {
@@ -13,7 +13,7 @@ export const Route = createFileRoute("/_auth")({
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
await hydrateSession();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
@@ -60,7 +60,7 @@ function DashboardSkeleton() {
active
style={{
width: w,
height: Math.round(token.fontSizeHeading5 * token.lineHeightHeading5),
height: Math.round(Number(token.fontSizeHeading5) * Number(token.lineHeightHeading5)),
}}
/>
);
+16 -30
View File
@@ -2,9 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users";
import type { User, CreateUserRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
@@ -32,8 +30,6 @@ export const Route = createFileRoute("/_auth/users/")({
component: UsersPage,
});
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
@@ -62,36 +58,26 @@ function UsersPage() {
resourceKey: "users",
});
const listQuery = {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
};
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
CreateUserRequest,
CreateUserRequest & { id: string }
>({
queryKey: [
"users",
search.limit,
search.offset,
search.keyword,
search.role,
search.sortField,
search.sortOrder,
],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
},
}),
select: (raw) => paginatedUserSchema.shape.data.parse(raw),
createFn: (values) =>
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
queryKey: userQueryKeys.list(listQuery),
queryFn: () => listUsers(listQuery),
select: (raw) => raw as { list: User[]; total: number },
createFn: (values) => createUser(values),
updateFn: ({ id, ...values }) => updateUser({ params: { id }, body: values }),
deleteFn: (id) => deleteUser({ id }),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: (values) => {
+5 -9
View File
@@ -2,12 +2,10 @@ import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-ro
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { hydrateSession, login } from "@/api/auth";
import { LoginRequestSchema } from "@/api/schemas";
import type { LoginRequest } from "@/api/schemas";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Theme } from "@/components/Icon";
@@ -35,11 +33,9 @@ function LoginPage() {
const loginMutation = useMutation({
mutationFn: async (values: LoginRequest) => {
const parsed = LoginRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
const tokens = await login(values);
setTokens(tokens);
await hydrateSession();
},
onSuccess: () => {
message.success("Login successful");
+5 -9
View File
@@ -2,11 +2,9 @@ import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-ro
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { hydrateSession, register } from "@/api/auth";
import { RegisterRequestSchema } from "@/api/schemas";
import type { RegisterRequest } from "@/api/auth";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Aurora } from "@/components/Aurora";
@@ -31,11 +29,9 @@ function RegisterPage() {
const registerMutation = useMutation({
mutationFn: async (values: RegisterRequest) => {
const parsed = RegisterRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
const tokens = await register(values);
setTokens(tokens);
await hydrateSession();
},
onSuccess: () => {
message.success("Account created successfully");
-172
View File
@@ -1,172 +0,0 @@
import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
const url = new URL(base, window.location.origin);
for (const [key, value] of Object.entries(params)) {
if (value != null) url.searchParams.set(key, String(value));
}
return url.toString();
}
function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {};
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
throw new HttpError(401, "Unauthorized");
}
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
return (envelope.data !== undefined ? envelope.data : json) as T;
}
export const httpClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("POST", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PUT", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
};
-18
View File
@@ -1,18 +0,0 @@
import { httpClient } from "@/utils/http";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
export async function fetchSessionAndApplyToStore(): Promise<void> {
const [userBase, permissions] = await Promise.all([
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)),
]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
import { defineConfig } from "vite-plus";
export default defineConfig({
resolve: {
@@ -1,14 +1,36 @@
---
title: Proxying API Requests
description: Configure API proxying between frontend and backend
description: MSW, API base URL, and wiring a real backend
---
# Proxying API Requests
Use a proxy layer in development to avoid CORS issues and keep API endpoints configurable.
## MSW (default in development)
Recommended approach:
Templates use **MSW** to intercept HTTP in development. Handlers live under `src/mocks/handlers`. You can demo login, menus, and user CRUD without a real backend.
- Define a consistent API prefix.
- Route requests by environment.
- Keep backend base URLs in environment variables.
The **worker directory** is configured via `package.json` **`msw.workerDirectory`** as `public`.
## API layer (templates)
Data flow: **Zod schemas → `defineApi` in `src/api/<module>/index.ts` → routes/components call resource functions** (`listUsers`, `login`, …). Do not import axios or `@/api/request` from UI code.
```text
src/api/
├── request.ts # axios, envelope unwrap, token refresh
├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
├── shared/ # API_PREFIX, pagination, params, menu helpers
├── auth/ # paths, schemas, login / hydrateSession
└── users/ # schemas, CRUD APIs, userQueryKeys, path constants
```
- REST path prefix: **`API_PREFIX`** in `src/api/shared/prefix.ts` (default `/api`). Auth and user paths (`AUTH_ENDPOINTS`, `USERS_PATH`) and **MSW handlers** import the same constants.
- New domains: mirror **`users`** — see each app **`.agents/skills/add-resource/SKILL.md`** and **`AGENTS.md`**.
## Talking to a real API
Transport lives in **`src/api/request.ts` (axios)**. `baseURL` comes from **`VITE_API_BASE_URL`** (`src/utils/constants.ts`). Call **`defineApi` exports** from `src/api/<module>/index.ts` — not axios from pages.
Point `VITE_API_BASE_URL` at your gateway and disable or bypass MSW per `main.tsx` / env flags when you no longer need mocks.
For CORS, configure the gateway the browser can reach, or proxy **`API_PREFIX`** (default `/api`) in `vite.config.ts` `server.proxy`.
@@ -23,6 +23,28 @@ description: Key directories and responsibilities in the repository
- `apps/docs`: Documentation site (Nextra + Next.js)
- `packages`: Shared packages and utilities
## Template app `src/` (basic / with-lingui)
Both templates share the same layout; **with-lingui** adds `src/locales/` and Lingui scripts.
```text
src/
├── api/
│ ├── request.ts # Transport (axios, defineApi, token refresh)
│ ├── schemas.ts # Compatibility re-exports
│ ├── shared/ # API_PREFIX, pagination, params, menu helpers
│ ├── auth/ # Auth paths, schemas, login / hydrateSession
│ └── users/ # User CRUD, query keys, path constants
├── components/
├── hooks/
├── mocks/ # MSW handlers (same path constants as api modules)
├── routes/
├── stores/
└── utils/ # Constants, menu tree — not HTTP transport
```
See **[Proxying API Requests](/en/backend/proxying-api-requests)** for the API layer and MSW/backend wiring.
## Recommendation
When creating reusable logic, prioritize placing it in `packages` rather than duplicating in app folders.
@@ -9,7 +9,7 @@ The templates include:
- User login and permission-based routing
- Dashboard and reusable business pages
- Request abstraction and API proxy support
- Modular API layer (`defineApi`, Zod schemas, shared `API_PREFIX`) and dev proxy / MSW
- Theme and layout customization
- Mock data support with MSW
- Unit tests and E2E tests
+10 -7
View File
@@ -1,6 +1,6 @@
---
title: basic
description: Lightweight admin template for single-language projects
description: Admin template with plain-string UI (default English)
---
# basic
@@ -9,15 +9,18 @@ description: Lightweight admin template for single-language projects
## Good Fit
- Internal tools
- Small-to-medium dashboard projects
- Teams not requiring i18n yet
- Internal tools and dashboards
- Teams that want a full scaffold first and may add i18n later
- Projects comfortable with English as the default UI language
## Highlights
- Clean route organization
- Common data table and form patterns
- Practical defaults for request handling and page layout
- Modular **`src/api/`** (`defineApi` per domain, shared **`API_PREFIX`**, Zod at boundaries)
- Clean route organization and reusable CRUD hooks
- MSW mocks aligned with the same path constants as the API modules
- **`AGENTS.md`** and **`.agents/skills/add-resource/`** for AI-assisted scaffolding
Full tree and scripts: [apps/basic/README.md](https://github.com/zuiidea/antd-admin/blob/master/apps/basic/README.md).
## Start Development
+4 -4
View File
@@ -7,10 +7,10 @@ description: Choose between basic and with-lingui templates
This repository provides two templates:
- **[basic](/en/templates/basic)**: Minimal setup, English UI by default.
- **[with-lingui](/en/templates/with-lingui)**: Includes Lingui i18n workflow and bilingual setup.
- **[basic](/en/templates/basic)**: Full admin template; UI copy as plain strings (default English).
- **[with-lingui](/en/templates/with-lingui)**: Same stack plus Lingui i18n (`en` + `zh`) and locale switching.
## How to Choose
- Choose **basic** if you only need one language.
- Choose **with-lingui** if you need structured multilingual support from day one.
- Choose **basic** for the default stack without a bundled i18n workflow (you can add i18n later).
- Choose **with-lingui** when you want Lingui catalogs and bilingual UI from day one.
@@ -15,9 +15,11 @@ description: Admin template with Lingui internationalization
## Highlights
- Lingui message extraction and compilation flow
- Language resource organization
- Built-in language switching patterns
- Same **`src/api/`** layout as **basic** (shared transport, `API_PREFIX`, `auth/` / `users/` modules)
- Lingui message extraction and compilation flow (`i18n:extract`, `i18n:compile`, `i18n:check`)
- Built-in **en** / **zh** switching with Ant Design locale wiring
Full tree and scripts: [apps/with-lingui/README.md](https://github.com/zuiidea/antd-admin/blob/master/apps/with-lingui/README.md).
## Start Development
+11 -4
View File
@@ -7,12 +7,19 @@ description: Run unit and e2e tests in this repository
Run tests from repository root or per app.
Common commands:
From a template app directory (`apps/basic` or `apps/with-lingui`):
```bash
pnpm run test:unit
pnpm run test:e2e
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # focused core flows
pnpm run test:e2e:ui
```
Monorepo-wide (if configured):
```bash
pnpm -r test
pnpm --filter ./apps/basic test
pnpm --filter ./apps/with-lingui test
```
Set up CI to run tests on pull requests before merge.
Set up CI to run `vp check` and E2E on pull requests before merge. See each app **`AGENTS.md`** for toolchain rules.
@@ -11,11 +11,11 @@ If this is your first time here:
1. Read **[Getting Started](/en/getting-started)** for setup and scripts.
2. Compare templates in **[Templates](/en/templates)**.
3. Go to **[Backend Integration](/en/backend/proxying-api-requests)** if you need API setup.
3. Go to **[Backend Integration](/en/backend/proxying-api-requests)** for MSW, the modular **`src/api/`** layer, and real backend wiring.
## Repo Structure
- `apps/basic`: English-only admin template
- `apps/basic`: Admin template (default English UI; add your own i18n or use `with-lingui`)
- `apps/with-lingui`: Internationalized template with Lingui
- `apps/docs`: Documentation site
@@ -11,8 +11,26 @@ description: MSW、API 基地址与对接真实后端
**Worker 目录**由 `package.json` 的 **`msw.workerDirectory`** 配置为 `public`,与 [public 目录](/styles-and-assets/public-folder) 说明一致。
## API 层(模板约定)
数据流:**Zod schema → `src/api/<module>/index.ts` 中的 `defineApi` → 路由/组件只调用资源函数**(如 `listUsers`、`login`)。页面不要 import axios 或 `@/api/request`。
```text
src/api/
├── request.ts # axios、响应 envelope、token 刷新
├── schemas.ts # 兼容 re-export;新代码用 @/api/shared 与各 module schemas
├── shared/ # API_PREFIX、分页/查询/菜单等共用 schema
├── auth/ # paths、schemas、登录与 hydrateSession
└── users/ # schemas、CRUD、userQueryKeys、路径常量
```
- REST 前缀:**`API_PREFIX`**(`src/api/shared/prefix.ts`,默认 `/api`)。`AUTH_ENDPOINTS`、`USERS_PATH` 与 **MSW handlers** 共用同一套常量。
- 新增资源:对照 **`users`**,见各应用 **`.agents/skills/add-resource/SKILL.md`** 与 **`AGENTS.md`**。
## 对接真实 API
将 **`src/utils/http.ts`(或项目内统一 HTTP 封装)** 中的 **baseURL** 指向真实网关,并按环境关闭或绕过 MSW 初始化(具体开关以模板 `main.tsx` / 环境变量为准)。
模板传输层为 **`src/api/request.ts`(axios)**:`baseURL` 来自环境变量 **`VITE_API_BASE_URL`**(见 `src/utils/constants.ts`)。业务调用请走 **`src/api/<module>/index.ts`** 导出的 `defineApi` 函数,不要在页面里直连 axios。
跨域时需由**浏览器可访问**的网关配置 **CORS**;或通过开发代理将 `/api` 转发到后端(在 `vite.config.ts` 配置 `server.proxy`,按团队规范填写目标地址)。
将 `VITE_API_BASE_URL` 指向真实网关,并按环境关闭或绕过 MSW 初始化(具体开关以模板 `main.tsx` / 环境变量为准)。
跨域时需由**浏览器可访问**的网关配置 **CORS**;或通过开发代理将 **`API_PREFIX`(默认 `/api`)** 转发到后端(在 `vite.config.ts` 配置 `server.proxy`,按团队规范填写目标地址)。
@@ -29,7 +29,7 @@ description: package.json 中与开发相关的 npm 脚本
|------|------|
| `pnpm run test:unit` | Vitest 单元测试 |
| `pnpm run test:e2e` | Playwright 全部 E2E |
| `pnpm run test:e2e:core` | 核心场景子集(登录、users CRUD、refresh、RBAC、URL 状态) |
| `pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts` | 核心流程(登录 + users CRUD) |
| `pnpm run test:e2e:ui` | Playwright UI 模式 |
## With Lingui 专有
@@ -9,16 +9,23 @@ description: 模板应用 src 目录职责说明
```
src/
├── api/ # Zod schema、接口常量与类型
├── api/
│ ├── request.ts # 传输层(axios、defineApi、刷新 token)
│ ├── schemas.ts # 兼容 barrel
│ ├── shared/ # API_PREFIX、分页/params/menu 等
│ ├── auth/ # 认证 API 与 paths
│ └── users/ # 用户 CRUD 与 query keys
├── components/ # 通用 UI(表格壳、表单弹窗、布局等)
├── hooks/ # 主题、权限、CRUD 等组合逻辑
├── locales/ # Lingui:仅 with-lingui(en/zh .po)
├── mocks/ # MSW handlers 与浏览器 worker
├── mocks/ # MSW handlers(路径与 api 模块常量一致)
├── routes/ # TanStack Router 文件路由
├── stores/ # Zustand(auth、settings 等持久化)
└── utils/ # HTTP 客户端、常量等
└── utils/ # 常量、菜单等(不含 HTTP 客户端)
```
API 约定详见 [开发代理与 Mock](/backend/proxying-api-requests)。
更完整的树状说明见:
- [apps/basic/README.md](https://github.com/zuiidea/antd-admin/blob/master/apps/basic/README.md)
@@ -13,7 +13,8 @@ description: 两模板共同具备的产品与技术能力
- **动态菜单与 RBAC**:服务端菜单、路由权限、403
- **表格 URL 状态**:分页、关键字、排序等与查询参数同步
- **MSW**:开发环境 Mock,无需真实后端即可跑通流程
- **Playwright E2E**:覆盖登录、CRUD、鉴权刷新、RBAC、URL 状态等(`test:e2e:core` 为核心子集)
- **模块化 API**:`defineApi` 分域、`API_PREFIX`、MSW 与真实后端共用路径常量
- **Playwright E2E**:覆盖登录、CRUD、鉴权刷新、RBAC、URL 状态等(可用 `pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts` 跑核心流程)
**With Lingui** 额外包含:LinguiJS 中英文、Ant Design locale 随语言切换、`.po` 抽取/编译工作流。
@@ -8,7 +8,7 @@ description: 依赖与模板演进时的建议
## 依赖升级
- 使用 **Vite+**(`vp`)管理依赖,避免绕过 `vp` 直接大面积 `pnpm up` 导致与模板锁定的工具链不一致。
- 升级 **Ant Design / TanStack / React** 大版本前,建议先阅读官方迁移说明,并在单分支上跑通 `vp check`、`test:unit`、`test:e2e:core` 与 `vp build`。
- 升级 **Ant Design / TanStack / React** 大版本前,建议先阅读官方迁移说明,并在单分支上跑通 `vp check`、`test:unit`、`pnpm run test:e2e`(或指定核心 spec)与 `vp build`。
## 跟随本仓库模板
+5 -3
View File
@@ -1,6 +1,6 @@
---
title: Basic 模板
description: apps/basic — 英文-only 模板说明摘要
description: apps/basic — 默认英文界面的管理后台模板
---
# Basic 模板
@@ -9,11 +9,13 @@ description: apps/basic — 英文-only 模板说明摘要
## 定位
英文界面、**不包含 Lingui**;Ant Design 使用固定 **`en_US`** locale。依赖相对精简,适合只需英文后台或自行接入其它 i18n 方案的团队。
完整的管理后台模板,UI 文案以源码中的 plain strings 为主,**默认语言为英文**;Ant Design 固定 **`en_US`**。未内置 Lingui 与语言切换,可自行接入 i18n,或选用 **with-lingui** 模板。
## 技术栈摘要
构建:[Vite+](https://viteplus.dev);UI:Ant Design 6;路由:TanStack Router;数据:TanStack Query;本地状态:Zustand;校验:Zod;Mock:MSW;E2E:Playwright。
构建:[Vite+](https://viteplus.dev);UI:Ant Design 6;路由:TanStack Router;数据:TanStack Query;本地状态:Zustand;校验:Zod;API:`defineApi` 分模块(`src/api/shared/`、`auth/`、`users/`);Mock:MSW;E2E:Playwright。
API 与 Mock 约定见 [开发代理与 Mock](/backend/proxying-api-requests)。
## 常用命令
+3 -3
View File
@@ -12,14 +12,14 @@ asIndexPage: true
| 项目 | `apps/basic` | `apps/with-lingui` |
|------|----------------|---------------------|
| **package.json `name`** | `antd-admin-basic` | `antd-admin` |
| **Lingui(i18n)** | 无(英文界面) | 有(`@lingui/core`、`@lingui/react`,en / zh) |
| **Lingui(i18n)** | 无内置(默认英文界面) | 有(`@lingui/core`、`@lingui/react`,en / zh) |
| **Ant Design `ConfigProvider` locale** | 固定 `antd/locale/en_US` | 随设置切换:`en` → `en_US`,`zh` → `zh_CN`(见 `src/routes/__root.tsx`) |
| **Zustand persist(settings)`name`** | `settings-storage-basic` | `settings-storage` |
## 选型建议
- 只要英文、希望依赖最少:选 **[Basic](/templates/basic)**。
- 需要中英文切换、Lingui 工作流与 Ant Design 随语言联动:选 **[With Lingui](/templates/with-lingui)**。
- 希望先上手完整模板、默认英文、后续可自行加 i18n:选 **[Basic](/templates/basic)**。
- 需要开箱即用的中英文切换、Lingui 工作流与 Ant Design 随语言联动:选 **[With Lingui](/templates/with-lingui)**。
## 子页面
@@ -13,7 +13,7 @@ description: apps/with-lingui — 中英双语与 Lingui 工作流
## 技术栈补充
在 Basic 技术栈基础上增加 Lingui 6;`src/locales/{en,zh}/messages.po` 为翻译源;`lingui.config.ts` 与 Vite 插件见应用内配置。
在 Basic 技术栈基础上增加 Lingui 6;**`src/api/` 目录与 Basic 同构**(`shared/`、`auth/`、`users/`)。`src/locales/{en,zh}/messages.po` 为翻译源;`lingui.config.ts` 与 Vite 插件见应用内配置。
## 国际化命令
@@ -33,4 +33,4 @@ vp dev
## 权威文档
完整技术栈表、功能列表、目录树、E2E 子集说明、以及 **Cursor 与 `.github/instructions` 的 AI 指引** 以 **[apps/with-lingui/README.md](https://github.com/zuiidea/antd-admin/blob/master/apps/with-lingui/README.md)** 为准。
完整技术栈表、功能列表、目录树、E2E 子集说明、以及 **AI 指引(`AGENTS.md`)** 以 **[apps/with-lingui/README.md](https://github.com/zuiidea/antd-admin/blob/master/apps/with-lingui/README.md)** 为准。
@@ -17,7 +17,8 @@ pnpm run test:unit
```bash
pnpm run test:e2e # 全部
pnpm run test:e2e:core # 核心子集(登录、users、refresh、RBAC、URL 状态)
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # 核心流程
pnpm run test:e2e -- e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts
```
首次在机器上跑 E2E 前,按 Playwright 文档安装浏览器依赖(`pnpm exec playwright install` 等,以官方说明为准)。
@@ -19,7 +19,7 @@ description: 本文档的阅读方式与章节说明
| **样式与资源** | 全局样式、CSS Modules、静态资源、public、代码分割 |
| **构建应用** | 路由、环境变量、性能、生产构建 |
| **测试** | 单元测试与 E2E |
| **后端对接** | MSW Mock、API 基地址与代理思路 |
| **后端对接** | MSW、`src/api/` 模块化与 `API_PREFIX`、真实 API 与代理 |
| **部署** | 构建产物与部署注意点 |
更完整的模板说明仍以仓库内 **`apps/*/README.md`** 为准;本站用中文串联常用路径,减少在 GitHub 间跳转的成本。
@@ -1,30 +1,35 @@
---
applyTo: "src/api/**/*.ts,src/mocks/**/*.ts,src/routes/_auth/**/*.tsx,e2e/**/*.spec.ts"
description: "Use when adding a new CRUD resource (roles, tenants, etc.). Keywords: add resource, new resource, scaffold, CRUD page."
name: add-resource
description: Add a new CRUD resource in with-lingui (mirror users). Use when scaffolding roles, tenants, or any list+form resource with MSW, routes, menu, and e2e.
---
# 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/` and the matching `e2e/*.spec.ts` files.
Add a list CRUD flow that mirrors **Users** (TanStack Router + Query + MSW + Lingui). **No codegen** — copy and adapt by hand. References: `src/routes/_auth/users/` and matching `e2e/*.spec.ts`.
## 0. Verification commands (after your changes)
Run from the **app root** (this project directory — where `package.json` and `vite.config.ts` live):
```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`)
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- Reuse `PaginatedResponseSchema(<Entity>Schema)` for list responses.
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Endpoint constants (`src/api/<slug>.ts`)
## 2. Resource API module (`src/api/<slug>/index.ts`)
- Export `<SLUG_UPPER>_ENDPOINTS`: `list` / `create` / `update(id)` / `delete(id)` with paths like `/api/<slug>`.
- Export path constants if useful for MSW/docs.
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
- `method` defaults to `GET`; mutations set `POST` / `PUT` / `DELETE`.
- Use segmented `params` / `query` / `body` + `output`; do not call axios from routes.
## 3. Mock data (`src/mocks/data.ts`)
@@ -37,7 +42,7 @@ pnpm run test:e2e -- e2e/<slug>.spec.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`).
- `createFileRoute("/_auth/<slug>/")`, wiring `useResourceCRUD` with resource functions (`listX`, `createX`, …) and `queryKeys` from `@/api/<slug>` (recommended: `optimistic: { update: true, delete: true }`), plus `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`)
-18
View File
@@ -1,18 +0,0 @@
# AI Instruction Files
This directory contains scoped instruction files used by AI coding agents.
## Files
- `frontend.instructions.md`: UI/routes/components/style changes
- `testing.instructions.md`: Playwright/MSW/test changes
- `api.instructions.md`: API client/schema/handler changes
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
- `add-resource.instructions.md`: add a new CRUD resource (manual / Agent checklist; mirror `users`)
## Authoring Rules
- Keep each file focused on one domain.
- Put trigger phrases in `description` so agents can discover the file.
- Use narrow `applyTo` patterns to avoid unnecessary context load.
- Keep instructions executable: short Do/Do Not/Validation sections.
@@ -1,31 +0,0 @@
---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions
## Purpose
Maintain strict API contracts and consistent server/client mock behavior.
## Do
- Keep request/response shapes schema-driven and type-safe.
- Reuse endpoint constants and shared response helpers.
- Keep handler response envelope consistent (`code`, `data`, `message`).
- MSW success payloads: use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` from `src/mocks/createHandler.ts` so responses match Zod contracts before `HttpResponse.json`.
- Ensure mock handlers reflect real API behavior and error semantics.
- Keep naming stable for endpoint keys and mutation/query keys.
## Do Not
- Do not return ad-hoc response shapes for one-off convenience.
- Do not bypass schema parsing in new client mutations/queries.
- Do not mix unrelated domain endpoints in the same module.
## Validation
- Verify TypeScript and schema parsing paths compile cleanly.
- Run: `pnpm exec vp check --no-fmt`
- After changing `createHandler` or handler envelopes: `pnpm run test:unit` (includes `src/mocks/createHandler.test.ts`).
@@ -1,31 +0,0 @@
---
applyTo: "src/**/*.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions
## Purpose
Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
## Do
- Preserve existing route contracts and UI behavior unless explicitly asked.
- Prefer reusable components and hooks over copy-paste logic.
- Keep styles token-driven using existing theme/store patterns.
- For Ant Design styling, follow this priority: component native API (`variant`/`type`/`size`/`styles`/`classNames`) -> `theme.useToken()` with inline `style` -> `className` + external CSS.
- Keep translations compatible with Lingui macros and catalogs.
- Follow current folder conventions under src/components and src/routes.
## Do Not
- Do not introduce new UI libraries when Ant Design already covers the need.
- Do not bypass existing theme/store abstractions with hardcoded global state.
- Do not write CSS overrides first when props or semantic `styles` can express the same UI.
- Do not refactor unrelated files in the same change.
## Validation
- Run: `pnpm exec vp check --no-fmt`
- For UI flow changes, run focused e2e where possible.
@@ -1,36 +0,0 @@
---
applyTo: "src/**/*.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
---
# Refactor Instructions
## Purpose
Reduce duplication while preserving behavior and public contracts.
## Do
- Keep refactors incremental and scoped to one concern per change.
- Prefer extracting shared utilities/hooks/components over large rewrites.
- Preserve existing external behavior and route/store contracts.
- Keep diffs reviewable with minimal unrelated formatting churn.
- Add or update lightweight comments only when logic is non-obvious.
## Do Not
- Do not change runtime behavior unless explicitly requested.
- Do not rename broadly across the codebase without clear benefit.
- Do not delete tests that still cover unique critical scenarios.
- Do not introduce large dependencies for minor convenience; prefer composition or small utilities.
## Considerations
- Bundle: Main app chunk is ~135KB (gzip ~41KB). Ant Design (vendor-antd) and TanStack (vendor-tanstack) are in separate chunks for parallel loading. Avoid adding large UI libraries when Ant Design covers the need.
- Type safety: Keep validations at API boundaries (Zod schemas) rather than adding more runtime checks throughout the app.
## Validation
- Confirm no new diagnostics in touched files.
- Run: `pnpm exec vp check --no-fmt`
- For significant changes, verify bundle size impact: `pnpm run build 2>&1 | grep "kB"`
@@ -1,31 +0,0 @@
---
## applyTo: "e2e/**/\*.ts,e2e/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
# Testing Instructions
## Purpose
Keep tests fast, deterministic, and aligned with core business flows.
## Do
- Prioritize core flows first (auth, users CRUD, permission gates).
- Keep assertions user-visible and behavior-focused.
- Reuse existing helper patterns in tests before adding new helpers.
- Keep mock handlers and mock data synchronized with API schemas.
- Prefer focused test runs over full-suite runs during iteration.
## Do Not
- Do not add brittle selectors tied to unstable DOM internals.
- Do not expand fixture data without a clear scenario need.
- Do not duplicate scenarios already covered by existing tests.
## Validation
- Run focused tests first, then broader checks if needed.
- Run: `pnpm exec vp check --no-fmt`
- Unit / hook logic (Vitest): `pnpm run test:unit` — tests live under `src/**/*.test.ts`.
+52 -62
View File
@@ -1,89 +1,79 @@
# Using Vite+, the Unified Toolchain for the Web
# antd-admin (with-lingui)
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
Vite+ template app (React 19, Ant Design 6, Lingui i18n, TanStack Router/Query, MSW). CLI details: [viteplus.dev](https://viteplus.dev).
## Vite+ Workflow
## Commands
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
Run from **this app root** (directory with `package.json` / `vite.config.ts`):
### Start
| Task | Command |
| ------------------------- | ---------------------------------------------------------------------------- |
| Install deps | `vp install` (after every pull) |
| Dev / build / preview | `vp dev`, `vp build`, `vp preview` |
| Format / lint / typecheck | `vp check`, `vp check --no-fmt`, `vp lint`, `vp fmt` |
| Unit tests | `vp test run` (`src/**/*.test.ts`) |
| E2E | `pnpm run test:e2e` / `test:e2e:core` |
| i18n | `pnpm run i18n:extract && pnpm run i18n:compile` after new translatable copy |
| Add/remove deps | `vp add`, `vp remove` — not raw `pnpm`/`npm`/`yarn` |
- create - Create a new project from a template
- migrate - Migrate an existing project to Vite+
- config - Configure hooks and agent integration
- staged - Run linters on staged files
- install (`i`) - Install dependencies
- env - Manage Node.js versions
**Pitfalls:** Do not install Vitest/Oxlint/Oxfmt/tsdown directly; use `vp`. Do not run `vp vitest` — use `vp test`. If a `package.json` script name conflicts with a built-in `vp` command, use `vp run <script>`. Import toolchain modules from `vite-plus`, not standalone `vite`/`vitest` packages.
### Develop
**Before finishing:** `vp check` and `vp test run`; run focused e2e when UI flows change.
- dev - Run the development server
- check - Run format, lint, and TypeScript type checks
- lint - Lint code
- fmt - Format code
- test - Run tests
**Monorepo:** If this app lives under [antd-admin](https://github.com/zuiidea/antd-admin), read repo-root `AGENTS.md` for turbo/git hooks; still run `vp` from this directory.
### Execute
---
- run - Run monorepo tasks
- exec - Execute a command from local `node_modules/.bin`
- dlx - Execute a package binary without installing it as a dependency
- cache - Manage the task cache
## Frontend
### Build
**Purpose:** Predictable, minimal UI changes under `src/`.
- build - Build for production
- pack - Build libraries
- preview - Preview production build
**Do:** Preserve route/UI contracts unless asked. Reuse components/hooks. Token-driven styles. Ant Design styling order: component API → `theme.useToken()` + inline `style` → `className` + CSS. Lingui macros/catalogs for copy. Follow `src/components` and `src/routes` layout.
### Manage Dependencies
**Do not:** New UI libs when Ant Design suffices; bypass theme/store; CSS-first overrides when props/`styles` work; unrelated refactors in the same change.
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
**Validation:** `pnpm exec vp check --no-fmt`; focused e2e when flows change.
- add - Add packages to dependencies
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
- update (`up`) - Update packages to latest versions
- dedupe - Deduplicate dependencies
- outdated - Check for outdated packages
- list (`ls`) - List installed packages
- why (`explain`) - Show why a package is installed
- info (`view`, `show`) - View package information from the registry
- link (`ln`) / unlink - Manage local package links
- pm - Forward a command to the package manager
---
### Maintain
## API
- upgrade - Update `vp` itself to the latest version
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
## Common Pitfalls
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vp test` command, run it using `vp run test`.
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
**Validation:** `vp check --no-fmt`.
## Review Checklist for Agents
---
- Run `vp install` after pulling remote changes and before getting started.
- Run `vp check` and `vp test` to validate changes.
## Testing
## Monorepo git hooks
**Purpose:** Fast, deterministic tests for core flows (auth, users CRUD, permissions).
When this app lives under a monorepo, `git config core.hooksPath` may point at `.vite-hooks/_` here while commits run with the **repository root** as cwd. The checked-in `.vite-hooks/pre-commit` script dispatches `vp staged` into `apps/with-lingui` and `apps/basic` based on staged paths (and runs `pnpm --dir packages/create run check-types` for matching `packages/create` sources) so each app’s `vite.config.ts` is found.
**Do:** User-visible assertions; reuse test helpers; keep mocks/schemas in sync; focused runs while iterating.
## AI Instruction Files
**Do not:** Brittle DOM-internal selectors; bloated fixtures; duplicate scenarios.
This repository also uses scoped AI instruction files in `.github/instructions/`.
**Validation:** Focused tests first; `pnpm exec vp check --no-fmt`; unit/hooks: `pnpm run test:unit`.
- `frontend.instructions.md` for UI/routes/components/style work
- `testing.instructions.md` for Playwright/MSW/test updates
- `api.instructions.md` for API/schema/handler changes
- `refactor.instructions.md` for behavior-preserving cleanup
- `add-resource.instructions.md` for adding a new CRUD resource (checklist; mirror `users`)
---
See `.github/instructions/README.md` for usage and authoring rules.
## Refactor
**Purpose:** Less duplication, same behavior and public contracts.
**Do:** One concern per change; extract shared utils/hooks/components; reviewable diffs; comments only when non-obvious.
**Do not:** Behavior changes unless requested; broad renames; drop unique test coverage; heavy deps for tiny wins.
**Considerations:** Main chunk ~135KB gzip ~41KB; vendor splits for antd/tanstack — avoid large extra UI libs. Validate at API boundaries (Zod), not app-wide runtime checks.
**Validation:** `pnpm exec vp check --no-fmt`; significant changes → `vp build` and watch chunk sizes in log.
---
## Add CRUD resource
Multi-step checklist (mirror **users**): use skill **`.agents/skills/add-resource/SKILL.md`** (invoke in Copilot/Codex/Claude or `@` that path in chat). After routes change, run `pnpm run build` to refresh `routeTree.gen.ts`.
+102 -63
View File
@@ -2,8 +2,6 @@
Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, scoped AI instructions, and type-safe contracts at every boundary.
**AI rules (Cursor):** `.cursor/rules/with-lingui-*.mdc` at the repo root mirrors `apps/with-lingui/.github/instructions/*.instructions.md` (same intent; `globs` align with each file’s `applyTo`). Prefer updating **both** when you change guidance.
[antd](https://github.com/ant-design/ant-design)
[react](https://react.dev)
[vite](https://viteplus.dev)
@@ -27,7 +25,7 @@ Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript 5.9 (strict mode) |
| Language | TypeScript (strict mode) |
## Features
@@ -36,6 +34,7 @@ Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **i18n** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales wired in root
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Modular API layer** — `defineApi` modules under `src/api/<domain>/`, shared Zod helpers, single `API_PREFIX`
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
@@ -49,8 +48,8 @@ Balanced admin scaffold: i18n (Lingui), MSW mocks, minimal RBAC, full CRUD, and
### Install & Run
```bash
vp install
vp dev
pnpm install
pnpm run dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
@@ -60,84 +59,126 @@ Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to
### Build
```bash
vp build
vp preview
pnpm run build
pnpm run preview
```
### Project Setup
```bash
pnpm run prepare
```
### Code Quality
```bash
pnpm run fmt
pnpm run lint
pnpm run check
```
### Unit Tests
```bash
pnpm run test:unit
```
### E2E Tests
```bash
vp run test:e2e
vp run test:e2e:core
vp run test:e2e:ui # interactive UI mode
pnpm run test:e2e
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # core flows only
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows: login, users CRUD, auth refresh, RBAC, URL state (see `package.json` script list).
### Lingui Scripts
```bash
# extract messages from source to .po catalogs
vp exec lingui extract -- --clean
# compile catalogs to runtime messages
vp exec lingui compile
# CI-oriented check: extract + ensure no pending locale diff
pnpm run i18n:check
```
## Project Structure
```
.github/instructions/ # Scoped AI recipes (mirrored in ../../.cursor/rules/with-lingui-*.mdc)
AGENTS.md # Agent rules (frontend/api/testing/refactor)
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── locales/ # LinguiJS catalogs (.po + compiled .js)
├── api/ # HTTP + contracts (see “API layer” below)
│ ├── request.ts # axios, defineApi, token refresh, envelope unwrap
│ ├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
│ ├── shared/ # API_PREFIX, pagination, params, menu, envelope helpers
│ ├── auth/ # paths, schemas, login/register/hydrateSession
│ └── users/ # schemas, listUsers CRUD, userQueryKeys, path constants
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
│ ├── FormModal/ # Generic modal form wrapper
│ ├── FilterToolbar/ # Reusable list page toolbar
│ ├── Auth/ # Permission gate component
│ ├── Aurora/ # Login page background effect
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
│ ├── NotFound/ # Not-found visual component
│ └── RouteError.tsx # Route-level error boundary UI
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
│ ├── useAppTheme.ts
│ ├── usePermission.ts
│ ├── useResourceCRUD.ts
│ ├── useCrudToasts.ts
│ ├── useUrlSearchState.ts
│ ├── useTableFitHeight.ts
│ └── tokenBuilders.ts
├── locales/ # Lingui catalogs + locale loader
│ ├── en/messages.po
│ └── zh/messages.po
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, users, …)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── zh/messages.po
│ └── loadLocaleCatalog.ts
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
│ ├── browser.ts
│ ├── createHandler.ts
│ ├── createHandler.test.ts
│ ├── data.ts
│ ├── utils.ts
│ └── handlers/
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # QueryClient + ConfigProvider + I18n wiring
│ ├── _auth.tsx # Protected route layout
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/users/index.tsx
│ ├── _auth/users/-Toolbar.tsx
│ ├── _auth/users/-FormModal.tsx
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── register/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
└── main.tsx # Entry point (MSW init → React render)
│ └── index.tsx
├── stores/ # Persisted auth/settings stores
├── utils/ # constants, app menu helpers
├── main.tsx # App bootstrap (MSW init + React render)
└── routeTree.gen.ts # Generated TanStack route tree
e2e/ # Playwright E2E tests
e2e/ # Playwright E2E suites
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
├── users.spec.ts
├── auth-refresh.spec.ts
├── rbac.spec.ts
└── url-state.spec.ts
```
## API layer
Same layout as [`apps/basic`](../basic/): **schemas → `defineApi` in `src/api/<module>/index.ts` → UI imports `@/api/users`, `@/api/auth`, etc.** Transport stays in `request.ts`; path constants (`API_PREFIX`, `AUTH_ENDPOINTS`, `USERS_PATH`) are shared with MSW handlers.
## Internationalization (i18n)
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: **en** (source) and **zh**.
### Workflow
@@ -150,15 +191,13 @@ Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`
## Extending the template
- **New CRUD resource:** See `.github/instructions/add-resource.instructions.md` (checklist; copy from `users`), 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`.
- **New CRUD resource:** Follow `.agents/skills/add-resource/SKILL.md` (mirror `users`), then `pnpm run i18n:extract && pnpm run i18n:compile`, and `pnpm run check`.
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
### AI instruction files
### AI guidance
**Single source of truth**: `apps/with-lingui/.github/instructions/` (`*.instructions.md`). Both humans and agents should maintain and review guidance from this directory.
**Cursor**: The root `.cursor/rules/with-lingui-*.mdc` files are intentionally thin rules (Approach A), containing only scope and pointer paths. Keep `globs` aligned with each instruction file's `applyTo`; for full details, reference the target `.instructions.md` file in chat with `@` or open it directly.
**Single source of truth:** **`AGENTS.md`** in this app (commands + Frontend/API/Testing/Refactor rules). New CRUD flows: `@.agents/skills/add-resource/SKILL.md`.
## Developer Notes
+25 -36
View File
@@ -11,53 +11,42 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vitest run",
"test:unit": "vp test run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:ui": "playwright test --ui",
"i18n:extract": "vp exec lingui extract -- --clean",
"i18n:compile": "vp exec lingui compile",
"i18n:check": "pnpm run i18n:extract && git diff --exit-code -- src/locales"
},
"dependencies": {
"@lingui/core": "^6.0.0",
"@lingui/react": "^6.0.0",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.25",
"antd": "^6.3.7",
"lucide-react": "^1.11.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"@lingui/core": "^6.10.0",
"@lingui/react": "^6.10.0",
"@tanstack/react-query": "^5.104.3",
"@tanstack/react-router": "^1.170.45",
"antd": "^6.6.6",
"axios": "^1.20.0",
"lucide-react": "^1.59.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@lingui/cli": "^6.0.0",
"@lingui/format-po": "^6.0.0",
"@lingui/swc-plugin": "^6.0.0",
"@lingui/vite-plugin": "^6.0.0",
"@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.28",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.13.6",
"@lingui/cli": "^6.10.0",
"@lingui/format-po": "^6.10.0",
"@lingui/swc-plugin": "^6.7.1",
"@lingui/vite-plugin": "^6.10.0",
"@playwright/test": "^1.64.1",
"@tanstack/router-plugin": "^1.168.47",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react-swc": "^4.3.3",
"msw": "^2.15.0",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest",
"vitest": "4.1.5"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
"vite-plus": "0.3.3"
},
"packageManager": "pnpm@10.33.2",
"msw": {
"workerDirectory": [
"public"
+2362 -1951
View File
File diff suppressed because it is too large Load Diff
+19 -9
View File
@@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.12.12";
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
const PACKAGE_VERSION = "2.15.0";
const INTEGRITY_CHECKSUM = "03cb67ac84128e63d7cd722a6e5b7f1e";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
@@ -129,8 +129,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get("content-type")
?.toLowerCase()
.startsWith("text/event-stream");
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
const responseClone = isEventStreamResponse ? null : response.clone();
sendToClient(
client,
@@ -143,15 +153,15 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
-19
View File
@@ -1,19 +0,0 @@
import type {
AuthTokens,
LoginRequest,
User,
MenuItem,
PermissionsList,
RegisterRequest,
} from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest };
+63
View File
@@ -0,0 +1,63 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RegisterRequestSchema,
} from "@/api/auth/schemas";
import type {
AuthTokens,
LoginRequest,
PermissionsList,
RegisterRequest,
} from "@/api/auth/schemas";
import { UserSchema, type User } from "@/api/users/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
export const login = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.login,
body: LoginRequestSchema,
output: AuthTokensSchema,
});
export const register = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.register,
body: RegisterRequestSchema,
output: AuthTokensSchema,
});
export const logout = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.logout,
output: z.null(),
});
export const getAuthUser = defineApi({
path: AUTH_ENDPOINTS.user,
output: AuthUserResponseSchema,
});
export const getPermissions = defineApi({
path: AUTH_ENDPOINTS.permissions,
output: PermissionsListSchema,
});
/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */
export async function hydrateSession(): Promise<void> {
const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
}
export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest };
+12
View File
@@ -0,0 +1,12 @@
import { API_PREFIX } from "@/api/shared/prefix";
const AUTH_BASE = `${API_PREFIX}/auth`;
export const AUTH_ENDPOINTS = {
login: `${AUTH_BASE}/login`,
register: `${AUTH_BASE}/register`,
refresh: `${AUTH_BASE}/refresh`,
logout: `${AUTH_BASE}/logout`,
user: `${AUTH_BASE}/user`,
permissions: `${AUTH_BASE}/permissions`,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod/v4";
import { UserSchema } from "@/api/users/schemas";
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
+302
View File
@@ -0,0 +1,302 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { z } from "zod/v4";
import { API_BASE_URL } from "@/utils/constants";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
type RequestConfig = InternalAxiosRequestConfig & {
skipAuthRefresh?: boolean;
_retry?: boolean;
};
export type CallOptions = {
signal?: AbortSignal;
};
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Envelope = {
code?: number;
data?: unknown;
message?: string;
};
function cleanParams(params?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!params) return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value != null) out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function unwrapEnvelope(json: unknown): unknown {
if (json == null || typeof json !== "object") return json;
const envelope = json as Envelope;
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
if (envelope.code !== undefined && envelope.data !== undefined) {
return envelope.data;
}
return json;
}
const client = axios.create({
baseURL: API_BASE_URL,
timeout: 15_000,
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
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 });
try {
const res = await client.post(REFRESH_PATH, body, {
skipAuthRefresh: true,
} as RequestConfig);
const next = AuthTokensSchema.parse(res.data);
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;
}
client.interceptors.response.use(
(response) => {
response.data = unwrapEnvelope(response.data);
return response;
},
async (error: AxiosError) => {
const status = error.response?.status;
const config = error.config as RequestConfig | undefined;
if (
status === 401 &&
config &&
!config._retry &&
!config.skipAuthRefresh &&
config.url !== REFRESH_PATH &&
!config.url?.endsWith(REFRESH_PATH)
) {
config._retry = true;
const refreshed = await refreshSessionTokens();
if (refreshed) {
return client.request(config);
}
throw new HttpError(401, "Unauthorized");
}
if (status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (status != null) {
throw new HttpError(status, `HTTP ${status}: ${error.response?.statusText ?? "Error"}`);
}
throw error;
},
);
async function transportRequest<T>(opts: {
method: HttpMethod;
url: string;
params?: Record<string, unknown>;
body?: unknown;
signal?: AbortSignal;
skipAuthRefresh?: boolean;
}): Promise<T> {
const res = await client.request<T>({
method: opts.method,
url: opts.url,
params: cleanParams(opts.params),
data: opts.body,
signal: opts.signal,
skipAuthRefresh: opts.skipAuthRefresh,
} as RequestConfig);
return res.data;
}
type ApiDefConfig<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
TOut extends z.ZodType,
> = {
method?: HttpMethod;
path: string;
params?: TParams;
query?: TQuery;
body?: TBody;
output: TOut;
};
type CallArgs<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
> = TParams extends z.ZodType
? TQuery extends z.ZodType
? TBody extends z.ZodType
? { params: z.infer<TParams>; query: z.infer<TQuery>; body: z.infer<TBody> }
: { params: z.infer<TParams>; query: z.infer<TQuery> }
: TBody extends z.ZodType
? { params: z.infer<TParams>; body: z.infer<TBody> }
: z.infer<TParams>
: TQuery extends z.ZodType
? TBody extends z.ZodType
? { query: z.infer<TQuery>; body: z.infer<TBody> }
: z.infer<TQuery>
: TBody extends z.ZodType
? z.infer<TBody>
: void;
function renderPath(path: string, params?: Record<string, unknown>): string {
if (!params) return path;
let url = path;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
}
return url;
}
export function defineApi<
TParams extends z.ZodType | undefined = undefined,
TQuery extends z.ZodType | undefined = undefined,
TBody extends z.ZodType | undefined = undefined,
TOut extends z.ZodType = z.ZodType,
>(
def: ApiDefConfig<TParams, TQuery, TBody, TOut>,
): (
...args: CallArgs<TParams, TQuery, TBody> extends void
? [options?: CallOptions]
: [args: CallArgs<TParams, TQuery, TBody>, options?: CallOptions]
) => Promise<z.infer<TOut>> {
const method: HttpMethod = def.method ?? "GET";
const segmentCount = (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0);
return async (...allArgs: unknown[]): Promise<z.infer<TOut>> => {
let callArgs: unknown;
let options: CallOptions | undefined;
if (segmentCount === 0) {
options = allArgs[0] as CallOptions | undefined;
} else {
callArgs = allArgs[0];
options = allArgs[1] as CallOptions | undefined;
}
let params: Record<string, unknown> | undefined;
let query: Record<string, unknown> | undefined;
let body: unknown;
if (segmentCount === 0) {
// no input segments
} else if (segmentCount === 1) {
if (def.params) {
params = def.params.parse(callArgs) as Record<string, unknown>;
} else if (def.query) {
query = def.query.parse(callArgs) as Record<string, unknown>;
} else if (def.body) {
body = def.body.parse(callArgs);
}
} else {
const shaped = (callArgs ?? {}) as {
params?: unknown;
query?: unknown;
body?: unknown;
};
if (def.params) {
params = def.params.parse(shaped.params) as Record<string, unknown>;
}
if (def.query) {
query = def.query.parse(shaped.query) as Record<string, unknown>;
}
if (def.body) {
body = def.body.parse(shaped.body);
}
}
const data = await transportRequest<unknown>({
method,
url: renderPath(def.path, params),
params: query,
body: def.body ? body : undefined,
signal: options?.signal,
});
return def.output.parse(data) as z.infer<TOut>;
};
}
+4 -154
View File
@@ -1,154 +1,4 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
export type PaginatedData<T> = {
list: T[];
total: number;
};
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
export * from "@/api/shared";
export * from "@/api/users/schemas";
export * from "@/api/auth/schemas";
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./envelope";
export * from "./menu";
export * from "./pagination";
export * from "./params";
export * from "./prefix";
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod/v4";
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
@@ -0,0 +1,14 @@
import { z } from "zod/v4";
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
@@ -0,0 +1,2 @@
/** REST API path prefix (no trailing slash). */
export const API_PREFIX = "/api" as const;
-10
View File
@@ -1,10 +0,0 @@
import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas";
export const USER_ENDPOINTS = {
list: "/api/users",
create: "/api/users",
update: (id: string) => `/api/users/${id}`,
delete: (id: string) => `/api/users/${id}`,
} as const;
export type { CreateUserRequest, UpdateUserRequest, User };
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import { IdParamsSchema } from "@/api/shared/params";
import { PaginatedDataSchema } from "@/api/shared/pagination";
import {
CreateUserRequestSchema,
ListUsersQuerySchema,
UpdateUserRequestSchema,
UserSchema,
} from "@/api/users/schemas";
import type {
CreateUserRequest,
ListUsersQuery,
UpdateUserRequest,
User,
} from "@/api/users/schemas";
import { API_PREFIX } from "@/api/shared/prefix";
export const USERS_PATH = `${API_PREFIX}/users`;
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
export const userQueryKeys = {
all: ["users"] as const,
list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const,
};
export const listUsers = defineApi({
path: USERS_PATH,
query: ListUsersQuerySchema,
output: PaginatedDataSchema(UserSchema),
});
export const createUser = defineApi({
method: "POST",
path: USERS_PATH,
body: CreateUserRequestSchema,
output: UserSchema,
});
export const updateUser = defineApi({
method: "PUT",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
body: UpdateUserRequestSchema,
output: UserSchema,
});
export const deleteUser = defineApi({
method: "DELETE",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
output: z.null(),
});
export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User };
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
@@ -103,7 +103,7 @@ function mapSkeletonColumn<RecordType extends object>(
...c,
sorter: false,
sortOrder: undefined,
render: (_: unknown, __: RecordType) => {
render: () => {
if (isActionColumn(c)) {
const w = actionSkeletonDiameter(c);
return (
@@ -1,4 +1,4 @@
import { Flex, Table, theme } from "antd";
import { Table, theme } from "antd";
import type { TableProps } from "antd";
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
import { useMemo } from "react";
@@ -12,8 +12,8 @@ export type DataTableProps<RecordType extends object = object> = {
lockScrollHeight?: boolean;
maxHeight?: number;
frameHeight?: number;
layoutRef?: Ref<HTMLDivElement | null>;
frameRef?: Ref<HTMLDivElement | null>;
layoutRef?: Ref<HTMLDivElement>;
frameRef?: Ref<HTMLDivElement>;
bottomExtra?: ReactNode;
} & TableProps<RecordType>;
@@ -67,14 +67,15 @@ export function DataTable<RecordType extends object = object>(
);
return (
<Flex
<div
ref={layoutRef}
vertical
gap={token.marginSM}
style={{
flex: "1 1 0%",
minHeight: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
gap: token.marginSM,
}}
>
<div ref={frameRef} style={outerStyle}>
@@ -110,6 +111,6 @@ export function DataTable<RecordType extends object = object>(
</div>
</div>
{bottomExtra}
</Flex>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More