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
zuiidea c18d288a4e Merge pull request #1169 from zuiidea/v6.1
Architecture upgrade
2026-04-28 17:58:30 +08:00
zuiidea 3d4551c9a4 docs: standardize README structure and remove oxfmt configs
Reorganize root README sections for template onboarding, translate remaining with-lingui README Chinese notes, and drop redundant .oxfmt.toml files from both templates.

Made-with: Cursor
2026-04-28 17:50:17 +08:00
zuiidea 8673d8398e docs: refine README for template-first onboarding
Focus root docs on init-antd-admin usage and keep template setup guidance fully in English with clearer CLI options.

Made-with: Cursor
2026-04-28 17:43:29 +08:00
zuiidea 0726321552 docs: add localized docs routes and language content
Migrate docs to language-prefixed app routes and split content into zh/en trees, including translated English navigation and pages.

Made-with: Cursor
2026-04-28 17:31:50 +08:00
zuiidea f12539812e chore: add root .githooks dispatcher and sync with-lingui
Wire monorepo pre-commit by staged paths; document setup; update Lingui template.

Made-with: Cursor
2026-04-28 12:04:29 +08:00
zuiidea 1cb0819011 docs: restructure Nextra IA, migrate middleware to proxy
Sync apps/basic instructions, hooks, and vercel config; refresh lockfile.

Made-with: Cursor
2026-04-28 11:53:31 +08:00
zuiidea 6ac8a62770 chore: update with-lingui pre-commit hook and i18n translations 2026-04-28 10:23:25 +08:00
zuiidea 335c77cc8c feat(basic): register API, MSW, /register route, dashboard timeline
Made-with: Cursor
2026-04-27 18:45:26 +08:00
zuiidea c8062e1299 ci(with-lingui): run i18n check in workflow
Add i18n:check step to GitHub Actions; refresh pnpm-lock.yaml.

Made-with: Cursor
2026-04-27 18:40:38 +08:00
zuiidea b277f522bd feat(with-lingui): register route, Lingui refresh, and MSW auth
Add register page and endpoint wiring; optional email on register schema;
shared AppFooter for login/register; MSW register and session mocks;
dashboard timeline and styles; locale catalogs and Lingui config.

Made-with: Cursor
2026-04-27 18:38:11 +08:00
zuiidea 39a6339c59 feat(templates): remove orders sample, align basic with with-lingui
- Drop orders routes, API, MSW, and e2e; users-only CRUD template
- Port HTTP refresh, hooks, RouteError, and core e2e to basic
- Lingui extract --clean in script; refresh catalogs
- Update add-resource docs, cursor rule, and optimization spec

Made-with: Cursor
2026-04-27 15:53:47 +08:00
zuiidea 4406e57556 fix(with-lingui): run vp staged from app roots in pre-commit
Git core.hooksPath points at this app while commits use the repo root cwd, so plain vp staged could not find vite.config.ts. Dispatch into apps/with-lingui and apps/basic by staged path; run packages/create check-types for matching TS sources. Document behavior in AGENTS.md.

Made-with: Cursor
2026-04-27 15:04:51 +08:00
zuiidea c5221c71be feat(with-lingui): orders CRUD, shared hooks, auth refresh, and docs
- Add second sample resource (orders): API, schemas, MSW, routes, menu, Lingui, and E2E.

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

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

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

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

- Bump packages/create and lockfile where applicable.

