mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-20 18:53:32 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bb7713b3 | ||
|
|
bb141469e1 | ||
|
|
c02f6dc2f2 | ||
|
|
ee87a66a84 | ||
|
|
a72fde0739 | ||
|
|
a2a84e2c04 | ||
|
|
44155206c0 | ||
|
|
2614328537 | ||
|
|
8ab7ade30b | ||
|
|
60ef2fe7f8 | ||
|
|
3d17e6d636 | ||
|
|
f28bae8f3e | ||
|
|
c18d288a4e | ||
|
|
3d4551c9a4 | ||
|
|
8673d8398e | ||
|
|
0726321552 | ||
|
|
f12539812e | ||
|
|
1cb0819011 | ||
|
|
6ac8a62770 | ||
|
|
335c77cc8c | ||
|
|
c8062e1299 | ||
|
|
b277f522bd | ||
|
|
39a6339c59 | ||
|
|
4406e57556 | ||
|
|
c5221c71be |
@@ -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,30 @@
|
||||
# Git hooks (repository root)
|
||||
|
||||
This directory holds **monorepo root-level** Git hooks. On commit, checks run in the matching subproject based on staged file paths (for example, `vp staged` under `apps/with-lingui` so that directory’s `vite.config.ts` is loaded correctly).
|
||||
|
||||
Per-app `.vite-hooks/` under template apps (e.g. `apps/with-lingui/.vite-hooks/`) are for **copying a template out on its own**. When developing in this repo, use the root hooks so `vp staged` at the repo root does not fail to find `vite.config.ts`.
|
||||
|
||||
## Monorepo and `vp config`
|
||||
|
||||
The root `package.json` `preinstall` sets `core.hooksPath=.githooks` before dependencies install and writes `vp.hooks.disabled=true` in **this clone** so parallel `prepare` scripts (`vp config`) from multiple workspaces do not fight over `.git/config` and cause lock conflicts. Template apps still use `"prepare": "vp config"`; when copied out alone they do not get the root `preinstall`.
|
||||
|
||||
## Enabling (one-time)
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
pnpm run setup:git-hooks
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath
|
||||
# Expected: .githooks
|
||||
```
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env sh
|
||||
# Monorepo pre-commit: run Vite+ / checks in the app that owns staged files.
|
||||
# Template copies under apps/*/ keep their own .vite-hooks for standalone use; this repo uses root hooksPath instead.
|
||||
set -e
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
|
||||
FAILED=0
|
||||
|
||||
run_in_dir() {
|
||||
dir="$1"
|
||||
shift
|
||||
(cd "$ROOT/$dir" && "$@") || FAILED=1
|
||||
}
|
||||
|
||||
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
|
||||
run_in_dir apps/with-lingui pnpm exec vp staged
|
||||
fi
|
||||
|
||||
if echo "$STAGED" | grep -q '^apps/basic/'; then
|
||||
run_in_dir apps/basic pnpm exec vp staged
|
||||
fi
|
||||
|
||||
if echo "$STAGED" | grep -q '^packages/create/'; then
|
||||
run_in_dir . pnpm --dir packages/create run check-types
|
||||
fi
|
||||
|
||||
if echo "$STAGED" | grep -q '^apps/docs/'; then
|
||||
run_in_dir apps/docs pnpm run lint
|
||||
fi
|
||||
|
||||
exit "$FAILED"
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.6
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -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
@@ -41,6 +41,10 @@ yarn-error.log*
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
.cursor
|
||||
|
||||
# Cursor: share project rules only; ignore other local Cursor data (agent rules: each app’s AGENTS.md)
|
||||
.cursor/*
|
||||
!.cursor/rules/
|
||||
!.cursor/rules/**
|
||||
|
||||
./docs/superpowers
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# antd-admin (monorepo)
|
||||
|
||||
Enterprise admin templates and docs. **AI and coding rules live in each app’s `AGENTS.md`**, not here.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `apps/basic` | English Vite+ template — see `apps/basic/AGENTS.md` |
|
||||
| `apps/with-lingui` | Lingui i18n template — see `apps/with-lingui/AGENTS.md` |
|
||||
| `apps/docs` | Nextra docs site |
|
||||
| `packages/create` | `init-antd-admin` CLI |
|
||||
|
||||
## Commands (repo root)
|
||||
|
||||
- `pnpm install` — sets `core.hooksPath=.githooks` via `preinstall`
|
||||
- `pnpm dev` / `pnpm build` / `pnpm fmt` / `pnpm lint` — turbo across workspaces
|
||||
|
||||
**Template app work:** `cd apps/basic` or `cd apps/with-lingui`, then use **`vp`** (`vp install`, `vp check`, …). Do not run app `vp staged` from root without that `cd`.
|
||||
|
||||
## Git hooks
|
||||
|
||||
`.githooks/pre-commit` runs checks by staged paths: `pnpm exec vp staged` in `apps/basic` / `apps/with-lingui`, typecheck for `packages/create`, lint for `apps/docs`.
|
||||
@@ -1,216 +1,83 @@
|
||||
# Antd Admin
|
||||
|
||||
Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. Ship either an **English-only** app (`apps/basic`) or a **Lingui i18n** variant (`apps/with-lingui`); see [Templates](#templates) below.
|
||||
## Introduction
|
||||
|
||||
[antd](https://github.com/ant-design/ant-design)
|
||||
[react](https://react.dev)
|
||||
[vite](https://viteplus.dev)
|
||||
[TypeScript](https://www.typescriptlang.org/)
|
||||
[GitHub issues](https://github.com/zuiidea/antd-admin/issues)
|
||||
[GitHub stars](https://github.com/zuiidea/antd-admin/stargazers)
|
||||
[License](http://opensource.org/licenses/MIT)
|
||||
[PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
|
||||
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
|
||||
|
||||
## Tech Stack
|
||||
Available templates:
|
||||
|
||||
- `basic`: Admin template with plain-string UI copy (default English; no bundled Lingui)
|
||||
- `with-lingui`: Lingui i18n workflow (`en` + `zh`) with locale switching
|
||||
|
||||
| Category | Technology |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
|
||||
| UI Framework | [Ant Design 6.x](https://ant.design) |
|
||||
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
|
||||
| Async State | [TanStack Query v5](https://tanstack.com/query) |
|
||||
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
|
||||
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
|
||||
| i18n | [LinguiJS](https://lingui.dev) in `**apps/with-lingui` only** (`apps/basic` is English-only, no Lingui) |
|
||||
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
|
||||
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
|
||||
| E2E Testing | [Playwright](https://playwright.dev) |
|
||||
| Language | TypeScript 5.9 (strict mode) |
|
||||
Scaffolding CLI:
|
||||
|
||||
- `init-antd-admin` (from `packages/create`)
|
||||
|
||||
## Features
|
||||
## Key Features
|
||||
|
||||
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
|
||||
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
|
||||
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
|
||||
- **i18n (with-lingui app)** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales follow the active locale in `apps/with-lingui`
|
||||
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
|
||||
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
|
||||
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
|
||||
- JWT login with access/refresh flow
|
||||
- Backend-driven menu and permission guards
|
||||
- URL-synced table state (pagination, sorting, search)
|
||||
- Dark mode theme switching
|
||||
- Mock-first local development (no backend required)
|
||||
- Modular API layer (`defineApi` per domain, Zod schemas, shared `API_PREFIX`)
|
||||
- Typed API boundaries and reusable CRUD patterns
|
||||
- React 19 + Ant Design 6 + TanStack Router/Query + Zustand + Zod
|
||||
- Optional Lingui-based i18n workflow (`with-lingui`)
|
||||
- E2E coverage with Playwright
|
||||
|
||||
## Templates
|
||||
## Quick Start
|
||||
|
||||
`npx init-antd-admin@latest` · `pnpm dlx init-antd-admin@latest`
|
||||
Use `init-antd-admin` to scaffold a standalone app from official templates.
|
||||
|
||||
|
||||
| Directory | Use when |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `[apps/with-lingui](./apps/with-lingui/)` | You want **LinguiJS** (en + zh), `.po` extract/compile, and a language switcher — this is the fuller reference stack. |
|
||||
| `[apps/basic](./apps/basic/)` | You want the **same product surface** with **English-only** UI (no Lingui, fixed `en_US` for Ant Design). |
|
||||
|
||||
|
||||
Pick one app directory, install, and run Vite+ from there:
|
||||
### Interactive
|
||||
|
||||
```bash
|
||||
cd apps/basic && vp install && vp dev
|
||||
# or
|
||||
cd apps/with-lingui && vp install && vp dev
|
||||
npx init-antd-admin@latest
|
||||
pnpm dlx init-antd-admin@latest
|
||||
yarn dlx init-antd-admin@latest
|
||||
bunx init-antd-admin@latest
|
||||
```
|
||||
|
||||
### 文档站(Nextra)
|
||||
|
||||
中文使用文档位于 `**apps/docs**`(Next.js + Nextra)。在仓库根或该目录安装依赖后:
|
||||
### Non-interactive examples
|
||||
|
||||
```bash
|
||||
cd apps/docs && pnpm install && pnpm dev
|
||||
pnpm dlx init-antd-admin@latest my-app --example basic -m pnpm
|
||||
pnpm dlx init-antd-admin@latest my-app --example with-lingui --skip-install
|
||||
```
|
||||
|
||||
浏览器打开 [http://localhost:3000](http://localhost:3000)。模板 Vite+ 应用仍为 [http://localhost:5173](http://localhost:5173)。开发与构建命令说明见 `[apps/docs/README.md](./apps/docs/README.md)`。
|
||||
|
||||
Core Playwright flows (from the app you are working in):
|
||||
|
||||
```bash
|
||||
cd apps/basic && pnpm run test:e2e:core
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org) >= 20
|
||||
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
|
||||
|
||||
### Install & Run
|
||||
|
||||
From the repository root, choose an app under `apps/` (see [Templates](#templates)), then:
|
||||
|
||||
```bash
|
||||
cd apps/with-lingui # or: cd apps/basic
|
||||
vp install
|
||||
vp dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
|
||||
|
||||
**Default credentials:** `admin` / `admin`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
vp build
|
||||
vp preview
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
Run from the app package (example: `apps/basic`):
|
||||
|
||||
```bash
|
||||
cd apps/basic
|
||||
pnpm run test:e2e
|
||||
pnpm run test:e2e:core
|
||||
pnpm run test:e2e:ui # interactive UI mode
|
||||
```
|
||||
|
||||
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Zod schemas, endpoint constants, type exports
|
||||
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||
│ ├── auth.ts # Auth endpoint constants
|
||||
│ └── user.ts # User CRUD endpoint constants
|
||||
├── components/
|
||||
│ ├── Aurora/ # Login background effect
|
||||
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||
│ ├── DataTable/ # Shared table shell, skeleton, empty state
|
||||
│ ├── FilterToolbar/ # Shared filter/action toolbar
|
||||
│ ├── FormModal/ # Reusable modal + form shell
|
||||
│ ├── Icon/ # Shared icons and theme toggle icon
|
||||
│ └── Layout/ # Admin shell (sidebar, header, main)
|
||||
│ ├── MainLayout/ # Main layout shell: index.tsx
|
||||
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
|
||||
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
|
||||
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
|
||||
│ └── Header/ # Top bar: index.tsx
|
||||
├── hooks/
|
||||
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
|
||||
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
|
||||
│ ├── usePermission.ts # Permission check hook
|
||||
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
|
||||
├── utils/
|
||||
│ ├── constants.ts # API base URL, static assets
|
||||
│ └── http.ts # HTTP client with JWT injection & error handling
|
||||
├── locales/ # LinguiJS catalogs (.po + compiled .js)
|
||||
│ ├── en/messages.po
|
||||
│ └── zh/messages.po
|
||||
├── mocks/
|
||||
│ ├── browser.ts # MSW worker setup
|
||||
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||
│ ├── data.ts # Mock seed data (users, menus)
|
||||
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
|
||||
│ └── handlers/ # Request handlers (auth, user CRUD)
|
||||
├── routes/ # TanStack Router file-based routes
|
||||
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
|
||||
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||
│ ├── _auth/dashboard/index.tsx
|
||||
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||
│ ├── _auth/403/index.tsx
|
||||
│ ├── login/index.tsx
|
||||
│ ├── 404/index.tsx
|
||||
│ └── index.tsx # Redirects / → /login
|
||||
├── stores/
|
||||
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
|
||||
│ ├── createPersistentStore.ts # Shared persisted-store factory
|
||||
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
|
||||
└── main.tsx # Entry point (MSW init → React render)
|
||||
|
||||
tests/
|
||||
└── e2e/ # Playwright E2E tests
|
||||
├── helpers.ts
|
||||
├── login.spec.ts
|
||||
└── users.spec.ts
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
|
||||
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
vp run i18n:extract
|
||||
vp run i18n:compile
|
||||
```
|
||||
|
||||
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
|
||||
|
||||
## Extending the template
|
||||
|
||||
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
||||
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
|
||||
|
||||
## Developer Notes
|
||||
|
||||
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
|
||||
- Run `vp check --no-fmt` for type/lint validation.
|
||||
- Core regression coverage lives in the login and users E2E flows.
|
||||
|
||||
## Pages
|
||||
### Common options
|
||||
|
||||
|
||||
| Route | Description |
|
||||
| ------------ | ----------------------------------------------------------- |
|
||||
| `/login` | Login form with validation |
|
||||
| `/dashboard` | Statistics overview cards |
|
||||
| `/users` | User CRUD table with search, pagination, create/edit/delete |
|
||||
| `/403` | Forbidden error page |
|
||||
| `/404` | Not found error page |
|
||||
| Option | Description |
|
||||
| ---------------------------- | ------------------------------------------ |
|
||||
| `[project-directory]` | Target folder |
|
||||
| `-e, --example <name\|url>` | Template name (`basic`, `with-lingui`) or URL |
|
||||
| `--example-path <path>` | Subpath inside remote repository |
|
||||
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
|
||||
| `--skip-install` | Skip dependency install |
|
||||
| `--skip-transforms` | Skip rewrite transforms |
|
||||
| `--no-git` | Skip git initialization |
|
||||
|
||||
|
||||
For detailed CLI behavior, see `[packages/create/README.md](./packages/create/README.md)`.
|
||||
|
||||
### AI / agent guidance
|
||||
|
||||
Each template app keeps **`AGENTS.md`** as the single source of truth (Cursor, Claude Code, Codex, etc.). Monorepo overview: [AGENTS.md](./AGENTS.md) at the repo root.
|
||||
|
||||
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
|
||||
|
||||
## Links
|
||||
|
||||
- [Ant Design](https://github.com/ant-design/ant-design)
|
||||
- [React](https://react.dev)
|
||||
- [Vite+](https://viteplus.dev)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Issues](https://github.com/zuiidea/antd-admin/issues)
|
||||
- [Stars](https://github.com/zuiidea/antd-admin/stargazers)
|
||||
- [PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: add-resource
|
||||
description: Add a new CRUD resource in antd-admin-basic (mirror users). Use when scaffolding roles, tenants, or any list+form resource with MSW, routes, menu, and e2e.
|
||||
---
|
||||
|
||||
# Add Resource Recipe
|
||||
|
||||
Add a list CRUD flow that mirrors **Users** (TanStack Router + Query + MSW). **No codegen** — copy and adapt by hand. References: `src/routes/_auth/users/` and matching `e2e/*.spec.ts`. UI copy is **English plain strings** (no i18n).
|
||||
|
||||
## 0. Verification commands (after your changes)
|
||||
|
||||
Run from the **app root** (this project directory — where `package.json` and `vite.config.ts` live):
|
||||
|
||||
```bash
|
||||
pnpm run build # refresh TanStack routeTree when routes change
|
||||
vp check --no-fmt
|
||||
pnpm run test:e2e -- e2e/<slug>.spec.ts
|
||||
```
|
||||
|
||||
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
|
||||
|
||||
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
|
||||
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
|
||||
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
|
||||
- Export `type <Entity> = z.infer<typeof ...>`.
|
||||
|
||||
## 2. Resource API module (`src/api/<slug>/index.ts`)
|
||||
|
||||
- Export path constants if useful for MSW/docs.
|
||||
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
|
||||
- `method` defaults to `GET`; mutations set `POST` / `PUT` / `DELETE`.
|
||||
- Use segmented `params` / `query` / `body` + `output`; do not call axios from routes.
|
||||
|
||||
## 3. Mock data (`src/mocks/data.ts`)
|
||||
|
||||
- Append a `MOCK_<ENTITIES>` array. For **admin** users, `permissions` must include `<slug>:view` (and create/edit/delete if you add writes), aligned with `GET /api/auth/permissions`.
|
||||
|
||||
## 4. MSW (`src/mocks/handlers/<slug>.ts` + `handlers/index.ts`)
|
||||
|
||||
- Follow `handlers/user.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
|
||||
- Register `...<slug>Handlers` in `handlers/index.ts`.
|
||||
|
||||
## 5. Route page (`src/routes/_auth/<slug>/`)
|
||||
|
||||
- `createFileRoute("/_auth/<slug>/")`, wiring `useResourceCRUD` with resource functions (`listX`, `createX`, …) and `queryKeys` from `@/api/<slug>` (recommended: `optimistic: { update: true, delete: true }`), plus `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`).
|
||||
- Use English string literals for labels, placeholders, and messages.
|
||||
|
||||
## 6. Menu and permissions (`src/utils/appMenu.ts`)
|
||||
|
||||
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key (see existing `IconLucide*` entries in `Sidebar`).
|
||||
- Keep behavior consistent with `canAccessPath` / 403.
|
||||
|
||||
## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`)
|
||||
|
||||
- In `MENU_LABELS`, add an English label for the menu item `name` (must match the `name` string from `appMenu`).
|
||||
- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`.
|
||||
|
||||
## 7. E2E (`e2e/<slug>.spec.ts`)
|
||||
|
||||
- Copy structure from `users.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
|
||||
|
||||
## 8. Verification
|
||||
|
||||
- `vp check --no-fmt`
|
||||
- `pnpm run test:e2e` (focused spec) or full suite as needed
|
||||
- `pnpm run build` and watch chunk sizes in the log
|
||||
|
||||
## Notes
|
||||
|
||||
- Before adding or renaming files, confirm nothing already exists at that path so you do not overwrite hand-written code.
|
||||
- After manual schema edits, treat the diff as source of truth; there is no automatic merge.
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# AI Instruction Files
|
||||
|
||||
This directory contains scoped instruction files used by AI coding agents.
|
||||
|
||||
## Files
|
||||
|
||||
- `frontend.instructions.md`: UI/routes/components/style changes
|
||||
- `testing.instructions.md`: Playwright/MSW/test changes
|
||||
- `api.instructions.md`: API client/schema/handler changes
|
||||
- `refactor.instructions.md`: behavior-preserving cleanup and deduplication
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
- Keep each file focused on one domain.
|
||||
- Put trigger phrases in `description` so agents can discover the file.
|
||||
- Use narrow `applyTo` patterns to avoid unnecessary context load.
|
||||
- Keep instructions executable: short Do/Do Not/Validation sections.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
|
||||
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
|
||||
---
|
||||
|
||||
# API Instructions
|
||||
|
||||
## Purpose
|
||||
|
||||
Maintain strict API contracts and consistent server/client mock behavior.
|
||||
|
||||
## Do
|
||||
|
||||
- Keep request/response shapes schema-driven and type-safe.
|
||||
- Reuse endpoint constants and shared response helpers.
|
||||
- Keep handler response envelope consistent (`code`, `data`, `message`).
|
||||
- Ensure mock handlers reflect real API behavior and error semantics.
|
||||
- Keep naming stable for endpoint keys and mutation/query keys.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Do not return ad-hoc response shapes for one-off convenience.
|
||||
- Do not bypass schema parsing in new client mutations/queries.
|
||||
- Do not mix unrelated domain endpoints in the same module.
|
||||
|
||||
## Validation
|
||||
|
||||
- Verify TypeScript and schema parsing paths compile cleanly.
|
||||
- Run: `vp check --no-fmt`
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
applyTo: "src/**/*.{ts,tsx,css}"
|
||||
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
|
||||
---
|
||||
|
||||
# Frontend Instructions
|
||||
|
||||
## Purpose
|
||||
|
||||
Keep frontend changes predictable, minimal, and reusable for AI-assisted edits.
|
||||
|
||||
## Do
|
||||
|
||||
- Preserve existing route contracts and UI behavior unless explicitly asked.
|
||||
- Prefer reusable components and hooks over copy-paste logic.
|
||||
- Keep styles token-driven using existing theme/store patterns.
|
||||
- For Ant Design styling, follow this priority: component native API (`variant`/`type`/`size`/`styles`/`classNames`) -> `theme.useToken()` with inline `style` -> `className` + external CSS.
|
||||
- Keep translations compatible with Lingui macros and catalogs.
|
||||
- Follow current folder conventions under src/components and src/routes.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Do not introduce new UI libraries when Ant Design already covers the need.
|
||||
- Do not bypass existing theme/store abstractions with hardcoded global state.
|
||||
- Do not write CSS overrides first when props or semantic `styles` can express the same UI.
|
||||
- Do not refactor unrelated files in the same change.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run: `vp check --no-fmt`
|
||||
- For UI flow changes, run focused e2e where possible.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
|
||||
## applyTo: "src/\*_/_.{ts,tsx}"
|
||||
|
||||
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
|
||||
|
||||
# Refactor Instructions
|
||||
|
||||
## Purpose
|
||||
|
||||
Reduce duplication while preserving behavior and public contracts.
|
||||
|
||||
## Do
|
||||
|
||||
- Keep refactors incremental and scoped to one concern per change.
|
||||
- Prefer extracting shared utilities/hooks/components over large rewrites.
|
||||
- Preserve existing external behavior and route/store contracts.
|
||||
- Keep diffs reviewable with minimal unrelated formatting churn.
|
||||
- Add or update lightweight comments only when logic is non-obvious.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Do not change runtime behavior unless explicitly requested.
|
||||
- Do not rename broadly across the codebase without clear benefit.
|
||||
- Do not delete tests that still cover unique critical scenarios.
|
||||
- Do not introduce large dependencies for minor convenience; prefer composition or small utilities.
|
||||
|
||||
## Considerations
|
||||
|
||||
- Bundle: Main app chunk is ~135KB (gzip ~41KB). Ant Design (vendor-antd) and TanStack (vendor-tanstack) are in separate chunks for parallel loading. Avoid adding large UI libraries when Ant Design covers the need.
|
||||
- Type safety: Keep validations at API boundaries (Zod schemas) rather than adding more runtime checks throughout the app.
|
||||
|
||||
## Validation
|
||||
|
||||
- Confirm no new diagnostics in touched files.
|
||||
- Run: `vp check --no-fmt`
|
||||
- For significant changes, verify bundle size impact: `vp build 2>&1 | grep "kB"`
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
|
||||
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
|
||||
|
||||
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
|
||||
|
||||
# Testing Instructions
|
||||
|
||||
## Purpose
|
||||
|
||||
Keep tests fast, deterministic, and aligned with core business flows.
|
||||
|
||||
## Do
|
||||
|
||||
- Prioritize core flows first (auth, users CRUD, permission gates).
|
||||
- Keep assertions user-visible and behavior-focused.
|
||||
- Reuse existing helper patterns in tests before adding new helpers.
|
||||
- Keep mock handlers and mock data synchronized with API schemas.
|
||||
- Prefer focused test runs over full-suite runs during iteration.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Do not add brittle selectors tied to unstable DOM internals.
|
||||
- Do not expand fixture data without a clear scenario need.
|
||||
- Do not duplicate scenarios already covered by existing tests.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run focused tests first, then broader checks if needed.
|
||||
- Run: `vp check --no-fmt`
|
||||
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
vp staged
|
||||
+51
-59
@@ -1,84 +1,76 @@
|
||||
# Using Vite+, the Unified Toolchain for the Web
|
||||
# antd-admin-basic
|
||||
|
||||
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.
|
||||
Vite+ template app (React 19, Ant Design 6, TanStack Router/Query, MSW). Default UI copy is English (no i18n). CLI details: [viteplus.dev](https://viteplus.dev).
|
||||
|
||||
## Vite+ Workflow
|
||||
## Commands
|
||||
|
||||
`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.
|
||||
Run from **this app root** (directory with `package.json` / `vite.config.ts`):
|
||||
|
||||
### Start
|
||||
| Task | Command |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| Install deps | `vp install` (after every pull) |
|
||||
| Dev / build / preview | `vp dev`, `vp build`, `vp preview` |
|
||||
| Format / lint / typecheck | `vp check`, `vp check --no-fmt`, `vp lint`, `vp fmt` |
|
||||
| Unit tests | `vp test run` |
|
||||
| E2E | `pnpm run test:e2e` (or focused spec via Playwright) |
|
||||
| Add/remove deps | `vp add`, `vp remove` — not raw `pnpm`/`npm`/`yarn` |
|
||||
|
||||
- create - Create a new project from a template
|
||||
- migrate - Migrate an existing project to Vite+
|
||||
- config - Configure hooks and agent integration
|
||||
- staged - Run linters on staged files
|
||||
- install (`i`) - Install dependencies
|
||||
- env - Manage Node.js versions
|
||||
**Pitfalls:** Do not install Vitest/Oxlint/Oxfmt/tsdown directly; use `vp`. Do not run `vp vitest` — use `vp test`. If a `package.json` script name conflicts with a built-in `vp` command, use `vp run <script>`. Import toolchain modules from `vite-plus` (e.g. `defineConfig`, test utils), not standalone `vite`/`vitest` packages.
|
||||
|
||||
### Develop
|
||||
**Before finishing:** `vp check` and `vp test run`; run focused e2e when UI flows change.
|
||||
|
||||
- dev - Run the development server
|
||||
- check - Run format, lint, and TypeScript type checks
|
||||
- lint - Lint code
|
||||
- fmt - Format code
|
||||
- test - Run tests
|
||||
---
|
||||
|
||||
### Execute
|
||||
## Frontend
|
||||
|
||||
- run - Run monorepo tasks
|
||||
- exec - Execute a command from local `node_modules/.bin`
|
||||
- dlx - Execute a package binary without installing it as a dependency
|
||||
- cache - Manage the task cache
|
||||
**Purpose:** Predictable, minimal UI changes under `src/`.
|
||||
|
||||
### Build
|
||||
**Do:** Preserve route/UI contracts unless asked. Reuse components/hooks. Token-driven styles via theme/store. Ant Design styling order: component API → `theme.useToken()` + inline `style` → `className` + CSS. English plain string literals. Follow `src/components` and `src/routes` layout.
|
||||
|
||||
- build - Build for production
|
||||
- pack - Build libraries
|
||||
- preview - Preview production build
|
||||
**Do not:** New UI libs when Ant Design suffices; bypass theme/store; CSS-first overrides when props/`styles` work; unrelated refactors in the same change.
|
||||
|
||||
### Manage Dependencies
|
||||
**Validation:** `vp check --no-fmt`; focused e2e when flows change.
|
||||
|
||||
Vite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.
|
||||
---
|
||||
|
||||
- add - Add packages to dependencies
|
||||
- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies
|
||||
- update (`up`) - Update packages to latest versions
|
||||
- dedupe - Deduplicate dependencies
|
||||
- outdated - Check for outdated packages
|
||||
- list (`ls`) - List installed packages
|
||||
- why (`explain`) - Show why a package is installed
|
||||
- info (`view`, `show`) - View package information from the registry
|
||||
- link (`ln`) / unlink - Manage local package links
|
||||
- pm - Forward a command to the package manager
|
||||
## API
|
||||
|
||||
### Maintain
|
||||
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
|
||||
|
||||
- upgrade - Update `vp` itself to the latest version
|
||||
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
|
||||
|
||||
These commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.
|
||||
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
|
||||
|
||||
## Common Pitfalls
|
||||
**Validation:** `vp check --no-fmt`.
|
||||
|
||||
- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.
|
||||
- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.
|
||||
- **Running scripts:** Vite+ commands take precedence over `package.json` scripts. If there is a `test` script defined in `scripts` that conflicts with the built-in `vp test` command, run it using `vp run test`.
|
||||
- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.
|
||||
- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.
|
||||
- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.
|
||||
- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.
|
||||
---
|
||||
|
||||
## Review Checklist for Agents
|
||||
## Testing
|
||||
|
||||
- Run `vp install` after pulling remote changes and before getting started.
|
||||
- Run `vp check` and `vp test` to validate changes.
|
||||
**Purpose:** Fast, deterministic tests for core flows (auth, users CRUD, permissions).
|
||||
|
||||
## AI Instruction Files
|
||||
**Do:** User-visible assertions; reuse test helpers; keep mocks/schemas in sync; focused runs while iterating.
|
||||
|
||||
This repository also uses scoped AI instruction files in `.github/instructions/`.
|
||||
**Do not:** Brittle DOM-internal selectors; bloated fixtures; duplicate scenarios.
|
||||
|
||||
- `frontend.instructions.md` for UI/routes/components/style work
|
||||
- `testing.instructions.md` for Playwright/MSW/test updates
|
||||
- `api.instructions.md` for API/schema/handler changes
|
||||
- `refactor.instructions.md` for behavior-preserving cleanup
|
||||
**Validation:** Focused tests first; `vp check --no-fmt`.
|
||||
|
||||
See `.github/instructions/README.md` for usage and authoring rules.
|
||||
---
|
||||
|
||||
## Refactor
|
||||
|
||||
**Purpose:** Less duplication, same behavior and public contracts.
|
||||
|
||||
**Do:** One concern per change; extract shared utils/hooks/components; reviewable diffs; comments only when non-obvious.
|
||||
|
||||
**Do not:** Behavior changes unless requested; broad renames; drop unique test coverage; heavy deps for tiny wins.
|
||||
|
||||
**Considerations:** Main chunk ~135KB gzip ~41KB; vendor splits for antd/tanstack — avoid large extra UI libs. Validate at API boundaries (Zod), not app-wide runtime checks.
|
||||
|
||||
**Validation:** `vp check --no-fmt`; significant changes → `vp build` and watch chunk sizes in log.
|
||||
|
||||
---
|
||||
|
||||
## Add CRUD resource
|
||||
|
||||
Multi-step checklist (mirror **users**): use skill **`.agents/skills/add-resource/SKILL.md`** (invoke in Copilot/Codex/Claude or `@` that path in chat). After routes change, run `pnpm run build` to refresh `routeTree.gen.ts`.
|
||||
|
||||
+101
-63
@@ -8,7 +8,7 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. For Lingui-based i18n (en + zh), use the sibling app [`apps/with-lingui`](../with-lingui/).
|
||||
Admin scaffold: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, **`AGENTS.md`** agent rules, and type-safe contracts at every boundary. Default UI language is English; for Lingui i18n (en + zh) out of the box, see [`apps/with-lingui`](../with-lingui/).
|
||||
|
||||
[](https://github.com/ant-design/ant-design)
|
||||
[](https://react.dev)
|
||||
@@ -31,19 +31,20 @@ English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and
|
||||
| Async State | [TanStack Query v5](https://tanstack.com/query) |
|
||||
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
|
||||
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
|
||||
| UI language | English strings in source (no i18n framework) |
|
||||
| UI copy | Plain strings in source (default English); no bundled i18n stack |
|
||||
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
|
||||
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
|
||||
| E2E Testing | [Playwright](https://playwright.dev) |
|
||||
| Language | TypeScript 5.9 (strict mode) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
|
||||
## Features
|
||||
|
||||
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
|
||||
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
|
||||
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
|
||||
- **English-only** — No Lingui or locale catalogs; Ant Design uses a fixed `en_US` locale. See [`apps/with-lingui`](../with-lingui/) for bilingual LinguiJS.
|
||||
- **Default locale** — UI copy in plain strings (English by default); Ant Design `ConfigProvider` uses `en_US`.
|
||||
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
|
||||
- **Modular API layer** — `defineApi` modules under `src/api/<domain>/`, shared Zod helpers, single `API_PREFIX`
|
||||
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
|
||||
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
|
||||
|
||||
@@ -56,12 +57,9 @@ English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and
|
||||
|
||||
### Install & Run
|
||||
|
||||
From the monorepo root, or after `cd apps/basic`:
|
||||
|
||||
```bash
|
||||
cd apps/basic
|
||||
vp install
|
||||
vp dev
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
|
||||
@@ -71,87 +69,127 @@ Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to
|
||||
### Build
|
||||
|
||||
```bash
|
||||
vp build
|
||||
vp preview
|
||||
pnpm run build
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
### Project Setup
|
||||
|
||||
```bash
|
||||
pnpm run prepare
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
pnpm run fmt
|
||||
pnpm run lint
|
||||
pnpm run check
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
pnpm run test:unit
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
```bash
|
||||
cd apps/basic
|
||||
pnpm run test:e2e
|
||||
pnpm run test:e2e:core
|
||||
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # core flows only
|
||||
pnpm run test:e2e:ui # interactive UI mode
|
||||
```
|
||||
|
||||
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
AGENTS.md # Agent rules (frontend/api/testing/refactor)
|
||||
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
|
||||
src/
|
||||
├── api/ # Zod schemas, endpoint constants, type exports
|
||||
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
|
||||
│ ├── auth.ts # Auth endpoint constants
|
||||
│ └── user.ts # User CRUD endpoint constants
|
||||
├── components/
|
||||
│ ├── Aurora/ # Login background effect
|
||||
│ ├── Auth/ # Auth (permission gate): index.tsx
|
||||
│ ├── DataTable/ # Shared table shell, skeleton, empty state
|
||||
│ ├── FilterToolbar/ # Shared filter/action toolbar
|
||||
│ ├── FormModal/ # Reusable modal + form shell
|
||||
│ ├── Icon/ # Shared icons and theme toggle icon
|
||||
│ └── Layout/ # Admin shell (sidebar, header, main)
|
||||
│ ├── MainLayout/ # Main layout shell: index.tsx
|
||||
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
|
||||
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
|
||||
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
|
||||
│ └── Header/ # Top bar: index.tsx
|
||||
├── hooks/
|
||||
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
|
||||
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
|
||||
│ ├── usePermission.ts # Permission check hook
|
||||
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
|
||||
├── utils/
|
||||
│ ├── constants.ts # API base URL, static assets
|
||||
│ └── http.ts # HTTP client with JWT injection & error handling
|
||||
├── mocks/
|
||||
│ ├── browser.ts # MSW worker setup
|
||||
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
|
||||
│ ├── data.ts # Mock seed data (users, menus)
|
||||
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
|
||||
│ └── handlers/ # Request handlers (auth, user CRUD)
|
||||
├── routes/ # TanStack Router file-based routes
|
||||
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, en_US)
|
||||
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
|
||||
├── api/ # HTTP + contracts (see “API layer” below)
|
||||
│ ├── request.ts # axios, defineApi, token refresh, envelope unwrap
|
||||
│ ├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
|
||||
│ ├── shared/ # API_PREFIX, pagination, params, menu, envelope helpers
|
||||
│ ├── auth/ # paths, schemas, login/register/hydrateSession
|
||||
│ └── users/ # schemas, listUsers CRUD, userQueryKeys, path constants
|
||||
├── components/ # Reusable UI primitives and layout shells
|
||||
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
|
||||
│ ├── DataTable/ # Shared table frame/skeleton/empty state
|
||||
│ ├── FormModal/ # Generic modal form wrapper
|
||||
│ ├── FilterToolbar/ # Reusable list page toolbar
|
||||
│ ├── Auth/ # Permission gate component
|
||||
│ ├── Aurora/ # Login page background effect
|
||||
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
|
||||
│ ├── NotFound/ # Not-found visual component
|
||||
│ └── RouteError.tsx # Route-level error boundary UI
|
||||
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
|
||||
│ ├── useAppTheme.ts
|
||||
│ ├── usePermission.ts
|
||||
│ ├── useResourceCRUD.ts
|
||||
│ ├── useCrudToasts.ts
|
||||
│ ├── useUrlSearchState.ts
|
||||
│ ├── useTableFitHeight.ts
|
||||
│ └── tokenBuilders.ts
|
||||
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
|
||||
│ ├── browser.ts
|
||||
│ ├── createHandler.ts
|
||||
│ ├── createHandler.test.ts
|
||||
│ ├── data.ts
|
||||
│ ├── utils.ts
|
||||
│ └── handlers/
|
||||
├── routes/ # TanStack Router file routes
|
||||
│ ├── __root.tsx # QueryClient + ConfigProvider (en_US) wiring
|
||||
│ ├── _auth.tsx # Protected route layout
|
||||
│ ├── _auth/dashboard/index.tsx
|
||||
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
|
||||
│ ├── _auth/users/index.tsx
|
||||
│ ├── _auth/users/-Toolbar.tsx
|
||||
│ ├── _auth/users/-FormModal.tsx
|
||||
│ ├── _auth/403/index.tsx
|
||||
│ ├── login/index.tsx
|
||||
│ ├── register/index.tsx
|
||||
│ ├── 404/index.tsx
|
||||
│ └── index.tsx # Redirects / → /login
|
||||
├── stores/
|
||||
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
|
||||
│ ├── createPersistentStore.ts # Shared persisted-store factory
|
||||
│ └── settings.ts # Settings store (darkMode, sidebar)
|
||||
└── main.tsx # Entry point (MSW init → React render)
|
||||
│ └── index.tsx
|
||||
├── stores/ # Persisted auth/settings stores
|
||||
├── utils/ # constants, app menu helpers
|
||||
├── main.tsx # App bootstrap (MSW init + React render)
|
||||
└── routeTree.gen.ts # Generated TanStack route tree
|
||||
|
||||
e2e/ # Playwright E2E tests
|
||||
e2e/ # Playwright E2E suites
|
||||
├── helpers.ts
|
||||
├── login.spec.ts
|
||||
└── users.spec.ts
|
||||
├── users.spec.ts
|
||||
├── auth-refresh.spec.ts
|
||||
├── rbac.spec.ts
|
||||
└── url-state.spec.ts
|
||||
```
|
||||
|
||||
## Language & i18n
|
||||
## API layer
|
||||
|
||||
This template is **English-only**: user-facing copy lives in components and routes as plain strings; there is no Lingui catalog or language switcher.
|
||||
Data flow: **Zod schemas → `defineApi` in `src/api/<module>/index.ts` → routes/components call resource functions** (never axios or `@/api/request` directly).
|
||||
|
||||
For **LinguiJS** (English + Chinese, `.po` extract/compile, and Ant Design locale switching), use [`apps/with-lingui`](../with-lingui/).
|
||||
| Piece | Role |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `request.ts` | Shared HTTP client, `{ code, data, message }` handling, 401 refresh |
|
||||
| `shared/` | `API_PREFIX` (`/api`), pagination/query helpers, shared menu types |
|
||||
| `auth/`, `users/` | Domain schemas + `defineApi` exports; MSW handlers import the same path constants |
|
||||
| `schemas.ts` | Compatibility re-exports; new code should import from `@/api/shared` and `@/api/<module>/schemas` |
|
||||
|
||||
Change the REST prefix in one place: `src/api/shared/prefix.ts` (`API_PREFIX`).
|
||||
|
||||
## Language
|
||||
|
||||
User-facing copy lives in components and routes as plain strings. **Default language is English**; Ant Design is wired to `en_US` in `__root.tsx`. This template does not include a locale switcher or translation catalogs—you can add your own i18n library or start from the [`with-lingui`](../with-lingui/) template for LinguiJS (en + zh).
|
||||
|
||||
## Extending the template
|
||||
|
||||
- **New CRUD resource:** Follow `.agents/skills/add-resource/SKILL.md` (mirror `users`), then `vp check --no-fmt`.
|
||||
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
|
||||
- **Add i18n:** Start from [`apps/with-lingui`](../with-lingui/) or port Lingui setup from that app; this `basic` tree intentionally omits it.
|
||||
- **Add i18n:** Integrate your preferred stack, or copy patterns from [`apps/with-lingui`](../with-lingui/).
|
||||
|
||||
### AI guidance
|
||||
|
||||
**Single source of truth:** **`AGENTS.md`** in this app. New CRUD flows: `@.agents/skills/add-resource/SKILL.md`.
|
||||
|
||||
## Developer Notes
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsAdmin } from "./helpers";
|
||||
|
||||
const expiredAuthStorage = () =>
|
||||
JSON.stringify({
|
||||
state: {
|
||||
tokens: {
|
||||
accessToken: "__EXPIRED_ACCESS__",
|
||||
refreshToken: "mock-refresh-token",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
},
|
||||
version: 0,
|
||||
});
|
||||
|
||||
test.describe("Auth refresh", () => {
|
||||
test.describe.configure({ retries: 2 });
|
||||
|
||||
test("401 on list triggers refresh then succeeds", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
|
||||
await page.evaluate((payload) => {
|
||||
localStorage.setItem("auth-storage", payload);
|
||||
}, expiredAuthStorage());
|
||||
|
||||
await page.reload({ waitUntil: "load" });
|
||||
|
||||
const usersListOk = page.waitForResponse(
|
||||
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.goto("/users", { waitUntil: "domcontentloaded" });
|
||||
await usersListOk;
|
||||
|
||||
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
|
||||
const thead = page.locator(".ant-table-thead");
|
||||
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,11 @@ export async function gotoUsers(page: Page) {
|
||||
await page.goto("/users");
|
||||
await expect(page).toHaveURL(/users/);
|
||||
}
|
||||
|
||||
export async function loginAsGuest(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/Username|用户名/).fill("guest");
|
||||
await page.getByLabel(/Password|密码/).fill("guest");
|
||||
await page.getByRole("button", { name: /Sign In|登录/ }).click();
|
||||
await expect(page).toHaveURL(/dashboard/);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsGuest } from "./helpers";
|
||||
|
||||
test.describe("RBAC", () => {
|
||||
test("guest without user:view cannot open /users", async ({ page }) => {
|
||||
await loginAsGuest(page);
|
||||
await page.goto("/users");
|
||||
await expect(page).toHaveURL(/403/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsAdmin } from "./helpers";
|
||||
|
||||
test.describe("URL search state", () => {
|
||||
test("persists keyword after reload", async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto("/users");
|
||||
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
|
||||
await searchInput.fill("zhao.ming");
|
||||
await searchInput.press("Enter");
|
||||
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ test.describe("User Management", () => {
|
||||
await searchInput.fill("zhao.ming");
|
||||
await searchInput.press("Enter");
|
||||
await expect(page).toHaveURL(/keyword=zhao\.ming/);
|
||||
/* Avoid strict-mode match on email cell containing substring "zhao.ming" */
|
||||
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -28,7 +27,6 @@ test.describe("User Management", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Avoid a second full `page.goto` after `/users`: reload can land on login before auth rehydrates. */
|
||||
test.describe("User Management — role in URL", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
|
||||
+20
-29
@@ -11,42 +11,33 @@
|
||||
"fmt": "vp fmt",
|
||||
"lint": "vp lint",
|
||||
"check": "vp check --no-fmt",
|
||||
"test:unit": "vp test run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.167.4",
|
||||
"antd": "^6.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"@tanstack/react-router": "^1.170.45",
|
||||
"antd": "^6.6.6",
|
||||
"axios": "^1.20.0",
|
||||
"lucide-react": "^1.59.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"zod": "^4.6.7",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tanstack/router-plugin": "^1.166.13",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"msw": "^2.12.12",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vite-plus": "latest"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"msw"
|
||||
]
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@tanstack/router-plugin": "^1.168.47",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||
"msw": "^2.15.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
|
||||
"vite-plus": "0.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
|
||||
@@ -1,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);
|
||||
|
||||
Generated
+1878
-2507
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = "2.12.12";
|
||||
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
|
||||
const PACKAGE_VERSION = "2.15.0";
|
||||
const INTEGRITY_CHECKSUM = "03cb67ac84128e63d7cd722a6e5b7f1e";
|
||||
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
|
||||
const activeClientIds = new Set();
|
||||
|
||||
@@ -129,8 +129,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||
|
||||
// Omit the body of server-sent event stream responses.
|
||||
// Cloning such responses would prevent client-side stream cancelations
|
||||
// from reaching the original stream (a teed stream only cancels its
|
||||
// source once both of its branches cancel) and would buffer the
|
||||
// entire stream into the unconsumed clone indefinitely.
|
||||
const isEventStreamResponse = response.headers
|
||||
.get("content-type")
|
||||
?.toLowerCase()
|
||||
.startsWith("text/event-stream");
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone();
|
||||
const responseClone = isEventStreamResponse ? null : response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
@@ -143,15 +153,15 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
type: response.type,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: responseClone ? responseClone.body : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList } from "./schemas";
|
||||
|
||||
export const AUTH_ENDPOINTS = {
|
||||
login: "/api/auth/login",
|
||||
refresh: "/api/auth/refresh",
|
||||
logout: "/api/auth/logout",
|
||||
user: "/api/auth/user",
|
||||
permissions: "/api/auth/permissions",
|
||||
} as const;
|
||||
|
||||
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList };
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod/v4";
|
||||
import { defineApi } from "@/api/request";
|
||||
import {
|
||||
AuthTokensSchema,
|
||||
AuthUserResponseSchema,
|
||||
LoginRequestSchema,
|
||||
PermissionsListSchema,
|
||||
RegisterRequestSchema,
|
||||
} from "@/api/auth/schemas";
|
||||
import type {
|
||||
AuthTokens,
|
||||
LoginRequest,
|
||||
PermissionsList,
|
||||
RegisterRequest,
|
||||
} from "@/api/auth/schemas";
|
||||
import { UserSchema, type User } from "@/api/users/schemas";
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
|
||||
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
|
||||
|
||||
export const login = defineApi({
|
||||
method: "POST",
|
||||
path: AUTH_ENDPOINTS.login,
|
||||
body: LoginRequestSchema,
|
||||
output: AuthTokensSchema,
|
||||
});
|
||||
|
||||
export const register = defineApi({
|
||||
method: "POST",
|
||||
path: AUTH_ENDPOINTS.register,
|
||||
body: RegisterRequestSchema,
|
||||
output: AuthTokensSchema,
|
||||
});
|
||||
|
||||
export const logout = defineApi({
|
||||
method: "POST",
|
||||
path: AUTH_ENDPOINTS.logout,
|
||||
output: z.null(),
|
||||
});
|
||||
|
||||
export const getAuthUser = defineApi({
|
||||
path: AUTH_ENDPOINTS.user,
|
||||
output: AuthUserResponseSchema,
|
||||
});
|
||||
|
||||
export const getPermissions = defineApi({
|
||||
path: AUTH_ENDPOINTS.permissions,
|
||||
output: PermissionsListSchema,
|
||||
});
|
||||
|
||||
/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */
|
||||
export async function hydrateSession(): Promise<void> {
|
||||
const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]);
|
||||
const user = UserSchema.parse({ ...userBase, permissions });
|
||||
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
|
||||
const { setUser, setMenus } = useAuthStore.getState();
|
||||
setUser(user);
|
||||
setMenus(menus);
|
||||
}
|
||||
|
||||
export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { API_PREFIX } from "@/api/shared/prefix";
|
||||
|
||||
const AUTH_BASE = `${API_PREFIX}/auth`;
|
||||
|
||||
export const AUTH_ENDPOINTS = {
|
||||
login: `${AUTH_BASE}/login`,
|
||||
register: `${AUTH_BASE}/register`,
|
||||
refresh: `${AUTH_BASE}/refresh`,
|
||||
logout: `${AUTH_BASE}/logout`,
|
||||
user: `${AUTH_BASE}/user`,
|
||||
permissions: `${AUTH_BASE}/permissions`,
|
||||
} as const;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod/v4";
|
||||
import { UserSchema } from "@/api/users/schemas";
|
||||
|
||||
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
|
||||
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
||||
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
||||
|
||||
export const AuthTokensSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||
|
||||
export const RefreshTokenRequestSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
|
||||
|
||||
export const LoginRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||
|
||||
/** Ant Design Form may submit `""` for an empty optional email */
|
||||
const registerOptionalEmailSchema = z
|
||||
.union([z.string(), z.undefined()])
|
||||
.transform((v) => {
|
||||
if (v === undefined) return undefined;
|
||||
const t = String(v).trim();
|
||||
return t === "" ? undefined : t;
|
||||
})
|
||||
.pipe(z.union([z.string().email(), z.undefined()]));
|
||||
|
||||
export const RegisterRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(6),
|
||||
email: registerOptionalEmailSchema,
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
|
||||
|
||||
export const PermissionsListSchema = z.array(z.string());
|
||||
|
||||
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
|
||||
@@ -0,0 +1,302 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
import { z } from "zod/v4";
|
||||
import { API_BASE_URL } from "@/utils/constants";
|
||||
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
|
||||
import { getAccessToken, useAuthStore } from "@/stores/auth";
|
||||
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
|
||||
|
||||
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
|
||||
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number;
|
||||
constructor(code: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type HttpRouter = {
|
||||
navigate: (opts: { to: string }) => void | Promise<void>;
|
||||
};
|
||||
|
||||
let httpRouter: HttpRouter | null = null;
|
||||
|
||||
export function installHttpRouter(router: HttpRouter): void {
|
||||
httpRouter = router;
|
||||
}
|
||||
|
||||
function navigateTo403(): void {
|
||||
void httpRouter?.navigate({ to: "/403" });
|
||||
}
|
||||
|
||||
function navigateToLogin(): void {
|
||||
void httpRouter?.navigate({ to: "/login" });
|
||||
}
|
||||
|
||||
type RequestConfig = InternalAxiosRequestConfig & {
|
||||
skipAuthRefresh?: boolean;
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
export type CallOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
type Envelope = {
|
||||
code?: number;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function cleanParams(params?: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
if (!params) return undefined;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null) out[key] = value;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function unwrapEnvelope(json: unknown): unknown {
|
||||
if (json == null || typeof json !== "object") return json;
|
||||
const envelope = json as Envelope;
|
||||
if (envelope.code !== undefined && envelope.code !== 0) {
|
||||
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
|
||||
}
|
||||
if (envelope.code !== undefined && envelope.data !== undefined) {
|
||||
return envelope.data;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 15_000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
let inflightRefresh: Promise<boolean> | null = null;
|
||||
|
||||
async function doRefreshSessionTokens(): Promise<boolean> {
|
||||
const { tokens, setTokens, logout } = useAuthStore.getState();
|
||||
const refreshToken = tokens?.refreshToken;
|
||||
if (!refreshToken) {
|
||||
logout();
|
||||
navigateToLogin();
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = RefreshTokenRequestSchema.parse({ refreshToken });
|
||||
|
||||
try {
|
||||
const res = await client.post(REFRESH_PATH, body, {
|
||||
skipAuthRefresh: true,
|
||||
} as RequestConfig);
|
||||
const next = AuthTokensSchema.parse(res.data);
|
||||
setTokens(next);
|
||||
return true;
|
||||
} catch {
|
||||
logout();
|
||||
navigateToLogin();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessionTokens(): Promise<boolean> {
|
||||
if (inflightRefresh) return inflightRefresh;
|
||||
inflightRefresh = doRefreshSessionTokens().finally(() => {
|
||||
inflightRefresh = null;
|
||||
});
|
||||
return inflightRefresh;
|
||||
}
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
response.data = unwrapEnvelope(response.data);
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const config = error.config as RequestConfig | undefined;
|
||||
|
||||
if (
|
||||
status === 401 &&
|
||||
config &&
|
||||
!config._retry &&
|
||||
!config.skipAuthRefresh &&
|
||||
config.url !== REFRESH_PATH &&
|
||||
!config.url?.endsWith(REFRESH_PATH)
|
||||
) {
|
||||
config._retry = true;
|
||||
const refreshed = await refreshSessionTokens();
|
||||
if (refreshed) {
|
||||
return client.request(config);
|
||||
}
|
||||
throw new HttpError(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
navigateTo403();
|
||||
throw new HttpError(403, "Forbidden");
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
throw new HttpError(status, `HTTP ${status}: ${error.response?.statusText ?? "Error"}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
async function transportRequest<T>(opts: {
|
||||
method: HttpMethod;
|
||||
url: string;
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
skipAuthRefresh?: boolean;
|
||||
}): Promise<T> {
|
||||
const res = await client.request<T>({
|
||||
method: opts.method,
|
||||
url: opts.url,
|
||||
params: cleanParams(opts.params),
|
||||
data: opts.body,
|
||||
signal: opts.signal,
|
||||
skipAuthRefresh: opts.skipAuthRefresh,
|
||||
} as RequestConfig);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
type ApiDefConfig<
|
||||
TParams extends z.ZodType | undefined,
|
||||
TQuery extends z.ZodType | undefined,
|
||||
TBody extends z.ZodType | undefined,
|
||||
TOut extends z.ZodType,
|
||||
> = {
|
||||
method?: HttpMethod;
|
||||
path: string;
|
||||
params?: TParams;
|
||||
query?: TQuery;
|
||||
body?: TBody;
|
||||
output: TOut;
|
||||
};
|
||||
|
||||
type CallArgs<
|
||||
TParams extends z.ZodType | undefined,
|
||||
TQuery extends z.ZodType | undefined,
|
||||
TBody extends z.ZodType | undefined,
|
||||
> = TParams extends z.ZodType
|
||||
? TQuery extends z.ZodType
|
||||
? TBody extends z.ZodType
|
||||
? { params: z.infer<TParams>; query: z.infer<TQuery>; body: z.infer<TBody> }
|
||||
: { params: z.infer<TParams>; query: z.infer<TQuery> }
|
||||
: TBody extends z.ZodType
|
||||
? { params: z.infer<TParams>; body: z.infer<TBody> }
|
||||
: z.infer<TParams>
|
||||
: TQuery extends z.ZodType
|
||||
? TBody extends z.ZodType
|
||||
? { query: z.infer<TQuery>; body: z.infer<TBody> }
|
||||
: z.infer<TQuery>
|
||||
: TBody extends z.ZodType
|
||||
? z.infer<TBody>
|
||||
: void;
|
||||
|
||||
function renderPath(path: string, params?: Record<string, unknown>): string {
|
||||
if (!params) return path;
|
||||
let url = path;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function defineApi<
|
||||
TParams extends z.ZodType | undefined = undefined,
|
||||
TQuery extends z.ZodType | undefined = undefined,
|
||||
TBody extends z.ZodType | undefined = undefined,
|
||||
TOut extends z.ZodType = z.ZodType,
|
||||
>(
|
||||
def: ApiDefConfig<TParams, TQuery, TBody, TOut>,
|
||||
): (
|
||||
...args: CallArgs<TParams, TQuery, TBody> extends void
|
||||
? [options?: CallOptions]
|
||||
: [args: CallArgs<TParams, TQuery, TBody>, options?: CallOptions]
|
||||
) => Promise<z.infer<TOut>> {
|
||||
const method: HttpMethod = def.method ?? "GET";
|
||||
const segmentCount = (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0);
|
||||
|
||||
return async (...allArgs: unknown[]): Promise<z.infer<TOut>> => {
|
||||
let callArgs: unknown;
|
||||
let options: CallOptions | undefined;
|
||||
|
||||
if (segmentCount === 0) {
|
||||
options = allArgs[0] as CallOptions | undefined;
|
||||
} else {
|
||||
callArgs = allArgs[0];
|
||||
options = allArgs[1] as CallOptions | undefined;
|
||||
}
|
||||
|
||||
let params: Record<string, unknown> | undefined;
|
||||
let query: Record<string, unknown> | undefined;
|
||||
let body: unknown;
|
||||
|
||||
if (segmentCount === 0) {
|
||||
// no input segments
|
||||
} else if (segmentCount === 1) {
|
||||
if (def.params) {
|
||||
params = def.params.parse(callArgs) as Record<string, unknown>;
|
||||
} else if (def.query) {
|
||||
query = def.query.parse(callArgs) as Record<string, unknown>;
|
||||
} else if (def.body) {
|
||||
body = def.body.parse(callArgs);
|
||||
}
|
||||
} else {
|
||||
const shaped = (callArgs ?? {}) as {
|
||||
params?: unknown;
|
||||
query?: unknown;
|
||||
body?: unknown;
|
||||
};
|
||||
if (def.params) {
|
||||
params = def.params.parse(shaped.params) as Record<string, unknown>;
|
||||
}
|
||||
if (def.query) {
|
||||
query = def.query.parse(shaped.query) as Record<string, unknown>;
|
||||
}
|
||||
if (def.body) {
|
||||
body = def.body.parse(shaped.body);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await transportRequest<unknown>({
|
||||
method,
|
||||
url: renderPath(def.path, params),
|
||||
params: query,
|
||||
body: def.body ? body : undefined,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
return def.output.parse(data) as z.infer<TOut>;
|
||||
};
|
||||
}
|
||||
@@ -1,131 +1,4 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
roles: z.array(z.string()),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
|
||||
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
|
||||
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
|
||||
|
||||
export const AuthTokensSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
|
||||
|
||||
export const LoginRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||
|
||||
export const PermissionsListSchema = z.array(z.string());
|
||||
|
||||
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
|
||||
|
||||
const BaseMenuNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
icon: z.string().nullable(),
|
||||
permissions: z.array(z.string()).nullable(),
|
||||
sort: z.int(),
|
||||
hidden: z.boolean(),
|
||||
});
|
||||
|
||||
export type MenuItem =
|
||||
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||
kind: "item";
|
||||
path: string;
|
||||
children: MenuItem[] | null;
|
||||
})
|
||||
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||
kind: "group";
|
||||
path: null;
|
||||
children: MenuItem[];
|
||||
});
|
||||
|
||||
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
|
||||
z.discriminatedUnion("kind", [
|
||||
BaseMenuNodeSchema.extend({
|
||||
kind: z.literal("item"),
|
||||
path: z.string(),
|
||||
children: z.array(MenuItemSchema).nullable(),
|
||||
}),
|
||||
BaseMenuNodeSchema.extend({
|
||||
kind: z.literal("group"),
|
||||
path: z.null(),
|
||||
children: z.array(MenuItemSchema),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||
return z.object({
|
||||
code: z.int(),
|
||||
data: dataSchema,
|
||||
message: z.string(),
|
||||
});
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
code: z.int(),
|
||||
data: z.object({
|
||||
list: z.array(itemSchema),
|
||||
total: z.int(),
|
||||
}),
|
||||
message: z.string(),
|
||||
});
|
||||
}
|
||||
|
||||
export type PaginatedData<T> = {
|
||||
list: T[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export const SearchParamsSchema = z.object({
|
||||
page: z.number().int().positive().catch(1),
|
||||
pageSize: z.number().int().positive().catch(10),
|
||||
sortField: z.string().nullable().catch(null),
|
||||
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof SearchParamsSchema>;
|
||||
|
||||
/** Ant Design Input submits `""` when empty; treat as null for optional email */
|
||||
const createUserEmailSchema = z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((v) => {
|
||||
if (v == null) return null;
|
||||
const t = String(v).trim();
|
||||
return t === "" ? null : t;
|
||||
})
|
||||
.pipe(z.union([z.string().email(), z.null()]));
|
||||
|
||||
export const CreateUserRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
email: createUserEmailSchema,
|
||||
roles: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
|
||||
|
||||
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
|
||||
|
||||
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
|
||||
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
|
||||
export * from "@/api/shared";
|
||||
export * from "@/api/users/schemas";
|
||||
export * from "@/api/auth/schemas";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||
return z.object({
|
||||
code: z.int(),
|
||||
data: dataSchema,
|
||||
message: z.string(),
|
||||
});
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
code: z.int(),
|
||||
data: z.object({
|
||||
list: z.array(itemSchema),
|
||||
total: z.int(),
|
||||
}),
|
||||
message: z.string(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./envelope";
|
||||
export * from "./menu";
|
||||
export * from "./pagination";
|
||||
export * from "./params";
|
||||
export * from "./prefix";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const BaseMenuNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
icon: z.string().nullable(),
|
||||
permissions: z.array(z.string()).nullable(),
|
||||
sort: z.int(),
|
||||
hidden: z.boolean(),
|
||||
});
|
||||
|
||||
export type MenuItem =
|
||||
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||
kind: "item";
|
||||
path: string;
|
||||
children: MenuItem[] | null;
|
||||
})
|
||||
| (z.infer<typeof BaseMenuNodeSchema> & {
|
||||
kind: "group";
|
||||
path: null;
|
||||
children: MenuItem[];
|
||||
});
|
||||
|
||||
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
|
||||
z.discriminatedUnion("kind", [
|
||||
BaseMenuNodeSchema.extend({
|
||||
kind: z.literal("item"),
|
||||
path: z.string(),
|
||||
children: z.array(MenuItemSchema).nullable(),
|
||||
}),
|
||||
BaseMenuNodeSchema.extend({
|
||||
kind: z.literal("group"),
|
||||
path: z.null(),
|
||||
children: z.array(MenuItemSchema),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export type PaginatedData<T> = {
|
||||
list: T[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
|
||||
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
list: z.array(itemSchema),
|
||||
total: z.int(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const IdParamsSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||
|
||||
export const SearchParamsSchema = z.object({
|
||||
page: z.number().int().positive().catch(1),
|
||||
pageSize: z.number().int().positive().catch(10),
|
||||
sortField: z.string().nullable().catch(null),
|
||||
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
|
||||
});
|
||||
|
||||
export type SearchParams = z.infer<typeof SearchParamsSchema>;
|
||||
@@ -0,0 +1,2 @@
|
||||
/** REST API path prefix (no trailing slash). */
|
||||
export const API_PREFIX = "/api" as const;
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { CreateUserRequest, UpdateUserRequest, User } from "./schemas";
|
||||
|
||||
export const USER_ENDPOINTS = {
|
||||
list: "/api/users",
|
||||
create: "/api/users",
|
||||
update: (id: string) => `/api/users/${id}`,
|
||||
delete: (id: string) => `/api/users/${id}`,
|
||||
} as const;
|
||||
|
||||
export type { CreateUserRequest, UpdateUserRequest, User };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod/v4";
|
||||
import { defineApi } from "@/api/request";
|
||||
import { IdParamsSchema } from "@/api/shared/params";
|
||||
import { PaginatedDataSchema } from "@/api/shared/pagination";
|
||||
import {
|
||||
CreateUserRequestSchema,
|
||||
ListUsersQuerySchema,
|
||||
UpdateUserRequestSchema,
|
||||
UserSchema,
|
||||
} from "@/api/users/schemas";
|
||||
import type {
|
||||
CreateUserRequest,
|
||||
ListUsersQuery,
|
||||
UpdateUserRequest,
|
||||
User,
|
||||
} from "@/api/users/schemas";
|
||||
import { API_PREFIX } from "@/api/shared/prefix";
|
||||
|
||||
export const USERS_PATH = `${API_PREFIX}/users`;
|
||||
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
|
||||
|
||||
export const userQueryKeys = {
|
||||
all: ["users"] as const,
|
||||
list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const,
|
||||
};
|
||||
|
||||
export const listUsers = defineApi({
|
||||
path: USERS_PATH,
|
||||
query: ListUsersQuerySchema,
|
||||
output: PaginatedDataSchema(UserSchema),
|
||||
});
|
||||
|
||||
export const createUser = defineApi({
|
||||
method: "POST",
|
||||
path: USERS_PATH,
|
||||
body: CreateUserRequestSchema,
|
||||
output: UserSchema,
|
||||
});
|
||||
|
||||
export const updateUser = defineApi({
|
||||
method: "PUT",
|
||||
path: USERS_MEMBER_PATH,
|
||||
params: IdParamsSchema,
|
||||
body: UpdateUserRequestSchema,
|
||||
output: UserSchema,
|
||||
});
|
||||
|
||||
export const deleteUser = defineApi({
|
||||
method: "DELETE",
|
||||
path: USERS_MEMBER_PATH,
|
||||
params: IdParamsSchema,
|
||||
output: z.null(),
|
||||
});
|
||||
|
||||
export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
roles: z.array(z.string()),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
export const ListUsersQuerySchema = z.object({
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
keyword: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
sortField: z.string().optional(),
|
||||
sortOrder: z.enum(["ascend", "descend"]).optional(),
|
||||
});
|
||||
|
||||
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
|
||||
|
||||
/** Ant Design Input submits `""` when empty; treat as null for optional email */
|
||||
const createUserEmailSchema = z
|
||||
.union([z.string(), z.null(), z.undefined()])
|
||||
.transform((v) => {
|
||||
if (v == null) return null;
|
||||
const t = String(v).trim();
|
||||
return t === "" ? null : t;
|
||||
})
|
||||
.pipe(z.union([z.string().email(), z.null()]));
|
||||
|
||||
export const CreateUserRequestSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
email: createUserEmailSchema,
|
||||
roles: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
|
||||
|
||||
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
|
||||
|
||||
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
|
||||
@@ -108,7 +108,7 @@ function mapSkeletonColumn<RecordType extends object>(
|
||||
...c,
|
||||
sorter: false,
|
||||
sortOrder: undefined,
|
||||
render: (_: unknown, __: RecordType) => {
|
||||
render: () => {
|
||||
if (isActionColumn(c)) {
|
||||
const w = actionSkeletonDiameter(c);
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flex, Table, theme } from "antd";
|
||||
import { Table, theme } from "antd";
|
||||
import type { TableProps } from "antd";
|
||||
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -20,9 +20,9 @@ export type DataTableProps<RecordType extends object = object> = {
|
||||
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
|
||||
frameHeight?: number;
|
||||
/** Flex column wrapping frame + optional `bottomExtra` */
|
||||
layoutRef?: Ref<HTMLDivElement | null>;
|
||||
layoutRef?: Ref<HTMLDivElement>;
|
||||
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
|
||||
frameRef?: Ref<HTMLDivElement | null>;
|
||||
frameRef?: Ref<HTMLDivElement>;
|
||||
/** Optional content below the bordered table frame */
|
||||
bottomExtra?: ReactNode;
|
||||
} & TableProps<RecordType>;
|
||||
@@ -77,14 +77,15 @@ export function DataTable<RecordType extends object = object>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<div
|
||||
ref={layoutRef}
|
||||
vertical
|
||||
gap={token.marginSM}
|
||||
style={{
|
||||
flex: "1 1 0%",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: token.marginSM,
|
||||
}}
|
||||
>
|
||||
<div ref={frameRef} style={outerStyle}>
|
||||
@@ -120,6 +121,6 @@ export function DataTable<RecordType extends object = object>(
|
||||
</div>
|
||||
</div>
|
||||
{bottomExtra}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { fetchSessionAndApplyToStore } from "./utils/session";
|
||||
import { useSettingsStore } from "./stores/settings";
|
||||
import { hydrateSession } from "./api/auth";
|
||||
import { installHttpRouter } from "./api/request";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
installHttpRouter(router);
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
@@ -22,11 +25,11 @@ async function enableMocking() {
|
||||
|
||||
enableMocking()
|
||||
.then(async () => {
|
||||
await useAuthStore.persist.rehydrate();
|
||||
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
|
||||
const { isAuthenticated, tokens } = useAuthStore.getState();
|
||||
if (isAuthenticated && tokens) {
|
||||
try {
|
||||
await fetchSessionAndApplyToStore();
|
||||
await hydrateSession();
|
||||
} catch {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
paginatedListSchema,
|
||||
paginatedWithSchema,
|
||||
successWithNullBody,
|
||||
successWithSchema,
|
||||
} from "./createHandler";
|
||||
import { UserSchema } from "@/api/schemas";
|
||||
|
||||
describe("successWithSchema", () => {
|
||||
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
|
||||
expect(() =>
|
||||
successWithSchema(UserSchema, {
|
||||
id: "1",
|
||||
username: "x",
|
||||
} as unknown),
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("returns JSON body with parsed data when valid", async () => {
|
||||
const row = {
|
||||
id: "1",
|
||||
username: "u",
|
||||
avatar: null,
|
||||
email: null,
|
||||
roles: [] as string[],
|
||||
permissions: [] as string[],
|
||||
};
|
||||
const res = successWithSchema(UserSchema, row);
|
||||
const body = (await res.json()) as { code: number; data: typeof row };
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.username).toBe("u");
|
||||
});
|
||||
});
|
||||
|
||||
describe("paginatedListSchema / paginatedWithSchema", () => {
|
||||
it("rejects invalid list shape", () => {
|
||||
const schema = paginatedListSchema(UserSchema);
|
||||
expect(() =>
|
||||
schema.parse({
|
||||
list: [{ id: "1" }],
|
||||
total: 1,
|
||||
}),
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("accepts valid paginated payload via paginatedWithSchema", async () => {
|
||||
const row = {
|
||||
id: "1",
|
||||
username: "u",
|
||||
avatar: null,
|
||||
email: null,
|
||||
roles: [] as string[],
|
||||
permissions: [] as string[],
|
||||
};
|
||||
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
|
||||
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
|
||||
expect(body.data.total).toBe(1);
|
||||
expect(body.data.list).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("successWithNullBody", () => {
|
||||
it("wraps null data with schema validation", async () => {
|
||||
const res = successWithNullBody();
|
||||
const body = (await res.json()) as { code: number; data: null };
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.code).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
import { http } from "msw";
|
||||
import { MOCK_USERS } from "../data";
|
||||
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
||||
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
|
||||
import {
|
||||
withDelay,
|
||||
errorResponse,
|
||||
ERROR_CODES,
|
||||
successWithSchema,
|
||||
successWithNullBody,
|
||||
} from "../createHandler";
|
||||
import {
|
||||
AuthTokensSchema,
|
||||
AuthUserResponseSchema,
|
||||
LoginRequestSchema,
|
||||
PermissionsListSchema,
|
||||
RefreshTokenRequestSchema,
|
||||
RegisterRequestSchema,
|
||||
} from "@/api/schemas";
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
|
||||
|
||||
const GUEST_REFRESH = "mock-guest-refresh";
|
||||
const REGISTERED_ACCESS = "mock-registered-access";
|
||||
const REGISTERED_REFRESH = "mock-registered-refresh";
|
||||
|
||||
/** Last successful register (in-memory) so /user matches the new account in MSW */
|
||||
let mockRegisteredSession: { username: string; email: string | null } | null = null;
|
||||
|
||||
function isGuestAuth(authorization: string | null): boolean {
|
||||
return (authorization ?? "").includes("mock-guest-access");
|
||||
}
|
||||
|
||||
function isRegisteredAuth(authorization: string | null): boolean {
|
||||
return (authorization ?? "").includes(REGISTERED_ACCESS);
|
||||
}
|
||||
|
||||
export const authHandlers = [
|
||||
http.post("/api/auth/login", async ({ request }) => {
|
||||
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
|
||||
await withDelay(300);
|
||||
const body = (await request.json()) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
if (body.username === "admin" && body.password === "admin") {
|
||||
return successResponse({
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await request.json();
|
||||
} catch {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
const parsed = LoginRequestSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
|
||||
}
|
||||
const { username, password } = parsed.data;
|
||||
if (username === "guest" && password === "guest") {
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: "mock-guest-access",
|
||||
refreshToken: GUEST_REFRESH,
|
||||
});
|
||||
}
|
||||
if (username === "admin" && password === "admin") {
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
});
|
||||
@@ -18,20 +61,100 @@ export const authHandlers = [
|
||||
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
|
||||
}),
|
||||
|
||||
http.post("/api/auth/refresh", async () => {
|
||||
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
|
||||
await withDelay(300);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await request.json();
|
||||
} catch {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
const parsed = RegisterRequestSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
|
||||
}
|
||||
const { username, password, email } = parsed.data;
|
||||
if (password.length < 6) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
|
||||
}
|
||||
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
|
||||
if (taken) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
|
||||
}
|
||||
mockRegisteredSession = {
|
||||
username,
|
||||
email: email ?? null,
|
||||
};
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: REGISTERED_ACCESS,
|
||||
refreshToken: REGISTERED_REFRESH,
|
||||
});
|
||||
}),
|
||||
|
||||
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
|
||||
await withDelay(100);
|
||||
return successResponse({
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await request.json();
|
||||
} catch {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
const parsed = RefreshTokenRequestSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
|
||||
}
|
||||
if (parsed.data.refreshToken === GUEST_REFRESH) {
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: "mock-guest-access-refreshed",
|
||||
refreshToken: "mock-guest-refresh-refreshed",
|
||||
});
|
||||
}
|
||||
if (
|
||||
parsed.data.refreshToken === REGISTERED_REFRESH ||
|
||||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
|
||||
) {
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: `${REGISTERED_ACCESS}-refreshed`,
|
||||
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
|
||||
});
|
||||
}
|
||||
return successWithSchema(AuthTokensSchema, {
|
||||
accessToken: "mock-new-access-token",
|
||||
refreshToken: "mock-new-refresh-token",
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("/api/auth/logout", () => successResponse(null)),
|
||||
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
|
||||
|
||||
http.get("/api/auth/user", () => {
|
||||
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (isRegisteredAuth(auth)) {
|
||||
const session = mockRegisteredSession;
|
||||
if (session) {
|
||||
return successWithSchema(AuthUserResponseSchema, {
|
||||
id: "reg-mock",
|
||||
username: session.username,
|
||||
avatar: null,
|
||||
email: session.email,
|
||||
roles: ["editor"],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isGuestAuth(auth)) {
|
||||
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
|
||||
}
|
||||
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
|
||||
return successResponse(userWithoutPermissions);
|
||||
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
|
||||
}),
|
||||
|
||||
http.get("/api/auth/permissions", () => successResponse(MOCK_USERS[0]!.permissions)),
|
||||
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (isRegisteredAuth(auth) && mockRegisteredSession) {
|
||||
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
|
||||
}
|
||||
if (isGuestAuth(auth)) {
|
||||
return successWithSchema(PermissionsListSchema, [] as string[]);
|
||||
}
|
||||
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { http } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { UserSchema } from "@/api/schemas";
|
||||
import { MOCK_USERS } from "../data";
|
||||
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
|
||||
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
|
||||
import {
|
||||
withDelay,
|
||||
errorResponse,
|
||||
ERROR_CODES,
|
||||
paginatedWithSchema,
|
||||
successWithSchema,
|
||||
successWithNullBody,
|
||||
} from "../createHandler";
|
||||
import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users";
|
||||
|
||||
let users = [...MOCK_USERS];
|
||||
|
||||
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
|
||||
|
||||
export const userHandlers = [
|
||||
http.get("/api/users", async ({ request }) => {
|
||||
http.get(USERS_PATH, async ({ request }) => {
|
||||
await withDelay(200);
|
||||
const auth = request.headers.get("authorization");
|
||||
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const { limit, offset } = parsePaginationParams(url.searchParams);
|
||||
const keyword = url.searchParams.get("keyword") ?? "";
|
||||
@@ -16,10 +31,10 @@ export const userHandlers = [
|
||||
const filtered = filterUsers(users, { keyword, role });
|
||||
const list = paginateList(filtered, limit, offset);
|
||||
|
||||
return successResponse({ list, total: filtered.length });
|
||||
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
|
||||
}),
|
||||
|
||||
http.post("/api/users", async ({ request }) => {
|
||||
http.post(USERS_PATH, async ({ request }) => {
|
||||
await withDelay(200);
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const newUser = {
|
||||
@@ -28,26 +43,26 @@ export const userHandlers = [
|
||||
avatar: null,
|
||||
email: typeof body.email === "string" ? body.email : null,
|
||||
roles: (body.roles as string[]) ?? [],
|
||||
permissions: [],
|
||||
permissions: [] as string[],
|
||||
};
|
||||
users.push(newUser);
|
||||
return successResponse(newUser);
|
||||
return successWithSchema(UserSchema, newUser);
|
||||
}),
|
||||
|
||||
http.put("/api/users/:id", async ({ params, request }) => {
|
||||
http.put(USERS_MEMBER_PATH, async ({ params, request }) => {
|
||||
await withDelay(200);
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const idx = users.findIndex((u) => u.id === params.id);
|
||||
if (idx === -1) {
|
||||
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
|
||||
}
|
||||
users[idx] = { ...users[idx], ...body };
|
||||
return successResponse(users[idx]);
|
||||
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
|
||||
return successWithSchema(UserSchema, users[idx]);
|
||||
}),
|
||||
|
||||
http.delete("/api/users/:id", async ({ params }) => {
|
||||
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
|
||||
await withDelay(200);
|
||||
users = users.filter((u) => u.id !== params.id);
|
||||
return successResponse(null);
|
||||
return successWithNullBody();
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { MainLayout } from "@/components/Layout/MainLayout";
|
||||
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
|
||||
import { hydrateSession } from "@/api/auth";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
beforeLoad: ({ location }) => {
|
||||
const { isAuthenticated, user } = useAuthStore.getState();
|
||||
beforeLoad: async ({ location }) => {
|
||||
const { isAuthenticated, user, tokens } = useAuthStore.getState();
|
||||
if (!isAuthenticated) {
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
|
||||
if (tokens && !user) {
|
||||
try {
|
||||
await hydrateSession();
|
||||
} catch {
|
||||
useAuthStore.getState().logout();
|
||||
throw redirect({ to: "/login" });
|
||||
}
|
||||
}
|
||||
|
||||
const { user: nextUser } = useAuthStore.getState();
|
||||
const path = normalizeAppPath(location.pathname);
|
||||
if (path === "/403") return;
|
||||
|
||||
if (!canAccessPath(location.pathname, user?.permissions)) {
|
||||
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
|
||||
throw redirect({ to: "/403" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
background-color: var(--dash-recent-hover-bg);
|
||||
}
|
||||
|
||||
.dash-chart-placeholder {
|
||||
transition: border-color 0.2s ease;
|
||||
.dash-timeline {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.dash-chart-placeholder:hover {
|
||||
border-color: var(--dash-chart-hover-border);
|
||||
.dash-timeline .ant-timeline-item-content {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.dash-skel-stat .ant-skeleton-input {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
|
||||
import type { TablePaginationConfig } from "antd/es/table/interface";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { USER_ENDPOINTS } from "@/api/user";
|
||||
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users";
|
||||
import type { User, CreateUserRequest } from "@/api/schemas";
|
||||
import { z } from "zod/v4";
|
||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { DataTable } from "@/components/DataTable";
|
||||
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
|
||||
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
|
||||
import { useCrudToasts } from "@/hooks/useCrudToasts";
|
||||
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
|
||||
import { Toolbar } from "./-Toolbar";
|
||||
import { FormModal } from "./-FormModal";
|
||||
|
||||
@@ -22,17 +23,13 @@ const UserSearchParamsSchema = z.object({
|
||||
role: z.string().catch(""),
|
||||
});
|
||||
|
||||
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
|
||||
|
||||
export const Route = createFileRoute("/_auth/users/")({
|
||||
validateSearch: (search) => UserSearchParamsSchema.parse(search),
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
|
||||
|
||||
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
|
||||
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
|
||||
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
|
||||
|
||||
function UsersPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
@@ -43,119 +40,67 @@ function UsersPage() {
|
||||
const toolbarRowRef = useRef<HTMLDivElement>(null);
|
||||
const middleSectionRef = useRef<HTMLDivElement>(null);
|
||||
const tableFrameRef = useRef<HTMLDivElement>(null);
|
||||
const tableAvailableRef = useRef(0);
|
||||
/**
|
||||
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
|
||||
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
|
||||
*/
|
||||
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
|
||||
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
|
||||
);
|
||||
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
|
||||
return Math.max(120, maxH - 40);
|
||||
});
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [keywordInput, setKeywordInput] = useState(search.keyword);
|
||||
const currentPage = Math.floor(search.offset / search.limit) + 1;
|
||||
|
||||
useEffect(() => {
|
||||
setKeywordInput(search.keyword);
|
||||
}, [search.keyword]);
|
||||
const setSearch = (next: UserSearch) => {
|
||||
void navigate({ search: next });
|
||||
};
|
||||
|
||||
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
|
||||
search,
|
||||
setSearch,
|
||||
});
|
||||
|
||||
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
|
||||
message,
|
||||
resourceKey: "users",
|
||||
});
|
||||
|
||||
const listQuery = {
|
||||
limit: search.limit,
|
||||
offset: search.offset,
|
||||
keyword: search.keyword || undefined,
|
||||
role: search.role || undefined,
|
||||
sortField: search.sortField ?? undefined,
|
||||
sortOrder: search.sortOrder ?? undefined,
|
||||
};
|
||||
|
||||
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
|
||||
{ list: User[]; total: number },
|
||||
CreateUserRequest,
|
||||
CreateUserRequest & { id: string }
|
||||
>({
|
||||
queryKey: [
|
||||
"users",
|
||||
search.limit,
|
||||
search.offset,
|
||||
search.keyword,
|
||||
search.role,
|
||||
search.sortField,
|
||||
search.sortOrder,
|
||||
],
|
||||
invalidateKey: ["users"],
|
||||
queryFn: () =>
|
||||
httpClient.get(USER_ENDPOINTS.list, {
|
||||
params: {
|
||||
limit: search.limit,
|
||||
offset: search.offset,
|
||||
keyword: search.keyword || undefined,
|
||||
role: search.role || undefined,
|
||||
sortField: search.sortField ?? undefined,
|
||||
sortOrder: search.sortOrder ?? undefined,
|
||||
},
|
||||
}),
|
||||
select: (raw) => paginatedUserSchema.shape.data.parse(raw),
|
||||
createFn: (values) =>
|
||||
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
|
||||
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
|
||||
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
|
||||
queryKey: userQueryKeys.list(listQuery),
|
||||
queryFn: () => listUsers(listQuery),
|
||||
select: (raw) => raw as { list: User[]; total: number },
|
||||
createFn: (values) => createUser(values),
|
||||
updateFn: ({ id, ...values }) => updateUser({ params: { id }, body: values }),
|
||||
deleteFn: (id) => deleteUser({ id }),
|
||||
optimistic: { update: true, delete: true },
|
||||
createLifecycle: {
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: "Created successfully",
|
||||
key: MESSAGE_KEY_USER_CREATE,
|
||||
});
|
||||
onSuccess: (values) => {
|
||||
crudToasts.createLifecycle?.onSuccess?.(values);
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: "Create failed",
|
||||
key: MESSAGE_KEY_USER_CREATE,
|
||||
});
|
||||
},
|
||||
onError: crudToasts.createLifecycle?.onError,
|
||||
},
|
||||
updateLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({
|
||||
content: "Updating…",
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: "Updated successfully",
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
});
|
||||
onMutate: crudToasts.updateLifecycle?.onMutate,
|
||||
onSuccess: (values) => {
|
||||
crudToasts.updateLifecycle?.onSuccess?.(values);
|
||||
setModalOpen(false);
|
||||
setEditingUser(null);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: "Update failed",
|
||||
key: MESSAGE_KEY_USER_UPDATE,
|
||||
});
|
||||
},
|
||||
onError: crudToasts.updateLifecycle?.onError,
|
||||
},
|
||||
deleteLifecycle: {
|
||||
onMutate: () => {
|
||||
message.loading({
|
||||
content: "Deleting…",
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success({
|
||||
content: "Deleted successfully",
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
message.error({
|
||||
content: "Delete failed",
|
||||
key: MESSAGE_KEY_USER_DELETE,
|
||||
});
|
||||
},
|
||||
onMutate: crudToasts.deleteLifecycle?.onMutate,
|
||||
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
|
||||
onError: crudToasts.deleteLifecycle?.onError,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -268,118 +213,28 @@ function UsersPage() {
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreVertical size={token.fontSize} />}
|
||||
aria-label="Row actions"
|
||||
/>
|
||||
</Dropdown>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const applySearch = (keyword: string) => {
|
||||
void navigate({
|
||||
search: {
|
||||
...search,
|
||||
keyword: keyword.trim(),
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showPagination = (data?.total ?? 0) > search.limit;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const shell = pageShellRef.current;
|
||||
const toolbarEl = toolbarRowRef.current;
|
||||
const mid = middleSectionRef.current;
|
||||
if (!shell || !toolbarEl || !mid) return;
|
||||
|
||||
/** Fixed small table header row height */
|
||||
const headReserve = 40;
|
||||
/** Ant Design Table size="small" row ~39px; slightly higher for Tags / selection column */
|
||||
const rowEstimate = 44;
|
||||
const paginationBlock = 56;
|
||||
|
||||
let rafRetry = 0;
|
||||
const maxRafRetries = 8;
|
||||
|
||||
const measure = () => {
|
||||
const toolbarRect = toolbarEl.getBoundingClientRect();
|
||||
/**
|
||||
* Do not use the page shell bottom: when the table stretches the document, shell.bottom moves down and
|
||||
* `available` becomes too large, so we skip scroll.y. Use the main content box bottom (viewport-bounded) instead.
|
||||
*/
|
||||
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
|
||||
const mainRect = mainEl?.getBoundingClientRect();
|
||||
const viewportBottom =
|
||||
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
|
||||
const clipBottom = mainRect
|
||||
? mainRect.bottom
|
||||
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
|
||||
|
||||
const maxMiddleFromShell = Math.max(
|
||||
0,
|
||||
Math.floor(clipBottom - toolbarRect.bottom - token.marginLG),
|
||||
);
|
||||
|
||||
const midHRaw = mid.clientHeight;
|
||||
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
|
||||
|
||||
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
|
||||
rafRetry += 1;
|
||||
requestAnimationFrame(() => requestAnimationFrame(measure));
|
||||
return;
|
||||
}
|
||||
|
||||
/** Bordered frame now includes Table built-in pagination */
|
||||
const frameInner = Math.max(0, Math.floor(midH));
|
||||
tableAvailableRef.current = frameInner;
|
||||
|
||||
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
|
||||
|
||||
const rowCount = data?.list?.length ?? 0;
|
||||
const pagReserve = showPagination ? paginationBlock : 0;
|
||||
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
|
||||
|
||||
if (isLoading && rowCount === 0) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowCount === 0 && !isLoading) {
|
||||
setTableScrollY(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const naturalBodyH = headReserve + rowCount * rowEstimate;
|
||||
const naturalTotalH = naturalBodyH + pagReserve;
|
||||
const slack = 12;
|
||||
|
||||
/**
|
||||
* Without scroll.y, Ant Design does not scroll .ant-table-body; the full table height moves scrolling to the page.
|
||||
* Row-height estimates can wrongly say “fits”. After clearing scroll.y, re-check frame scrollHeight vs clientHeight.
|
||||
*/
|
||||
if (naturalTotalH + slack > frameInner) {
|
||||
setTableScrollY(bodyMax);
|
||||
return;
|
||||
}
|
||||
|
||||
setTableScrollY(undefined);
|
||||
requestAnimationFrame(() => {
|
||||
const frame = tableFrameRef.current;
|
||||
const avail = tableAvailableRef.current;
|
||||
const minFrame = headReserve + pagReserve;
|
||||
if (!frame || avail <= minFrame) return;
|
||||
if (frame.scrollHeight > frame.clientHeight + 1) {
|
||||
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(shell);
|
||||
ro.observe(mid);
|
||||
return () => ro.disconnect();
|
||||
}, [showPagination, data?.list?.length, isLoading, token.marginLG]);
|
||||
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
|
||||
pageShellRef,
|
||||
toolbarRef: toolbarRowRef,
|
||||
middleRef: middleSectionRef,
|
||||
tableFrameRef,
|
||||
marginLG: token.marginLG,
|
||||
rowCount: data?.list?.length ?? 0,
|
||||
isLoading,
|
||||
showPagination,
|
||||
});
|
||||
|
||||
const tablePagination: false | TablePaginationConfig = useMemo(
|
||||
() =>
|
||||
@@ -415,10 +270,10 @@ function UsersPage() {
|
||||
ref={toolbarRowRef}
|
||||
keywordInput={keywordInput}
|
||||
onKeywordChange={setKeywordInput}
|
||||
onSearch={applySearch}
|
||||
onSearch={applyKeyword}
|
||||
onClearSearch={() => {
|
||||
setKeywordInput("");
|
||||
applySearch("");
|
||||
setSearch({ ...search, keyword: "", offset: 0 });
|
||||
}}
|
||||
roleValue={search.role || undefined}
|
||||
onRoleChange={(role) =>
|
||||
@@ -440,7 +295,7 @@ function UsersPage() {
|
||||
<DataTable<User>
|
||||
layoutRef={middleSectionRef}
|
||||
frameRef={tableFrameRef}
|
||||
lockScrollHeight={tableScrollY != null}
|
||||
lockScrollHeight={lockScrollHeight}
|
||||
maxHeight={tableAreaMaxHeight}
|
||||
frameHeight={
|
||||
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||
import { LoginRequestSchema, AuthTokensSchema } from "@/api/schemas";
|
||||
import { fetchSessionAndApplyToStore } from "@/utils/session";
|
||||
import { hydrateSession, login } from "@/api/auth";
|
||||
import { LoginRequestSchema } from "@/api/schemas";
|
||||
import type { LoginRequest } from "@/api/schemas";
|
||||
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||
import { Theme } from "@/components/Icon";
|
||||
@@ -35,11 +33,9 @@ function LoginPage() {
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (values: LoginRequest) => {
|
||||
const parsed = LoginRequestSchema.parse(values);
|
||||
const tokens = await httpClient.post(AUTH_ENDPOINTS.login, parsed);
|
||||
const validTokens = AuthTokensSchema.parse(tokens);
|
||||
setTokens(validTokens);
|
||||
await fetchSessionAndApplyToStore();
|
||||
const tokens = await login(values);
|
||||
setTokens(tokens);
|
||||
await hydrateSession();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("Login successful");
|
||||
@@ -140,12 +136,7 @@ function LoginPage() {
|
||||
label={<span style={{ fontWeight: 500 }}>Username</span>}
|
||||
rules={[{ required: true, message: "Please enter username" }]}
|
||||
>
|
||||
<Input
|
||||
id="login-username"
|
||||
aria-label="Username"
|
||||
placeholder="admin"
|
||||
size="large"
|
||||
/>
|
||||
<Input id="login-username" aria-label="Username" placeholder="admin" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -192,6 +183,14 @@ function LoginPage() {
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Flex justify="center" style={{ marginTop: token.margin }}>
|
||||
<Typography.Text type="secondary">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/register" style={{ color: token.colorPrimary }}>
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Flex
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { hydrateSession, register } from "@/api/auth";
|
||||
import { RegisterRequestSchema } from "@/api/schemas";
|
||||
import type { RegisterRequest } from "@/api/auth";
|
||||
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||
import { Aurora } from "@/components/Aurora";
|
||||
import { AppFooter } from "@/components/Layout/AppFooter";
|
||||
import "../login/index.css";
|
||||
|
||||
export const Route = createFileRoute("/register/")({
|
||||
beforeLoad: () => {
|
||||
const { isAuthenticated } = useAuthStore.getState();
|
||||
if (isAuthenticated) {
|
||||
throw redirect({ to: "/dashboard" });
|
||||
}
|
||||
},
|
||||
component: RegisterPage,
|
||||
});
|
||||
|
||||
function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const setTokens = useAuthStore((s) => s.setTokens);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (values: RegisterRequest) => {
|
||||
const tokens = await register(values);
|
||||
setTokens(tokens);
|
||||
await hydrateSession();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("Account created successfully");
|
||||
void navigate({ to: "/dashboard" });
|
||||
},
|
||||
onError: (err) => {
|
||||
message.error(err instanceof Error ? err.message : "Registration failed");
|
||||
},
|
||||
});
|
||||
|
||||
const shellStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
isolation: "isolate",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: token.colorBgLayout,
|
||||
color: token.colorText,
|
||||
};
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
flex: "1 1 0%",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
maxWidth: 384,
|
||||
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
|
||||
backdropFilter: "blur(18px) saturate(1.35)",
|
||||
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
|
||||
borderColor: token.colorBorderSecondary,
|
||||
boxShadow: token.boxShadow,
|
||||
["--login-card-fallback-bg" as string]: token.colorBgElevated,
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical style={shellStyle}>
|
||||
<Aurora />
|
||||
<Flex vertical style={contentStyle}>
|
||||
<Flex
|
||||
flex={1}
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ padding: token.padding, minHeight: 0 }}
|
||||
>
|
||||
<Card
|
||||
className="login-page__card"
|
||||
style={cardStyle}
|
||||
styles={{
|
||||
body: { padding: token.paddingLG, background: "transparent" },
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={token.margin}
|
||||
wrap="wrap"
|
||||
style={{ marginBottom: token.marginLG }}
|
||||
>
|
||||
<img
|
||||
src={APP_FAVICON_SRC}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
draggable={false}
|
||||
style={{ display: "block", flexShrink: 0 }}
|
||||
/>
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "-0.025em",
|
||||
lineHeight: 1.2,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{APP_BRAND_NAME}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
const payload = RegisterRequestSchema.parse(values);
|
||||
registerMutation.mutate(payload);
|
||||
}}
|
||||
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
|
||||
validateTrigger={"onBlur"}
|
||||
requiredMark={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={<span style={{ fontWeight: 500 }}>Username</span>}
|
||||
rules={[{ required: true, message: "Please enter your username" }]}
|
||||
>
|
||||
<Input placeholder="new-user" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={<span style={{ fontWeight: 500 }}>Email address</span>}
|
||||
rules={[{ type: "email", message: "Please enter a valid email address" }]}
|
||||
>
|
||||
<Input placeholder="you@example.com" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={<span style={{ fontWeight: 500 }}>Password</span>}
|
||||
rules={[
|
||||
{ required: true, message: "Please enter your password" },
|
||||
{ min: 6, message: "Password must be at least 6 characters long" },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Enter at least 6 characters" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
|
||||
dependencies={["password"]}
|
||||
rules={[
|
||||
{ required: true, message: "Please confirm your password" },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("password") === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("Passwords do not match"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Re-enter your password" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={registerMutation.isPending}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Flex justify="center" style={{ marginTop: token.margin }}>
|
||||
<Typography.Text type="secondary">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" style={{ color: token.colorPrimary }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Flex
|
||||
vertical
|
||||
align="center"
|
||||
style={{
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<AppFooter />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { API_BASE_URL } from "./constants";
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: number;
|
||||
constructor(code: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
|
||||
params?: Record<string, string | number | null | undefined>;
|
||||
};
|
||||
|
||||
function buildUrl(path: string, params?: RequestOptions["params"]): string {
|
||||
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
|
||||
if (!params) return base;
|
||||
const url = new URL(base, window.location.origin);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null) url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem("auth-storage");
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
const token = parsed?.state?.tokens?.accessToken;
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
const url = buildUrl(path, options?.params);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
...(options?.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (json.code !== undefined && json.code !== 0) {
|
||||
throw new ApiError(json.code, json.message ?? "Unknown error");
|
||||
}
|
||||
|
||||
return json.data !== undefined ? json.data : json;
|
||||
}
|
||||
|
||||
export const httpClient = {
|
||||
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("POST", path, body, options),
|
||||
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
|
||||
request<T>("PUT", path, body, options),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>("DELETE", path, undefined, options),
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { AUTH_ENDPOINTS } from "@/api/auth";
|
||||
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
|
||||
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
|
||||
export async function fetchSessionAndApplyToStore(): Promise<void> {
|
||||
const [userBase, permissions] = await Promise.all([
|
||||
httpClient.get(AUTH_ENDPOINTS.user).then((d) => AuthUserResponseSchema.parse(d)),
|
||||
httpClient.get(AUTH_ENDPOINTS.permissions).then((d) => PermissionsListSchema.parse(d)),
|
||||
]);
|
||||
const user = UserSchema.parse({ ...userBase, permissions });
|
||||
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
|
||||
const { setUser, setMenus } = useAuthStore.getState();
|
||||
setUser(user);
|
||||
setMenus(menus);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotFoundPage } from "nextra-theme-docs";
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`(若存在于仓库中)。
|
||||
@@ -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",
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('nextra').MetaRecord} */
|
||||
export default {
|
||||
"proxying-api-requests": "Proxying API Requests",
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Proxying API Requests
|
||||
description: MSW, API base URL, and wiring a real backend
|
||||
---
|
||||
|
||||
# Proxying API Requests
|
||||
|
||||
## MSW (default in development)
|
||||
|
||||
Templates use **MSW** to intercept HTTP in development. Handlers live under `src/mocks/handlers`. You can demo login, menus, and user CRUD without a real backend.
|
||||
|
||||
The **worker directory** is configured via `package.json` **`msw.workerDirectory`** as `public`.
|
||||
|
||||
## API layer (templates)
|
||||
|
||||
Data flow: **Zod schemas → `defineApi` in `src/api/<module>/index.ts` → routes/components call resource functions** (`listUsers`, `login`, …). Do not import axios or `@/api/request` from UI code.
|
||||
|
||||
```text
|
||||
src/api/
|
||||
├── request.ts # axios, envelope unwrap, token refresh
|
||||
├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
|
||||
├── shared/ # API_PREFIX, pagination, params, menu helpers
|
||||
├── auth/ # paths, schemas, login / hydrateSession
|
||||
└── users/ # schemas, CRUD APIs, userQueryKeys, path constants
|
||||
```
|
||||
|
||||
- REST path prefix: **`API_PREFIX`** in `src/api/shared/prefix.ts` (default `/api`). Auth and user paths (`AUTH_ENDPOINTS`, `USERS_PATH`) and **MSW handlers** import the same constants.
|
||||
- New domains: mirror **`users`** — see each app **`.agents/skills/add-resource/SKILL.md`** and **`AGENTS.md`**.
|
||||
|
||||
## Talking to a real API
|
||||
|
||||
Transport lives in **`src/api/request.ts` (axios)**. `baseURL` comes from **`VITE_API_BASE_URL`** (`src/utils/constants.ts`). Call **`defineApi` exports** from `src/api/<module>/index.ts` — not axios from pages.
|
||||
|
||||
Point `VITE_API_BASE_URL` at your gateway and disable or bypass MSW per `main.tsx` / env flags when you no longer need mocks.
|
||||
|
||||
For CORS, configure the gateway the browser can reach, or proxy **`API_PREFIX`** (default `/api`) in `vite.config.ts` `server.proxy`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Deployment
|
||||
description: Deployment guidance and rollout checklist
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
Before production rollout:
|
||||
|
||||
- Validate environment variables
|
||||
- Run full build and tests
|
||||
- Verify critical user journeys
|
||||
- Set up monitoring and error tracking
|
||||
|
||||
Deploy progressively when possible and prepare a rollback plan.
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('nextra').MetaRecord} */
|
||||
export default {
|
||||
"bundle-analysis": "Bundle Analysis",
|
||||
"https-in-development": "HTTPS in Development",
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Bundle Analysis
|
||||
description: Analyze bundle size and optimize package usage
|
||||
---
|
||||
|
||||
# Bundle Analysis
|
||||
|
||||
Use your build tooling and analyzer plugins to inspect bundle composition.
|
||||
|
||||
Focus on:
|
||||
|
||||
- Heavy dependencies
|
||||
- Duplicate packages
|
||||
- Over-imported UI modules
|
||||
|
||||
After optimization, rerun build and compare the output artifacts.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: HTTPS in Development
|
||||
description: Enable HTTPS for local development when needed
|
||||
---
|
||||
|
||||
# HTTPS in Development
|
||||
|
||||
If your scenario requires secure context (for example some browser APIs), enable HTTPS in local dev by configuring your dev server certificates.
|
||||
|
||||
Keep certificates out of version control and share setup instructions with your team.
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('nextra').MetaRecord} */
|
||||
export default {
|
||||
index: "Getting Started",
|
||||
"available-scripts": "Available Scripts",
|
||||
"folder-structure": "Folder Structure",
|
||||
"supported-browsers": "Supported Browsers",
|
||||
"supported-features": "Supported Features",
|
||||
"updating-releases": "Updating Releases",
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Available Scripts
|
||||
description: Common pnpm scripts and usage in this monorepo
|
||||
---
|
||||
|
||||
# Available Scripts
|
||||
|
||||
## Workspace-Level
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm -r build
|
||||
pnpm -r lint
|
||||
pnpm -r test
|
||||
```
|
||||
|
||||
## docs (`apps/docs`)
|
||||
|
||||
```bash
|
||||
pnpm --filter ./apps/docs dev
|
||||
pnpm --filter ./apps/docs build
|
||||
pnpm --filter ./apps/docs start
|
||||
```
|
||||
|
||||
## basic (`apps/basic`)
|
||||
|
||||
```bash
|
||||
pnpm --filter ./apps/basic dev
|
||||
pnpm --filter ./apps/basic build
|
||||
pnpm --filter ./apps/basic preview
|
||||
```
|
||||
|
||||
## with-lingui (`apps/with-lingui`)
|
||||
|
||||
```bash
|
||||
pnpm --filter ./apps/with-lingui dev
|
||||
pnpm --filter ./apps/with-lingui build
|
||||
pnpm --filter ./apps/with-lingui preview
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `--filter` to scope commands to a single app.
|
||||
- Run commands at the repository root for consistent workspace behavior.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Folder Structure
|
||||
description: Key directories and responsibilities in the repository
|
||||
---
|
||||
|
||||
# Folder Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├─ apps/
|
||||
│ ├─ basic/
|
||||
│ ├─ with-lingui/
|
||||
│ └─ docs/
|
||||
├─ packages/
|
||||
├─ pnpm-workspace.yaml
|
||||
└─ turbo.json
|
||||
```
|
||||
|
||||
## Directory Guide
|
||||
|
||||
- `apps/basic`: Main admin template (English)
|
||||
- `apps/with-lingui`: Admin template with Lingui i18n
|
||||
- `apps/docs`: Documentation site (Nextra + Next.js)
|
||||
- `packages`: Shared packages and utilities
|
||||
|
||||
## Template app `src/` (basic / with-lingui)
|
||||
|
||||
Both templates share the same layout; **with-lingui** adds `src/locales/` and Lingui scripts.
|
||||
|
||||
```text
|
||||
src/
|
||||
├── api/
|
||||
│ ├── request.ts # Transport (axios, defineApi, token refresh)
|
||||
│ ├── schemas.ts # Compatibility re-exports
|
||||
│ ├── shared/ # API_PREFIX, pagination, params, menu helpers
|
||||
│ ├── auth/ # Auth paths, schemas, login / hydrateSession
|
||||
│ └── users/ # User CRUD, query keys, path constants
|
||||
├── components/
|
||||
├── hooks/
|
||||
├── mocks/ # MSW handlers (same path constants as api modules)
|
||||
├── routes/
|
||||
├── stores/
|
||||
└── utils/ # Constants, menu tree — not HTTP transport
|
||||
```
|
||||
|
||||
See **[Proxying API Requests](/en/backend/proxying-api-requests)** for the API layer and MSW/backend wiring.
|
||||
|
||||
## Recommendation
|
||||
|
||||
When creating reusable logic, prioritize placing it in `packages` rather than duplicating in app folders.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Getting Started
|
||||
description: Install dependencies, start development, and build applications
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Run Projects
|
||||
|
||||
```bash
|
||||
# Run docs
|
||||
pnpm --filter ./apps/docs dev
|
||||
|
||||
# Run basic template
|
||||
pnpm --filter ./apps/basic dev
|
||||
|
||||
# Run Lingui template
|
||||
pnpm --filter ./apps/with-lingui dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm --filter ./apps/basic build
|
||||
pnpm --filter ./apps/with-lingui build
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Scripts: [Available Scripts](/en/getting-started/available-scripts)
|
||||
- Structure: [Folder Structure](/en/getting-started/folder-structure)
|
||||
- Browser support: [Supported Browsers](/en/getting-started/supported-browsers)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Supported Browsers
|
||||
description: Browser compatibility policy for this project
|
||||
---
|
||||
|
||||
# Supported Browsers
|
||||
|
||||
This project targets modern evergreen browsers:
|
||||
|
||||
- Latest Chrome
|
||||
- Latest Edge
|
||||
- Latest Firefox
|
||||
- Latest Safari
|
||||
|
||||
For old browser support requirements, evaluate and add polyfills case-by-case based on your user base.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Supported Features
|
||||
description: Core features available in the templates
|
||||
---
|
||||
|
||||
# Supported Features
|
||||
|
||||
The templates include:
|
||||
|
||||
- User login and permission-based routing
|
||||
- Dashboard and reusable business pages
|
||||
- Modular API layer (`defineApi`, Zod schemas, shared `API_PREFIX`) and dev proxy / MSW
|
||||
- Theme and layout customization
|
||||
- Mock data support with MSW
|
||||
- Unit tests and E2E tests
|
||||
|
||||
Feature details may vary slightly between `basic` and `with-lingui`.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Updating Releases
|
||||
description: Recommendations for keeping your project up to date
|
||||
---
|
||||
|
||||
# Updating Releases
|
||||
|
||||
To keep your project updated:
|
||||
|
||||
1. Pull the latest code from upstream.
|
||||
2. Run `pnpm install` to refresh lockfile-resolved dependencies.
|
||||
3. Verify with `build`, `lint`, and `test`.
|
||||
4. Resolve breaking changes before releasing.
|
||||
|
||||
For production apps, perform updates on a branch and validate in CI before merging.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Overview
|
||||
description: antd-admin monorepo and docs guide
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
This repository provides two runnable admin templates: **[`apps/basic`](https://github.com/zuiidea/antd-admin/tree/master/apps/basic)** (English UI) and **[`apps/with-lingui`](https://github.com/zuiidea/antd-admin/tree/master/apps/with-lingui)** (Lingui-based bilingual UI).
|
||||
|
||||
The stack centers on **React 19, Ant Design 6, TanStack Router & Query, Vite+ (`vp`), MSW, and Playwright**.
|
||||
|
||||
The sidebar is organized as: Welcome → Getting Started → Templates → Development → Styles & Assets → Building → Testing → Backend Integration → Deployment.
|
||||
|
||||
Start from **[Getting Started](/en/getting-started)**, then choose a template in **[Templates](/en/templates)**.
|
||||
|
||||
GitHub: [zuiidea/antd-admin](https://github.com/zuiidea/antd-admin)
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('nextra').MetaRecord} */
|
||||
export default {
|
||||
"code-splitting": "Code Splitting",
|
||||
"css-modules": "CSS Modules",
|
||||
"images-fonts-files": "Images, Fonts, and Files",
|
||||
"public-folder": "Public Folder",
|
||||
stylesheet: "Stylesheet",
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user