Compare commits

..
16 Commits
Author SHA1 Message Date
cursor[bot] 60ef2fe7f8 Merge pull request #1179 from zuiidea/cursor/upgrade-basic-lingui-deps-dc29
chore(deps): upgrade basic and with-lingui within current majors
2026-10-17 08:37:57 +00:00
Cursor Agentandzuiidea 3d17e6d636 chore(deps): upgrade basic and with-lingui within current majors
Align shared app dependencies, bump Lingui to 6.10, and move Vite+ to 0.3.3.
Drop the removed vite-plus-test override, import tests from vite-plus/test, and
fix TypeScript/lint issues surfaced by antd and oxlint.

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

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

Made-with: Cursor

* chore: regenerate route tree files

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

Made-with: Cursor

* ci: fix with-lingui e2e script name

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Bump packages/create and lockfile where applicable.

Made-with: Cursor
2026-04-27 14:47:55 +08:00
246 changed files with 13125 additions and 13157 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
@@ -0,0 +1,15 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
+15
View File
@@ -0,0 +1,15 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
+15
View File
@@ -0,0 +1,15 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
+26
View File
@@ -0,0 +1,26 @@
# Git hooks(仓库根)
本目录为 **monorepo 根级** Git 钩子。提交时根据暂存文件路径,在对应子项目目录执行检查(例如 `apps/with-lingui` 下执行 `vp staged`,以便正确读取该目录的 `vite.config.ts`)。
模板应用目录内的 `.vite-hooks/`(如 `apps/with-lingui/.vite-hooks/`)留给 **模板单独拷贝出去使用**;在本仓库开发时请使用根级钩子,避免在仓库根执行 `vp staged` 找不到 `vite.config.ts`。
## 启用方式(一次性)
在仓库根目录执行:
```bash
git config core.hooksPath .githooks
```
或使用:
```bash
pnpm run setup:git-hooks
```
验证:
```bash
git config core.hooksPath
# 应输出:.githooks
```
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env sh
# Monorepo pre-commit: run Vite+ / checks in the app that owns staged files.
# Template copies under apps/*/ keep their own .vite-hooks for standalone use; this repo uses root hooksPath instead.
set -e
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FAILED=0
run_in_dir() {
dir="$1"
shift
(cd "$ROOT/$dir" && "$@") || FAILED=1
}
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
run_in_dir apps/with-lingui vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic vp staged
fi
if echo "$STAGED" | grep -q '^packages/create/'; then
run_in_dir . pnpm --dir packages/create run check-types
fi
if echo "$STAGED" | grep -q '^apps/docs/'; then
run_in_dir apps/docs pnpm run lint
fi
exit "$FAILED"
@@ -40,7 +40,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.6
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v4
+51
View File
@@ -0,0 +1,51 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.2
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: I18n check
working-directory: apps/with-lingui
run: pnpm run i18n:check
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+5 -1
View File
@@ -41,6 +41,10 @@ yarn-error.log*
# Git worktrees
.worktrees/
.cursor
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
+51 -230
View File
@@ -1,257 +1,78 @@
# Antd Admin
Balanced admin monorepo: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. Ship either an **English-only** app (`apps/basic`) or a **Lingui i18n** variant (`apps/with-lingui`); see [Templates](#templates) below.
## Introduction
[antd](https://github.com/ant-design/ant-design)
[react](https://react.dev)
[vite](https://viteplus.dev)
[TypeScript](https://www.typescriptlang.org/)
[GitHub issues](https://github.com/zuiidea/antd-admin/issues)
[GitHub stars](https://github.com/zuiidea/antd-admin/stargazers)
[License](http://opensource.org/licenses/MIT)
[PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
## Tech Stack
Available templates:
- `basic`: English-only setup
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
| Category | Technology |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
| UI Framework | [Ant Design 6.x](https://ant.design) |
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
| Async State | [TanStack Query v5](https://tanstack.com/query) |
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
| i18n | [LinguiJS](https://lingui.dev) in `**apps/with-lingui` only** (`apps/basic` is English-only, no Lingui) |
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript 5.9 (strict mode) |
Scaffolding CLI:
- `init-antd-admin` (from `packages/create`)
## Features
## Key Features
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **i18n (with-lingui app)** — LinguiJS with English (`en`) and Chinese (`zh`); Ant Design locales follow the active locale in `apps/with-lingui`
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
- JWT login with access/refresh flow
- Backend-driven menu and permission guards
- URL-synced table state (pagination, sorting, search)
- Dark mode theme switching
- Mock-first local development (no backend required)
- Typed API boundaries and reusable CRUD patterns
- React 19 + Ant Design 6 + TanStack Router/Query + Zustand + Zod
- Optional Lingui-based i18n workflow (`with-lingui`)
- E2E coverage with Playwright
## Templates
## Quick Start
`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
Follow these steps to set up and run the project:
### Prerequisites
- **Node.js 20+**: Ensure you have Node.js installed.
- **pnpm**: Install pnpm globally using `npm install -g pnpm`.
### Installation
1. Clone the repository:
```bash
git clone https://github.com/zuiidea/antd-admin.git
cd antd-admin
```
2. Install dependencies:
```bash
pnpm install
```
### Running the Project
- **Basic App**:
```bash
pnpm --filter apps/basic dev
```
- **Lingui i18n App**:
```bash
pnpm --filter apps/with-lingui dev
```
### Testing
Run Playwright end-to-end tests:
```bash
pnpm test:e2e
```
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
### Install & Run
From the repository root, choose an app under `apps/` (see [Templates](#templates)), then:
```bash
cd apps/with-lingui # or: cd apps/basic
vp install
vp dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
**Default credentials:** `admin` / `admin`
### Build
```bash
vp build
vp preview
```
### E2E Tests
Run from the app package (example: `apps/basic`):
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── locales/ # LinguiJS catalogs (.po + compiled .js)
│ ├── en/messages.po
│ └── zh/messages.po
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, I18n)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, locale, sidebar)
└── main.tsx # Entry point (MSW init → React render)
tests/
└── e2e/ # Playwright E2E tests
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
```
## Internationalization (i18n)
The project uses [LinguiJS](https://lingui.dev) with compile-time macros. Locales: `**en**` (source) and `**zh**`.
### Workflow
```bash
vp run i18n:extract
vp run i18n:compile
```
Ant Design strings follow the active locale via `ConfigProvider` in `__root.tsx`.
## Extending the template
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Drop i18n:** Remove Lingui packages, vite/swc plugins, and replace `t` macros with plain strings (larger refactor).
## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
- Run `vp check --no-fmt` for type/lint validation.
- Core regression coverage lives in the login and users E2E flows.
## Pages
### Common options
| Route | Description |
| ------------ | ----------------------------------------------------------- |
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
| Option | Description |
| ---------------------------- | -------------------------------- |
| `[project-directory]` | Target folder |
| `-e, --example <name | url>` |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
For detailed CLI behavior, see `[packages/create/README.md](./packages/create/README.md)`.
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
## Links
- [Ant Design](https://github.com/ant-design/ant-design)
- [React](https://react.dev)
- [Vite+](https://viteplus.dev)
- [TypeScript](https://www.typescriptlang.org/)
- [Issues](https://github.com/zuiidea/antd-admin/issues)
- [Stars](https://github.com/zuiidea/antd-admin/stargazers)
- [PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
## License
MIT
+3 -2
View File
@@ -1,7 +1,8 @@
---
applyTo: "src/api/**/*.ts,src/utils/http.ts,src/mocks/handlers/**/*.ts,src/mocks/createHandler.ts"
## applyTo: "src/api/**/\*.ts,src/utils/http.ts,src/mocks/handlers/**/\*.ts,src/mocks/createHandler.ts"
description: "Use when editing API clients, schemas, HTTP behavior, response types, or MSW endpoint handlers. Keywords: api, http, schema, zod, handler, endpoint, auth, users."
---
# API Instructions
+3 -2
View File
@@ -1,7 +1,8 @@
---
applyTo: "src/**/*.{ts,tsx,css}"
## applyTo: "src/\*_/_.{ts,tsx,css}"
description: "Use when editing React UI, route pages, components, styles, layout, i18n text, or Ant Design behavior. Keywords: frontend, component, page, ui, layout, style, antd, i18n."
---
# Frontend Instructions
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "src/\*_/_.{ts,tsx}"
## applyTo: "src/_/_.{ts,tsx}"
description: "Use when refactoring existing code for deduplication, modularization, naming cleanup, or maintainability improvements without behavior changes. Keywords: refactor, cleanup, simplify, deduplicate, maintainability."
+1 -1
View File
@@ -1,6 +1,6 @@
---
## applyTo: "tests/**/\*.ts,tests/**/_.tsx,src/mocks/\*\*/_.ts,playwright.config.ts"
## applyTo: "tests/**/.ts,tests/**/_.tsx,src/mocks//_.ts,playwright.config.ts"
description: "Use when adding or editing tests, Playwright cases, MSW handlers, mock data, or test configuration. Keywords: test, e2e, playwright, mock, msw, coverage."
-25
View File
@@ -1,25 +0,0 @@
# oxfmt configuration for antd-admin
# Reference: https://oxc.rs/docs/guide/formatter
# Line width for formatting
line_width = 100
# Indentation
indent_width = 2
use_tabs = false
# Semicolons
semi = true
# Quotes
single_quote = false
# Trailing commas
trailing_comma = "es5"
# Arrows
arrow_parens = "always"
# Spaces
bracket_spacing = true
space_before_function_parentheses = false
+1
View File
@@ -0,0 +1 @@
vp staged
+68 -54
View File
@@ -56,12 +56,9 @@ English-only admin scaffold (no Lingui): MSW mocks, minimal RBAC, full CRUD, and
### Install & Run
From the monorepo root, or after `cd apps/basic`:
```bash
cd apps/basic
vp install
vp dev
pnpm install
pnpm run dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
@@ -71,75 +68,92 @@ Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to
### Build
```bash
vp build
vp preview
pnpm run build
pnpm run preview
```
### Project Setup
```bash
pnpm run prepare
```
### Code Quality
```bash
pnpm run fmt
pnpm run lint
pnpm run check
```
### Unit Tests
```bash
pnpm run test:unit
```
### E2E Tests
```bash
cd apps/basic
pnpm run test:e2e
pnpm run test:e2e:core
pnpm run test:e2e:ui # interactive UI mode
```
`test:e2e:core` runs the scaffold's highest-value flows only: login and users CRUD.
## Project Structure
```
```text
src/
├── api/ # Zod schemas, endpoint constants, type exports
│ ├── schemas.ts # Domain models (User, AuthTokens, MenuItem, etc.)
│ ├── auth.ts # Auth endpoint constants
│ └── user.ts # User CRUD endpoint constants
├── components/
│ ├── Aurora/ # Login background effect
│ ├── Auth/ # Auth (permission gate): index.tsx
│ ├── DataTable/ # Shared table shell, skeleton, empty state
│ ├── FilterToolbar/ # Shared filter/action toolbar
│ ├── FormModal/ # Reusable modal + form shell
│ ├── Icon/ # Shared icons and theme toggle icon
│ └── Layout/ # Admin shell (sidebar, header, main)
│ ├── MainLayout/ # Main layout shell: index.tsx
│ ├── AppFooter/ # Login footer: Powered by + GitHub → zuiidea/antd-admin
│ ├── Sidebar/ # Dynamic menu sidebar: index.tsx
│ ├── UserMenu/ # User dropdown in sidebar: index.tsx + index.css
│ └── Header/ # Top bar: index.tsx
├── hooks/
│ ├── tokenBuilders.ts # Shared Ant Design token/config builders
│ ├── useAppTheme.ts # Theme selection hook (ConfigProvider)
│ ├── usePermission.ts # Permission check hook
│ └── useResourceCRUD.ts # Shared CRUD query/mutation wiring
├── utils/
│ ├── constants.ts # API base URL, static assets
│ └── http.ts # HTTP client with JWT injection & error handling
├── mocks/
│ ├── browser.ts # MSW worker setup
│ ├── createHandler.ts # Shared MSW success/error/delay helpers
│ ├── data.ts # Mock seed data (users, menus)
│ ├── utils.ts # Mock-only helpers (filters, pagination, demo avatar URLs)
│ └── handlers/ # Request handlers (auth, user CRUD)
├── routes/ # TanStack Router file-based routes
│ ├── __root.tsx # Root layout (QueryClient, ConfigProvider, en_US)
│ ├── _auth.tsx # Auth guard layout (redirects to /login)
├── api/ # Zod models + endpoint contracts
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
│ ├── FormModal/ # Generic modal form wrapper
│ ├── FilterToolbar/ # Reusable list page toolbar
│ ├── Auth/ # Permission gate component
│ ├── Aurora/ # Login page background effect
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
│ ├── NotFound/ # Not-found visual component
│ └── RouteError.tsx # Route-level error boundary UI
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
│ ├── useAppTheme.ts
│ ├── usePermission.ts
│ ├── useResourceCRUD.ts
│ ├── useCrudToasts.ts
│ ├── useUrlSearchState.ts
│ ├── useTableFitHeight.ts
│ └── tokenBuilders.ts
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
│ ├── browser.ts
│ ├── createHandler.ts
│ ├── createHandler.test.ts
│ ├── data.ts
│ ├── utils.ts
│ └── handlers/
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # QueryClient + ConfigProvider (en_US) wiring
│ ├── _auth.tsx # Protected route layout
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx # Full CRUD with URL-synced search params
│ ├── _auth/users/index.tsx
│ ├── _auth/users/-Toolbar.tsx
│ ├── _auth/users/-FormModal.tsx
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── register/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx # Redirects / → /login
├── stores/
│ ├── auth.ts # Auth store (tokens, user, menus, permissions)
│ ├── createPersistentStore.ts # Shared persisted-store factory
│ └── settings.ts # Settings store (darkMode, sidebar)
└── main.tsx # Entry point (MSW init → React render)
│ └── index.tsx
├── stores/ # Persisted auth/settings stores
├── utils/ # HTTP client, constants, session/menu helpers
├── main.tsx # App bootstrap (MSW init + React render)
└── routeTree.gen.ts # Generated TanStack route tree
e2e/ # Playwright E2E tests
e2e/ # Playwright E2E suites
├── helpers.ts
├── login.spec.ts
└── users.spec.ts
├── users.spec.ts
├── auth-refresh.spec.ts
├── rbac.spec.ts
└── url-state.spec.ts
```
## Language & i18n
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
+9 -27
View File
@@ -9,34 +9,16 @@ export async function loginAsAdmin(page: Page) {
await expect(page).toHaveURL(/dashboard/);
}
/** Navigate to the users page */
export async function gotoUsers(page: Page): Promise<void> {
export async function gotoUsers(page: Page) {
await loginAsAdmin(page);
await page.goto("/users");
await page.waitForSelector(".ant-table-thead");
await expect(page).toHaveURL(/users/);
}
/** Verify table headers are visible */
export async function verifyTableHeaders(page: Page): Promise<void> {
const thead = page.locator(".ant-table-thead");
await Promise.all([
thead.getByText(/Username|用户名/).isVisible(),
thead.getByText(/Email|邮箱/).isVisible(),
thead.getByText(/Roles|角色/).isVisible(),
]);
}
/** Search for a user by keyword */
export async function searchUser(page: Page, keyword: string): Promise<void> {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill(keyword);
await searchInput.press("Enter");
await page.waitForURL((url) => url.searchParams.get("keyword") === keyword);
}
/** Verify role-based filtering */
export async function verifyRoleFilter(page: Page, role: string): Promise<void> {
await page.goto(`/users?role=${role}`);
await page.waitForSelector(".ant-table-thead");
const roleCell = page.getByRole("cell", { name: new RegExp(role, "i") });
await roleCell.first().isVisible();
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
+17 -9
View File
@@ -1,10 +1,5 @@
import { test, expect } from "@playwright/test";
import {
gotoUsers,
verifyTableHeaders,
searchUser,
verifyRoleFilter,
} from "./helpers";
import { gotoUsers, loginAsAdmin } from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
@@ -12,11 +7,17 @@ test.describe("User Management", () => {
});
test("should display user table", async ({ page }) => {
await verifyTableHeaders(page);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
});
test("should search users", async ({ page }) => {
await searchUser(page, "zhao.ming");
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -27,7 +28,14 @@ test.describe("User Management", () => {
});
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users?role=admin");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
});
test("should filter users by role", async ({ page }) => {
await verifyRoleFilter(page, "admin");
await expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
});
});
+18 -17
View File
@@ -11,31 +11,32 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vp test run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.100.3",
"@tanstack/react-router": "^1.168.24",
"antd": "^6.3.6",
"lucide-react": "^1.11.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6",
"zustand": "^5.0.12"
"@tanstack/react-query": "^5.104.3",
"@tanstack/react-router": "^1.170.45",
"antd": "^6.6.6",
"lucide-react": "^1.59.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.26",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"msw": "^2.13.6",
"@playwright/test": "^1.64.1",
"@tanstack/router-plugin": "^1.168.47",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react-swc": "^4.3.3",
"msw": "^2.15.0",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest"
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
"vite-plus": "0.3.3"
},
"packageManager": "pnpm@10.33.2",
"msw": {
"workerDirectory": [
"public"
-1
View File
@@ -1,6 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
/** Node global when `tsconfig` does not include `@types/node` (pre-commit may lint before lockfile updates). */
declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI);
+1878 -2507
View File
File diff suppressed because it is too large Load Diff
+124 -99
View File
@@ -7,111 +7,114 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.12.12";
const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener("install", function () {
self.skipWaiting();
});
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener("message", async function (event) {
const clientId = Reflect.get(event.source || {}, "id");
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return;
return
}
const client = await self.clients.get(clientId);
const client = await self.clients.get(clientId)
if (!client) {
return;
return
}
const allClients = await self.clients.matchAll({
type: "window",
});
type: 'window',
})
switch (event.data) {
case "KEEPALIVE_REQUEST": {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: "KEEPALIVE_RESPONSE",
});
break;
type: 'KEEPALIVE_RESPONSE',
})
break
}
case "INTEGRITY_CHECK_REQUEST": {
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: "INTEGRITY_CHECK_RESPONSE",
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
})
break
}
case "MOCK_ACTIVATE": {
activeClientIds.add(clientId);
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: "MOCKING_ENABLED",
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
})
break
}
case "CLIENT_CLOSED": {
activeClientIds.delete(clientId);
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
self.registration.unregister()
}
break;
break
}
}
});
})
addEventListener("fetch", function (event) {
const requestInterceptedAt = Date.now();
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === "navigate") {
return;
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
return;
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
return
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
@@ -119,23 +122,38 @@ addEventListener("fetch", function (event) {
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone();
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: "RESPONSE",
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
@@ -143,19 +161,21 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response;
return response
}
/**
@@ -167,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client;
return client
}
if (client?.frameType === "top-level") {
return client;
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: "window",
});
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === "visible";
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
return activeClientIds.has(client.id)
})
}
/**
@@ -203,34 +223,36 @@ async function resolveMainClient(event) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get("accept");
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(",").map((value) => value.trim());
const filteredValues = values.filter((value) => value !== "msw/passthrough");
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set("accept", filteredValues.join(", "));
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete("accept");
headers.delete('accept')
}
}
return fetch(requestClone, { headers });
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
@@ -238,15 +260,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: "REQUEST",
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
@@ -254,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
},
},
[serializedRequest.body],
);
)
switch (clientMessage.type) {
case "MOCK_RESPONSE": {
return respondWithMock(clientMessage.data);
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case "PASSTHROUGH": {
return passthrough();
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough();
return passthrough()
}
/**
@@ -277,18 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
return reject(event.data.error)
}
resolve(event.data);
};
resolve(event.data)
}
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
@@ -301,17 +326,17 @@ function respondWithMock(response) {
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
return Response.error()
}
const mockedResponse = new Response(response.body, response);
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
})
return mockedResponse;
return mockedResponse
}
/**
@@ -332,5 +357,5 @@ async function serializeRequest(request) {
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
}
-13
View File
@@ -1,13 +0,0 @@
import type { CreateAdminRequest, UpdateAdminRequest, User } from "./schemas";
export const ADMIN_ENDPOINTS = {
list: "/api/admins",
create: "/api/admins",
detail: (id: string) => `/api/admins/${id}`,
update: (id: string) => `/api/admins/${id}`,
delete: (id: string) => `/api/admins/${id}`,
} as const;
export type Admin = User;
export type { CreateAdminRequest, UpdateAdminRequest };
+2 -2
View File
@@ -1,10 +1,10 @@
import type {
AuthTokens,
LoginRequest,
RegisterRequest,
User,
MenuItem,
PermissionsList,
RegisterRequest,
} from "./schemas";
export const AUTH_ENDPOINTS = {
@@ -16,4 +16,4 @@ export const AUTH_ENDPOINTS = {
permissions: "/api/auth/permissions",
} as const;
export type { AuthTokens, LoginRequest, RegisterRequest, User, MenuItem, PermissionsList };
export type { AuthTokens, LoginRequest, User, MenuItem, PermissionsList, RegisterRequest };
-3
View File
@@ -1,3 +0,0 @@
export const LOG_ENDPOINTS = {
list: "/api/logs",
} as const;
-8
View File
@@ -1,8 +0,0 @@
import type { MenuItem } from "./schemas";
export const MENU_ENDPOINTS = {
list: "/api/menus",
update: "/api/menus",
} as const;
export type { MenuItem };
-6
View File
@@ -1,6 +0,0 @@
export const NOTIFICATION_ENDPOINTS = {
list: "/api/notifications",
markRead: (id: string) => `/api/notifications/${id}/read`,
markAllRead: "/api/notifications/read-all",
delete: (id: string) => `/api/notifications/${id}`,
} as const;
-6
View File
@@ -1,6 +0,0 @@
export const PROFILE_ENDPOINTS = {
get: "/api/auth/user",
update: "/api/user/profile",
changePassword: "/api/user/password",
uploadAvatar: "/api/user/avatar",
} as const;
-56
View File
@@ -1,56 +0,0 @@
import { z } from "zod/v4";
// ── Notification ───────────────────────────────────────
export const NotificationSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
type: z.enum(["system", "mention", "task", "alert"]),
read: z.boolean(),
createdAt: z.string(),
createdBy: z.string().nullable().optional(),
});
export type Notification = z.infer<typeof NotificationSchema>;
// ── Audit Log ──────────────────────────────────────────
export const AuditLogSchema = z.object({
id: z.string(),
user: z.string(),
action: z.string(),
resource: z.string(),
resourceId: z.string().nullable(),
detail: z.string().nullable(),
ip: z.string().nullable(),
status: z.enum(["success", "failure"]),
createdAt: z.string(),
});
export type AuditLog = z.infer<typeof AuditLogSchema>;
// ── Profile ────────────────────────────────────────────
export const UpdateProfileSchema = z.object({
realName: z.string().optional(),
nickName: z.string().optional(),
email: z.string().email().optional(),
mobile: z.string().optional(),
department: z.string().optional(),
remark: z.string().optional(),
avatar: z.string().optional(),
});
export type UpdateProfile = z.infer<typeof UpdateProfileSchema>;
export const ChangePasswordSchema = z.object({
oldPassword: z.string().min(1),
newPassword: z.string().min(6),
confirmPassword: z.string().min(6),
}).refine(
(data) => data.newPassword === data.confirmPassword,
{ message: "Passwords do not match", path: ["confirmPassword"] },
);
export type ChangePassword = z.infer<typeof ChangePasswordSchema>;
+18 -46
View File
@@ -7,15 +7,6 @@ export const UserSchema = z.object({
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
realName: z.string().nullable().optional(),
nickName: z.string().nullable().optional(),
mobile: z.string().nullable().optional(),
department: z.string().nullable().optional(),
role: z.string().nullable().optional(),
status: z.number().int().optional(),
remark: z.string().nullable().optional(),
lastLogin: z.string().nullable().optional(),
createdBy: z.string().nullable().optional(),
});
export type User = z.infer<typeof UserSchema>;
@@ -31,6 +22,12 @@ export const AuthTokensSchema = z.object({
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
@@ -38,43 +35,24 @@ export const LoginRequestSchema = z.object({
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().optional(),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
const optionalNullableString = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
});
export const CreateAdminRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: z.string().email().nullable().optional(),
realName: optionalNullableString.optional(),
nickName: optionalNullableString.optional(),
mobile: optionalNullableString.optional(),
department: optionalNullableString.optional(),
role: optionalNullableString.optional(),
remark: optionalNullableString.optional(),
status: z.number().int().catch(1),
permissions: z.array(z.string()).default([]),
roles: z.array(z.string()).default(["editor"]),
});
export type CreateAdminRequest = z.infer<typeof CreateAdminRequestSchema>;
export const UpdateAdminRequestSchema = CreateAdminRequestSchema.partial();
export type UpdateAdminRequest = z.infer<typeof UpdateAdminRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
@@ -167,13 +145,7 @@ const createUserEmailSchema = z
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
mobile: z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
}),
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
@@ -1,156 +0,0 @@
import { useState } from "react";
import { Button, Drawer, Empty, Flex, Input, Tag, Typography, theme } from "antd";
import { Bot, Send, Sparkles } from "lucide-react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type ChatRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: ChatRole;
content: string;
};
function buildAssistantReply(prompt: string): string {
const lower = prompt.toLowerCase();
if (lower.includes("menu")) {
return "You can edit menu names, icons, visibility and permissions in Menus page, then save to apply globally.";
}
if (lower.includes("permission") || lower.includes("auth")) {
return "Try checking user permissions first, then route mapping in app menu rules, and finally API response payload.";
}
if (lower.includes("dashboard")) {
return "Dashboard now supports timeline blocks. You can add monthly milestones or deployment events to make it useful.";
}
return "I can help with admin, users, menus, and dashboard changes. Tell me what you want to update.";
}
export function GlobalAIChat() {
const { token } = theme.useToken();
const enabled = useAIStore((s) => s.enabled);
const model = useAIStore((s) => s.model);
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
if (!enabled) return null;
const send = () => {
const text = input.trim();
if (!text) return;
const userMessage: ChatMessage = {
id: `u-${Date.now()}`,
role: "user",
content: text,
};
const assistantMessage: ChatMessage = {
id: `a-${Date.now()}`,
role: "assistant",
content: buildAssistantReply(text),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInput("");
};
return (
<>
{!open ? (
<Button
type="primary"
shape="round"
size="large"
icon={<Sparkles size={token.fontSize} />}
onClick={() => setOpen(true)}
style={{
position: "fixed",
right: token.marginLG,
bottom: token.marginLG,
zIndex: 1001,
boxShadow: token.boxShadowSecondary,
}}
>
Ask AI
</Button>
) : null}
<Drawer
open={open}
title={
<Flex align="center" gap={token.marginXS}>
<Bot size={token.fontSizeLG} />
<span>Global AI Assistant</span>
</Flex>
}
size="large"
onClose={() => setOpen(false)}
destroyOnClose={false}
>
<Flex vertical gap={token.margin} style={{ height: "100%" }}>
<Tag color="blue" variant="filled" style={{ width: "fit-content" }}>
Context: current admin workspace
</Tag>
<Tag color="purple" variant="filled" style={{ width: "fit-content" }}>
Model: {model}
</Tag>
<Flex
vertical
gap={token.marginSM}
style={{
flex: 1,
overflow: "auto",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
padding: token.paddingSM,
}}
>
{messages.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Start a conversation about users, admins, menus, or dashboard."
/>
) : (
messages.map((msg) => (
<Flex
key={msg.id}
justify={msg.role === "user" ? "flex-end" : "flex-start"}
>
<div
style={{
maxWidth: "85%",
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
background:
msg.role === "user" ? token.colorPrimaryBgHover : token.colorFillQuaternary,
}}
>
<Text>{msg.content}</Text>
</div>
</Flex>
))
)}
</Flex>
<Input.TextArea
value={input}
rows={3}
placeholder="Ask anything about this admin app..."
onChange={(e) => setInput(e.target.value)}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
send();
}
}}
/>
<Button type="primary" icon={<Send size={token.fontSize} />} onClick={send}>
Send
</Button>
</Flex>
</Drawer>
</>
);
}
@@ -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,119 +0,0 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { Button, Result, Typography, theme, Flex, Collapse } from "antd";
import { reportError, ErrorSeverity } from "@/utils/errorReporter";
export interface ErrorBoundaryProps {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const componentStack = errorInfo.componentStack ?? undefined;
reportError(error, ErrorSeverity.ERROR, { componentStack });
this.props.onError?.(error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset);
}
return (
<DefaultErrorFallback
error={this.state.error}
onReset={this.handleReset}
/>
);
}
return this.props.children;
}
}
function DefaultErrorFallback({
error,
onReset,
}: {
error: Error;
onReset: () => void;
}) {
const { token } = theme.useToken();
return (
<Flex
justify="center"
align="center"
style={{
minHeight: "60vh",
padding: token.paddingLG,
}}
>
<Result
status="error"
title="Something went wrong"
subTitle={
<Flex vertical gap={token.marginSM} style={{ maxWidth: 560 }}>
<Typography.Text type="secondary">{error.message}</Typography.Text>
<Collapse
size="small"
items={[
{
key: "stack",
label: "Error details",
children: (
<Typography.Paragraph
code
style={{
fontSize: token.fontSizeSM,
maxHeight: 200,
overflow: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{error.stack ?? error.message}
</Typography.Paragraph>
),
},
]}
/>
</Flex>
}
extra={[
<Button type="primary" key="retry" onClick={onReset}>
Try again
</Button>,
<Button
key="reload"
onClick={() => {
window.location.reload();
}}
>
Reload page
</Button>,
]}
/>
</Flex>
);
}
@@ -1,105 +0,0 @@
import { Button, App, Tooltip } from "antd";
import { Download } from "lucide-react";
import { useCallback, useState } from "react";
export interface ExportButtonProps<TData extends Record<string, unknown>> {
/** Data to export */
data: TData[];
/** Column definitions: header label and data key or custom accessor */
columns: {
title: string;
dataIndex?: keyof TData & string;
render?: (record: TData) => string;
}[];
/** Filename without extension */
filename?: string;
/** Button text */
children?: React.ReactNode;
/** Disable button */
disabled?: boolean;
/** Click handler before export (e.g. confirm) */
onBeforeExport?: () => boolean | Promise<boolean>;
}
/**
* Reusable CSV export button.
* Works with any tabular data + column definitions.
*/
export function ExportButton<TData extends Record<string, unknown>>({
data,
columns,
filename = "export",
children = "Export CSV",
disabled,
onBeforeExport,
}: ExportButtonProps<TData>) {
const { message } = App.useApp();
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
if (onBeforeExport) {
const ok = await onBeforeExport();
if (!ok) return;
}
setExporting(true);
try {
// Build CSV content
const header = columns.map((c) => `"${c.title}"`).join(",");
const rows = data.map((record) =>
columns
.map((c) => {
let value: unknown;
if (c.render) {
value = c.render(record);
} else if (c.dataIndex) {
value = record[c.dataIndex];
} else {
value = "";
}
// Escape quotes and wrap
const str = String(value ?? "");
return `"${str.replace(/"/g, '""')}"`;
})
.join(","),
);
const csv = [header, ...rows].join("\n");
// Add BOM for Excel UTF-8 compatibility
const bom = "\uFEFF";
const blob = new Blob([bom + csv], { type: "text/csv;charset=utf-8;" });
// Trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success(`Exported ${data.length} rows`);
} catch {
message.error("Export failed");
} finally {
setExporting(false);
}
}, [data, columns, filename, onBeforeExport, message]);
return (
<Tooltip title={`Export ${data.length} rows as CSV`}>
<Button
icon={<Download size={14} />}
onClick={handleExport}
loading={exporting}
disabled={disabled || !data.length}
>
{children}
</Button>
</Tooltip>
);
}
@@ -1,129 +0,0 @@
import { Upload, App, theme } from "antd";
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
import { Upload as UploadIcon, Inbox } from "lucide-react";
import { useState } from "react";
export interface FileUploadProps {
/** Upload endpoint URL */
action?: string;
/** Accepted MIME types, e.g. "image/*,.pdf" */
accept?: string;
/** Maximum file size in bytes */
maxSize?: number;
/** Allow multiple files */
multiple?: boolean;
/** Max file count */
maxCount?: number;
/** Custom on-success callback */
onSuccess?: (file: UploadFile, response: unknown) => void;
/** Custom on-error callback */
onError?: (file: UploadFile, error: unknown) => void;
/** Button text */
children?: React.ReactNode;
/** Custom request override (e.g. MSW) */
customRequest?: UploadProps["customRequest"];
/** Control visibility of file list */
showUploadList?: boolean;
}
/**
* Drag-and-drop file upload component built on Ant Design Upload.
* Supports size/type validation and visual feedback.
*/
export function FileUpload({
action = "/api/files/upload",
accept,
maxSize = 10 * 1024 * 1024, // 10 MB
multiple = false,
maxCount = 5,
onSuccess,
onError,
customRequest,
children,
showUploadList = true,
}: FileUploadProps) {
const { message } = App.useApp();
const { token } = theme.useToken();
const [uploading, setUploading] = useState(false);
const beforeUpload = (file: File): boolean | Promise<File> => {
const allowed = accept?.split(",").map((s) => s.trim());
if (allowed?.length && !allowed.some((a) => matchMime(file, a))) {
message.error(`File type not supported: ${file.type || file.name}`);
return false;
}
if (file.size > maxSize) {
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
message.error(`File exceeds ${maxMB} MB limit: ${file.name}`);
return false;
}
return true;
};
const handleChange: UploadProps["onChange"] = (info) => {
const { file } = info;
if (file.status === "uploading") {
setUploading(true);
}
if (file.status === "done") {
setUploading(false);
onSuccess?.(file, file.response);
}
if (file.status === "error") {
setUploading(false);
onError?.(file, file.error);
}
};
return (
<Upload.Dragger
name="file"
action={action}
accept={accept}
multiple={multiple}
maxCount={maxCount}
beforeUpload={beforeUpload}
onChange={handleChange}
customRequest={customRequest}
showUploadList={showUploadList}
style={{ padding: token.padding }}
>
<p className="ant-upload-drag-icon" style={{ marginBottom: token.marginSM }}>
{uploading ? (
<UploadIcon size={32} style={{ color: token.colorPrimary, opacity: 0.7 }} />
) : (
<Inbox size={32} style={{ color: token.colorTextQuaternary }} />
)}
</p>
<p className="ant-upload-text" style={{ fontWeight: 500 }}>
{children || "Click or drag file to this area to upload"}
</p>
<p className="ant-upload-hint" style={{ color: token.colorTextSecondary }}>
{accept
? `Supported: ${accept}. Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`
: `Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`}
{multiple ? `. Up to ${maxCount} files` : ""}
</p>
</Upload.Dragger>
);
}
function matchMime(file: File, pattern: string): boolean {
if (!pattern) return true;
// Simple glob: "image/*"
if (pattern.endsWith("/*")) {
const base = pattern.slice(0, -2);
return file.type.startsWith(base + "/");
}
// Extension match: ".pdf"
if (pattern.startsWith(".")) {
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
}
// Exact MIME
return file.type === pattern;
}
+98 -273
View File
@@ -1,317 +1,142 @@
/**
* Header — breadcrumb + global actions (notification bell, fullscreen, theme).
*
* ── Architecture ──
* Left: mobile sidebar toggle (lg-) + breadcrumb
* Right: NotificationBell (popover) → Fullscreen → Theme toggle
*
* ── Data flow ──
* breadcrumb ← authStore.menus (flat-tree match) + current route path
* notification badge ← useQuery poll (30s interval)
*/
import {
Badge,
Button,
Divider,
Flex,
Grid,
Layout,
Popover,
Space,
Typography,
theme,
} from "antd";
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import {
Bell,
Book,
BrainCircuit,
Briefcase,
CheckCheck,
CircleDashed,
Fullscreen,
History,
Home,
Minimize2,
PanelLeft,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} from "lucide-react";
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
import type { MenuItem } from "@/api/schemas";
import type { Notification } from "@/api/schemas-ext";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
const { Header: AntHeader } = Layout;
// ── Route → label mapping (fallback when not found in menu tree) ──
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/notifications": "Notifications",
"/logs": "Audit Logs",
"/profile": "Profile",
"/users": "Users",
"/403": "403",
};
// ── Menu icon string → Lucide component ──
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideHistory: History,
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
// ── Utilities ──
/** Normalize pathname: strip trailing slash */
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
/** Flatten a recursive menu tree into a flat list */
function flattenMenuItems(nodes: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
result.push(node);
if (node.kind === "group") { result.push(...flattenMenuItems(node.children)); continue; }
if (node.children?.length) result.push(...flattenMenuItems(node.children));
}
return result;
}
/** Find the deepest menu item matching the current path */
function findBestMenuMatch(nodes: MenuItem[], pathname: string): MenuItem | null {
const p = normalizePath(pathname);
const candidates = flattenMenuItems(nodes)
.filter((n): n is Extract<MenuItem, { kind: "item" }> => {
if (n.kind !== "item") return false;
const target = normalizePath(n.path);
return p === target || p.startsWith(`${target}/`);
});
if (!candidates.length) return null;
candidates.sort((a, b) => b.path.length - a.path.length);
return candidates[0];
}
function resolveMenuIcon(icon: string | null): LucideIcon {
return (icon && MENU_ICON_MAP[icon]) || CircleDashed;
}
// ── Props ──
export type HeaderProps = {
/** Hide breadcrumb; routes may override via `staticData.hideBreadcrumb` */
/**
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
*/
showBreadcrumb?: boolean;
};
// ── Main component ──
export function Header({ showBreadcrumb: showProp = true }: HeaderProps) {
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const menus = useAuthStore((s) => s.menus);
const location = useLocation();
const matches = useMatches();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
handler();
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
const iconSize = token.fontSize;
// ── Breadcrumb ──
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const match = findBestMenuMatch(menus, path);
const leafLabel = match?.name ?? PATH_LABEL[path] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon = path === "/403" ? ShieldAlert : match ? resolveMenuIcon(match.icon) : Home;
// Render a breadcrumb link label
const crumb = (IconCmp: LucideIcon, label: ReactNode, link?: "/dashboard") => {
const row = <><IconCmp size={token.fontSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} /><span>{label}</span></>;
const rowStyle = { display: "inline-flex" as const, alignItems: "center" as const, gap: token.marginXS, color: "inherit" as const };
return link ? <Link to={link} style={rowStyle}>{row}</Link> : <span style={rowStyle}>{row}</span>;
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
const row = (
<>
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
<span>{label}</span>
</>
);
const rowStyle = {
display: "inline-flex" as const,
alignItems: "center" as const,
gap: token.marginXS,
color: "inherit" as const,
};
if (linkTo) {
return (
<Link to={linkTo} style={rowStyle}>
{row}
</Link>
);
}
return <span style={rowStyle}>{row}</span>;
};
const onDashboard = path === "/dashboard" || path === "/";
const items = onDashboard
? [{ title: crumb(Home, "Dashboard") }]
: [{ title: crumb(Home, "Dashboard", "/dashboard") }, { title: crumb(leafIcon, leafLabel) },
...(segments.length > 1 ? [{ title: segments.slice(1).join(" / ") }] : [])];
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
const hideByRoute = (matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined)?.hideBreadcrumb === true;
const showBc = showProp && !hideByRoute && items.length > 0;
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel = leafLabelKey;
const breadcrumbItems: ItemType[] = [];
const onDashboard = path === "/dashboard" || path === "/";
if (onDashboard) {
breadcrumbItems.push({
title: crumb(Home, "Dashboard"),
});
} else {
breadcrumbItems.push({
title: crumb(Home, "Dashboard", "/dashboard"),
});
breadcrumbItems.push({
title: crumb(leafIcon, leafLabel),
});
if (segments.length > 1) {
const tail = segments.slice(1).join(" / ");
if (tail) {
breadcrumbItems.push({ title: tail });
}
}
}
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
const showBreadcrumb =
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
return (
<AntHeader style={{ background: "transparent", borderBottom: `1px solid ${token.colorBorderSecondary}`, padding: `0 ${token.padding}px`, gap: token.sizeLG, display: "flex" }}>
{/* Left */}
<Flex align="center" flex={1} style={{ minWidth: 0, overflow: "hidden" }}>
{isMobile && <Button type="text" size="small" onClick={toggleSidebar} icon={<PanelLeft size={token.size} />} aria-label="Toggle sidebar" />}
{showBc && (
<AntHeader
style={{
background: "transparent",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: `0 ${token.padding}px`,
gap: token.sizeLG,
display: "flex",
}}
>
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
{isMobile ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
) : null}
{showBreadcrumb ? (
<>
{isMobile && <Divider vertical />}
{isMobile ? (
<Typography.Text ellipsis style={{ fontSize: token.fontSizeLG, fontWeight: 600 }}>
{leafLabel}
</Typography.Text>
) : (
// Desktop: full Breadcrumb component
<Flex align="center" style={{ minWidth: 0 }}>
{/* antd Breadcrumb expects items, but we render them inline for simplicity */}
{items.map((it, i) => (
<span key={i}>
{i > 0 && <span style={{ margin: `0 ${token.marginXXS}px`, color: token.colorTextQuaternary }}>/</span>}
{it.title}
</span>
))}
</Flex>
)}
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
</>
)}
) : null}
</Flex>
{/* Right */}
<Space>
<NotificationTrigger />
<Button type="text" onClick={() => document.fullscreenElement ? void document.exitFullscreen() : void document.documentElement.requestFullscreen()}
icon={isFullscreen ? <Minimize2 size={token.size} /> : <Fullscreen size={token.size} />} aria-label="Toggle fullscreen" />
<Button type="text" onClick={toggleDarkMode} icon={<Theme size={token.size} />} aria-label="Toggle theme" />
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
</Space>
</AntHeader>
);
}
// ═══════════════════════════════════════════════════════
// Notification Bell (Popover)
// ═══════════════════════════════════════════════════════
/** Inline notification item for the popover dropdown */
function NotifRow({ n, onMark }: { n: Notification; onMark: () => void }) {
const { token } = theme.useToken();
return (
<div
onClick={onMark}
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
cursor: n.read ? "default" : "pointer",
background: n.read ? "transparent" : token.colorFillQuaternary,
borderRadius: token.borderRadius,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Flex justify="space-between" align="start" gap={token.marginXS}>
<Flex vertical gap={2} style={{ minWidth: 0 }}>
<Typography.Text strong={!n.read} ellipsis style={{ fontSize: token.fontSizeSM }}>
{n.title}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ fontSize: 11 }}>
{n.content}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{new Date(n.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
{!n.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
</Flex>
</div>
);
}
function NotificationTrigger() {
const { token } = theme.useToken();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
// Poll unread-only + latest 5 for popover (lightweight)
const { data: unreadList = [] } = useQuery({
queryKey: ["notifications", "bell-unread"],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: { unreadOnly: "true" },
}),
refetchInterval: 30_000,
});
const unreadCount = unreadList.length;
// Load full list when popover opens (for any read ones)
const { data: fullList = [] } = useQuery({
queryKey: ["notifications", "bell-all"],
queryFn: () => httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list),
enabled: open,
});
const merged = open
? [...fullList].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 5)
: [];
const markAsRead = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
});
const content = (
<div style={{ width: 360, maxHeight: 440, overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Flex justify="space-between" align="center" style={{ padding: `${token.paddingSM}px ${token.padding}px`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Typography.Text strong>Notifications</Typography.Text>
{unreadCount > 0 && (
<Button type="link" size="small" icon={<CheckCheck size={token.fontSize} />}
loading={markAll.isPending} onClick={() => markAll.mutate()}>
Mark all read
</Button>
)}
</Flex>
<div style={{ flex: 1, overflow: "auto" }}>
{merged.length === 0 ? (
<Flex justify="center" style={{ padding: token.paddingMD * 2 }}>
<Typography.Text type="secondary">No notifications</Typography.Text>
</Flex>
) : (
merged.map((n) => <NotifRow key={n.id} n={n} onMark={() => !n.read && markAsRead.mutate(n.id)} />)
)}
</div>
<Flex justify="center" style={{ padding: token.paddingSM, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Link to="/notifications" onClick={() => setOpen(false)}>
<Typography.Link>View All Notifications</Typography.Link>
</Link>
</Flex>
</div>
);
return (
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
<Button type="text" icon={<Bell size={token.size} />} aria-label="Notifications" />
</Badge>
</Popover>
);
}
@@ -2,7 +2,6 @@ import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
import { GlobalAIChat } from "@/components/AIChat/GlobalAIChat";
const { Content } = Layout;
@@ -35,7 +34,6 @@ export function MainLayout() {
<Outlet />
</Content>
</Flex>
<GlobalAIChat />
</Layout>
);
}
@@ -8,13 +8,11 @@ import {
Folder,
Home,
PanelLeft,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
BrainCircuit,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
@@ -32,13 +30,6 @@ const MENU_LABELS: Record<string, string> = {
Projects: "Projects",
Dashboard: "Dashboard",
Users: "Users",
Admins: "Admins",
Menus: "Menus",
Settings: "Settings",
Notifications: "Notifications",
Logs: "Audit Logs",
Profile: "Profile",
"AI Settings": "AI Settings",
"Design Engineering": "Design Engineering",
"Sales & Marketing": "Sales & Marketing",
};
@@ -62,8 +53,6 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
IconLucideShieldCheck: ShieldCheck,
IconLucideBrainCircuit: BrainCircuit,
};
function renderMenuIcon(icon: string | null, size = 16) {
+24
View File
@@ -0,0 +1,24 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title="Something went wrong"
subTitle={detail || "Please try again or return to the dashboard."}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
Retry
</Button>
}
/>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: "Created successfully", key: keys.create });
},
onError: () => {
message.error({ content: "Create failed", key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Updated successfully", key: keys.update });
},
onError: () => {
message.error({ content: "Update failed", key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Deleted successfully", key: keys.delete });
},
onError: () => {
message.error({ content: "Delete failed", key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message],
);
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vite-plus/test";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
+114 -17
View File
@@ -1,14 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues, error: unknown) => void;
onError?: (values: TValues) => void;
};
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
queryKey: readonly unknown[];
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,39 +99,73 @@ 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) => {
options.createLifecycle?.onError?.(values, error);
onError: (_error, values) => {
options.createLifecycle?.onError?.(values);
},
});
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) => {
options.updateLifecycle?.onError?.(values, error);
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) => {
options.deleteLifecycle?.onError?.(id, error);
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
},
});
+133
View File
@@ -0,0 +1,133 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const {
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
marginLG,
rowCount,
isLoading,
showPagination,
} = args;
const tableAvailableRef = useRef(0);
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [
showPagination,
rowCount,
isLoading,
marginLG,
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
+5 -17
View File
@@ -3,13 +3,12 @@ import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installGlobalErrorHandlers } from "./utils/errorReporter";
// 全局兜底异常捕获(window.onerror / unhandledrejection)
installGlobalErrorHandlers();
import { installHttpRouter } from "./utils/http";
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
@@ -17,27 +16,16 @@ declare module "@tanstack/react-router" {
}
}
async function enableMocking() {
const enableMockInBuild = import.meta.env.VITE_ENABLE_MOCK === "true";
if (!import.meta.env.DEV && !enableMockInBuild) return;
const { worker } = await import("./mocks/browser");
worker.start({
onUnhandledRequest(_, print) {
if (import.meta.env.DEV) {
// 开发环境下 warn
print.warning();
} else {
// 生产/构建 mock 环境下 error
print.error();
}
},
});
return worker.start({ onUnhandledRequest: "bypass" });
}
enableMocking()
.then(async () => {
await useAuthStore.persist.rehydrate();
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vite-plus/test";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
+33 -21
View File
@@ -1,30 +1,17 @@
import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
/**
* Standard API response format
*/
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
/**
* MSW handler factory functions
* Provides consistent response wrapping and error handling
*/
/**
* Wrap a handler function with standard response format
* Automatically handles delay, error responses, and data wrapping
*/
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
/**
* Create a success response
*/
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
@@ -33,9 +20,37 @@ export function successResponse<T>(data: T, message: string = "ok") {
});
}
/**
* Create an error response
*/
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
export function successWithSchema<T extends ZodTypeAny>(
schema: T,
data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
@@ -44,9 +59,6 @@ export function errorResponse(code: number, message: string, data: unknown = nul
});
}
/**
* Predefined error codes
*/
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
+37 -461
View File
@@ -1,466 +1,42 @@
import type { MenuItem, User } from "@/api/schemas";
import { APP_MENU_TREE } from "@/utils/appMenu";
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
const ADMIN_PERMISSIONS = [
"user:view",
"user:create",
"user:edit",
"user:delete",
"admin:view",
"admin:create",
"admin:edit",
"admin:delete",
];
const EDITOR_PERMISSIONS = ["user:view"];
export interface MockAuthUser extends User {
password: string;
}
function permissionsFromRoles(roles: string[]): string[] {
return roles.includes("admin") ? [...ADMIN_PERMISSIONS] : [...EDITOR_PERMISSIONS];
}
function uniquePermissions(permissions: string[]): string[] {
return [...new Set(permissions)];
}
function createMockAuthUser(seed: {
id: string;
username: string;
password: string;
email: string | null;
roles: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
status?: number;
remark?: string | null;
createdBy?: string | null;
}): MockAuthUser {
return {
id: seed.id,
username: seed.username,
password: seed.password,
avatar: vercelAvatarUrl(seed.username),
email: seed.email,
roles: seed.roles,
permissions: permissionsFromRoles(seed.roles),
realName: seed.realName ?? null,
nickName: seed.nickName ?? null,
mobile: seed.mobile ?? null,
department: seed.department ?? null,
role: seed.role ?? null,
status: seed.status ?? 1,
remark: seed.remark ?? null,
lastLogin: null,
createdBy: seed.createdBy ?? "system",
};
}
const authUsers: MockAuthUser[] = [
createMockAuthUser({
id: "1",
username: "admin",
password: "admin",
email: "ops.admin@northstar.io",
roles: ["admin"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
remark: "Full access account",
}),
createMockAuthUser({
id: "2",
username: "guest",
password: "guest",
email: "guest@northstar.io",
roles: ["editor"],
realName: "Guest User",
nickName: "Guest",
mobile: "15100000000",
department: "External",
role: "Viewer",
remark: "Read-only account",
}),
createMockAuthUser({
id: "3",
username: "zhao.ming",
password: "zhao1234",
email: "zhao.ming@northstar.io",
roles: ["editor"],
realName: "Zhao Ming",
nickName: "Ming",
department: "Product",
role: "Editor",
}),
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
["sarah.chen", "sarah.chen@northstar.io"],
["james.park", "james.park@northstar.io"],
["emma.garcia", "emma.garcia@northstar.io"],
["ryan.kim", "ryan.kim@northstar.io"],
["olivia.tanaka", "olivia.tanaka@northstar.io"],
["liam.patel", "liam.patel@northstar.io"],
["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"],
];
let nextId = authUsers.length + 1;
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
const usersData: User[] = [
{
id: "u1",
username: "olivia",
avatar: vercelAvatarUrl("olivia"),
email: "olivia@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Olivia Martin",
nickName: "Olivia",
mobile: "13600000001",
department: "Product",
role: "Product Manager",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u2",
username: "jackson",
avatar: vercelAvatarUrl("jackson"),
email: "jackson@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Jackson Lee",
nickName: "JL",
mobile: "13600000002",
department: "Sales",
role: "Sales Lead",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
{
id: "u3",
username: "isabella",
avatar: vercelAvatarUrl("isabella"),
email: "isabella@northstar.io",
roles: ["editor"],
permissions: ["user:view"],
realName: "Isabella Nguyen",
nickName: "Isa",
mobile: "13600000003",
department: "Marketing",
role: "Campaign Specialist",
status: 1,
remark: null,
lastLogin: null,
createdBy: "seed",
},
];
const adminsData: User[] = [
{
id: "a1",
username: "admin",
avatar: vercelAvatarUrl("admin"),
email: "ops.admin@northstar.io",
roles: ["admin"],
permissions: ["*"],
realName: "Li Si",
nickName: "Super Admin",
mobile: "13800000000",
department: "Operations",
role: "Administrator",
status: 1,
remark: "Full access account",
lastLogin: null,
createdBy: "system",
},
{
id: "a2",
username: "guest-admin",
avatar: vercelAvatarUrl("guest-admin"),
email: "guest.admin@northstar.io",
roles: ["editor"],
permissions: ["admin:view"],
realName: "Guest Admin",
nickName: "GA",
mobile: "13800000001",
department: "Support",
role: "Support Admin",
status: 1,
remark: null,
lastLogin: null,
createdBy: "system",
},
];
let nextUserId = usersData.length + 1;
let nextAdminId = adminsData.length + 1;
let menusData: MenuItem[] = JSON.parse(JSON.stringify(APP_MENU_TREE)) as MenuItem[];
function stripPassword(user: MockAuthUser): User {
const { password: _password, ...rest } = user;
return rest;
}
export function issueMockTokens(userId: string) {
return {
accessToken: `mock-access-token:${userId}`,
refreshToken: `mock-refresh-token:${userId}`,
};
}
export function parseUserIdFromAccessToken(authHeader: string | null): string | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice(7);
if (!token.startsWith("mock-access-token:")) return null;
return token.slice("mock-access-token:".length) || null;
}
export function parseUserIdFromRefreshToken(token: string | null | undefined): string | null {
if (!token || !token.startsWith("mock-refresh-token:")) return null;
return token.slice("mock-refresh-token:".length) || null;
}
export function listUsers(): User[] {
return usersData.map((u) => ({ ...u }));
}
export function listAdmins(): User[] {
return adminsData.map((u) => ({ ...u }));
}
export function findAdminById(adminId: string): User | undefined {
return adminsData.find((u) => u.id === adminId);
}
export function findAdminByUsername(username: string): User | undefined {
return adminsData.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function findUserById(userId: string): MockAuthUser | undefined {
return authUsers.find((u) => u.id === userId);
}
export function findUserByUsername(username: string): MockAuthUser | undefined {
return authUsers.find((u) => u.username.toLowerCase() === username.trim().toLowerCase());
}
export function authenticateUser(username: string, password: string): MockAuthUser | undefined {
const user = findUserByUsername(username);
if (!user || user.password !== password || user.status !== 1) return undefined;
user.lastLogin = new Date().toISOString();
return user;
}
export function registerUser(payload: {
username: string;
password: string;
email?: string;
}): MockAuthUser {
const user = createMockAuthUser({
id: String(nextId++),
username: payload.username,
password: payload.password,
email: payload.email ?? null,
roles: ["editor"],
realName: payload.username,
nickName: payload.username,
department: "Community",
role: "Editor",
remark: "Registered from mock auth endpoint",
createdBy: "self-register",
});
user.lastLogin = new Date().toISOString();
authUsers.push(user);
return user;
}
export function createUser(payload: {
username: string;
email: string | null;
mobile: string | null;
}): User {
const user: User = {
id: `u${nextUserId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles: ["editor"],
permissions: permissionsFromRoles(["editor"]),
realName: payload.username,
nickName: payload.username,
mobile: payload.mobile,
department: "Operations",
role: "User",
status: 1,
remark: null,
lastLogin: null,
createdBy: "users-page",
};
usersData.push(user);
return { ...user };
}
export function createAdmin(payload: {
username: string;
password: string;
email: string | null;
roles: string[];
permissions: string[];
realName?: string | null;
nickName?: string | null;
mobile?: string | null;
department?: string | null;
role?: string | null;
remark?: string | null;
status?: number;
}): User {
const roles = payload.roles.length ? payload.roles : ["editor"];
const derivedPermissions = payload.permissions.length
? uniquePermissions(payload.permissions)
: permissionsFromRoles(roles);
const user: User = {
id: `a${nextAdminId++}`,
username: payload.username,
avatar: vercelAvatarUrl(payload.username),
email: payload.email,
roles,
permissions: derivedPermissions,
realName: payload.realName ?? payload.username,
nickName: payload.nickName ?? payload.username,
mobile: payload.mobile ?? null,
department: payload.department ?? "Administration",
role: payload.role ?? (roles.includes("admin") ? "Administrator" : "Editor"),
remark: payload.remark ?? null,
status: payload.status ?? 1,
lastLogin: null,
createdBy: "admins-page",
};
adminsData.push(user);
const authUser = createMockAuthUser({
id: user.id,
username: user.username,
password: payload.password,
email: user.email,
roles: user.roles,
realName: user.realName,
nickName: user.nickName,
mobile: user.mobile,
department: user.department,
role: user.role,
status: user.status,
remark: user.remark,
createdBy: user.createdBy,
});
authUser.permissions = user.permissions;
authUsers.push(authUser);
return { ...user };
}
export function updateUser(userId: string, payload: Partial<User>): User | undefined {
const user = usersData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.permissions) user.permissions = payload.permissions;
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
return { ...user };
}
export function updateAdmin(
userId: string,
payload: Partial<User> & { password?: string | null },
): User | undefined {
const user = adminsData.find((u) => u.id === userId);
if (!user) return undefined;
if (payload.username != null) {
user.username = payload.username;
user.avatar = vercelAvatarUrl(payload.username);
}
if (payload.email !== undefined) user.email = payload.email;
if (payload.roles) {
user.roles = payload.roles;
user.permissions = permissionsFromRoles(payload.roles);
}
if (payload.realName !== undefined) user.realName = payload.realName ?? null;
if (payload.nickName !== undefined) user.nickName = payload.nickName ?? null;
if (payload.mobile !== undefined) user.mobile = payload.mobile ?? null;
if (payload.department !== undefined) user.department = payload.department ?? null;
if (payload.role !== undefined) user.role = payload.role ?? null;
if (payload.status !== undefined) user.status = payload.status;
if (payload.remark !== undefined) user.remark = payload.remark ?? null;
if (payload.permissions) {
user.permissions = uniquePermissions(payload.permissions);
}
if (payload.roles && !payload.permissions) {
user.permissions = permissionsFromRoles(payload.roles);
}
const authUser = authUsers.find((u) => u.id === userId);
if (authUser) {
authUser.username = user.username;
authUser.avatar = user.avatar;
authUser.email = user.email;
authUser.roles = user.roles;
authUser.permissions = user.permissions;
authUser.realName = user.realName;
authUser.nickName = user.nickName;
authUser.mobile = user.mobile;
authUser.department = user.department;
authUser.role = user.role;
authUser.status = user.status;
authUser.remark = user.remark;
if (payload.password && payload.password.trim().length > 0) {
authUser.password = payload.password;
}
}
return { ...user };
}
export function deleteAdmin(userId: string): boolean {
const idx = adminsData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
adminsData.splice(idx, 1);
const authIdx = authUsers.findIndex((u) => u.id === userId);
if (authIdx !== -1) {
authUsers.splice(authIdx, 1);
}
return true;
}
export function deleteUser(userId: string): boolean {
const idx = usersData.findIndex((u) => u.id === userId);
if (idx === -1) return false;
usersData.splice(idx, 1);
return true;
}
export function toPublicUser(user: MockAuthUser): User {
return stripPassword(user);
}
export function listMenus(): MenuItem[] {
return JSON.parse(JSON.stringify(menusData)) as MenuItem[];
}
export function saveMenus(nextMenus: MenuItem[]): MenuItem[] {
menusData = JSON.parse(JSON.stringify(nextMenus)) as MenuItem[];
return listMenus();
}
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
avatar: vercelAvatarUrl(username),
email,
roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
}));
-154
View File
@@ -1,154 +0,0 @@
import { http } from "msw";
import {
createAdmin,
deleteAdmin,
findAdminById,
findAdminByUsername,
findUserById,
findUserByUsername,
listAdmins,
listMenus,
parseUserIdFromAccessToken,
saveMenus,
updateAdmin,
} from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
export const adminHandlers = [
http.get("/api/admins", async ({ request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(listAdmins(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return successResponse({ list, total: filtered.length });
}),
http.get("/api/admins/:id", async ({ params, request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const admin = findAdminById(String(params.id));
if (!admin) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(admin);
}),
http.post("/api/admins", async ({ request }) => {
await withDelay(240);
if (!hasPoint(request.headers.get("authorization"), "admin:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username and password are required");
}
if (findAdminByUsername(username) || findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const created = createAdmin({
username,
password,
email: typeof body.email === "string" ? body.email : null,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : ["editor"],
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : [],
realName: typeof body.realName === "string" ? body.realName : null,
nickName: typeof body.nickName === "string" ? body.nickName : null,
mobile: typeof body.mobile === "string" ? body.mobile : null,
department: typeof body.department === "string" ? body.department : null,
role: typeof body.role === "string" ? body.role : null,
remark: typeof body.remark === "string" ? body.remark : null,
status: typeof body.status === "number" ? body.status : 1,
});
return successResponse(created);
}),
http.put("/api/admins/:id", async ({ params, request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const nextUsername = typeof body.username === "string" ? body.username.trim() : undefined;
const existingAdmin = nextUsername ? findAdminByUsername(nextUsername) : undefined;
const existingAuth = nextUsername ? findUserByUsername(nextUsername) : undefined;
if (
(existingAdmin && existingAdmin.id !== String(params.id)) ||
(existingAuth && existingAuth.id !== String(params.id))
) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const updated = updateAdmin(String(params.id), {
username: nextUsername,
email: typeof body.email === "string" || body.email === null ? (body.email as string | null) : undefined,
roles: Array.isArray(body.roles) ? (body.roles as string[]) : undefined,
permissions: Array.isArray(body.permissions) ? (body.permissions as string[]) : undefined,
realName: typeof body.realName === "string" || body.realName === null ? (body.realName as string | null) : undefined,
nickName: typeof body.nickName === "string" || body.nickName === null ? (body.nickName as string | null) : undefined,
mobile: typeof body.mobile === "string" || body.mobile === null ? (body.mobile as string | null) : undefined,
department:
typeof body.department === "string" || body.department === null
? (body.department as string | null)
: undefined,
role: typeof body.role === "string" || body.role === null ? (body.role as string | null) : undefined,
remark: typeof body.remark === "string" || body.remark === null ? (body.remark as string | null) : undefined,
status: typeof body.status === "number" ? body.status : undefined,
password: typeof body.password === "string" && body.password.length > 0 ? body.password : undefined,
});
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(updated);
}),
http.delete("/api/admins/:id", async ({ params, request }) => {
await withDelay(180);
if (!hasPoint(request.headers.get("authorization"), "admin:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const ok = deleteAdmin(String(params.id));
if (!ok) {
return errorResponse(ERROR_CODES.NOT_FOUND, "Admin not found");
}
return successResponse(null);
}),
http.get("/api/menus", async ({ request }) => {
await withDelay(120);
if (!hasPoint(request.headers.get("authorization"), "admin:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
return successResponse(listMenus());
}),
http.put("/api/menus", async ({ request }) => {
await withDelay(220);
if (!hasPoint(request.headers.get("authorization"), "admin:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as { menus?: unknown };
if (!Array.isArray(body?.menus)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid menus payload");
}
return successResponse(saveMenus(body.menus as any));
}),
];
+131 -53
View File
@@ -1,81 +1,159 @@
import { http } from "msw";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
authenticateUser,
findUserById,
findUserByUsername,
issueMockTokens,
parseUserIdFromAccessToken,
parseUserIdFromRefreshToken,
registerUser,
toPublicUser,
} from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
const REGISTERED_REFRESH = "mock-registered-refresh";
/** Last successful register (in-memory) so /user matches the new account in MSW */
let mockRegisteredSession: { username: string; email: string | null } | null = null;
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
function isRegisteredAuth(authorization: string | null): boolean {
return (authorization ?? "").includes(REGISTERED_ACCESS);
}
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
await withDelay(300);
const body = (await request.json()) as {
username: string;
password: string;
};
const user = authenticateUser(body.username, body.password);
if (user) {
return successResponse(issueMockTokens(user.id));
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",
});
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
await withDelay(350);
const body = (await request.json()) as {
username?: string;
password?: string;
email?: string;
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,
};
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
const email = typeof body.email === "string" ? body.email.trim() : undefined;
if (!username || password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register payload");
}
if (findUserByUsername(username)) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already exists");
}
const user = registerUser({ username, password, email });
return successResponse(issueMockTokens(user.id));
return successWithSchema(AuthTokensSchema, {
accessToken: REGISTERED_ACCESS,
refreshToken: REGISTERED_REFRESH,
});
}),
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
const body = (await request.json().catch(() => ({}))) as { refreshToken?: string };
const userId = parseUserIdFromRefreshToken(body.refreshToken);
if (!userId || !findUserById(userId)) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Invalid refresh token");
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
return successResponse(issueMockTokens(userId));
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
if (
parsed.data.refreshToken === REGISTERED_REFRESH ||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
) {
return successWithSchema(AuthTokensSchema, {
accessToken: `${REGISTERED_ACCESS}-refreshed`,
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
}),
http.post("/api/auth/logout", () => successResponse(null)),
http.post("/api/auth/logout", () => successWithNullBody()),
http.get("/api/auth/user", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
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"],
});
}
}
const { permissions: _permissions, ...userWithoutPermissions } = toPublicUser(user);
return successResponse(userWithoutPermissions);
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
}
return successResponse(user.permissions);
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
];
+1 -10
View File
@@ -1,13 +1,4 @@
import { authHandlers } from "./auth";
import { adminHandlers } from "./admin";
import { userHandlers } from "./user";
import { notificationHandlers } from "./notification";
import { logHandlers } from "./log";
export const handlers = [
...authHandlers,
...adminHandlers,
...userHandlers,
...notificationHandlers,
...logHandlers,
];
export const handlers = [...authHandlers, ...userHandlers];
-57
View File
@@ -1,57 +0,0 @@
import { http, HttpResponse, delay } from "msw";
import type { AuditLog } from "@/api/schemas-ext";
const ACTIONS = ["create", "update", "delete", "login", "logout", "export", "import", "approve"];
const RESOURCES = ["user", "role", "menu", "file", "system", "notification", "report"];
const USERS = ["admin", "manager", "alice", "bob", "john_doe", "system"];
function genLogs(): AuditLog[] {
const logs: AuditLog[] = [];
for (let i = 0; i < 42; i++) {
const action = ACTIONS[i % ACTIONS.length];
const resource = RESOURCES[i % RESOURCES.length];
logs.push({
id: `log-${i}`,
user: USERS[i % USERS.length],
action,
resource,
resourceId: `res-${(i % 20) + 1}`,
detail: `${action} ${resource} record #${(i % 20) + 1}`,
ip: `192.168.1.${(i % 254) + 1}`,
status: i % 7 === 0 ? "failure" : "success",
createdAt: new Date(Date.now() - (i * 7200000)).toISOString(),
});
}
return logs;
}
const logs = genLogs();
export const logHandlers = [
http.get("/api/logs", async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const user = url.searchParams.get("user");
const action = url.searchParams.get("action");
const status = url.searchParams.get("status");
const keyword = url.searchParams.get("keyword");
let list = [...logs];
if (user) list = list.filter((l) => l.user === user);
if (action) list = list.filter((l) => l.action === action);
if (status) list = list.filter((l) => l.status === status);
if (keyword) {
const kw = keyword.toLowerCase();
list = list.filter(
(l) =>
l.detail?.toLowerCase().includes(kw) ||
l.resource.toLowerCase().includes(kw) ||
l.user.toLowerCase().includes(kw),
);
}
// Return list directly (standard pattern for list APIs)
return HttpResponse.json(list);
}),
];
@@ -1,122 +0,0 @@
import { http, HttpResponse, delay } from "msw";
import type { Notification } from "@/api/schemas-ext";
let notifications: Notification[] = [
{
id: "n1",
title: "System update available",
content: "Version 6.1.0 is now available with performance improvements and bug fixes.",
type: "system",
read: false,
createdAt: new Date(Date.now() - 3600000).toISOString(),
createdBy: "System",
},
{
id: "n2",
title: "New user registered",
content: "User 'john_doe' has created an account and is pending approval.",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 7200000).toISOString(),
createdBy: "admin",
},
{
id: "n3",
title: "Task assigned: Review PR #42",
content: "You have been assigned to review 'feat: add export functionality'.",
type: "task",
read: false,
createdAt: new Date(Date.now() - 10800000).toISOString(),
createdBy: "manager",
},
{
id: "n4",
title: "Backup completed",
content: "Daily database backup completed successfully. Size: 2.3 GB.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: "System",
},
{
id: "n5",
title: "Security alert: Multiple failed logins",
content: "Detected 15 failed login attempts from IP 192.168.1.100 in the last 5 minutes.",
type: "alert",
read: true,
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
createdBy: "System",
},
{
id: "n6",
title: "Comment on 'Dashboard Redesign'",
content: "Alice commented: 'The new layout looks great! Let's discuss the chart placement.'",
type: "mention",
read: false,
createdAt: new Date(Date.now() - 86400000 * 3).toISOString(),
createdBy: "alice",
},
{
id: "n7",
title: "Monthly report ready",
content: "The April 2026 analytics report has been generated and is ready for review.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 5).toISOString(),
createdBy: "System",
},
{
id: "n8",
title: "Deployment to production",
content: "Release v6.0.0 has been deployed to production environment at 14:30 UTC.",
type: "system",
read: true,
createdAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdBy: "admin",
},
];
export const notificationHandlers = [
http.get("/api/notifications", async ({ request }) => {
await delay(600);
const url = new URL(request.url);
const type = url.searchParams.get("type");
const unreadOnly = url.searchParams.get("unreadOnly") === "true";
let list = notifications.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
if (type) {
list = list.filter((n) => n.type === type);
}
if (unreadOnly) {
list = list.filter((n) => !n.read);
}
return HttpResponse.json({
code: 0,
data: list,
message: "OK",
});
}),
http.put("/api/notifications/:id/read", async ({ params }) => {
await delay(200);
const n = notifications.find((n) => n.id === params.id);
if (n) n.read = true;
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.put("/api/notifications/read-all", async () => {
await delay(300);
notifications.forEach((n) => (n.read = true));
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
http.delete("/api/notifications/:id", async ({ params }) => {
await delay(200);
notifications = notifications.filter((n) => n.id !== params.id);
return HttpResponse.json({ code: 0, data: null, message: "OK" });
}),
];
+35 -34
View File
@@ -1,66 +1,67 @@
import { http } from "msw";
import { createUser, deleteUser, listUsers, updateUser } from "../data";
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 { findUserById, parseUserIdFromAccessToken } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
function hasPoint(authHeader: string | null, point: string): boolean {
const userId = parseUserIdFromAccessToken(authHeader);
const user = userId ? findUserById(userId) : undefined;
if (!user) return false;
return user.permissions.includes("*") || user.permissions.includes(point);
}
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:view")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
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") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(listUsers(), { keyword, role });
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 }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:create")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const newUser = createUser({
username: String(body.username ?? ""),
const newUser = {
id: String(users.length + 1),
username: String(body.username),
avatar: null,
email: typeof body.email === "string" ? body.email : null,
mobile: typeof body.mobile === "string" ? body.mobile : null,
});
return successResponse(newUser);
roles: (body.roles as string[]) ?? [],
permissions: [] as string[],
};
users.push(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:edit")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
const body = (await request.json()) as Record<string, unknown>;
const updated = updateUser(String(params.id), body);
if (!updated) {
const idx = users.findIndex((u) => u.id === params.id);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
return successResponse(updated);
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete("/api/users/:id", async ({ params, request }) => {
http.delete("/api/users/:id", async ({ params }) => {
await withDelay(200);
if (!hasPoint(request.headers.get("authorization"), "user:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
deleteUser(String(params.id));
return successResponse(null);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
}),
];
+49 -203
View File
@@ -9,39 +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 RegisterIndexRouteImport } from './routes/register/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 AuthProfileIndexRouteImport } from './routes/_auth/profile/index'
import { Route as AuthNotificationsIndexRouteImport } from './routes/_auth/notifications/index'
import { Route as AuthMenusIndexRouteImport } from './routes/_auth/menus/index'
import { Route as AuthLogsIndexRouteImport } from './routes/_auth/logs/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthAiSettingsIndexRouteImport } from './routes/_auth/ai-settings/index'
import { Route as AuthAdminsIndexRouteImport } from './routes/_auth/admins/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 AuthAdminsIdRouteImport } from './routes/_auth/admins/$id'
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 RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
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({
@@ -49,29 +32,19 @@ const R404IndexRoute = R404IndexRouteImport.update({
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthProfileIndexRoute = AuthProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => AuthRoute,
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthNotificationsIndexRoute = AuthNotificationsIndexRouteImport.update({
id: '/notifications/',
path: '/notifications/',
getParentRoute: () => AuthRoute,
} as any)
const AuthMenusIndexRoute = AuthMenusIndexRouteImport.update({
id: '/menus/',
path: '/menus/',
getParentRoute: () => AuthRoute,
} as any)
const AuthLogsIndexRoute = AuthLogsIndexRouteImport.update({
id: '/logs/',
path: '/logs/',
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
@@ -79,24 +52,9 @@ const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAiSettingsIndexRoute = AuthAiSettingsIndexRouteImport.update({
id: '/ai-settings/',
path: '/ai-settings/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIndexRoute = AuthAdminsIndexRouteImport.update({
id: '/admins/',
path: '/admins/',
getParentRoute: () => AuthRoute,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminsIdRoute = AuthAdminsIdRouteImport.update({
id: '/admins/$id',
path: '/admins/$id',
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
@@ -105,15 +63,8 @@ export interface FileRoutesByFullPath {
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403/': typeof Auth403IndexRoute
'/admins/': typeof AuthAdminsIndexRoute
'/ai-settings/': typeof AuthAiSettingsIndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/logs/': typeof AuthLogsIndexRoute
'/menus/': typeof AuthMenusIndexRoute
'/notifications/': typeof AuthNotificationsIndexRoute
'/profile/': typeof AuthProfileIndexRoute
'/users/': typeof AuthUsersIndexRoute
}
export interface FileRoutesByTo {
@@ -121,15 +72,8 @@ export interface FileRoutesByTo {
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/admins/$id': typeof AuthAdminsIdRoute
'/403': typeof Auth403IndexRoute
'/admins': typeof AuthAdminsIndexRoute
'/ai-settings': typeof AuthAiSettingsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/logs': typeof AuthLogsIndexRoute
'/menus': typeof AuthMenusIndexRoute
'/notifications': typeof AuthNotificationsIndexRoute
'/profile': typeof AuthProfileIndexRoute
'/users': typeof AuthUsersIndexRoute
}
export interface FileRoutesById {
@@ -139,15 +83,8 @@ export interface FileRoutesById {
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/admins/$id': typeof AuthAdminsIdRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/admins/': typeof AuthAdminsIndexRoute
'/_auth/ai-settings/': typeof AuthAiSettingsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/logs/': typeof AuthLogsIndexRoute
'/_auth/menus/': typeof AuthMenusIndexRoute
'/_auth/notifications/': typeof AuthNotificationsIndexRoute
'/_auth/profile/': typeof AuthProfileIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
}
export interface FileRouteTypes {
@@ -157,32 +94,11 @@ export interface FileRouteTypes {
| '/404/'
| '/login/'
| '/register/'
| '/admins/$id'
| '/403/'
| '/admins/'
| '/ai-settings/'
| '/dashboard/'
| '/logs/'
| '/menus/'
| '/notifications/'
| '/profile/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/404'
| '/login'
| '/register'
| '/admins/$id'
| '/403'
| '/admins'
| '/ai-settings'
| '/dashboard'
| '/logs'
| '/menus'
| '/notifications'
| '/profile'
| '/users'
to: '/' | '/404' | '/login' | '/register' | '/403' | '/dashboard' | '/users'
id:
| '__root__'
| '/'
@@ -190,15 +106,8 @@ export interface FileRouteTypes {
| '/404/'
| '/login/'
| '/register/'
| '/_auth/admins/$id'
| '/_auth/403/'
| '/_auth/admins/'
| '/_auth/ai-settings/'
| '/_auth/dashboard/'
| '/_auth/logs/'
| '/_auth/menus/'
| '/_auth/notifications/'
| '/_auth/profile/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
}
@@ -212,13 +121,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -226,18 +128,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
@@ -247,39 +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
parentRoute: typeof AuthRoute
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/profile/': {
id: '/_auth/profile/'
path: '/profile'
fullPath: '/profile/'
preLoaderRoute: typeof AuthProfileIndexRouteImport
parentRoute: typeof AuthRoute
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/notifications/': {
id: '/_auth/notifications/'
path: '/notifications'
fullPath: '/notifications/'
preLoaderRoute: typeof AuthNotificationsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/menus/': {
id: '/_auth/menus/'
path: '/menus'
fullPath: '/menus/'
preLoaderRoute: typeof AuthMenusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/logs/': {
id: '/_auth/logs/'
path: '/logs'
fullPath: '/logs/'
preLoaderRoute: typeof AuthLogsIndexRouteImport
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
@@ -289,60 +170,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/ai-settings/': {
id: '/_auth/ai-settings/'
path: '/ai-settings'
fullPath: '/ai-settings/'
preLoaderRoute: typeof AuthAiSettingsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/': {
id: '/_auth/admins/'
path: '/admins'
fullPath: '/admins/'
preLoaderRoute: typeof AuthAdminsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admins/$id': {
id: '/_auth/admins/$id'
path: '/admins/$id'
fullPath: '/admins/$id'
preLoaderRoute: typeof AuthAdminsIdRouteImport
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
AuthAdminsIdRoute: typeof AuthAdminsIdRoute
Auth403IndexRoute: typeof Auth403IndexRoute
AuthAdminsIndexRoute: typeof AuthAdminsIndexRoute
AuthAiSettingsIndexRoute: typeof AuthAiSettingsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthLogsIndexRoute: typeof AuthLogsIndexRoute
AuthMenusIndexRoute: typeof AuthMenusIndexRoute
AuthNotificationsIndexRoute: typeof AuthNotificationsIndexRoute
AuthProfileIndexRoute: typeof AuthProfileIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAdminsIdRoute: AuthAdminsIdRoute,
Auth403IndexRoute: Auth403IndexRoute,
AuthAdminsIndexRoute: AuthAdminsIndexRoute,
AuthAiSettingsIndexRoute: AuthAiSettingsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthLogsIndexRoute: AuthLogsIndexRoute,
AuthMenusIndexRoute: AuthMenusIndexRoute,
AuthNotificationsIndexRoute: AuthNotificationsIndexRoute,
AuthProfileIndexRoute: AuthProfileIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
}
+30 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect } from "react";
import { useEffect, useMemo } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
@@ -6,20 +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 { ErrorBoundary } from "@/components/ErrorBoundary";
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";
}, []);
@@ -28,19 +42,18 @@ function RootComponent() {
}, [darkMode]);
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</App>
</ConfigProvider>
</QueryClientProvider>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+14 -3
View File
@@ -2,18 +2,29 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { fetchSessionAndApplyToStore } from "@/utils/session";
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = useAuthStore.getState();
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await fetchSessionAndApplyToStore();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, user?.permissions)) {
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" });
}
},
-108
View File
@@ -1,108 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Avatar, Button, Card, Col, Descriptions, Flex, Row, Space, Spin, Tag, theme } from "antd";
import { httpClient } from "@/utils/http";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/admins/$id")({
component: AdminDetailPage,
});
function AdminDetailPage() {
const { id } = Route.useParams();
const { message } = App.useApp();
const { token } = theme.useToken();
const adminQuery = useQuery({
queryKey: ["admin-detail", id],
queryFn: async () => {
const raw = await httpClient.get(ADMIN_ENDPOINTS.detail(id));
return UserSchema.parse(raw);
},
});
if (adminQuery.isLoading) {
return (
<Flex align="center" justify="center" style={{ minHeight: 240 }}>
<Spin size="large" />
</Flex>
);
}
if (adminQuery.isError || !adminQuery.data) {
return <Card>Admin not found.</Card>;
}
const admin = adminQuery.data;
return (
<Card>
<Row gutter={[24, 24]} align="middle" style={{ marginBottom: token.marginLG }}>
<Col>
<Avatar src={admin.avatar ?? undefined} size={96}>
{admin.username?.[0]?.toUpperCase()}
</Avatar>
</Col>
<Col flex="auto">
<Flex vertical gap={token.marginXS}>
<div>
<strong>Username:</strong> {admin.username}
</div>
<div>
<strong>Real Name:</strong> {admin.realName || "-"}
</div>
<div>
<strong>Nick Name:</strong> {admin.nickName || "-"}
</div>
<Space wrap>
<Tag color={admin.status === 1 ? "success" : "default"}>
{admin.status === 1 ? "Enabled" : "Disabled"}
</Tag>
<Tag>{admin.role || "No title"}</Tag>
{admin.department ? <Tag color="processing">{admin.department}</Tag> : null}
</Space>
<Space style={{ marginTop: token.marginSM }}>
<Button type="primary" onClick={() => message.info("Password reset simulated")}>Reset Password</Button>
<Button
onClick={() => message.info(`${admin.status === 1 ? "Disable" : "Enable"} action simulated`)}
>
{admin.status === 1 ? "Disable Account" : "Enable Account"}
</Button>
</Space>
</Flex>
</Col>
<Col style={{ width: 260 }}>
<div>
<strong>Last Login:</strong>
</div>
<div style={{ color: token.colorTextSecondary }}>{admin.lastLogin || "-"}</div>
<div style={{ marginTop: token.marginSM }}>
<strong>Created By:</strong> {admin.createdBy || "-"}
</div>
</Col>
</Row>
<Descriptions column={1} bordered>
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email || "-"}</Descriptions.Item>
<Descriptions.Item label="Mobile">{admin.mobile || "-"}</Descriptions.Item>
<Descriptions.Item label="Remark">{admin.remark || "-"}</Descriptions.Item>
</Descriptions>
<Card type="inner" title="Permission List" style={{ marginTop: token.marginLG }}>
{admin.permissions.length ? (
<Space size={[8, 8]} wrap>
{admin.permissions.map((permission) => (
<Tag key={permission} color="blue">
{permission}
</Tag>
))}
</Space>
) : (
<div style={{ color: token.colorTextSecondary }}>No permissions</div>
)}
</Card>
</Card>
);
}
@@ -1,104 +0,0 @@
import { Form, Input, Select, Switch } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateAdminRequest, Admin } from "@/api/admin";
import { BaseFormModal } from "@/components/FormModal";
import { APP_PERMISSION_OPTIONS } from "@/utils/appMenu";
export type AdminFormValues = Omit<CreateAdminRequest, "status"> & {
password?: string;
status?: boolean;
};
export type FormModalProps = {
open: boolean;
editingAdmin: Admin | null;
form: FormInstance<AdminFormValues>;
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: AdminFormValues) => void;
};
export function FormModal({
open,
editingAdmin,
form,
confirmLoading,
onCancel,
onFinish,
}: FormModalProps) {
return (
<BaseFormModal<AdminFormValues>
open={open}
title={editingAdmin ? "Edit Admin" : "New Admin"}
okText="Save"
cancelText="Cancel"
form={form}
confirmLoading={confirmLoading}
onCancel={onCancel}
onFinish={onFinish}
>
<Form.Item
name="username"
label="Username"
rules={[{ required: true, message: "Please enter username" }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={
editingAdmin
? [{ min: 6, message: "Password must be at least 6 characters" }]
: [
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
]
}
extra={editingAdmin ? "Leave blank to keep the current password" : undefined}
>
<Input.Password />
</Form.Item>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
<Form.Item name="nickName" label="Nick Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ type: "email", message: "Invalid email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="role" label="Title">
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="Permissions">
<Select mode="multiple" options={[...APP_PERMISSION_OPTIONS]} />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="status" label="Enabled" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
</Form.Item>
</BaseFormModal>
);
}
@@ -1,91 +0,0 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, ShieldCheck } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
canCreate: boolean;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
canCreate,
onCreateClick,
},
ref,
) {
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder="Search admin"
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<ShieldCheck size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[keywordInput, onClearSearch, onKeywordChange, onRoleChange, onSearch, roleValue, token.fontSize],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create Admin
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
@@ -1,364 +0,0 @@
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Card, Dropdown, Flex, Form, Space, Tag, App, theme } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useMemo, useState } from "react";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { ADMIN_ENDPOINTS } from "@/api/admin";
import type { Admin, CreateAdminRequest, UpdateAdminRequest } from "@/api/admin";
import {
CreateAdminRequestSchema,
PaginatedResponseSchema,
UpdateAdminRequestSchema,
UserSchema,
} from "@/api/schemas";
import { httpClient } from "@/utils/http";
import { useAuthStore } from "@/stores/auth";
import { FormModal, type AdminFormValues } from "./-FormModal";
import { Toolbar } from "./-Toolbar";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) return error.message;
return fallback;
}
const AdminSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(20),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch("")
});
export const Route = createFileRoute("/_auth/admins/")({
validateSearch: (search) => AdminSearchParamsSchema.parse(search),
component: AdminsPage,
});
const paginatedAdminSchema = PaginatedResponseSchema(UserSchema);
function AdminsPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const [editingAdmin, setEditingAdmin] = useState<Admin | null>(null);
const [form] = Form.useForm<AdminFormValues>();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("admin:create");
const canEdit = hasPermission("admin:edit");
const canDelete = hasPermission("admin:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: Admin[]; total: number },
CreateAdminRequest,
UpdateAdminRequest & { id: string }
>({
queryKey: [
"admins",
search.limit,
search.offset,
search.keyword,
search.role,
search.sortField,
search.sortOrder,
],
invalidateKey: ["admins"],
queryFn: () =>
httpClient.get(ADMIN_ENDPOINTS.list, {
params: {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
},
}),
select: (raw) => paginatedAdminSchema.shape.data.parse(raw),
createFn: (values) => httpClient.post(ADMIN_ENDPOINTS.create, CreateAdminRequestSchema.parse(values)),
updateFn: ({ id, ...values }) =>
httpClient.put(ADMIN_ENDPOINTS.update(id), UpdateAdminRequestSchema.parse(values)),
deleteFn: (id) => httpClient.delete(ADMIN_ENDPOINTS.delete(id)),
createLifecycle: {
onSuccess: () => {
message.success("Admin created");
setModalOpen(false);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Create admin failed")),
},
updateLifecycle: {
onSuccess: () => {
message.success("Admin updated");
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
},
onError: (_values, error) => message.error(getErrorMessage(error, "Update admin failed")),
},
deleteLifecycle: {
onSuccess: () => message.success("Admin deleted"),
onError: (_id, error) => message.error(getErrorMessage(error, "Delete admin failed")),
},
});
const confirmDelete = (record: Admin) => {
modal.confirm({
title: "Delete admin?",
content: `This will remove ${record.username}.`,
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = useMemo(
() => [
{
title: "Admin",
dataIndex: "username",
key: "username",
width: 320,
render: (_: unknown, record: Admin) => (
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar size={28} src={record.avatar ?? undefined}>
{record.username?.[0]?.toUpperCase()}
</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Link
to="/admins/$id"
params={{ id: record.id }}
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
>
{record.username}
</Link>
<span style={{ color: token.colorTextSecondary, fontSize: token.fontSizeSM }}>
{record.realName || record.email || "No profile info"}
</span>
</Flex>
</Flex>
),
},
{
title: "Role",
dataIndex: "role",
key: "role",
width: 240,
render: (_: unknown, record: Admin) => (
<Space wrap>
{(record.roles ?? []).map((role) => (
<Tag key={role}>{role}</Tag>
))}
</Space>
),
},
{
title: "Permissions",
dataIndex: "permissions",
key: "permissions",
width: 720,
render: (_: unknown, record: Admin) => {
const permissions = record.permissions ?? [];
const isAdmin = (record.roles ?? []).includes("admin");
if (isAdmin || permissions.includes("*")) {
return <Tag color="gold">*</Tag>;
}
return (
<Space size={[4, 4]} wrap>
{permissions.length ? permissions.map((point) => <Tag key={point}>{point}</Tag>) : <Tag>none</Tag>}
</Space>
);
},
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 150,
render: (status: number | undefined) => (
<Tag color={status === 1 ? "success" : "default"}>{status === 1 ? "Enabled" : "Disabled"}</Tag>
),
},
{
title: "Actions",
key: "actions",
width: 120,
align: "right" as const,
render: (_: unknown, record: Admin) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingAdmin(record);
form.setFieldsValue({
...record,
password: "",
status: record.status === 1,
} as AdminFormValues);
setModalOpen(true);
},
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
],
[
canDelete,
canEdit,
form,
modal,
token.colorTextSecondary,
token.fontSize,
token.fontSizeSM,
token.marginSM,
],
);
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const handleSubmit = (values: AdminFormValues) => {
if (editingAdmin && !canEdit) {
message.error("No permission to edit admins");
return;
}
if (!editingAdmin && !canCreate) {
message.error("No permission to create admins");
return;
}
const payload = {
...values,
email: values.email || null,
realName: values.realName || null,
nickName: values.nickName || null,
mobile: values.mobile || null,
department: values.department || null,
role: values.role || null,
remark: values.remark || null,
status: values.status ? 1 : 0,
roles: values.roles?.length ? values.roles : ["editor"],
permissions: values.permissions ?? [],
};
if (editingAdmin) {
const updatePayload = {
...payload,
password: payload.password?.trim() ? payload.password : undefined,
};
updateMutation.mutate({ id: editingAdmin.id, ...updatePayload });
return;
}
createMutation.mutate(payload as CreateAdminRequest);
};
const pagination: TablePaginationConfig = {
current: currentPage,
pageSize: search.limit,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize,
offset: (page - 1) * pageSize,
},
});
},
};
return (
<Card style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Flex vertical gap={token.margin} style={{ flex: 1, minHeight: 0 }}>
<Toolbar
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onClearSearch={() => applySearch("")}
roleValue={search.role || undefined}
onRoleChange={(role) => {
void navigate({
search: {
...search,
role,
offset: 0,
},
});
}}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingAdmin(null);
form.resetFields();
form.setFieldsValue({ roles: ["admin"], permissions: ["admin:view"], status: true });
setModalOpen(true);
}}
/>
<DataTable<Admin>
dataSource={data?.list ?? []}
columns={columns}
rowKey="id"
pagination={pagination}
loading={isLoading}
size="small"
scroll={{ x: "max-content" }}
/>
</Flex>
<FormModal
open={modalOpen}
editingAdmin={editingAdmin}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditingAdmin(null);
form.resetFields();
}}
onFinish={handleSubmit}
/>
</Card>
);
}
@@ -1,84 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { App, Button, Card, Flex, Form, Input, InputNumber, Select, Switch, Typography, theme } from "antd";
import { useMemo } from "react";
import { useAIStore } from "@/stores/ai";
const { Text } = Typography;
type AIFormValues = {
enabled: boolean;
provider: "openai" | "azure-openai" | "anthropic";
model: string;
systemPrompt: string;
temperature: number;
maxTokens: number;
};
export const Route = createFileRoute("/_auth/ai-settings/")({
component: AISettingsPage,
});
function AISettingsPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<AIFormValues>();
const { enabled, provider, model, systemPrompt, temperature, maxTokens, setConfig } = useAIStore();
const initialValues = useMemo(
() => ({ enabled, provider, model, systemPrompt, temperature, maxTokens }),
[enabled, provider, model, systemPrompt, temperature, maxTokens],
);
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="AI Configuration">
<Form
form={form}
layout="vertical"
initialValues={initialValues}
onFinish={(values) => {
setConfig(values);
message.success("AI settings saved");
}}
>
<Form.Item label="Enable AI" name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Provider" name="provider" rules={[{ required: true }]}>
<Select
options={[
{ label: "OpenAI", value: "openai" },
{ label: "Azure OpenAI", value: "azure-openai" },
{ label: "Anthropic", value: "anthropic" },
]}
/>
</Form.Item>
<Form.Item label="Model" name="model" rules={[{ required: true, message: "Model is required" }]}>
<Input placeholder="gpt-5.3-codex" />
</Form.Item>
<Form.Item
label="System Prompt"
name="systemPrompt"
rules={[{ required: true, message: "System prompt is required" }]}
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item label="Temperature" name="temperature">
<InputNumber min={0} max={1} step={0.1} style={{ width: 200 }} />
</Form.Item>
<Form.Item label="Max Tokens" name="maxTokens">
<InputNumber min={256} max={8192} step={128} style={{ width: 200 }} />
</Form.Item>
<Text type="secondary" style={{ display: "block", marginBottom: token.margin }}>
This page configures global AI behavior for the app shell and AI assistants.
</Text>
<Button type="primary" htmlType="submit">
Save AI Settings
</Button>
</Form>
</Card>
</Flex>
);
}
+14 -10
View File
@@ -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)),
}}
/>
);
@@ -197,37 +197,37 @@ function DashboardPage() {
const timelineItems = useMemo(
() => [
{
color: "green",
color: "green" as const,
content: (
<Flex vertical gap={4}>
<Text strong>08:30 Deploy V3.2.0</Text>
<Text strong>08:30 · Deploy V3.2.0</Text>
<Text type="secondary">Release branch merged and production rollout completed.</Text>
</Flex>
),
},
{
color: "blue",
color: "blue" as const,
content: (
<Flex vertical gap={4}>
<Text strong>10:10 Menu policy updated</Text>
<Text strong>10:10 · Menu policy updated</Text>
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
</Flex>
),
},
{
color: "gold",
color: "gold" as const,
content: (
<Flex vertical gap={4}>
<Text strong>13:20 Security review</Text>
<Text strong>13:20 · Security review</Text>
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
</Flex>
),
},
{
color: "red",
color: "red" as const,
content: (
<Flex vertical gap={4}>
<Text strong>15:50 Incident recovery</Text>
<Text strong>15:50 · Incident recovery</Text>
<Text type="secondary">User creation spike handled and queue restored.</Text>
</Flex>
),
@@ -288,7 +288,11 @@ function DashboardPage() {
<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
-440
View File
@@ -1,440 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import {
App,
Button,
Card,
Divider,
Flex,
Form,
Input,
InputNumber,
Switch,
Tree,
Typography,
theme,
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { httpClient } from "@/utils/http";
import { MENU_ENDPOINTS } from "@/api/menu";
import { MenuItemSchema } from "@/api/schemas";
import type { MenuItem } from "@/api/schemas";
import { fetchSessionAndApplyToStore } from "@/utils/session";
import { FolderPlus, Plus, Trash2 } from "lucide-react";
const { Text } = Typography;
export const Route = createFileRoute("/_auth/menus/")({
component: MenusPage,
});
type MenuFormValues = {
name: string;
path: string;
icon: string;
sort: number;
hidden: boolean;
permissionsCsv: string;
};
type NodeContext = {
node: MenuItem;
parentId: string | null;
index: number;
};
function cloneMenus(menus: MenuItem[]): MenuItem[] {
return JSON.parse(JSON.stringify(menus)) as MenuItem[];
}
function nextNodeId(): string {
return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
function withChildren(item: MenuItem, children: MenuItem[]): MenuItem {
if (item.kind === "group") {
return { ...item, children };
}
return { ...item, children: children.length ? children : null };
}
function normalizeSort(items: MenuItem[]): MenuItem[] {
return items.map((item, index) => {
if (item.kind === "group") {
return { ...item, sort: index, children: normalizeSort(item.children) };
}
if (!item.children?.length) {
return { ...item, sort: index };
}
return { ...item, sort: index, children: normalizeSort(item.children) };
});
}
function buildTreeData(items: MenuItem[]): DataNode[] {
return items
.slice()
.sort((a, b) => a.sort - b.sort)
.map((item) => {
const suffix = item.hidden ? " (hidden)" : "";
const title = `${item.name}${suffix}`;
return {
key: item.id,
title: item.kind === "group" ? `${title}` : `${title} ${item.path}`,
children: item.children?.length ? buildTreeData(item.children) : undefined,
};
});
}
function findNodeContext(items: MenuItem[], id: string, parentId: string | null = null): NodeContext | null {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item.id === id) return { node: item, parentId, index };
if (item.children?.length) {
const found = findNodeContext(item.children, id, item.id);
if (found) return found;
}
}
return null;
}
function findNode(items: MenuItem[], id: string): MenuItem | null {
return findNodeContext(items, id)?.node ?? null;
}
function updateNode(items: MenuItem[], id: string, patch: Partial<MenuItem>): MenuItem[] {
return items.map((item) => {
if (item.id === id) {
if (item.kind === "group") {
return {
...item,
name: patch.name ?? item.name,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
return {
...item,
name: patch.name ?? item.name,
path: patch.path && typeof patch.path === "string" ? patch.path : item.path,
icon: patch.icon ?? item.icon,
permissions: patch.permissions ?? item.permissions,
sort: patch.sort ?? item.sort,
hidden: patch.hidden ?? item.hidden,
};
}
if (!item.children?.length) return item;
return withChildren(item, updateNode(item.children, id, patch));
});
}
function removeNode(items: MenuItem[], id: string): { next: MenuItem[]; removed: MenuItem | null } {
let removed: MenuItem | null = null;
const next: MenuItem[] = [];
for (const item of items) {
if (item.id === id) {
removed = item;
continue;
}
if (item.children?.length) {
const childResult = removeNode(item.children, id);
if (childResult.removed) {
removed = childResult.removed;
next.push(withChildren(item, childResult.next));
continue;
}
}
next.push(item);
}
return { next, removed };
}
function insertChild(items: MenuItem[], parentId: string, child: MenuItem): MenuItem[] {
return items.map((item) => {
if (item.id === parentId) {
const currentChildren = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort([...currentChildren, child]));
}
if (!item.children?.length) return item;
return withChildren(item, insertChild(item.children, parentId, child));
});
}
function replaceSiblingList(
items: MenuItem[],
parentId: string | null,
transform: (siblings: MenuItem[]) => MenuItem[],
): MenuItem[] {
if (parentId == null) {
return normalizeSort(transform(items));
}
return items.map((item) => {
if (item.id === parentId) {
const siblings = item.children ? [...item.children] : [];
return withChildren(item, normalizeSort(transform(siblings)));
}
if (!item.children?.length) return item;
return withChildren(item, replaceSiblingList(item.children, parentId, transform));
});
}
function permissionsToCsv(permissions: string[] | null): string {
return permissions?.join(",") ?? "";
}
function csvToPermissions(csv: string): string[] | null {
const list = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return list.length ? list : null;
}
function MenusPage() {
const { message } = App.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm<MenuFormValues>();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draftMenus, setDraftMenus] = useState<MenuItem[] | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["menus"],
queryFn: () => httpClient.get(MENU_ENDPOINTS.list),
select: (raw) => MenuItemSchema.array().parse(raw),
});
const menus = draftMenus ?? data ?? [];
const selectedNode = selectedId ? findNode(menus, selectedId) : null;
const selectedContext = selectedId ? findNodeContext(menus, selectedId) : null;
const treeData = useMemo(() => buildTreeData(menus), [menus]);
const saveMutation = useMutation({
mutationFn: (nextMenus: MenuItem[]) => httpClient.put(MENU_ENDPOINTS.update, { menus: nextMenus }),
onSuccess: async () => {
setDraftMenus(null);
await fetchSessionAndApplyToStore();
message.success("Menus saved");
},
onError: (error) => {
const content = error instanceof Error ? error.message : "Save menus failed";
message.error(content);
},
});
const bindForm = (node: MenuItem | null) => {
if (!node) {
form.resetFields();
return;
}
form.setFieldsValue({
name: node.name,
path: node.path ?? "",
icon: node.icon ?? "",
sort: node.sort,
hidden: node.hidden,
permissionsCsv: permissionsToCsv(node.permissions),
});
};
const createDefaultItem = (name: string): MenuItem => ({
id: nextNodeId(),
kind: "item",
name,
path: `/path-${Date.now()}`,
icon: "IconLucideCircleDashed",
permissions: null,
sort: 0,
hidden: false,
children: null,
});
const createDefaultGroup = (): MenuItem => ({
id: nextNodeId(),
kind: "group",
name: "New Group",
path: null,
icon: "IconLucideSettings",
permissions: null,
sort: 0,
hidden: false,
children: [],
});
const canDrop = () => true;
const handleDrop = (info: {
dragNode: DataNode;
node: DataNode;
dropToGap: boolean;
dropPosition: number;
}) => {
const dragId = String(info.dragNode.key);
const targetId = String(info.node.key);
if (dragId === targetId) return;
const removed = removeNode(menus, dragId);
if (!removed.removed) return;
const targetPosStr = String((info.node as { pos?: string }).pos ?? "0");
const rawPosition = Number(targetPosStr.split("-").at(-1) ?? 0);
const relativeDrop = info.dropPosition - rawPosition;
if (!info.dropToGap) {
const withChild = insertChild(removed.next, targetId, removed.removed);
setDraftMenus(normalizeSort(withChild));
return;
}
const targetCtx = findNodeContext(removed.next, targetId);
if (!targetCtx) return;
const insertIndex = relativeDrop < 0 ? targetCtx.index : targetCtx.index + 1;
const withSibling = replaceSiblingList(removed.next, targetCtx.parentId, (siblings) => {
const next = [...siblings];
next.splice(insertIndex, 0, removed.removed as MenuItem);
return next;
});
setDraftMenus(normalizeSort(withSibling));
};
return (
<Flex vertical gap={token.marginLG} style={{ flex: 1, minHeight: 0 }}>
<Card title="Menus Editor" loading={isLoading}>
<Flex gap={token.marginSM} wrap style={{ marginBottom: token.margin }}>
<Button
icon={<FolderPlus size={token.fontSize} />}
onClick={() => {
const next = normalizeSort([...menus, createDefaultGroup()]);
setDraftMenus(next);
}}
>
Add Group
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedContext}
onClick={() => {
if (!selectedContext) return;
const next = replaceSiblingList(menus, selectedContext.parentId, (siblings) => {
const copy = [...siblings];
copy.splice(selectedContext.index + 1, 0, createDefaultItem("New Item"));
return copy;
});
setDraftMenus(normalizeSort(next));
}}
>
Add Sibling Item
</Button>
<Button
icon={<Plus size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const next = insertChild(menus, selectedNode.id, createDefaultItem("New Child Item"));
setDraftMenus(normalizeSort(next));
}}
>
Add Child Item
</Button>
<Button
danger
icon={<Trash2 size={token.fontSize} />}
disabled={!selectedNode}
onClick={() => {
if (!selectedNode) return;
const removed = removeNode(menus, selectedNode.id);
setDraftMenus(normalizeSort(removed.next));
setSelectedId(null);
form.resetFields();
}}
>
Delete
</Button>
</Flex>
<Divider style={{ marginBlock: token.marginSM }} />
<Flex gap={token.marginLG} align="stretch" style={{ minHeight: 420 }}>
<Card
style={{ flex: "0 0 360px", overflow: "auto" }}
styles={{ body: { padding: token.paddingSM } }}
>
<Tree
draggable
allowDrop={canDrop}
selectedKeys={selectedId ? [selectedId] : []}
treeData={treeData}
onDrop={handleDrop}
onSelect={(keys) => {
const nextId = String(keys[0] ?? "");
setSelectedId(nextId || null);
bindForm(nextId ? findNode(menus, nextId) : null);
}}
/>
</Card>
<Card style={{ flex: 1 }}>
{selectedNode ? (
<Form
form={form}
layout="vertical"
onValuesChange={(_, values) => {
if (!selectedId) return;
const next = updateNode(menus, selectedId, {
name: values.name,
path: selectedNode.kind === "item" ? (values.path || "/") : null,
icon: values.icon || null,
sort: values.sort ?? 0,
hidden: !!values.hidden,
permissions: csvToPermissions(values.permissionsCsv || ""),
});
setDraftMenus(normalizeSort(next));
}}
>
<Form.Item label="Name" name="name" rules={[{ required: true, message: "Name is required" }]}>
<Input />
</Form.Item>
<Form.Item label="Path" name="path" extra="Group nodes should keep path empty">
<Input disabled={selectedNode.kind === "group"} />
</Form.Item>
<Form.Item label="Icon" name="icon">
<Input placeholder="IconLucideSettings" />
</Form.Item>
<Form.Item label="Sort" name="sort">
<InputNumber style={{ width: 180 }} />
</Form.Item>
<Form.Item label="Permissions (comma separated)" name="permissionsCsv">
<Input placeholder="admin:view,user:view" />
</Form.Item>
<Form.Item label="Hidden" name="hidden" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
) : (
<Text type="secondary">Select one menu node to edit, or use the actions above to create nodes.</Text>
)}
</Card>
</Flex>
<Flex justify="flex-end" style={{ marginTop: token.margin }}>
<Button
type="primary"
loading={saveMutation.isPending}
onClick={() => {
void saveMutation.mutate(cloneMenus(menus));
}}
>
Save Menus
</Button>
</Flex>
</Card>
</Flex>
);
}
@@ -1,137 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { App, Badge, Button, Flex, List, Segmented, Space, Tag, Tooltip, Typography, theme } from "antd";
import { AtSign, CheckCheck, ClipboardList, Info, AlertTriangle, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { httpClient } from "@/utils/http";
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
import { NotificationSchema } from "@/api/schemas-ext";
import type { Notification } from "@/api/schemas-ext";
export const Route = createFileRoute("/_auth/notifications/")({ component: NotificationsPage });
const TYPE_META = {
system: { Icon: Info, color: "blue", label: "System" },
mention: { Icon: AtSign, color: "purple", label: "Mention" },
task: { Icon: ClipboardList, color: "green", label: "Task" },
alert: { Icon: AlertTriangle, color: "red", label: "Alert" },
} as const;
function NotificationsPage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const qc = useQueryClient();
const [type, setType] = useState("all");
const { data: raw = [], isLoading } = useQuery({
queryKey: ["notifications", type],
queryFn: () =>
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
params: type !== "all" ? { type } : undefined,
}),
});
const items = useMemo(
() => raw
.filter((n) => NotificationSchema.safeParse(n).success)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[raw],
);
const inv = () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); };
const mark = useMutation({
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
onSuccess: inv,
});
const markAll = useMutation({
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
onSuccess: () => { message.success("All marked as read"); inv(); },
});
const del = useMutation({
mutationFn: (id: string) => httpClient.delete(NOTIFICATION_ENDPOINTS.delete(id)),
onSuccess: () => { message.success("Deleted"); inv(); },
});
const unread = items.filter((n) => !n.read).length;
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={token.marginSM}>
<Typography.Title level={4} style={{ margin: 0 }}>
Notifications
{unread > 0 && <Badge count={unread} style={{ marginInlineStart: token.marginSM }} overflowCount={99} />}
</Typography.Title>
<Space>
<Segmented
size="small"
value={type}
onChange={(v) => setType(v as string)}
options={[
{ label: "All", value: "all" },
{ label: "Unread", value: "unreadOnly" },
...Object.entries(TYPE_META).map(([k, m]) => ({ label: m.label, value: k })),
]}
/>
{unread > 0 && (
<Button size="small" icon={<CheckCheck size={token.fontSize} />}
onClick={() => markAll.mutate()} loading={markAll.isPending}>
Mark all read
</Button>
)}
</Space>
</Flex>
<List
loading={isLoading}
dataSource={items}
style={{ flex: 1, overflow: "auto" }}
locale={{ emptyText: "No notifications" }}
renderItem={(item) => {
const { Icon, color } = TYPE_META[item.type];
return (
<List.Item
key={item.id}
onClick={() => !item.read && mark.mutate(item.id)}
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
borderRadius: token.borderRadiusLG,
background: item.read ? "transparent" : token.colorFillQuaternary,
cursor: item.read ? "default" : "pointer",
marginBottom: token.marginXXS,
}}
actions={[
<Tooltip title="Delete" key="x">
<Button type="text" size="small" danger icon={<Trash2 size={token.fontSize} />}
onClick={(e) => { e.stopPropagation(); del.mutate(item.id); }} />
</Tooltip>,
]}
>
<List.Item.Meta
avatar={<Tag color={color} style={{ display: "inline-flex", alignItems: "center", gap: 4, marginInlineEnd: 0 }}><Icon size={12} /></Tag>}
title={
<Flex align="center" gap={token.marginXS}>
{!item.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
<Typography.Text strong={!item.read}>{item.title}</Typography.Text>
</Flex>
}
description={
<Flex vertical gap={4}>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ margin: 0, fontSize: token.fontSizeSM }}>
{item.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{new Date(item.createdAt).toLocaleString()}
</Typography.Text>
</Flex>
}
/>
</List.Item>
);
}}
/>
</Flex>
);
}
@@ -1,300 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import {
App, Button, Card, Col, Divider, Flex, Form, Input, Row, Typography,
theme, Descriptions, Tag, Avatar, Tabs, Space, Upload
} from "antd";
import {
Mail, Phone, Building, Shield, Clock, Upload as UploadIcon,
Save, Key
} from "lucide-react";
import { useState } from "react";
import { httpClient } from "@/utils/http";
import { PROFILE_ENDPOINTS } from "@/api/profile";
import { useAuthStore } from "@/stores/auth";
import { UpdateProfileSchema, ChangePasswordSchema } from "@/api/schemas-ext";
import type { UpdateProfile, ChangePassword } from "@/api/schemas-ext";
import { UserSchema } from "@/api/schemas";
export const Route = createFileRoute("/_auth/profile/")({
component: ProfilePage,
});
function ProfilePage() {
const { token } = theme.useToken();
const { message } = App.useApp();
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [editing, setEditing] = useState(false);
const profileMutation = useMutation({
mutationFn: (values: UpdateProfile) =>
httpClient.put(PROFILE_ENDPOINTS.update, UpdateProfileSchema.parse(values)),
onSuccess: (data) => {
const validated = UserSchema.parse(data);
setUser(validated);
setEditing(false);
message.success("Profile updated");
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Update failed");
},
});
const passwordMutation = useMutation({
mutationFn: (values: ChangePassword) =>
httpClient.put(
PROFILE_ENDPOINTS.changePassword,
ChangePasswordSchema.parse(values),
),
onSuccess: () => {
message.success("Password changed");
passwordForm.resetFields();
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Change password failed");
},
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
// Simulated upload
await new Promise((r) => setTimeout(r, 1000));
return { url: URL.createObjectURL(file) };
},
onSuccess: (data) => {
// Optimistically update avatar
if (user) {
setUser({ ...user, avatar: data.url });
}
message.success("Avatar updated");
},
});
if (!user) {
return (
<Flex justify="center" align="center" style={{ minHeight: 200 }}>
<Typography.Text type="secondary">Loading profile...</Typography.Text>
</Flex>
);
}
const tabItems = [
{
key: "info",
label: "Profile",
children: (
<Flex vertical gap={token.marginMD}>
<Flex align="center" gap={token.marginLG} wrap="wrap">
<Upload
showUploadList={false}
accept="image/*"
customRequest={({ file }) => {
avatarMutation.mutate(file as File);
}}
>
<div style={{ position: "relative", cursor: "pointer" }}>
<Avatar
size={80}
src={user.avatar}
shape="circle"
style={{
border: `2px solid ${token.colorBorderSecondary}`,
}}
>
{user.username?.[0]?.toUpperCase()}
</Avatar>
<div
style={{
position: "absolute",
bottom: 0,
right: 0,
background: token.colorPrimary,
borderRadius: "50%",
padding: 4,
display: "flex",
}}
>
<UploadIcon size={12} style={{ color: "#fff" }} />
</div>
</div>
</Upload>
<Flex vertical gap={4}>
<Typography.Title level={4} style={{ margin: 0 }}>
{user.realName || user.username}
</Typography.Title>
<Typography.Text type="secondary">
@{user.username}
</Typography.Text>
<Space size={4}>
{user.roles.map((r) => (
<Tag key={r} color="blue" style={{ textTransform: "capitalize" }}>
{r}
</Tag>
))}
</Space>
</Flex>
</Flex>
<Divider style={{ margin: 0 }} />
{!editing ? (
<>
<Descriptions size="small" column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label={<><Mail size={12} /> Email</>}>
{user.email || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Phone size={12} /> Mobile</>}>
{user.mobile || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Building size={12} /> Department</>}>
{user.department || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Shield size={12} /> Role</>}>
{user.role || "—"}
</Descriptions.Item>
<Descriptions.Item label="Nickname">
{user.nickName || "—"}
</Descriptions.Item>
<Descriptions.Item label="Remark">
{user.remark || "—"}
</Descriptions.Item>
<Descriptions.Item label={<><Clock size={12} /> Last Login</>}>
{user.lastLogin
? new Date(user.lastLogin).toLocaleString()
: "—"}
</Descriptions.Item>
<Descriptions.Item label="Created By">
{user.createdBy || "—"}
</Descriptions.Item>
</Descriptions>
<Button
type="primary"
onClick={() => {
profileForm.setFieldsValue({
email: user.email,
mobile: user.mobile,
department: user.department,
nickName: user.nickName,
realName: user.realName,
remark: user.remark,
});
setEditing(true);
}}
style={{ width: "fit-content" }}
>
Edit Profile
</Button>
</>
) : (
<Form
form={profileForm}
layout="vertical"
onFinish={(values) => profileMutation.mutate(values)}
style={{ maxWidth: 480 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="realName" label="Real Name">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="nickName" label="Nickname">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item name="email" label="Email" rules={[{ type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="mobile" label="Mobile">
<Input />
</Form.Item>
<Form.Item name="department" label="Department">
<Input />
</Form.Item>
<Form.Item name="remark" label="Remark">
<Input.TextArea rows={2} />
</Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={profileMutation.isPending}
icon={<Save size={token.fontSize} />}
>
Save
</Button>
<Button onClick={() => {
setEditing(false);
profileForm.resetFields();
}}>
Cancel
</Button>
</Space>
</Form>
)}
</Flex>
),
},
{
key: "password",
label: "Change Password",
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={(values) => passwordMutation.mutate(values)}
style={{ maxWidth: 400 }}
>
<Form.Item
name="oldPassword"
label="Current Password"
rules={[{ required: true, message: "Please enter current password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Form.Item
name="newPassword"
label="New Password"
rules={[
{ required: true, message: "Please enter new password" },
{ min: 6, message: "Minimum 6 characters" },
]}
>
<Input.Password placeholder="At least 6 characters" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Confirm Password"
rules={[{ required: true, message: "Please confirm new password" }]}
>
<Input.Password placeholder="••••••" />
</Form.Item>
<Button
type="primary"
htmlType="submit"
loading={passwordMutation.isPending}
icon={<Key size={token.fontSize} />}
>
Change Password
</Button>
</Form>
),
},
];
return (
<Flex vertical gap={token.marginMD} style={{ flex: 1, minHeight: 0 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Profile
</Typography.Title>
<Card>
<Tabs items={tabItems} />
</Card>
</Flex>
);
}
@@ -1,4 +1,4 @@
import { Form, Input } from "antd";
import { Form, Input, Select } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateUserRequest, User } from "@/api/schemas";
import { BaseFormModal } from "@/components/FormModal";
@@ -39,10 +39,17 @@ export function FormModal({
<Input />
</Form.Item>
<Form.Item
name="mobile"
label="Mobile"
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
>
<Input />
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
+42 -11
View File
@@ -1,8 +1,9 @@
import { Button, Input, theme } from "antd";
import { Plus } from "lucide-react";
import { Button, Input, Select, theme } from "antd";
import { Plus, UserRound } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
/** Search + role slot `minWidth` for FilterToolbar collapse math */
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
@@ -10,12 +11,21 @@ export type ToolbarProps = {
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
canCreate: boolean;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ keywordInput, onKeywordChange, onSearch, onClearSearch, canCreate, onCreateClick },
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
onCreateClick,
},
ref,
) {
const { token } = theme.useToken();
@@ -37,8 +47,34 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<UserRound size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[
keywordInput,
onClearSearch,
onKeywordChange,
onRoleChange,
onSearch,
roleValue,
token.fontSize,
],
[keywordInput, onClearSearch, onKeywordChange, onSearch],
);
return (
@@ -46,12 +82,7 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
ref={ref}
slots={slots}
actions={
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
Create User
</Button>
}
+99 -236
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Form, App, Dropdown, theme, Flex } from "antd";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
@@ -10,19 +10,23 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useAuthStore } from "@/stores/auth";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const UserSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(100),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().optional().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().optional().catch(null),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch("")
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
@@ -30,10 +34,6 @@ export const Route = createFileRoute("/_auth/users/")({
const paginatedUserSchema = PaginatedResponseSchema(UserSchema);
const MESSAGE_KEY_USER_CREATE = "user-mutation-create";
const MESSAGE_KEY_USER_UPDATE = "user-mutation-update";
const MESSAGE_KEY_USER_DELETE = "user-mutation-delete";
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
@@ -44,31 +44,23 @@ function UsersPage() {
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const tableAvailableRef = useRef(0);
/**
* If both start as undefined on the first frame, the table grows to full row height (flex min-height:auto)
* and the main content area scrolls. Seed from the viewport; useLayoutEffect then refines (e.g. available≈901, bodyMax≈861).
*/
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const hasPermission = useAuthStore((s) => s.hasPermission);
const canCreate = hasPermission("user:create");
const canEdit = hasPermission("user:edit");
const canDelete = hasPermission("user:delete");
const [keywordInput, setKeywordInput] = useState(search.keyword);
const currentPage = Math.floor(search.offset / search.limit) + 1;
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
@@ -84,7 +76,6 @@ function UsersPage() {
search.sortField,
search.sortOrder,
],
invalidateKey: ["users"],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
@@ -101,66 +92,29 @@ function UsersPage() {
httpClient.post(USER_ENDPOINTS.create, CreateUserRequestSchema.parse(values)),
updateFn: ({ id, ...values }) => httpClient.put(USER_ENDPOINTS.update(id), values),
deleteFn: (id) => httpClient.delete(USER_ENDPOINTS.delete(id)),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: () => {
message.success({
content: "Created successfully",
key: MESSAGE_KEY_USER_CREATE,
});
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: () => {
message.error({
content: "Create failed",
key: MESSAGE_KEY_USER_CREATE,
});
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: () => {
message.loading({
content: "Updating…",
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Updated successfully",
key: MESSAGE_KEY_USER_UPDATE,
});
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: () => {
message.error({
content: "Update failed",
key: MESSAGE_KEY_USER_UPDATE,
});
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: () => {
message.loading({
content: "Deleting…",
key: MESSAGE_KEY_USER_DELETE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Deleted successfully",
key: MESSAGE_KEY_USER_DELETE,
});
},
onError: () => {
message.error({
content: "Delete failed",
key: MESSAGE_KEY_USER_DELETE,
});
},
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
@@ -220,20 +174,27 @@ function UsersPage() {
title: "Roles",
dataIndex: "roles",
key: "roles",
render: (_: unknown, record: User) => {
if (!record.roles || record.roles.length === 0) return "—";
return (
<span style={{ whiteSpace: "nowrap" }}>
{record.roles.join(", ")}
</span>
);
},
},
{
title: "Mobile",
dataIndex: "mobile",
key: "mobile",
render: (mobile: string | null | undefined) => mobile || "—",
sorter: true,
sortOrder: search.sortField === "roles" ? search.sortOrder : null,
render: (roles: string[]) => (
<Space wrap>
{roles.map((role) => (
<Tag
key={role}
variant="outlined"
styles={{
root: {
borderRadius: 9999,
background: "transparent",
boxShadow: "none",
},
}}
>
{role}
</Tag>
))}
</Space>
),
},
{
title: "Actions",
@@ -241,10 +202,11 @@ function UsersPage() {
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => {
const items = [
canEdit
? {
render: (_: unknown, record: User) => (
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
@@ -253,138 +215,40 @@ function UsersPage() {
form.setFieldsValue(record);
setModalOpen(true);
},
}
: null,
canDelete
? {
},
{
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
}
: null,
].filter((item): item is NonNullable<typeof item> => item != null);
if (!items.length) {
return <span style={{ color: token.colorTextSecondary }}>—</span>;
}
return (
<Dropdown menu={{ items }} placement="bottomRight">
<Button type="text" icon={<MoreVertical size={token.fontSize} />} />
</Dropdown>
);
},
},
],
}}
placement="bottomRight"
>
<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(
() =>
@@ -420,14 +284,22 @@ function UsersPage() {
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applySearch}
onSearch={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
applySearch("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
canCreate={canCreate}
roleValue={search.role || undefined}
onRoleChange={(role) =>
void navigate({
search: {
...search,
role,
offset: 0,
},
})
}
onCreateClick={() => {
if (!canCreate) return;
setEditingUser(null);
form.resetFields();
setModalOpen(true);
@@ -437,7 +309,7 @@ function UsersPage() {
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={tableScrollY != null}
lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
@@ -452,9 +324,8 @@ function UsersPage() {
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
onChange={(_pagination, _filters, sorter) => {
if (Array.isArray(sorter)) return;
// 修正排序回退逻辑:无排序时清空 sortField/sortOrder,否则用当前字段和顺序
const nextSortField = sorter.order ? String(sorter.field) : undefined;
const nextSortOrder = sorter.order ? sorter.order : undefined;
const nextSortField = sorter.order ? String(sorter.field) : "username";
const nextSortOrder = sorter.order ? sorter.order : "descend";
void navigate({
search: {
...search,
@@ -477,16 +348,8 @@ function UsersPage() {
}}
onFinish={(values) => {
if (editingUser) {
if (!canEdit) {
message.error("No permission to edit users");
return;
}
updateMutation.mutate({ ...values, id: editingUser.id });
} else {
if (!canCreate) {
message.error("No permission to create users");
return;
}
createMutation.mutate(values);
}
}}
+14
View File
@@ -5,3 +5,17 @@
-webkit-backdrop-filter: none !important;
}
}
/* Keep browser autofill from painting a solid background over the glass card. */
.login-page__card .ant-input:-webkit-autofill,
.login-page__card .ant-input:-webkit-autofill:hover,
.login-page__card .ant-input:-webkit-autofill:focus,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:hover,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--ant-color-text, currentColor) !important;
caret-color: var(--ant-color-text, currentColor);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 9999s ease-out 0s;
}
+17 -19
View File
@@ -1,4 +1,4 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
@@ -140,12 +140,7 @@ function LoginPage() {
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input
id="login-username"
aria-label="Username"
placeholder="admin"
size="large"
/>
<Input id="login-username" aria-label="Username" placeholder="admin" size="large" />
</Form.Item>
<Form.Item
@@ -171,9 +166,13 @@ function LoginPage() {
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>Auto login</Checkbox>
</Form.Item>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Demo: admin/admin or guest/guest
</Typography.Text>
<Typography.Link
href="#"
onClick={(e) => e.preventDefault()}
style={{ fontSize: token.fontSizeSM }}
>
Forgot password?
</Typography.Link>
</Flex>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
@@ -187,16 +186,15 @@ function LoginPage() {
Sign In
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
No account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Create one
</Link>
</Typography.Text>
</Flex>
</Form>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Don&apos;t have an account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Sign up
</Link>
</Typography.Text>
</Flex>
</Card>
</Flex>
<Flex
+30 -13
View File
@@ -10,6 +10,7 @@ import { fetchSessionAndApplyToStore } from "@/utils/session";
import type { RegisterRequest } from "@/api/auth";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Aurora } from "@/components/Aurora";
import { AppFooter } from "@/components/Layout/AppFooter";
import "../login/index.css";
export const Route = createFileRoute("/register/")({
@@ -37,11 +38,11 @@ function RegisterPage() {
await fetchSessionAndApplyToStore();
},
onSuccess: () => {
message.success("Register successful");
message.success("Account created successfully");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Register failed");
message.error(err instanceof Error ? err.message : "Registration failed");
},
});
@@ -77,7 +78,12 @@ function RegisterPage() {
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex flex={1} align="center" justify="center" style={{ padding: token.padding, minHeight: 0 }}>
<Flex
flex={1}
align="center"
justify="center"
style={{ padding: token.padding, minHeight: 0 }}
>
<Card
className="login-page__card"
style={cardStyle}
@@ -121,20 +127,21 @@ function RegisterPage() {
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 username" }]}
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 (optional)</span>}
rules={[{ type: "email", message: "Please enter a valid 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>
@@ -143,19 +150,19 @@ function RegisterPage() {
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters long" },
]}
>
<Input.Password placeholder="At least 6 characters" size="large" />
<Input.Password placeholder="Enter at least 6 characters" size="large" />
</Form.Item>
<Form.Item
name="confirmPassword"
label={<span style={{ fontWeight: 500 }}>Confirm Password</span>}
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
dependencies={["password"]}
rules={[
{ required: true, message: "Please confirm password" },
{ required: true, message: "Please confirm your password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
@@ -166,7 +173,7 @@ function RegisterPage() {
}),
]}
>
<Input.Password placeholder="Repeat your password" size="large" />
<Input.Password placeholder="Re-enter your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
@@ -177,7 +184,7 @@ function RegisterPage() {
block
size="large"
>
Create Account
Create account
</Button>
</Form.Item>
@@ -192,6 +199,16 @@ function RegisterPage() {
</Form>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
-28
View File
@@ -1,28 +0,0 @@
import { createPersistentStore } from "./createPersistentStore";
type AIProvider = "openai" | "azure-openai" | "anthropic";
interface AIState {
enabled: boolean;
provider: AIProvider;
model: string;
systemPrompt: string;
temperature: number;
maxTokens: number;
setConfig: (next: Partial<Omit<AIState, "setConfig">>) => void;
}
export const useAIStore = createPersistentStore<AIState>(
(set) => ({
enabled: true,
provider: "openai",
model: "gpt-5.3-codex",
systemPrompt: "You are an enterprise assistant for admin operations.",
temperature: 0.2,
maxTokens: 2048,
setConfig: (next) => set((state) => ({ ...state, ...next })),
}),
{
name: "ai-settings-storage",
},
);
+8
View File
@@ -55,3 +55,11 @@ export const useAuthStore = createPersistentStore<AuthState>(
},
},
);
export function getAuthTokens(): AuthTokens | null {
return useAuthStore.getState().tokens;
}
export function getAccessToken(): string | null {
return useAuthStore.getState().tokens?.accessToken ?? null;
}
+19 -79
View File
@@ -1,20 +1,5 @@
import type { MenuItem } from "@/api/schemas";
export const APP_PERMISSION_OPTIONS = [
{ label: "Users: View", value: "user:view" },
{ label: "Users: Create", value: "user:create" },
{ label: "Users: Edit", value: "user:edit" },
{ label: "Users: Delete", value: "user:delete" },
{ label: "Admins: View", value: "admin:view" },
{ label: "Admins: Create", value: "admin:create" },
{ label: "Admins: Edit", value: "admin:edit" },
{ label: "Admins: Delete", value: "admin:delete" },
] as const;
/**
* 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",
@@ -48,74 +33,41 @@ export const APP_MENU_TREE: MenuItem[] = [
sort: 1,
hidden: false,
},
{
id: "5",
kind: "item",
name: "Admins",
path: "/admins",
icon: "IconLucideShieldCheck",
children: null,
permissions: ["admin:view"],
sort: 2,
hidden: false,
},
{
id: "8",
kind: "item",
name: "Notifications",
path: "/notifications",
icon: "IconLucideHistory",
children: null,
permissions: null,
sort: 3,
hidden: false,
},
{
id: "10",
kind: "item",
name: "Logs",
path: "/logs",
icon: "IconLucideBookOpen",
children: null,
permissions: ["admin:view"],
sort: 5,
hidden: false,
},
],
},
{
id: "g-settings",
id: "g-projects",
kind: "group",
name: "Settings",
name: "Projects",
path: null,
icon: "IconLucideSettings",
permissions: ["admin:view"],
sort: 1,
hidden: false,
icon: "IconLucideFolderKanban",
children: [
{
id: "6",
id: "3",
kind: "item",
name: "Menus",
path: "/menus",
icon: "IconLucideSettings",
name: "Design Engineering",
path: "/design-engineering",
icon: "IconLucideBookOpen",
children: null,
permissions: ["admin:view"],
permissions: null,
sort: 0,
hidden: false,
},
{
id: "7",
id: "4",
kind: "item",
name: "AI Settings",
path: "/ai-settings",
icon: "IconLucideSparkles",
name: "Sales & Marketing",
path: "/sales-marketing",
icon: "IconLucideBriefcase",
children: null,
permissions: ["admin:view"],
permissions: ["user:view"],
sort: 1,
hidden: false,
},
],
permissions: null,
sort: 1,
hidden: false,
},
];
@@ -157,30 +109,18 @@ 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);
// 只允许精确匹配和一级子路由
if (p === "/admins" || /^\/admins\/[\w-]+$/.test(p)) return "admin:view";
if (p === "/users" || /^\/users\/[\w-]+$/.test(p)) return "user:view";
const map: Record<string, string | null> = {
"/dashboard": null,
"/notifications": null,
"/logs": "admin:view",
"/profile": null,
"/menus": "admin:view",
"/ai-settings": "admin:view",
"/users": "user:view",
"/design-engineering": null,
"/sales-marketing": "user:view",
"/403": null,
};
return map[p] ?? null;
-112
View File
@@ -1,112 +0,0 @@
/**
* 全局异常上报工具
*
* 能力:
* - 分级记录错误(INFO / WARN / ERROR / FATAL)
* - 捕获 window.onerror / unhandledrejection
* - 支持自定义上报回调(接入 Sentry / 自建平台)
* - 在 ErrorBoundary 内复用
*/
export const ErrorSeverity = {
INFO: "info",
WARN: "warn",
ERROR: "error",
FATAL: "fatal",
} as const;
export type ErrorSeverity = (typeof ErrorSeverity)[keyof typeof ErrorSeverity];
interface ErrorContext {
componentStack?: string;
url?: string;
timestamp?: number;
[key: string]: unknown;
}
type ReportCallback = (
error: Error | string,
severity: ErrorSeverity,
context?: ErrorContext,
) => void;
let reportFn: ReportCallback | null = null;
/**
* 注册自定义上报回调。
* 不调用则默认只 console 输出。
*/
export function registerErrorReporter(fn: ReportCallback): void {
reportFn = fn;
}
/** 上报一个异常 */
export function reportError(
error: Error | string,
severity: ErrorSeverity = ErrorSeverity.ERROR,
context?: ErrorContext,
): void {
const err = typeof error === "string" ? new Error(error) : error;
const ctx: ErrorContext = {
url: typeof window !== "undefined" ? window.location.href : undefined,
timestamp: Date.now(),
...context,
};
// 默认 console 兜底
const consoleFn =
severity === ErrorSeverity.FATAL || severity === ErrorSeverity.ERROR
? console.error
: severity === ErrorSeverity.WARN
? console.warn
: console.info;
consoleFn("[ErrorReporter]", err, ctx);
reportFn?.(err, severity, ctx);
}
/** 上报一个 warning 级别的消息(不会打断用户交互) */
export function reportWarning(message: string, context?: ErrorContext): void {
reportError(message, ErrorSeverity.WARN, context);
}
/** 上报 info 级别的日志 */
export function reportInfo(message: string, context?: ErrorContext): void {
reportError(message, ErrorSeverity.INFO, context);
}
// —— 全局兜底捕获 ——
const WINDOW_CAPTURED_KEY = "__antd_admin_error_captured__";
function installGlobalErrorHandlers(): void {
if (typeof window === "undefined") return;
if ((window as unknown as Record<string, unknown>)[WINDOW_CAPTURED_KEY]) return;
(window as unknown as Record<string, unknown>)[WINDOW_CAPTURED_KEY] = true;
// 捕获未被 try-catch 覆盖的同步/异步错误
window.addEventListener("error", (event) => {
// 跳过资源加载错误(如图片 404),只处理 JS 异常
if (event.error) {
reportError(event.error, ErrorSeverity.FATAL, {
url: event.filename,
line: event.lineno,
col: event.colno,
message: event.message,
});
}
});
// 捕获未被 catch 的 Promise reject
window.addEventListener("unhandledrejection", (event) => {
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
reportError(error, ErrorSeverity.FATAL, {
message: "Unhandled Promise Rejection",
});
});
}
export { installGlobalErrorHandlers };
+100 -257
View File
@@ -1,8 +1,7 @@
import { API_BASE_URL } from "./constants";
import { useAuthStore } from "@/stores/auth";
import { reportWarning } from "@/utils/errorReporter";
// ── Error types ────────────────────────────────────────
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
export class ApiError extends Error {
code: number;
@@ -22,127 +21,75 @@ export class HttpError extends Error {
}
}
// ── Types ──────────────────────────────────────────────
export interface RequestOptions extends Omit<RequestInit, "method" | "body" | "signal"> {
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
params?: Record<string, string | number | null | undefined>;
/**
* Number of retry attempts for transient failures (5xx, network errors).
* Default: 0 (no retry). Set to 1-3 for idempotent GET requests.
*/
retry?: number;
/** Base delay in ms for exponential backoff. Default: 1000. */
retryBaseMs?: number;
/**
* AbortController signal for request cancellation.
* Also available as `options.signal` (standard fetch).
*/
signal?: AbortSignal;
/** Skip automatic token refresh on 401. Default: false. */
skipAuthRefresh?: boolean;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
// ── Token management ───────────────────────────────────
let inflightRefresh: Promise<boolean> | null = null;
interface StoredAuth {
state?: {
tokens?: { accessToken: string; refreshToken: string } | null;
};
}
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
const url = buildUrl(AUTH_ENDPOINTS.refresh);
function readAuthFromStorage() {
try {
const raw = localStorage.getItem("auth-storage");
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
logout();
navigateToLogin();
return false;
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
logout();
navigateToLogin();
return false;
}
const next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
return null;
logout();
navigateToLogin();
return false;
}
}
function getAccessToken(): string | null {
return readAuthFromStorage()?.state?.tokens?.accessToken ?? null;
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
function getRefreshToken(): string | null {
return readAuthFromStorage()?.state?.tokens?.refreshToken ?? null;
}
function writeTokens(accessToken: string, refreshToken: string) {
const stored = readAuthFromStorage() ?? {};
stored.state = { ...stored.state, tokens: { accessToken, refreshToken } };
localStorage.setItem("auth-storage", JSON.stringify(stored));
}
/**
* Shared in-flight refresh promise.
* Multiple concurrent 401s should share one refresh call.
*/
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error("No refresh token available");
}
const url = API_BASE_URL
? `${API_BASE_URL}/api/auth/refresh`
: "/api/auth/refresh";
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
} catch (err) {
// Network error during refresh → can't recover
useAuthStore.getState().logout();
throw err;
}
if (!res.ok) {
useAuthStore.getState().logout();
throw new HttpError(res.status, `Token refresh failed: ${res.statusText}`);
}
const json = await res.json();
if (json.code !== undefined && json.code !== 0) {
useAuthStore.getState().logout();
throw new ApiError(json.code, json.message ?? "Token refresh failed");
}
const data = json.data ?? json;
const { accessToken, refreshToken: newRefreshToken } = data as {
accessToken: string;
refreshToken: string;
};
writeTokens(accessToken, newRefreshToken);
// Also update in-memory store so Zustand subscribers are aware
const authStore = useAuthStore.getState();
authStore.setTokens({ accessToken, refreshToken: newRefreshToken });
return accessToken;
})().finally(() => {
refreshPromise = null;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return refreshPromise;
return inflightRefresh;
}
// ── Helpers ────────────────────────────────────────────
function buildUrl(
path: string,
params?: RequestOptions["params"],
): string {
function buildUrl(path: string, params?: RequestOptions["params"]): string {
const base = path.startsWith("http") ? path : `${API_BASE_URL}${path}`;
if (!params) return base;
const url = new URL(base, window.location.origin);
@@ -154,176 +101,72 @@ function buildUrl(
function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
if (token) return { Authorization: `Bearer ${token}` };
return {};
}
/** Whether this fetch error is transient (retryable) */
function isTransientError(err: unknown): boolean {
if (err instanceof HttpError) {
// 5xx server errors are transient
return err.status >= 500 && err.status < 600;
}
if (err instanceof TypeError) {
// Network errors (fetch throws TypeError for network failures)
return true;
}
return false;
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
// ── Core request function ──────────────────────────────
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const {
params,
retry = 0,
retryBaseMs = 1000,
signal,
skipAuthRefresh = false,
headers: extraHeaders,
...restOptions
} = options ?? {};
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const url = buildUrl(path, params);
const maxAttempts = retry + 1;
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(extraHeaders as Record<string, string>),
};
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal, // Standard AbortSignal, fetch will throw AbortError on cancel
...restOptions,
});
} catch (err) {
// AbortError → rethrow immediately without retry
if (err instanceof DOMException && err.name === "AbortError") {
throw err;
}
const fetchErr =
err instanceof TypeError
? new HttpError(0, `Network error: ${(err as Error).message}`)
: err;
// Retry transient errors, but only on remaining attempts
if (attempt < maxAttempts - 1 && isTransientError(fetchErr)) {
const backoff = retryBaseMs * Math.pow(2, attempt);
reportWarning(
`Request failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${backoff}ms:\n` +
`${method} ${url}`,
);
await delay(backoff);
continue;
}
throw fetchErr;
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
}
// ── 401 → try refresh, then retry ──
if (res.status === 401 && !skipAuthRefresh) {
try {
const newToken = await refreshAccessToken();
// Retry with fresh token
const retryHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${newToken}`,
...(extraHeaders as Record<string, string>),
};
const retryRes = await fetch(url, {
method,
headers: retryHeaders,
body: body ? JSON.stringify(body) : undefined,
signal,
...restOptions,
});
if (!retryRes.ok) {
throw new HttpError(
retryRes.status,
`HTTP ${retryRes.status}: ${retryRes.statusText}`,
);
}
const retryJson = await retryRes.json();
if (retryJson.code !== undefined && retryJson.code !== 0) {
throw new ApiError(
retryJson.code,
retryJson.message ?? "Unknown error",
);
}
return retryJson.data !== undefined ? retryJson.data : retryJson;
} catch (refreshErr) {
// refreshAccessToken calls logout() internally on failure
throw refreshErr;
}
}
// ── Non-401 error ──
if (!res.ok) {
const httpErr = new HttpError(
res.status,
`HTTP ${res.status}: ${res.statusText}`,
);
if (attempt < maxAttempts - 1 && isTransientError(httpErr)) {
const backoff = retryBaseMs * Math.pow(2, attempt);
reportWarning(
`Request failed (attempt ${attempt + 1}/${maxAttempts}), retrying in ${backoff}ms:\n` +
`${method} ${url}`,
);
await delay(backoff);
continue;
}
throw httpErr;
}
// ── Parse response ──
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;
throw new HttpError(401, "Unauthorized");
}
// Should never reach here, but TypeScript needs it
throw new HttpError(0, "Max retries exceeded");
if (res.status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (!res.ok) {
throw new HttpError(res.status, `HTTP ${res.status}: ${res.statusText}`);
}
const json: unknown = await res.json();
const envelope = json as { code?: number; data?: unknown; message?: string };
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
return (envelope.data !== undefined ? envelope.data : json) as T;
}
// ── Public API ─────────────────────────────────────────
export const httpClient = {
get: <T>(path: string, options?: RequestOptions) =>
request<T>("GET", path, undefined, options),
get: <T>(path: string, options?: RequestOptions) => request<T>("GET", path, undefined, options),
post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("POST", path, body, options),
put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PUT", path, body, options),
patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
request<T>("PATCH", path, body, options),
delete: <T>(path: string, options?: RequestOptions) =>
request<T>("DELETE", path, undefined, options),
};
+3 -6
View File
@@ -1,20 +1,17 @@
import { httpClient } from "@/utils/http";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { MENU_ENDPOINTS } from "@/api/menu";
import { AuthUserResponseSchema, PermissionsListSchema, UserSchema } from "@/api/schemas";
import { MenuItemSchema } from "@/api/schemas";
import { filterMenuTreeByPermissions } from "@/utils/appMenu";
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, menuTree] = await Promise.all([
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)),
httpClient.get(MENU_ENDPOINTS.list).then((d) => MenuItemSchema.array().parse(d)),
]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(menuTree, permissions);
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
+7 -7
View File
@@ -1,8 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vite-plus";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
+26 -5
View File
@@ -2,16 +2,37 @@
基于 [Next.js](https://nextjs.org) App Router、[Nextra 4](https://nextra.site) 与 `nextra-theme-docs` 的中文文档,与模板应用(Vite+、`apps/basic`、`apps/with-lingui`)同仓维护。
## 内容目录(侧栏分组)
| 分组 | 路径(`content/` 下) |
| ----- | ----------------------------------------------------------------- |
| 概述 | `index.mdx` |
| 欢迎 | `welcome/documentation-intro.mdx` |
| 入门 | `getting-started/`(快速开始、`folder-structure`、`available-scripts` …) |
| 模板 | `templates/`(模板列表、`basic`、`with-lingui`) |
| 开发 | `development/` |
| 样式与资源 | `styles-and-assets/` |
| 构建应用 | `building/` |
| 测试 | `testing/` |
| 后端对接 | `backend/` |
| 部署 | `deployment/` |
侧边栏顺序与标题由各目录旁 `_meta.js` 及根目录 `content/_meta.js` 配置。
## 端口说明
| 项目 | 默认开发地址 |
| ---- | ------------ |
| 模板应用(`vp dev`) | [http://localhost:5173](http://localhost:5173) |
| 项目 | 默认开发地址 |
| ---------------- | ---------------------------------------------- |
| 模板应用(`vp dev`) | [http://localhost:5173](http://localhost:5173) |
| 本文档站(`pnpm dev`) | [http://localhost:3000](http://localhost:3000) |
## 常用命令
在 **`apps/docs`** 目录下:
在 `**apps/docs**` 目录下:
```bash
pnpm install # 首次或依赖变更后
@@ -29,4 +50,4 @@ cd apps/docs && pnpm install && pnpm dev
## 设计说明
实现与信息架构见:`docs/specs/2026-04-15-apps-docs-nextra-plan.md`(及同目录 design spec,若存在)。
初始实现见 `docs/specs/2026-04-15-apps-docs-nextra-plan.md`。
-31
View File
@@ -1,31 +0,0 @@
import { generateStaticParamsFor, importPage } from "nextra/pages";
import { useMDXComponents as getMDXComponents } from "../../mdx-components";
export const generateStaticParams = generateStaticParamsFor("mdxPath");
/** Nextra Docs Theme 在运行时会提供 `wrapper`;类型上可能为 optional。 */
const Wrapper = getMDXComponents().wrapper!;
type PageProps = { params: Promise<{ mdxPath?: string[] }> };
export async function generateMetadata(props: PageProps) {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
return metadata;
}
export default async function Page(props: PageProps) {
const params = await props.params;
const {
default: MDXContent,
toc,
metadata,
sourceCode,
} = await importPage(params.mdxPath);
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}
@@ -0,0 +1,33 @@
import { importPage } from "nextra/pages";
import type { ReactNode } from "react";
import { useMDXComponents as getMDXComponents } from "../../../mdx-components";
const Wrapper =
getMDXComponents().wrapper ?? ((props: { children?: ReactNode }) => <>{props.children}</>);
type PageProps = { params: Promise<{ lang: string; mdxPath?: string[] }> };
function normalizePath(pathSegments?: string[]) {
return (pathSegments ?? []).filter((segment): segment is string => Boolean(segment));
}
export async function generateMetadata(props: PageProps) {
const params = await props.params;
const mdxPath = normalizePath(params.mdxPath);
const { metadata } = await importPage(mdxPath, params.lang);
return metadata;
}
export default async function Page(props: PageProps) {
const params = await props.params;
const mdxPath = normalizePath(params.mdxPath);
const pageData = await importPage(mdxPath, params.lang);
const MDXContent = pageData.default ?? (() => null);
const { toc, metadata, sourceCode } = pageData;
return (
<Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}>
<MDXContent {...props} params={params} />
</Wrapper>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Footer, Layout, Navbar, NotFoundPage } from "nextra-theme-docs";
import { getPageMap } from "nextra/page-map";
import "nextra-theme-docs/style.css";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type { ReactElement, ReactNode } from "react";
const LOCALES = ["zh", "en"] as const;
type Locale = (typeof LOCALES)[number];
function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value);
}
function copyFor(locale: Locale) {
if (locale === "en") {
return {
htmlLang: "en",
title: "Antd Admin Docs",
description:
"Antd Admin template guide: choose between basic and with-lingui, then configure and run.",
};
}
return {
htmlLang: "zh-CN",
title: "Antd Admin 文档",
description: "antd-admin 模板使用说明:basic 与 with-lingui 选型、安装与配置。",
};
}
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
if (!isLocale(lang)) return {};
const copy = copyFor(lang);
return {
title: {
default: copy.title,
template: `%s – ${copy.title}`,
},
description: copy.description,
};
}
export default async function RootLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}): Promise<ReactElement> {
const { lang } = await params;
if (!isLocale(lang)) notFound();
const copy = copyFor(lang);
const localizedPageMap = await getPageMap(`/${lang}`);
const pageMap = (Array.isArray(localizedPageMap) ? localizedPageMap : []).filter(Boolean);
return (
<html lang={copy.htmlLang} dir="ltr" suppressHydrationWarning>
<body>
<Layout
navbar={
<Navbar
logo={<b>{copy.title}</b>}
projectLink="https://github.com/zuiidea/antd-admin"
/>
}
pageMap={pageMap}
docsRepositoryBase="https://github.com/zuiidea/antd-admin/tree/master"
i18n={[
{ locale: "zh", name: "中文" },
{ locale: "en", name: "English" },
]}
footer={
<Footer>
MIT {new Date().getFullYear()} ©{" "}
<a href="https://github.com/zuiidea/antd-admin" rel="noreferrer" target="_blank">
antd-admin
</a>
</Footer>
}
>
{children}
</Layout>
</body>
</html>
);
}
export function NotFound() {
return <NotFoundPage />;
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundPage } from "nextra-theme-docs";
export default function NotFound() {
return <NotFoundPage />;
}
-54
View File
@@ -1,54 +0,0 @@
import { Footer, Layout, Navbar } from "nextra-theme-docs";
import { Head } from "nextra/components";
import { getPageMap } from "nextra/page-map";
import "nextra-theme-docs/style.css";
import type { Metadata } from "next";
import type { ReactElement, ReactNode } from "react";
export const metadata: Metadata = {
title: {
default: "Antd Admin 文档",
template: "%s – Antd Admin 文档",
},
description: "antd-admin 模板使用说明:basic 与 with-lingui 选型、安装与配置。",
};
const navbar = (
<Navbar
logo={<b>Antd Admin 文档</b>}
projectLink="https://github.com/zuiidea/antd-admin"
/>
);
const footer = (
<Footer>
MIT {new Date().getFullYear()} ©{" "}
<a href="https://github.com/zuiidea/antd-admin" rel="noreferrer" target="_blank">
antd-admin
</a>
</Footer>
);
export default async function RootLayout({
children,
}: {
children: ReactNode;
}): Promise<ReactElement> {
const pageMap = await getPageMap();
return (
<html lang="zh-CN" dir="ltr" suppressHydrationWarning>
<Head />
<body>
<Layout
navbar={navbar}
pageMap={pageMap}
docsRepositoryBase="https://github.com/zuiidea/antd-admin/tree/master"
footer={footer}
>
{children}
</Layout>
</body>
</html>
);
}
-17
View File
@@ -1,17 +0,0 @@
---
title: 配置参考
description: 环境变量与构建相关简短说明
---
# 配置参考
## 模板应用(`apps/basic`、`apps/with-lingui`)
- 使用 **Vite+**(`vp`)管理依赖与开发/构建;具体脚本见各目录 `package.json`。
- **MSW** 在开发环境拦截 API;worker 目录由各应用 `package.json` 的 `msw.workerDirectory` 配置。
- 更完整的栈说明、目录树与测试命令见对应应用的 **README**(仓库内 `apps/basic/README.md`、`apps/with-lingui/README.md`)。
## 文档站(`apps/docs`)
- **Next.js + Nextra**,无首期 `basePath`;生产构建:`pnpm run build`。
- 本地开发端口 **3000**,见 [apps/docs README](https://github.com/zuiidea/antd-admin/blob/master/apps/docs/README.md) 与设计说明 `docs/specs/2026-04-15-apps-docs-nextra-design.md`(若存在于仓库中)。
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('nextra').MetaRecord} */
export default {
index: "Overview",
welcome: "Welcome",
"getting-started": "Getting Started",
templates: "Templates",
development: "Development",
"styles-and-assets": "Styles & Assets",
building: "Building",
testing: "Testing",
backend: "Backend Integration",
deployment: "Deployment",
};
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('nextra').MetaRecord} */
export default {
"proxying-api-requests": "Proxying API Requests",
};
@@ -0,0 +1,14 @@
---
title: Proxying API Requests
description: Configure API proxying between frontend and backend
---
# Proxying API Requests
Use a proxy layer in development to avoid CORS issues and keep API endpoints configurable.
Recommended approach:
- Define a consistent API prefix.
- Route requests by environment.
- Keep backend base URLs in environment variables.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('nextra').MetaRecord} */
export default {
"environment-variables": "Environment Variables",
performance: "Performance",
"production-build": "Production Build",
router: "Router",
};
@@ -0,0 +1,12 @@
---
title: Environment Variables
description: Manage environment configuration safely across environments
---
# Environment Variables
Use environment variables to separate configuration from code.
- Keep secrets out of source files.
- Use environment-specific values for dev/staging/prod.
- Document required variables for contributors.
@@ -0,0 +1,13 @@
---
title: Performance
description: Practical optimization checklist for admin applications
---
# Performance
Focus on practical wins:
- Reduce unnecessary rerenders
- Split heavy modules
- Cache requests where appropriate
- Measure before and after each optimization
@@ -0,0 +1,19 @@
---
title: Production Build
description: Build and validate for production deployment
---
# Production Build
Before deployment:
1. Run production build.
2. Verify lint and tests.
3. Validate key user flows in preview/staging.
Example:
```bash
pnpm --filter ./apps/basic build
pnpm --filter ./apps/with-lingui build
```

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