Made-with: Cursor
2026-04-27 14:47:55 +08:00
247 changed files with 20042 additions and 14459 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
+30
View File
@@ -0,0 +1,30 @@
# Git hooks (repository root)
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).
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
# Expected: .githooks
```
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env sh
# Monorepo pre-commit: run Vite+ / checks in the app that owns staged files.
# Template copies under apps/*/ keep their own .vite-hooks for standalone use; this repo uses root hooksPath instead.
set -e
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FAILED=0
run_in_dir() {
dir="$1"
shift
(cd "$ROOT/$dir" && "$@") || FAILED=1
}
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
run_in_dir apps/with-lingui pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^packages/create/'; then
run_in_dir . pnpm --dir packages/create run check-types
fi
if echo "$STAGED" | grep -q '^apps/docs/'; then
run_in_dir apps/docs pnpm run lint
fi
exit "$FAILED"
@@ -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
+51
View File
@@ -0,0 +1,51 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.2
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: I18n check
working-directory: apps/with-lingui
run: pnpm run i18n:check
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+5 -1
View File
@@ -41,6 +41,10 @@ yarn-error.log*
# Git worktrees
.worktrees/
.cursor
# Cursor: share project rules only; ignore other local Cursor data (agent rules: each app’s AGENTS.md)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
+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`.
+56 -189
View File
@@ -1,216 +1,83 @@
# Antd Admin
Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. Ship either an **English-only** app (`apps/basic`) or a **Lingui i18n** variant (`apps/with-lingui`); see [Templates](#templates) below.
## Introduction
[antd](https://github.com/ant-design/ant-design)
[react](https://react.dev)
[vite](https://viteplus.dev)
[TypeScript](https://www.typescriptlang.org/)
[GitHub issues](https://github.com/zuiidea/antd-admin/issues)
[GitHub stars](https://github.com/zuiidea/antd-admin/stargazers)
[License](http://opensource.org/licenses/MIT)
[PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
## Tech Stack
Available templates:
- `basic`: Admin template with plain-string UI copy (default English; no bundled Lingui)
- `with-lingui`: Lingui i18n workflow (`en` + `zh`) with locale switching
| Category | Technology |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
| UI Framework | [Ant Design 6.x](https://ant.design) |
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
| 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) |
| i18n | [LinguiJS](https://lingui.dev) in `**apps/with-lingui` only** (`apps/basic` is English-only, no Lingui) |
| 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) |
Scaffolding CLI:
- `init-antd-admin` (from `packages/create`)
## Features
## Key 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
- **i18n (with-lingui app)** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales follow the active locale in `apps/with-lingui`
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
- JWT login with access/refresh flow
- Backend-driven menu and permission guards
- 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`)
- E2E coverage with Playwright
## Templates
## Quick Start
`npx init-antd-admin@latest` · `pnpm dlx init-antd-admin@latest`
Use `init-antd-admin` to scaffold a standalone app from official templates.
| Directory | Use when |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `[apps/with-lingui](./apps/with-lingui/)` | You want **LinguiJS** (en + zh), `.po` extract/compile, and a language switcher — this is the fuller reference stack. |
| `[apps/basic](./apps/basic/)` | You want the **same product surface** with **English-only** UI (no Lingui, fixed `en_US` for Ant Design). |
Pick one app directory, install, and run Vite+ from there:
### Interactive
```bash
cd apps/basic && vp install && vp dev
# or
cd apps/with-lingui && vp install && vp dev
npx init-antd-admin@latest
pnpm dlx init-antd-admin@latest
yarn dlx init-antd-admin@latest
bunx init-antd-admin@latest
```
### 文档站(Nextra)
中文使用文档位于 `**apps/docs**`(Next.js + Nextra)。在仓库根或该目录安装依赖后:
### Non-interactive examples
```bash
cd apps/docs && pnpm install && pnpm dev
pnpm dlx init-antd-admin@latest my-app --example basic -m pnpm
pnpm dlx init-antd-admin@latest my-app --example with-lingui --skip-install
```
浏览器打开 [http://localhost:3000](http://localhost:3000)。模板 Vite+ 应用仍为 [http://localhost:5173](http://localhost:5173)。开发与构建命令说明见 `[apps/docs/README.md](./apps/docs/README.md)`。
Core Playwright flows (from the app you are working in):
```bash
cd apps/basic && pnpm run test:e2e:core
```
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
### Install & Run
From the repository root, choose an app under `apps/` (see [Templates](#templates)), then:
```bash
cd apps/with-lingui # or: cd apps/basic
vp install
vp dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
**Default credentials:** `admin` / `admin`
### Build
```bash
vp build
vp preview
```
### E2E Tests
Run from the app package (example: `apps/basic`):
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
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
```
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)
│ ├── 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, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/403/index.tsx
│ ├── login/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)
tests/
└── e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
```
## Internationalization (i18n)
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
### Workflow
```bash
vp run i18n:extract
vp run i18n:compile
```
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
## Extending the template
- **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).
## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
- Run `vp check --no-fmt` for type/lint validation.
- Core regression coverage lives in the login and users E2E flows.
## Pages
### Common options
| Route | Description |
| ------------ | ----------------------------------------------------------- |
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
| 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)
## Links
- [Ant Design](https://github.com/ant-design/ant-design)
- [React](https://react.dev)
- [Vite+](https://viteplus.dev)
- [TypeScript](https://www.typescriptlang.org/)
- [Issues](https://github.com/zuiidea/antd-admin/issues)
- [Stars](https://github.com/zuiidea/antd-admin/stargazers)
- [PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
## License
MIT
@@ -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.
-29
View File
@@ -1,29 +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,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: `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`
-25
View File
@@ -1,25 +0,0 @@
# oxfmt configuration for antd-admin
# Reference: https://oxc.rs/docs/guide/formatter
# Line width for formatting
line_width = 100
# Indentation
indent_width = 2
use_tabs = false
# Semicolons
semi = true
# Quotes
single_quote = false
# Trailing commas
trailing_comma = "es5"
# Arrows
arrow_parens = "always"
# Spaces
bracket_spacing = true
space_before_function_parentheses = false
+1
View File
@@ -0,0 +1 @@
vp staged
+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
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+8
View File
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
-2
View File
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -28,7 +27,6 @@ test.describe("User Management", () => {
});
});
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
+20 -29
View File
@@ -11,42 +11,33 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vp test run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.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"
},
"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"
-1
View File
@@ -1,6 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI);
+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] : [],
);
}
-11
View File
@@ -1,11 +0,0 @@
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
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 };
+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 -131
View File
@@ -1,131 +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 LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
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>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title="Something went wrong"
subTitle={detail || "Please try again or return to the dashboard."}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
Retry
</Button>
}
/>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: "Created successfully", key: keys.create });
},
onError: () => {
message.error({ content: "Create failed", key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Updated successfully", key: keys.update });
},
onError: () => {
message.error({ content: "Update failed", key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Deleted successfully", key: keys.delete });
},
onError: () => {
message.error({ content: "Delete failed", key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message],
);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vite-plus/test";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
+109 -12
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
@@ -6,9 +7,23 @@ type MutationLifecycle<TValues> = {
onError?: (values: TValues) => void;
};
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
queryKey: readonly unknown[];
invalidateKey: readonly unknown[];
invalidateKey?: readonly unknown[];
queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>;
@@ -17,15 +32,63 @@ type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>;
optimistic?: OptimisticFlags;
};
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
data: TListData | undefined;
isLoading: boolean;
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
deleteMutation: UseMutationResult<unknown, Error, string>;
};
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
TListData,
TCreateValues,
TUpdateValues
>;
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
export function applyOptimisticListUpdate<
TItem extends { id: string },
TUpdate extends { id: string },
>(data: ResourceListData<TItem>, values: TUpdate): { list: TItem[]; total: number } {
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
return {
list: data.list.map((row) =>
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
),
total: data.total,
};
}
export function applyOptimisticListDelete<TItem extends { id: string }>(
data: ResourceListData<TItem>,
id: string,
): { list: TItem[]; total: number } {
return {
list: data.list.filter((row) => row.id !== id),
total: Math.max(0, data.total - 1),
};
}
export function useResourceCRUD<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
) {
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
const queryClient = useQueryClient();
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
const queryKey = options.queryKey;
const opt = options.optimistic;
const query = useQuery({
queryKey: options.queryKey,
queryKey,
queryFn: options.queryFn,
select: options.select,
});
@@ -36,7 +99,7 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
options.createLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
@@ -46,28 +109,62 @@ export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
const updateMutation = useMutation({
mutationFn: options.updateFn,
onMutate: (values) => {
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.update && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListUpdate(
previous as ResourceListData<{ id: string }>,
values as { id: string } & Record<string, unknown>,
) as unknown as TListData,
);
}
options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
onMutate: (id) => {
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.delete && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListDelete(
previous as ResourceListData<{ id: string }>,
id,
) as unknown as TListData,
);
}
options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id) => {
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
},
});
+133
View File
@@ -0,0 +1,133 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const {
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
marginLG,
rowCount,
isLoading,
showPagination,
} = args;
const tableAvailableRef = useRef(0);
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [
showPagination,
rowCount,
isLoading,
marginLG,
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
+6 -3
View File
@@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { useSettingsStore } from "./stores/settings";
import { hydrateSession } from "./api/auth";
import { installHttpRouter } from "./api/request";
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
@@ -22,11 +25,11 @@ async function enableMocking() {
enableMocking()
.then(async () => {
await useAuthStore.persist.rehydrate();
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
await fetchSessionAndApplyToStore();
await hydrateSession();
} catch {
useAuthStore.getState().logout();
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vite-plus/test";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
+33 -21
View File
@@ -1,30 +1,17 @@
import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
/**
* Standard API response format
*/
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
/**
* MSW handler factory functions
* Provides consistent response wrapping and error handling
*/
/**
* Wrap a handler function with standard response format
* Automatically handles delay, error responses, and data wrapping
*/
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
/**
* Create a success response
*/
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
@@ -33,9 +20,37 @@ export function successResponse<T>(data: T, message: string = "ok") {
});
}
/**
* Create an error response
*/
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
export function successWithSchema<T extends ZodTypeAny>(
schema: T,
data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
@@ -44,9 +59,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
});
}
/**
* Predefined error codes
*/
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
+17 -1
View File
@@ -1,7 +1,6 @@
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
/** Demo logins look like real org accounts (first.last @ northstar.io). Index 0 stays `admin` for mock login. */
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
@@ -14,8 +13,25 @@ const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["ava.nguyen", "ava.nguyen@northstar.io"],
["noah.berg", "noah.berg@northstar.io"],
["mia.silva", "mia.silva@northstar.io"],
["lucas.rossi", "lucas.rossi@northstar.io"],
["isabella.ali", "isabella.ali@northstar.io"],
["ethan.kovacs", "ethan.kovacs@northstar.io"],
["sophia.dubois", "sophia.dubois@northstar.io"],
["mateo.santos", "mateo.santos@northstar.io"],
["harper.ivanov", "harper.ivanov@northstar.io"],
["elijah.mohamed", "elijah.mohamed@northstar.io"],
["amelia.schmidt", "amelia.schmidt@northstar.io"],
["henry.okafor", "henry.okafor@northstar.io"],
];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
+138 -15
View File
@@ -1,16 +1,59 @@
import { http } from "msw";
import { MOCK_USERS } from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
const REGISTERED_REFRESH = "mock-registered-refresh";
/** Last successful register (in-memory) so /user matches the new account in MSW */
let mockRegisteredSession: { username: string; email: string | null } | null = null;
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
function isRegisteredAuth(authorization: string | null): boolean {
return (authorization ?? "").includes(REGISTERED_ACCESS);
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
await withDelay(300);
const body = (await request.json()) as {
username: string;
password: string;
};
if (body.username === "admin" && body.password === "admin") {
return successResponse({
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = LoginRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
}
const { username, password } = parsed.data;
if (username === "guest" && password === "guest") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access",
refreshToken: GUEST_REFRESH,
});
}
if (username === "admin" && password === "admin") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
});
@@ -18,20 +61,100 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/refresh", async () => {
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RegisterRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
}
const { username, password, email } = parsed.data;
if (password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
}
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
if (taken) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
}
mockRegisteredSession = {
username,
email: email ?? null,
};
return successWithSchema(AuthTokensSchema, {
accessToken: REGISTERED_ACCESS,
refreshToken: REGISTERED_REFRESH,
});
}),
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
await withDelay(100);
return successResponse({
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
if (
parsed.data.refreshToken === REGISTERED_REFRESH ||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
) {
return successWithSchema(AuthTokensSchema, {
accessToken: `${REGISTERED_ACCESS}-refreshed`,
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
}),
http.post("/api/auth/logout", () => successResponse(null)),
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
http.get("/api/auth/user", () => {
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
if (session) {
return successWithSchema(AuthUserResponseSchema, {
id: "reg-mock",
username: session.username,
avatar: null,
email: session.email,
roles: ["editor"],
});
}
}
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successResponse(userWithoutPermissions);
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
}
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
];
+27 -12
View File
@@ -1,13 +1,28 @@
import { http } from "msw";
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
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}`) {
return new HttpResponse(null, { status: 401 });
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
@@ -16,10 +31,10 @@ export const userHandlers = [
const filtered = filterUsers(users, { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
http.post(USERS_PATH, async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
@@ -28,26 +43,26 @@ export const userHandlers = [
avatar: null,
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [],
permissions: [] as string[],
};
users.push(newUser);
return successResponse(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
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);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body };
return successResponse(users[idx]);
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successResponse(null);
return successWithNullBody();
}),
];
+67 -41
View File
@@ -9,26 +9,22 @@
// 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 LoginIndexRouteImport } from './routes/login/index'
import { Route as AuthRouteImport } from './routes/_auth'
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 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: '/',
getParentRoute: () => rootRouteImport,
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const R404IndexRoute = R404IndexRouteImport.update({
@@ -36,9 +32,19 @@ const R404IndexRoute = R404IndexRouteImport.update({
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
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/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
@@ -46,9 +52,9 @@ const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
@@ -56,6 +62,7 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/403/': typeof Auth403IndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/users/': typeof AuthUsersIndexRoute
@@ -64,6 +71,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/403': typeof Auth403IndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/users': typeof AuthUsersIndexRoute
@@ -74,21 +82,30 @@ export interface FileRoutesById {
'/_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/' | '/403/' | '/dashboard/' | '/users/'
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/register/'
| '/403/'
| '/dashboard/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/403' | '/dashboard' | '/users'
to: '/' | '/404' | '/login' | '/register' | '/403' | '/dashboard' | '/users'
id:
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/register/'
| '/_auth/403/'
| '/_auth/dashboard/'
| '/_auth/users/'
@@ -99,17 +116,11 @@ export interface RootRouteChildren {
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -117,11 +128,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
@@ -131,11 +142,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
'/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/': {
@@ -145,11 +170,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
}
@@ -174,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthRoute: AuthRouteWithChildren,
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+29 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect } from "react";
import { useEffect, useMemo } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
@@ -6,19 +6,34 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { RouteError } from "@/components/RouteError";
import "@/index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
function AppQueryBridge({ children }: { children: React.ReactNode }) {
const { message } = App.useApp();
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: {
onError: (err) => {
message.error(err instanceof Error ? err.message : String(err));
},
},
},
}),
[message],
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useLayoutEffect(() => {
useEffect(() => {
document.documentElement.lang = "en";
}, []);
@@ -27,17 +42,18 @@ function RootComponent() {
}, [darkMode]);
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</App>
</ConfigProvider>
</QueryClientProvider>
</AppQueryBridge>
</App>
</ConfigProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+14 -3
View File
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { hydrateSession } from "@/api/auth";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState();
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await hydrateSession();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) {
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" });
}
},
@@ -20,12 +20,12 @@
background-color: var(--dash-recent-hover-bg);
}
.dash-chart-placeholder {
transition: border-color 0.2s ease;
.dash-timeline {
padding-top: 4px;
}
.dash-chart-placeholder:hover {
border-color: var(--dash-chart-hover-border);
.dash-timeline .ant-timeline-item-content {
min-height: 52px;
}
.dash-skel-stat .ant-skeleton-input {
+60 -22
View File
@@ -1,7 +1,7 @@
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton } from "antd";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton, Timeline, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
import "./index.css";
@@ -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)),
}}
/>
);
@@ -194,6 +194,48 @@ function DashboardPage() {
[],
);
const timelineItems = useMemo(
() => [
{
color: "green" as const,
content: (
<Flex vertical gap={4}>
<Text strong>08:30 · Deploy V3.2.0</Text>
<Text type="secondary">Release branch merged and production rollout completed.</Text>
</Flex>
),
},
{
color: "blue" as const,
content: (
<Flex vertical gap={4}>
<Text strong>10:10 · Menu policy updated</Text>
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
</Flex>
),
},
{
color: "gold" as const,
content: (
<Flex vertical gap={4}>
<Text strong>13:20 · Security review</Text>
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
</Flex>
),
},
{
color: "red" as const,
content: (
<Flex vertical gap={4}>
<Text strong>15:50 · Incident recovery</Text>
<Text type="secondary">User creation spike handled and queue restored.</Text>
</Flex>
),
},
],
[],
);
if (isPending) {
return <DashboardSkeleton />;
}
@@ -228,33 +270,29 @@ function DashboardPage() {
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={<Title level={5} style={{ margin: 0 }}>Overview</Title>}
title={
<Flex align="center" gap={token.marginSM}>
<Title level={5} style={{ margin: 0 }}>
Timeline
</Title>
<Tag variant="filled" color="processing">
Today
</Tag>
</Flex>
}
>
<Flex
className="dash-chart-placeholder"
style={{
height: 300,
width: "100%",
backgroundColor: token.colorBgLayout,
borderRadius: token.borderRadius,
alignItems: "center",
justifyContent: "center",
border: `1px dashed ${token.colorBorder}`,
["--dash-chart-hover-bg" as string]: token.colorBgLayout,
["--dash-chart-hover-border" as string]: token.colorTextTertiary,
}}
>
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Chart Placeholder
</Text>
</Flex>
<Timeline className="dash-timeline" items={timelineItems} />
</Card>
</Col>
<Col xs={24} lg={10}>
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={<Title level={5} style={{ margin: 0 }}>Recent Sales</Title>}
title={
<Title level={5} style={{ margin: 0 }}>
Recent Sales
</Title>
}
>
<Flex
vertical
@@ -66,7 +66,15 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onRoleChange, onSearch, roleValue, token.fontSize],
[
keywordInput,
onClearSearch,
onKeywordChange,
onRoleChange,
onSearch,
roleValue,
token.fontSize,
],
);
return (
+64 -209
View File
@@ -1,15 +1,16 @@
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 { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
import { useMemo, useRef, useState } from "react";
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";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
@@ -22,17 +23,13 @@ const UserSearchParamsSchema = z.object({
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
});
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
@@ -43,119 +40,67 @@ function UsersPage() {
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const tableAvailableRef = useRef(0);
/**
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
*/
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const 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,
],
invalidateKey: ["users"],
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: () => {
message.success({
content: "Created successfully",
key: MESSAGE_KEY_USER_CREATE,
});
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: () => {
message.error({
content: "Create failed",
key: MESSAGE_KEY_USER_CREATE,
});
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: () => {
message.loading({
content: "Updating…",
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Updated successfully",
key: MESSAGE_KEY_USER_UPDATE,
});
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: () => {
message.error({
content: "Update failed",
key: MESSAGE_KEY_USER_UPDATE,
});
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: () => {
message.loading({
content: "Deleting…",
key: MESSAGE_KEY_USER_DELETE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Deleted successfully",
key: MESSAGE_KEY_USER_DELETE,
});
},
onError: () => {
message.error({
content: "Delete failed",
key: MESSAGE_KEY_USER_DELETE,
});
},
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
@@ -268,118 +213,28 @@ function UsersPage() {
}}
placement="bottomRight"
>
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
<Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label="Row actions"
/>
</Dropdown>
),
},
];
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const showPagination = (data?.total ?? 0) > search.limit;
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRowRef.current;
const mid = middleSectionRef.current;
if (!shell || !toolbarEl || !mid) return;
/** Fixed small table header row height */
const headReserve = 40;
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
/**
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
*/
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
/** Bordered frame now includes Table built-in pagination */
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const rowCount = data?.list?.length ?? 0;
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
/**
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
*/
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
@@ -415,10 +270,10 @@ function UsersPage() {
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onSearch={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
applySearch("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
@@ -440,7 +295,7 @@ function UsersPage() {
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={tableScrollY != null}
lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
+14
View File
@@ -5,3 +5,17 @@
-webkit-backdrop-filter: none !important;
}
}
/* Keep browser autofill from painting a solid background over the glass card. */
.login-page__card .ant-input:-webkit-autofill,
.login-page__card .ant-input:-webkit-autofill:hover,
.login-page__card .ant-input:-webkit-autofill:focus,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:hover,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--ant-color-text, currentColor) !important;
caret-color: var(--ant-color-text, currentColor);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 9999s ease-out 0s;
}
+15 -16
View File
@@ -1,13 +1,11 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
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");
@@ -140,12 +136,7 @@ function LoginPage() {
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input
id="login-username"
aria-label="Username"
placeholder="admin"
size="large"
/>
<Input id="login-username" aria-label="Username" placeholder="admin" size="large" />
</Form.Item>
<Form.Item
@@ -192,6 +183,14 @@ function LoginPage() {
</Button>
</Form.Item>
</Form>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Don&apos;t have an account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Sign up
</Link>
</Typography.Text>
</Flex>
</Card>
</Flex>
<Flex
+211
View File
@@ -0,0 +1,211 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth";
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";
import { AppFooter } from "@/components/Layout/AppFooter";
import "../login/index.css";
export const Route = createFileRoute("/register/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: RegisterPage,
});
function RegisterPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const { token } = theme.useToken();
const registerMutation = useMutation({
mutationFn: async (values: RegisterRequest) => {
const tokens = await register(values);
setTokens(tokens);
await hydrateSession();
},
onSuccess: () => {
message.success("Account created successfully");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Registration failed");
},
});
const shellStyle: CSSProperties = {
position: "relative",
isolation: "isolate",
minHeight: "100vh",
backgroundColor: token.colorBgLayout,
color: token.colorText,
};
const contentStyle: CSSProperties = {
position: "relative",
zIndex: 1,
flex: "1 1 0%",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
};
const cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
backdropFilter: "blur(18px) saturate(1.35)",
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
borderColor: token.colorBorderSecondary,
boxShadow: token.boxShadow,
["--login-card-fallback-bg" as string]: token.colorBgElevated,
};
return (
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex
flex={1}
align="center"
justify="center"
style={{ padding: token.padding, minHeight: 0 }}
>
<Card
className="login-page__card"
style={cardStyle}
styles={{
body: { padding: token.paddingLG, background: "transparent" },
}}
>
<Flex
align="center"
justify="center"
gap={token.margin}
wrap="wrap"
style={{ marginBottom: token.marginLG }}
>
<img
src={APP_FAVICON_SRC}
alt=""
width={32}
height={32}
draggable={false}
style={{ display: "block", flexShrink: 0 }}
/>
<Typography.Title
level={3}
style={{
margin: 0,
fontWeight: "bold",
letterSpacing: "-0.025em",
lineHeight: 1.2,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</Typography.Title>
</Flex>
<Form
layout="vertical"
onFinish={(values) => {
const payload = RegisterRequestSchema.parse(values);
registerMutation.mutate(payload);
}}
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
validateTrigger={"onBlur"}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter your username" }]}
>
<Input placeholder="new-user" size="large" />
</Form.Item>
<Form.Item
name="email"
label={<span style={{ fontWeight: 500 }}>Email address</span>}
rules={[{ type: "email", message: "Please enter a valid email address" }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters long" },
]}
>
<Input.Password placeholder="Enter at least 6 characters" size="large" />
</Form.Item>
<Form.Item
name="confirmPassword"
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
dependencies={["password"]}
rules={[
{ required: true, message: "Please confirm your password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("Passwords do not match"));
},
}),
]}
>
<Input.Password placeholder="Re-enter your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={registerMutation.isPending}
block
size="large"
>
Create account
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Already have an account?{" "}
<Link to="/login" style={{ color: token.colorPrimary }}>
Sign in
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
}
+8
View File
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
},
},
);
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
-9
View File
@@ -1,9 +1,5 @@
import type { MenuItem } from "@/api/schemas";
/**
* Built-in menu tree. Visibility is computed with {@link filterMenuTreeByPermissions}
* from `GET /api/auth/permissions` (must stay consistent with route permission map below).
*/
export const APP_MENU_TREE: MenuItem[] = [
{
id: "g-platform",
@@ -113,16 +109,11 @@ export function filterMenuTreeByPermissions(
return walk(nodes);
}
/** Normalized pathname (no trailing slash except `/`) */
export function normalizeAppPath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
/**
* Route → permission required to open the page. `null` = no permission gate.
* Keep in sync with {@link APP_MENU_TREE} paths.
*/
export function requiredPermissionForPath(pathname: string): string | null {
const p = normalizeAppPath(pathname);
const map: Record<string, string | null> = {
-89
View File
@@ -1,89 +0,0 @@
import { API_BASE_URL } from "./constants";
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>;
};
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> {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return {};
const parsed = JSON.parse(raw);
const token = parsed?.state?.tokens?.accessToken;
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
return {};
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
): 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 ? JSON.stringify(body) : undefined,
...options,
});
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
}
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);
}
+7 -7
View File
@@ -1,8 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vite-plus";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
+26 -5
View File
@@ -2,16 +2,37 @@
基于 [Next.js](https://nextjs.org) App Router、[Nextra 4](https://nextra.site) 与 `nextra-theme-docs` 的中文文档,与模板应用(Vite+、`apps/basic`、`apps/with-lingui`)同仓维护。
## 内容目录(侧栏分组)
| 分组 | 路径(`content/` 下) |
| ----- | ----------------------------------------------------------------- |
| 概述 | `index.mdx` |
| 欢迎 | `welcome/documentation-intro.mdx` |
| 入门 | `getting-started/`(快速开始、`folder-structure`、`available-scripts` …) |
| 模板 | `templates/`(模板列表、`basic`、`with-lingui`) |
| 开发 | `development/` |
| 样式与资源 | `styles-and-assets/` |
| 构建应用 | `building/` |
| 测试 | `testing/` |
| 后端对接 | `backend/` |
| 部署 | `deployment/` |
侧边栏顺序与标题由各目录旁 `_meta.js` 及根目录 `content/_meta.js` 配置。
## 端口说明
| 项目 | 默认开发地址 |
| ---- | ------------ |
| 模板应用(`vp dev`) | [http://localhost:5173](http://localhost:5173) |
| 项目 | 默认开发地址 |
| ---------------- | ---------------------------------------------- |
| 模板应用(`vp dev`) | [http://localhost:5173](http://localhost:5173) |
| 本文档站(`pnpm dev`) | [http://localhost:3000](http://localhost:3000) |
## 常用命令
在 **`apps/docs`** 目录下:
在 `**apps/docs**` 目录下:
```bash
pnpm install # 首次或依赖变更后
@@ -29,4 +50,4 @@ cd apps/docs && pnpm install && pnpm dev
## 设计说明
实现与信息架构见:`docs/specs/2026-04-15-apps-docs-nextra-plan.md`(及同目录 design spec,若存在)。
初始实现见 `docs/specs/2026-04-15-apps-docs-nextra-plan.md`。
-31
View File
@@ -1,31 +0,0 @@
import { generateStaticParamsFor, importPage } from "nextra/pages";
import { useMDXComponents as getMDXComponents } from "../../mdx-components";
export const generateStaticParams = generateStaticParamsFor("mdxPath");
/** Nextra Docs Theme 在运行时会提供 `wrapper`;类型上可能为 optional。 */
const Wrapper = getMDXComponents().wrapper!;
type PageProps = { params: Promise<{ mdxPath?: string[] }> };
export async function generateMetadata(props: PageProps) {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
return metadata;
}
export default async function Page(props: PageProps) {
const params = await props.params;
const {
default: MDXContent,
toc,
metadata,
sourceCode,
} = await importPage(params.mdxPath);
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}
@@ -0,0 +1,33 @@
import { importPage } from "nextra/pages";
import type { ReactNode } from "react";
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
const Wrapper =
getMDXComponents().wrapper ?? ((props: { children?: ReactNode }) => <>{props.children}</>);
type PageProps = { params: Promise<{ lang: string; mdxPath?: string[] }> };
function normalizePath(pathSegments?: string[]) {
return (pathSegments ?? []).filter((segment): segment is string => Boolean(segment));
}
export async function generateMetadata(props: PageProps) {
const params = await props.params;
const mdxPath = normalizePath(params.mdxPath);
const { metadata } = await importPage(mdxPath, params.lang);
return metadata;
}
export default async function Page(props: PageProps) {
const params = await props.params;
const mdxPath = normalizePath(params.mdxPath);
const pageData = await importPage(mdxPath, params.lang);
const MDXContent = pageData.default ?? (() => null);
const { toc, metadata, sourceCode } = pageData;
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Footer, Layout, Navbar, NotFoundPage } from "nextra-theme-docs";
import { getPageMap } from "nextra/page-map";
import "nextra-theme-docs/style.css";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type { ReactElement, ReactNode } from "react";
const LOCALES = ["zh", "en"] as const;
type Locale = (typeof LOCALES)[number];
function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value);
}
function copyFor(locale: Locale) {
if (locale === "en") {
return {
htmlLang: "en",
title: "Antd Admin Docs",
description:
"Antd Admin template guide: choose between basic and with-lingui, then configure and run.",
};
}
return {
htmlLang: "zh-CN",
title: "Antd Admin 文档",
description: "antd-admin 模板使用说明:basic 与 with-lingui 选型、安装与配置。",
};
}
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
if (!isLocale(lang)) return {};
const copy = copyFor(lang);
return {
title: {
default: copy.title,
template: `%s – ${copy.title}`,
},
description: copy.description,
};
}
export default async function RootLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}): Promise<ReactElement> {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const copy = copyFor(lang);
const localizedPageMap = await getPageMap(`/${lang}`);
const pageMap = (Array.isArray(localizedPageMap) ? localizedPageMap : []).filter(Boolean);
return (
<html lang={copy.htmlLang} dir="ltr" suppressHydrationWarning>
<body>
<Layout
navbar={
<Navbar
logo={<b>{copy.title}</b>}
projectLink="https://github.com/zuiidea/antd-admin"
/>
}
pageMap={pageMap}
docsRepositoryBase="https://github.com/zuiidea/antd-admin/tree/master"
i18n={[
{ locale: "zh", name: "中文" },
{ locale: "en", name: "English" },
]}
footer={
<Footer>
MIT {new Date().getFullYear()} ©{" "}
<a href="https://github.com/zuiidea/antd-admin" rel="noreferrer" target="_blank">
antd-admin
</a>
</Footer>
}
>
{children}
</Layout>
</body>
</html>
);
}
export function NotFound() {
return <NotFoundPage />;
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundPage } from "nextra-theme-docs";
export default function NotFound() {
return <NotFoundPage />;
}
-54
View File
@@ -1,54 +0,0 @@
import { Footer, Layout, Navbar } from "nextra-theme-docs";
import { Head } from "nextra/components";
import { getPageMap } from "nextra/page-map";
import "nextra-theme-docs/style.css";
import type { Metadata } from "next";
import type { ReactElement, ReactNode } from "react";
export const metadata: Metadata = {
title: {
default: "Antd Admin 文档",
template: "%s – Antd Admin 文档",
},
description: "antd-admin 模板使用说明:basic 与 with-lingui 选型、安装与配置。",
};
const navbar = (
<Navbar
logo={<b>Antd Admin 文档</b>}
projectLink="https://github.com/zuiidea/antd-admin"
/>
);
const footer = (
<Footer>
MIT {new Date().getFullYear()} ©{" "}
<a href="https://github.com/zuiidea/antd-admin" rel="noreferrer" target="_blank">
antd-admin
</a>
</Footer>
);
export default async function RootLayout({
children,
}: {
children: ReactNode;
}): Promise<ReactElement> {
const pageMap = await getPageMap();
return (
<html lang="zh-CN" dir="ltr" suppressHydrationWarning>
<Head />
<body>
<Layout
navbar={navbar}
pageMap={pageMap}
docsRepositoryBase="https://github.com/zuiidea/antd-admin/tree/master"
footer={footer}
>
{children}
</Layout>
</body>
</html>
);
}
-17
View File
@@ -1,17 +0,0 @@
---
title: 配置参考
description: 环境变量与构建相关简短说明
---
# 配置参考
## 模板应用(`apps/basic`、`apps/with-lingui`)
- 使用 **Vite+**(`vp`)管理依赖与开发/构建;具体脚本见各目录 `package.json`。
- **MSW** 在开发环境拦截 API;worker 目录由各应用 `package.json` 的 `msw.workerDirectory` 配置。
- 更完整的栈说明、目录树与测试命令见对应应用的 **README**(仓库内 `apps/basic/README.md`、`apps/with-lingui/README.md`)。
## 文档站(`apps/docs`)
- **Next.js + Nextra**,无首期 `basePath`;生产构建:`pnpm run build`。
- 本地开发端口 **3000**,见 [apps/docs README](https://github.com/zuiidea/antd-admin/blob/master/apps/docs/README.md) 与设计说明 `docs/specs/2026-04-15-apps-docs-nextra-design.md`(若存在于仓库中)。
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('nextra').MetaRecord} */
export default {
index: "Overview",
welcome: "Welcome",
"getting-started": "Getting Started",
templates: "Templates",
development: "Development",
"styles-and-assets": "Styles & Assets",
building: "Building",
testing: "Testing",
backend: "Backend Integration",
deployment: "Deployment",
};
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('nextra').MetaRecord} */
export default {
"proxying-api-requests": "Proxying API Requests",
};
@@ -0,0 +1,36 @@
---
title: Proxying API Requests
description: MSW, API base URL, and wiring a real backend
---
# Proxying API Requests
## MSW (default in development)
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.
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`.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('nextra').MetaRecord} */
export default {
"environment-variables": "Environment Variables",
performance: "Performance",
"production-build": "Production Build",
router: "Router",
};
@@ -0,0 +1,12 @@
---
title: Environment Variables
description: Manage environment configuration safely across environments
---
# Environment Variables
Use environment variables to separate configuration from code.
- Keep secrets out of source files.
- Use environment-specific values for dev/staging/prod.
- Document required variables for contributors.
@@ -0,0 +1,13 @@
---
title: Performance
description: Practical optimization checklist for admin applications
---
# Performance
Focus on practical wins:
- Reduce unnecessary rerenders
- Split heavy modules
- Cache requests where appropriate
- Measure before and after each optimization
@@ -0,0 +1,19 @@
---
title: Production Build
description: Build and validate for production deployment
---
# Production Build
Before deployment:
1. Run production build.
2. Verify lint and tests.
3. Validate key user flows in preview/staging.
Example:
```bash
pnpm --filter ./apps/basic build
pnpm --filter ./apps/with-lingui build
```
+14
View File
@@ -0,0 +1,14 @@
---
title: Router
description: Routing conventions and recommendations
---
# Router
Use clear route grouping and layout boundaries.
Recommendations:
- Keep feature routes close to feature modules
- Avoid deeply coupled route dependencies
- Handle auth/permission checks in a predictable layer
+15
View File
@@ -0,0 +1,15 @@
---
title: Deployment
description: Deployment guidance and rollout checklist
---
# Deployment
Before production rollout:
- Validate environment variables
- Run full build and tests
- Verify critical user journeys
- Set up monitoring and error tracking
Deploy progressively when possible and prepare a rollback plan.
@@ -0,0 +1,5 @@
/** @type {import('nextra').MetaRecord} */
export default {
"bundle-analysis": "Bundle Analysis",
"https-in-development": "HTTPS in Development",
};
@@ -0,0 +1,16 @@
---
title: Bundle Analysis
description: Analyze bundle size and optimize package usage
---
# Bundle Analysis
Use your build tooling and analyzer plugins to inspect bundle composition.
Focus on:
- Heavy dependencies
- Duplicate packages
- Over-imported UI modules
After optimization, rerun build and compare the output artifacts.
@@ -0,0 +1,10 @@
---
title: HTTPS in Development
description: Enable HTTPS for local development when needed
---
# HTTPS in Development
If your scenario requires secure context (for example some browser APIs), enable HTTPS in local dev by configuring your dev server certificates.
Keep certificates out of version control and share setup instructions with your team.
@@ -0,0 +1,9 @@
/** @type {import('nextra').MetaRecord} */
export default {
index: "Getting Started",
"available-scripts": "Available Scripts",
"folder-structure": "Folder Structure",
"supported-browsers": "Supported Browsers",
"supported-features": "Supported Features",
"updating-releases": "Updating Releases",
};
@@ -0,0 +1,44 @@
---
title: Available Scripts
description: Common pnpm scripts and usage in this monorepo
---
# Available Scripts
## Workspace-Level
```bash
pnpm install
pnpm -r build
pnpm -r lint
pnpm -r test
```
## docs (`apps/docs`)
```bash
pnpm --filter ./apps/docs dev
pnpm --filter ./apps/docs build
pnpm --filter ./apps/docs start
```
## basic (`apps/basic`)
```bash
pnpm --filter ./apps/basic dev
pnpm --filter ./apps/basic build
pnpm --filter ./apps/basic preview
```
## with-lingui (`apps/with-lingui`)
```bash
pnpm --filter ./apps/with-lingui dev
pnpm --filter ./apps/with-lingui build
pnpm --filter ./apps/with-lingui preview
```
## Notes
- Use `--filter` to scope commands to a single app.
- Run commands at the repository root for consistent workspace behavior.
@@ -0,0 +1,50 @@
---
title: Folder Structure
description: Key directories and responsibilities in the repository
---
# Folder Structure
```text
.
├─ apps/
│ ├─ basic/
│ ├─ with-lingui/
│ └─ docs/
├─ packages/
├─ pnpm-workspace.yaml
└─ turbo.json
```
## Directory Guide
- `apps/basic`: Main admin template (English)
- `apps/with-lingui`: Admin template with Lingui i18n
- `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.
@@ -0,0 +1,43 @@
---
title: Getting Started
description: Install dependencies, start development, and build applications
---
# Getting Started
## Requirements
- Node.js 20+
- pnpm 9+
## Install Dependencies
```bash
pnpm install
```
## Run Projects
```bash
# Run docs
pnpm --filter ./apps/docs dev
# Run basic template
pnpm --filter ./apps/basic dev
# Run Lingui template
pnpm --filter ./apps/with-lingui dev
```
## Build
```bash
pnpm --filter ./apps/basic build
pnpm --filter ./apps/with-lingui build
```
## Next Steps
- Scripts: [Available Scripts](/en/getting-started/available-scripts)
- Structure: [Folder Structure](/en/getting-started/folder-structure)
- Browser support: [Supported Browsers](/en/getting-started/supported-browsers)
@@ -0,0 +1,15 @@
---
title: Supported Browsers
description: Browser compatibility policy for this project
---
# Supported Browsers
This project targets modern evergreen browsers:
- Latest Chrome
- Latest Edge
- Latest Firefox
- Latest Safari
For old browser support requirements, evaluate and add polyfills case-by-case based on your user base.
@@ -0,0 +1,17 @@
---
title: Supported Features
description: Core features available in the templates
---
# Supported Features
The templates include:
- User login and permission-based routing
- Dashboard and reusable business pages
- 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
Feature details may vary slightly between `basic` and `with-lingui`.
@@ -0,0 +1,15 @@
---
title: Updating Releases
description: Recommendations for keeping your project up to date
---
# Updating Releases
To keep your project updated:
1. Pull the latest code from upstream.
2. Run `pnpm install` to refresh lockfile-resolved dependencies.
3. Verify with `build`, `lint`, and `test`.
4. Resolve breaking changes before releasing.
For production apps, perform updates on a branch and validate in CI before merging.
+16
View File
@@ -0,0 +1,16 @@
---
title: Overview
description: antd-admin monorepo and docs guide
---
# Overview
This repository provides two runnable admin templates: **[`apps/basic`](https://github.com/zuiidea/antd-admin/tree/master/apps/basic)** (English UI) and **[`apps/with-lingui`](https://github.com/zuiidea/antd-admin/tree/master/apps/with-lingui)** (Lingui-based bilingual UI).
The stack centers on **React 19, Ant Design 6, TanStack Router & Query, Vite+ (`vp`), MSW, and Playwright**.
The sidebar is organized as: Welcome → Getting Started → Templates → Development → Styles & Assets → Building → Testing → Backend Integration → Deployment.
Start from **[Getting Started](/en/getting-started)**, then choose a template in **[Templates](/en/templates)**.
GitHub: [zuiidea/antd-admin](https://github.com/zuiidea/antd-admin)
@@ -0,0 +1,8 @@
/** @type {import('nextra').MetaRecord} */
export default {
"code-splitting": "Code Splitting",
"css-modules": "CSS Modules",
"images-fonts-files": "Images, Fonts, and Files",
"public-folder": "Public Folder",
stylesheet: "Stylesheet",
};

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