Compare commits

...
Author SHA1 Message Date
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
zuiidea 2d46d7bf27 chore(create): rename npm package to init-antd-admin
Replace antd-admin-cli (name taken on npm). Bin init-antd-admin;
release tags init-antd-admin-v*; workflow publish-init-antd-admin.yml.

Made-with: Cursor
2026-04-15 14:11:49 +08:00
zuiidea 0ecfefcd97 chore(create): rename npm package to antd-admin-cli
Unscoped name avoids npm similarity block with create-antd-admin.
Bin command antd-admin-cli; release tags antd-admin-cli-v*; workflow
publish-antd-admin-cli.yml.

Made-with: Cursor
2026-04-15 12:05:19 +08:00
zuiidea 57cd84b5ce chore(create): rename npm package to create-antdadmin
Use bin/create-antdadmin, npm create antdadmin, and tag prefix
create-antdadmin-v*. Workflow file publish-create-antdadmin.yml.
Env vars CREATE_ANTD_ADMIN_* unchanged.

Made-with: Cursor
2026-04-15 11:58:45 +08:00
zuiidea ca88d6567d fix(create): allow local npm publish without provenance
Remove publishConfig.provenance; CI passes --provenance on npm publish so
GitHub Actions still gets attestations with id-token: write.

Made-with: Cursor
2026-04-15 11:42:49 +08:00
223 changed files with 19028 additions and 13582 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
@@ -0,0 +1,15 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
+15
View File
@@ -0,0 +1,15 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
+26
View File
@@ -0,0 +1,26 @@
# Git hooks(仓库根)
本目录为 **monorepo 根级** Git 钩子。提交时根据暂存文件路径,在对应子项目目录执行检查(例如 `apps/with-lingui` 下执行 `vp staged`,以便正确读取该目录的 `vite.config.ts`)。
模板应用目录内的 `.vite-hooks/`(如 `apps/with-lingui/.vite-hooks/`)留给 **模板单独拷贝出去使用**;在本仓库开发时请使用根级钩子,避免在仓库根执行 `vp staged` 找不到 `vite.config.ts`。
## 启用方式(一次性)
在仓库根目录执行:
```bash
git config core.hooksPath .githooks
```
或使用:
```bash
pnpm run setup:git-hooks
```
验证:
```bash
git config core.hooksPath
# 应输出:.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 vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic 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"
@@ -1,4 +1,4 @@
# Publish npm package `create-antd-admin` from packages/create
# Publish npm package `init-antd-admin` from packages/create
#
# Setup (one-time):
# 1. npmjs.com → Access Tokens → Granular or Automation token with publish rights
@@ -6,24 +6,24 @@
# Name: NPM_TOKEN Value: <token>
#
# Release:
# A) Manual: Actions → "Publish create-antd-admin" → Run workflow
# B) Tag: git tag create-antd-admin-v1.0.3 && git push origin create-antd-admin-v1.0.3
# A) Manual: Actions → "Publish init-antd-admin" → Run workflow
# B) Tag: git tag init-antd-admin-v1.0.3 && git push origin init-antd-admin-v1.0.3
# (tag suffix must match packages/create/package.json "version")
name: Publish create-antd-admin
name: Publish init-antd-admin
on:
workflow_dispatch:
push:
tags:
- "create-antd-admin-v*"
- "init-antd-admin-v*"
permissions:
contents: read
id-token: write
concurrency:
group: publish-create-antd-admin
group: publish-init-antd-admin
cancel-in-progress: true
jobs:
@@ -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
@@ -53,17 +53,17 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test create-antd-admin
- name: Test init-antd-admin
run: pnpm test
working-directory: packages/create
- name: Build create-antd-admin
- name: Build init-antd-admin
run: pnpm run build
working-directory: packages/create
- name: Publish to npm
# provenance: see packages/create/package.json publishConfig (requires id-token: write)
run: npm publish --access public
# Provenance only in CI (OIDC). publishConfig must not force provenance or local npm publish fails.
run: npm publish --access public --provenance
working-directory: packages/create
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+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:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
+51 -189
View File
@@ -1,216 +1,78 @@
# 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`: English-only setup
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
| 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)
- 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
`pnpm dlx create-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>` |
| `--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)`.
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
+3 -2
View File
@@ -1,7 +1,8 @@
---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
## applyTo: "src/api/**/\*.ts,src/utils/http.ts,src/mocks/handlers/**/\*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions
+3 -2
View File
@@ -1,7 +1,8 @@
---
applyTo: "src/**/*.{ts,tsx,css}"
## applyTo: "src/\*_/_.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "src/\*_/_.{ts,tsx}"
## applyTo: "src/_/_.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
## applyTo: "tests/**/.ts,tests/**/_.tsx,src/mocks//_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
-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
+68 -54
View File
@@ -56,12 +56,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,75 +68,92 @@ 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:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
```text
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/ # Zod models + endpoint contracts
├── 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/ # HTTP client, constants, session/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
+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);
+19 -29
View File
@@ -11,42 +11,32 @@
"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",
"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
+124 -99
View File
@@ -7,111 +7,114 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.12.12";
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener("install", function () {
self.skipWaiting();
});
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener("message", async function (event) {
const clientId = Reflect.get(event.source || {}, "id");
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return;
return
}
const client = await self.clients.get(clientId);
const client = await self.clients.get(clientId)
if (!client) {
return;
return
}
const allClients = await self.clients.matchAll({
type: "window",
});
type: 'window',
})
switch (event.data) {
case "KEEPALIVE_REQUEST": {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: "KEEPALIVE_RESPONSE",
});
break;
type: 'KEEPALIVE_RESPONSE',
})
break
}
case "INTEGRITY_CHECK_REQUEST": {
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: "INTEGRITY_CHECK_RESPONSE",
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
})
break
}
case "MOCK_ACTIVATE": {
activeClientIds.add(clientId);
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: "MOCKING_ENABLED",
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
})
break
}
case "CLIENT_CLOSED": {
activeClientIds.delete(clientId);
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
self.registration.unregister()
}
break;
break
}
}
});
})
addEventListener("fetch", function (event) {
const requestInterceptedAt = Date.now();
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === "navigate") {
return;
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
return;
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
return
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
@@ -119,23 +122,38 @@ addEventListener("fetch", function (event) {
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
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,
{
type: "RESPONSE",
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
@@ -143,19 +161,21 @@ 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]
: [],
)
}
return response;
return response
}
/**
@@ -167,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client;
return client
}
if (client?.frameType === "top-level") {
return client;
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: "window",
});
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === "visible";
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
return activeClientIds.has(client.id)
})
}
/**
@@ -203,34 +223,36 @@ async function resolveMainClient(event) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get("accept");
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(",").map((value) => value.trim());
const filteredValues = values.filter((value) => value !== "msw/passthrough");
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set("accept", filteredValues.join(", "));
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete("accept");
headers.delete('accept')
}
}
return fetch(requestClone, { headers });
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
@@ -238,15 +260,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: "REQUEST",
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
@@ -254,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
},
},
[serializedRequest.body],
);
)
switch (clientMessage.type) {
case "MOCK_RESPONSE": {
return respondWithMock(clientMessage.data);
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case "PASSTHROUGH": {
return passthrough();
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough();
return passthrough()
}
/**
@@ -277,18 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
return reject(event.data.error)
}
resolve(event.data);
};
resolve(event.data)
}
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
@@ -301,17 +326,17 @@ function respondWithMock(response) {
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
return Response.error()
}
const mockedResponse = new Response(response.body, response);
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
})
return mockedResponse;
return mockedResponse
}
/**
@@ -332,5 +357,5 @@ async function serializeRequest(request) {
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
}
+10 -2
View File
@@ -1,11 +1,19 @@
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
import type {
AuthTokens,
LoginRequest,
User,
MenuItem,
PermissionsList,
RegisterRequest,
} from "./schemas";
export const AUTH_ENDPOINTS = {
login: "/api/auth/login",
register: "/api/auth/register",
refresh: "/api/auth/refresh",
logout: "/api/auth/logout",
user: "/api/auth/user",
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList };
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest };
+24
View File
@@ -22,6 +22,12 @@ export const AuthTokensSchema = z.object({
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
@@ -29,6 +35,24 @@ export const LoginRequestSchema = z.object({
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>;
@@ -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 (
+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 };
}
+4 -1
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 { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installHttpRouter } from "./utils/http";
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
@@ -22,7 +25,7 @@ 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 {
@@ -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,
+136 -14
View File
@@ -1,16 +1,58 @@
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";
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 }) => {
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 +60,100 @@ export const authHandlers = [
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/refresh", async () => {
http.post("/api/auth/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("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
return successResponse({
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
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("/api/auth/logout", () => successWithNullBody()),
http.get("/api/auth/user", () => {
http.get("/api/auth/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("/api/auth/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);
}),
];
+22 -8
View File
@@ -1,13 +1,27 @@
import { http } from "msw";
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
@@ -16,7 +30,7 @@ export const userHandlers = [
const filtered = filterUsers(users, { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post("/api/users", async ({ request }) => {
@@ -28,10 +42,10 @@ export const userHandlers = [
avatar: null,
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [],
permissions: [] as string[],
};
users.push(newUser);
return successResponse(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
@@ -41,13 +55,13 @@ export const userHandlers = [
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body };
return successResponse(users[idx]);
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successResponse(null);
return successWithNullBody();
}),
];
+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 { fetchSessionAndApplyToStore } from "@/utils/session";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState();
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) {
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" });
}
},
@@ -20,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 (
+48 -179
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
@@ -10,6 +10,9 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
@@ -22,6 +25,8 @@ const UserSearchParamsSchema = z.object({
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
@@ -29,10 +34,6 @@ export const Route = createFileRoute("/_auth/users/")({
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
@@ -43,27 +44,23 @@ function UsersPage() {
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const tableAvailableRef = useRef(0);
/**
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
*/
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
@@ -79,7 +76,6 @@ function UsersPage() {
search.sortField,
search.sortOrder,
],
invalidateKey: ["users"],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
@@ -96,66 +92,29 @@ function UsersPage() {
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: () => {
message.success({
content: "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 +227,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 +284,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 +309,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;
}
+10 -7
View File
@@ -1,4 +1,4 @@
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";
@@ -140,12 +140,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 +187,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
+215
View File
@@ -0,0 +1,215 @@
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 { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RegisterRequestSchema } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import 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 parsed = RegisterRequestSchema.parse(values);
const tokens = await httpClient.post(AUTH_ENDPOINTS.register, parsed);
const validTokens = AuthTokensSchema.parse(tokens);
setTokens(validTokens);
await fetchSessionAndApplyToStore();
},
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> = {
+97 -14
View File
@@ -1,4 +1,7 @@
import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error {
code: number;
@@ -22,6 +25,70 @@ type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
@@ -33,23 +100,25 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
}
function getAuthHeaders(): Record<string, string> {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return {};
const parsed = JSON.parse(raw);
const token = parsed?.state?.tokens?.accessToken;
if (token) return { Authorization: `Bearer ${token}` };
} catch {
// noop
}
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {};
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
@@ -61,21 +130,35 @@ async function request<T>(
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
throw new HttpError(401, "Unauthorized");
}
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json = await res.json();
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (json.code !== undefined && json.code !== 0) {
throw new ApiError(json.code, json.message ?? "Unknown error");
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
return json.data !== undefined ? json.data : json;
return (envelope.data !== undefined ? envelope.data : json) as T;
}
export const httpClient = {
+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,14 @@
---
title: Proxying API Requests
description: Configure API proxying between frontend and backend
---
# Proxying API Requests
Use a proxy layer in development to avoid CORS issues and keep API endpoints configurable.
Recommended approach:
- Define a consistent API prefix.
- Route requests by environment.
- Keep backend base URLs in environment variables.
+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,28 @@
---
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
## 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
- Request abstraction and API proxy support
- 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",
};
@@ -0,0 +1,10 @@
---
title: Code Splitting
description: Reduce initial payload with route-level and module-level splitting
---
# Code Splitting
Apply code splitting for large routes and rarely used modules to improve first-load performance.
Use dynamic imports where appropriate and avoid loading heavy modules on initial render.
@@ -0,0 +1,10 @@
---
title: CSS Modules
description: Scoped styling strategy in the project
---
# CSS Modules
Use CSS Modules for component-scoped styles to reduce global style conflicts.
Adopt clear naming and keep styles colocated with the related component.
@@ -0,0 +1,10 @@
---
title: Images, Fonts, and Files
description: Best practices for static asset management
---
# Images, Fonts, and Files
Place static assets in predictable directories and reference them consistently.
For large files, consider CDN delivery and caching strategy to improve load performance.
@@ -0,0 +1,10 @@
---
title: Public Folder
description: How to use public assets in apps
---
# Public Folder
Use the public folder for static files that should be served directly.
Keep naming stable and avoid storing sensitive content in public assets.
@@ -0,0 +1,13 @@
---
title: Stylesheet
description: Organize global and component styles clearly
---
# Stylesheet
Keep a clear separation between:
- Global style tokens and resets
- Component-local styles
This helps maintain consistency and avoids accidental style leakage.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('nextra').MetaRecord} */
export default {
index: "Templates",
basic: "basic",
"with-lingui": "with-lingui",
};
+26
View File
@@ -0,0 +1,26 @@
---
title: basic
description: Lightweight admin template for single-language projects
---
# basic
`apps/basic` is a streamlined admin template focused on fast startup and simple extension.
## Good Fit
- Internal tools
- Small-to-medium dashboard projects
- Teams not requiring i18n yet
## Highlights
- Clean route organization
- Common data table and form patterns
- Practical defaults for request handling and page layout
## Start Development
```bash
pnpm --filter ./apps/basic dev
```
+16
View File
@@ -0,0 +1,16 @@
---
title: Templates
description: Choose between basic and with-lingui templates
---
# Templates
This repository provides two templates:
- **[basic](/en/templates/basic)**: Minimal setup, English UI by default.
- **[with-lingui](/en/templates/with-lingui)**: Includes Lingui i18n workflow and bilingual setup.
## How to Choose
- Choose **basic** if you only need one language.
- Choose **with-lingui** if you need structured multilingual support from day one.
@@ -0,0 +1,26 @@
---
title: with-lingui
description: Admin template with Lingui internationalization
---
# with-lingui
`apps/with-lingui` adds multilingual capabilities based on Lingui while keeping the same admin architecture.
## Good Fit
- Products serving multiple regions/languages
- Teams that want standardized translation workflow
- Projects requiring language switch in UI
## Highlights
- Lingui message extraction and compilation flow
- Language resource organization
- Built-in language switching patterns
## Start Development
```bash
pnpm --filter ./apps/with-lingui dev
```
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('nextra').MetaRecord} */
export default {
"running-tests": "Running Tests",
"debugging-tests": "Debugging Tests",
};
@@ -0,0 +1,15 @@
---
title: Debugging Tests
description: Diagnose flaky or failing tests effectively
---
# Debugging Tests
When tests fail:
1. Reproduce locally with focused scope.
2. Inspect stack traces and assertions.
3. Isolate flaky dependencies (time, network, global state).
4. Stabilize with proper setup/teardown.
Prefer fixing root causes over adding retries.
@@ -0,0 +1,18 @@
---
title: Running Tests
description: Run unit and e2e tests in this repository
---
# Running Tests
Run tests from repository root or per app.
Common commands:
```bash
pnpm -r test
pnpm --filter ./apps/basic test
pnpm --filter ./apps/with-lingui test
```
Set up CI to run tests on pull requests before merge.
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('nextra').MetaRecord} */
export default {
"documentation-intro": "Documentation Overview",
};
@@ -0,0 +1,24 @@
---
title: Documentation Overview
description: How to use this documentation and choose a project template
---
# Documentation Overview
The docs in this project are built with **Nextra + Next.js App Router**.
If this is your first time here:
1. Read **[Getting Started](/en/getting-started)** for setup and scripts.
2. Compare templates in **[Templates](/en/templates)**.
3. Go to **[Backend Integration](/en/backend/proxying-api-requests)** if you need API setup.
## Repo Structure
- `apps/basic`: English-only admin template
- `apps/with-lingui`: Internationalized template with Lingui
- `apps/docs`: Documentation site
## Recommended Reading Order
Welcome → Getting Started → Templates → Development → Building → Testing → Deployment

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