Compare commits

..
1 Commits
239 changed files with 13909 additions and 12830 deletions
-26
View File
@@ -1,26 +0,0 @@
---
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 */}
```
@@ -1,15 +0,0 @@
---
description: "Add a new CRUD resource / scaffold (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/src/routes/_auth/**/*.tsx,apps/with-lingui/e2e/**/*.spec.ts
alwaysApply: false
---
# with-lingui · Add resource (thin rule)
**Single source of truth:**
`apps/with-lingui/.github/instructions/add-resource.instructions.md`
When adding a resource, follow that recipe (mirror the `users` example). On conflict, the `.instructions.md` file wins.
`@apps/with-lingui/.github/instructions/add-resource.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
description: "API clients, Zod schemas, HTTP, MSW handlers (with-lingui)."
globs: apps/with-lingui/src/api/**/*.ts,apps/with-lingui/src/utils/http.ts,apps/with-lingui/src/mocks/handlers/**/*.ts,apps/with-lingui/src/mocks/createHandler.ts
alwaysApply: false
---
# with-lingui · API(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/api.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/api.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
description: "React UI, routes, components, styles, layout, i18n, Ant Design (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx,css}
alwaysApply: false
---
# with-lingui · Frontend(薄规则)
**唯一细则来源**(勿在此文件重复维护长文):
`apps/with-lingui/.github/instructions/frontend.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的 Do / Do Not / Validation;如有冲突,以 `.instructions.md` 为准。
需要全文进上下文时,在对话里引用:`@apps/with-lingui/.github/instructions/frontend.instructions.md`。
-15
View File
@@ -1,15 +0,0 @@
---
description: "Refactors without behavior change — dedupe, naming, structure (with-lingui)."
globs: apps/with-lingui/src/**/*.{ts,tsx}
alwaysApply: false
---
# with-lingui · Refactor(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/refactor.instructions.md`
做行为保持式重构时遵守该文件;与 frontend 规则同时命中时,**重构意图**以本文件指向的 refactor 指令为准,其余 UI 细节仍以 `frontend.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/refactor.instructions.md`
-15
View File
@@ -1,15 +0,0 @@
---
description: "Playwright e2e, MSW mocks, test config (with-lingui)."
globs: apps/with-lingui/e2e/**/*.ts,apps/with-lingui/e2e/**/*.tsx,apps/with-lingui/src/mocks/**/*.ts,apps/with-lingui/playwright.config.ts
alwaysApply: false
---
# with-lingui · Testing(薄规则)
**唯一细则来源**:
`apps/with-lingui/.github/instructions/testing.instructions.md`
编辑符合 `globs` 的文件时,遵守该文件中的约定;冲突以 `.instructions.md` 为准。
`@apps/with-lingui/.github/instructions/testing.instructions.md`
-26
View File
@@ -1,26 +0,0 @@
# 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
@@ -1,34 +0,0 @@
#!/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"
-51
View File
@@ -1,51 +0,0 @@
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
- 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:core
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+1 -5
View File
@@ -41,10 +41,6 @@ yarn-error.log*
# Git worktrees
.worktrees/
# Cursor:仅共享项目规则;其余本机数据仍忽略(方案 A — 细则单源在 apps/with-lingui/.github/instructions)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
.cursor
./docs/superpowers
+230 -47
View File
@@ -1,73 +1,256 @@
# Antd Admin
## Introduction
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.
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
[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)
Available templates:
- `basic`: English-only setup
- `with-lingui`: Bilingual setup (`en` + `zh`) with Lingui
## Tech Stack
Scaffolding CLI:
- `init-antd-admin` (from `packages/create`)
## Key Features
| 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) |
- 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
## Quick Start
## Features
Use `init-antd-admin` to scaffold a standalone app from official templates.
- **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
### Interactive
## Templates
`npx init-antd-admin@latest` · `pnpm dlx init-antd-admin@latest`
| 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:
```bash
npx init-antd-admin@latest
pnpm dlx init-antd-admin@latest
yarn dlx init-antd-admin@latest
bunx init-antd-admin@latest
cd apps/basic && vp install && vp dev
# or
cd apps/with-lingui && vp install && vp dev
```
### Non-interactive examples
### 文档站(Nextra)
中文使用文档位于 `**apps/docs**`(Next.js + Nextra)。在仓库根或该目录安装依赖后:
```bash
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
cd apps/docs && pnpm install && pnpm dev
```
### Common options
浏览器打开 [http://localhost:3000](http://localhost:3000)。模板 Vite+ 应用仍为 [http://localhost:5173](http://localhost:5173)。开发与构建命令说明见 `[apps/docs/README.md](./apps/docs/README.md)`。
| Option | Description |
| --- | --- |
| `[project-directory]` | Target folder |
| `-e, --example <name|url>` | Template source |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
Core Playwright flows (from the app you are working in):
For detailed CLI behavior, see [`packages/create/README.md`](./packages/create/README.md).
```bash
cd apps/basic && pnpm run test:e2e:core
```
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
## Getting Started
## Links
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
| 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 |
- [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
+2 -3
View File
@@ -1,8 +1,7 @@
---
## 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
+2 -3
View File
@@ -1,8 +1,7 @@
---
## 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
@@ -0,0 +1,25 @@
# 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
@@ -1 +0,0 @@
vp staged
-39
View File
@@ -1,39 +0,0 @@
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 });
});
});
+27 -9
View File
@@ -9,16 +9,34 @@ export async function loginAsAdmin(page: Page) {
await expect(page).toHaveURL(/dashboard/);
}
export async function gotoUsers(page: Page) {
await loginAsAdmin(page);
/** Navigate to the users page */
export async function gotoUsers(page: Page): Promise<void> {
await page.goto("/users");
await expect(page).toHaveURL(/users/);
await page.waitForSelector(".ant-table-thead");
}
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/);
/** 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();
}
-10
View File
@@ -1,10 +0,0 @@
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
@@ -1,19 +0,0 @@
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();
});
});
+9 -17
View File
@@ -1,5 +1,10 @@
import { test, expect } from "@playwright/test";
import { gotoUsers, loginAsAdmin } from "./helpers";
import {
gotoUsers,
verifyTableHeaders,
searchUser,
verifyRoleFilter,
} from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
@@ -7,17 +12,11 @@ test.describe("User Management", () => {
});
test("should display user table", async ({ 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();
await verifyTableHeaders(page);
});
test("should search users", async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await searchUser(page, "zhao.ming");
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
@@ -28,14 +27,7 @@ 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 expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
await verifyRoleFilter(page, "admin");
});
});
+9 -22
View File
@@ -11,16 +11,15 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts e2e/auth-refresh.spec.ts e2e/rbac.spec.ts e2e/url-state.spec.ts",
"test:e2e:core": "playwright test e2e/login.spec.ts e2e/users.spec.ts",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"antd": "^6.3.3",
"lucide-react": "^1.7.0",
"@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",
@@ -28,26 +27,14 @@
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.166.13",
"@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.12.12",
"typescript": "~5.9.3",
"msw": "^2.13.6",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest",
"vitest": "3.0.5"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"onlyBuiltDependencies": [
"esbuild",
"msw"
]
"vite-plus": "latest"
},
"msw": {
"workerDirectory": [
+1
View File
@@ -1,5 +1,6 @@
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);
+13
View File
@@ -0,0 +1,13 @@
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, User, MenuItem, PermissionsList, RegisterRequest };
export type { AuthTokens, LoginRequest, RegisterRequest, User, MenuItem, PermissionsList };
+3
View File
@@ -0,0 +1,3 @@
export const LOG_ENDPOINTS = {
list: "/api/logs",
} as const;
+8
View File
@@ -0,0 +1,8 @@
import type { MenuItem } from "./schemas";
export const MENU_ENDPOINTS = {
list: "/api/menus",
update: "/api/menus",
} as const;
export type { MenuItem };
+6
View File
@@ -0,0 +1,6 @@
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
@@ -0,0 +1,6 @@
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
@@ -0,0 +1,56 @@
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>;
+46 -18
View File
@@ -7,6 +7,15 @@ 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>;
@@ -22,12 +31,6 @@ 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),
@@ -35,24 +38,43 @@ export const LoginRequestSchema = z.object({
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
email: z.string().email().optional(),
});
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>;
@@ -145,7 +167,13 @@ const createUserEmailSchema = z
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
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;
}),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
@@ -0,0 +1,156 @@
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>
</>
);
}
@@ -0,0 +1,119 @@
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>
);
}
@@ -0,0 +1,105 @@
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>
);
}
@@ -0,0 +1,129 @@
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;
}
+270 -95
View File
@@ -1,142 +1,317 @@
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
/**
* 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 { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
import {
Bell,
Book,
BrainCircuit,
Briefcase,
CheckCheck,
CircleDashed,
Fullscreen,
History,
Home,
Minimize2,
PanelLeft,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} 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",
"/users": "Users",
"/notifications": "Notifications",
"/logs": "Audit Logs",
"/profile": "Profile",
"/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 = {
/**
* 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).
*/
/** Hide breadcrumb; routes may override via `staticData.hideBreadcrumb` */
showBreadcrumb?: boolean;
};
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
// ── Main component ──
export function Header({ showBreadcrumb: showProp = 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);
const iconSize = token.fontSize;
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>;
};
useEffect(() => {
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
handler();
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
// ── Breadcrumb ──
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 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;
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel = leafLabelKey;
const breadcrumbItems: ItemType[] = [];
// 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 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(" / ") }] : [])];
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;
const hideByRoute = (matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined)?.hideBreadcrumb === true;
const showBc = showProp && !hideByRoute && items.length > 0;
return (
<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 ? (
<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 && (
<>
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
{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>
)}
</>
) : null}
)}
</Flex>
{/* Right */}
<Space>
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
<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" />
</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,6 +2,7 @@ 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;
@@ -34,6 +35,7 @@ export function MainLayout() {
<Outlet />
</Content>
</Flex>
<GlobalAIChat />
</Layout>
);
}
@@ -8,11 +8,13 @@ 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";
@@ -30,6 +32,13 @@ 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",
};
@@ -53,6 +62,8 @@ 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
@@ -1,24 +0,0 @@
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
@@ -1,66 +0,0 @@
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],
);
}
@@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
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");
});
});
+17 -114
View File
@@ -1,29 +1,14 @@
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) => void;
onError?: (values: TValues, error: unknown) => void;
};
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 },
> = {
type UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues> = {
queryKey: readonly unknown[];
invalidateKey?: readonly unknown[];
invalidateKey: readonly unknown[];
queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>;
@@ -32,63 +17,15 @@ type UseResourceCRUDOptions<
createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>;
optimistic?: OptimisticFlags;
};
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 },
>(
export function useResourceCRUD<TListData, TCreateValues, TUpdateValues>(
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,
queryKey: options.queryKey,
queryFn: options.queryFn,
select: options.select,
});
@@ -99,73 +36,39 @@ export function useResourceCRUD<
options.createLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
options.createLifecycle?.onError?.(values);
onError: (error, values) => {
options.createLifecycle?.onError?.(values, error);
},
});
const updateMutation = useMutation({
mutationFn: options.updateFn,
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,
);
}
onMutate: (values) => {
options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values);
onError: (error, values) => {
options.updateLifecycle?.onError?.(values, error);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
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,
);
}
onMutate: (id) => {
options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
void queryClient.invalidateQueries({ queryKey: options.invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
onError: (error, id) => {
options.deleteLifecycle?.onError?.(id, error);
},
});
-133
View File
@@ -1,133 +0,0 @@
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
@@ -1,29 +0,0 @@
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 };
}
+17 -5
View File
@@ -3,12 +3,13 @@ import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { useSettingsStore } from "./stores/settings";
import { fetchSessionAndApplyToStore } from "./utils/session";
import { installHttpRouter } from "./utils/http";
import { installGlobalErrorHandlers } from "./utils/errorReporter";
// 全局兜底异常捕获(window.onerror / unhandledrejection)
installGlobalErrorHandlers();
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
@@ -16,16 +17,27 @@ 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");
return worker.start({ onUnhandledRequest: "bypass" });
worker.start({
onUnhandledRequest(_, print) {
if (import.meta.env.DEV) {
// 开发环境下 warn
print.warning();
} else {
// 生产/构建 mock 环境下 error
print.error();
}
},
});
}
enableMocking()
.then(async () => {
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
await useAuthStore.persist.rehydrate();
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
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);
});
});
+21 -33
View File
@@ -1,17 +1,30 @@
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,
@@ -20,37 +33,9 @@ export function successResponse<T>(data: T, message: string = "ok") {
});
}
/** 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);
}
/**
* Create an error response
*/
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
@@ -59,6 +44,9 @@ export function errorResponse(code: number, message: string, data: unknown = nul
});
}
/**
* Predefined error codes
*/
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
+461 -37
View File
@@ -1,42 +1,466 @@
import type { User } from "@/api/schemas";
import type { MenuItem, User } from "@/api/schemas";
import { APP_MENU_TREE } from "@/utils/appMenu";
import { vercelAvatarUrl } from "./utils";
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"],
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",
}),
];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
let nextId = authUsers.length + 1;
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"],
}));
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();
}
+154
View File
@@ -0,0 +1,154 @@
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));
}),
];
+53 -131
View File
@@ -1,159 +1,81 @@
import { http } from "msw";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
const REGISTERED_REFRESH = "mock-registered-refresh";
/** Last successful register (in-memory) so /user matches the new account in MSW */
let mockRegisteredSession: { username: string; email: string | null } | null = null;
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
function isRegisteredAuth(authorization: string | null): boolean {
return (authorization ?? "").includes(REGISTERED_ACCESS);
}
authenticateUser,
findUserById,
findUserByUsername,
issueMockTokens,
parseUserIdFromAccessToken,
parseUserIdFromRefreshToken,
registerUser,
toPublicUser,
} from "../data";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
export const authHandlers = [
http.post("/api/auth/login", async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = 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",
});
const body = (await request.json()) as {
username: string;
password: string;
};
const user = authenticateUser(body.username, body.password);
if (user) {
return successResponse(issueMockTokens(user.id));
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post("/api/auth/register", async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RegisterRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
}
const { username, password, email } = parsed.data;
if (password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
}
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
if (taken) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
}
mockRegisteredSession = {
username,
email: email ?? null,
await withDelay(350);
const body = (await request.json()) as {
username?: string;
password?: string;
email?: string;
};
return successWithSchema(AuthTokensSchema, {
accessToken: REGISTERED_ACCESS,
refreshToken: REGISTERED_REFRESH,
});
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));
}),
http.post("/api/auth/refresh", async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
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");
}
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",
});
return successResponse(issueMockTokens(userId));
}),
http.post("/api/auth/logout", () => successWithNullBody()),
http.post("/api/auth/logout", () => successResponse(null)),
http.get("/api/auth/user", ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
if (session) {
return successWithSchema(AuthUserResponseSchema, {
id: "reg-mock",
username: session.username,
avatar: null,
email: session.email,
roles: ["editor"],
});
}
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
const { permissions: _permissions, ...userWithoutPermissions } = toPublicUser(user);
return successResponse(userWithoutPermissions);
}),
http.get("/api/auth/permissions", ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
const userId = parseUserIdFromAccessToken(request.headers.get("authorization"));
const user = userId ? findUserById(userId) : undefined;
if (!user) {
return errorResponse(ERROR_CODES.UNAUTHORIZED, "Unauthorized");
}
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
return successResponse(user.permissions);
}),
];
+10 -1
View File
@@ -1,4 +1,13 @@
import { authHandlers } from "./auth";
import { adminHandlers } from "./admin";
import { userHandlers } from "./user";
import { notificationHandlers } from "./notification";
import { logHandlers } from "./log";
export const handlers = [...authHandlers, ...userHandlers];
export const handlers = [
...authHandlers,
...adminHandlers,
...userHandlers,
...notificationHandlers,
...logHandlers,
];
+57
View File
@@ -0,0 +1,57 @@
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);
}),
];
@@ -0,0 +1,122 @@
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" });
}),
];
+34 -35
View File
@@ -1,67 +1,66 @@
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { http } from "msw";
import { createUser, deleteUser, listUsers, updateUser } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import { withDelay, successResponse, errorResponse, ERROR_CODES } from "../createHandler";
import { findUserById, parseUserIdFromAccessToken } from "../data";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
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 userHandlers = [
http.get("/api/users", async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
if (!hasPoint(request.headers.get("authorization"), "user: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(users, { keyword, role });
const filtered = filterUsers(listUsers(), { keyword, role });
const list = paginateList(filtered, limit, offset);
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
return successResponse({ 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 = {
id: String(users.length + 1),
username: String(body.username),
avatar: null,
const newUser = createUser({
username: String(body.username ?? ""),
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [] as string[],
};
users.push(newUser);
return successWithSchema(UserSchema, newUser);
mobile: typeof body.mobile === "string" ? body.mobile : null,
});
return successResponse(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 idx = users.findIndex((u) => u.id === params.id);
if (idx === -1) {
const updated = updateUser(String(params.id), body);
if (!updated) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
return successResponse(updated);
}),
http.delete("/api/users/:id", async ({ params }) => {
http.delete("/api/users/:id", async ({ params, request }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
if (!hasPoint(request.headers.get("authorization"), "user:delete")) {
return errorResponse(ERROR_CODES.FORBIDDEN, "Forbidden");
}
deleteUser(String(params.id));
return successResponse(null);
}),
];
+299 -138
View File
@@ -8,184 +8,345 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as AuthRouteImport } from "./routes/_auth";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as RegisterIndexRouteImport } from "./routes/register/index";
import { Route as LoginIndexRouteImport } from "./routes/login/index";
import { Route as R404IndexRouteImport } from "./routes/404/index";
import { Route as AuthUsersIndexRouteImport } from "./routes/_auth/users/index";
import { Route as AuthDashboardIndexRouteImport } from "./routes/_auth/dashboard/index";
import { Route as Auth403IndexRouteImport } from "./routes/_auth/403/index";
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as RegisterIndexRouteImport } from './routes/register/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
import { Route as 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 Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as AuthAdminsIdRouteImport } from './routes/_auth/admins/$id'
const AuthRoute = AuthRouteImport.update({
id: "/_auth",
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: "/register/",
path: "/register/",
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: "/login/",
path: "/login/",
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const R404IndexRoute = R404IndexRouteImport.update({
id: "/404/",
path: "/404/",
id: '/404/',
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: "/users/",
path: "/users/",
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any);
} as any)
const AuthProfileIndexRoute = AuthProfileIndexRouteImport.update({
id: '/profile/',
path: '/profile/',
getParentRoute: () => AuthRoute,
} 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/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: "/dashboard/",
path: "/dashboard/",
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any);
} 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/",
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any);
} as any)
const AuthAdminsIdRoute = AuthAdminsIdRouteImport.update({
id: '/admins/$id',
path: '/admins/$id',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/403/": typeof Auth403IndexRoute;
"/dashboard/": typeof AuthDashboardIndexRoute;
"/users/": typeof AuthUsersIndexRoute;
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/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 {
"/": typeof IndexRoute;
"/404": typeof R404IndexRoute;
"/login": typeof LoginIndexRoute;
"/register": typeof RegisterIndexRoute;
"/403": typeof Auth403IndexRoute;
"/dashboard": typeof AuthDashboardIndexRoute;
"/users": typeof AuthUsersIndexRoute;
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/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 {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/_auth": typeof AuthRouteWithChildren;
"/404/": typeof R404IndexRoute;
"/login/": typeof LoginIndexRoute;
"/register/": typeof RegisterIndexRoute;
"/_auth/403/": typeof Auth403IndexRoute;
"/_auth/dashboard/": typeof AuthDashboardIndexRoute;
"/_auth/users/": typeof AuthUsersIndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/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 {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/404/" | "/login/" | "/register/" | "/403/" | "/dashboard/" | "/users/";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/404" | "/login" | "/register" | "/403" | "/dashboard" | "/users";
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/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'
id:
| "__root__"
| "/"
| "/_auth"
| "/404/"
| "/login/"
| "/register/"
| "/_auth/403/"
| "/_auth/dashboard/"
| "/_auth/users/";
fileRoutesById: FileRoutesById;
| '__root__'
| '/'
| '/_auth'
| '/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
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
AuthRoute: typeof AuthRouteWithChildren;
R404IndexRoute: typeof R404IndexRoute;
LoginIndexRoute: typeof LoginIndexRoute;
RegisterIndexRoute: typeof RegisterIndexRoute;
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/_auth": {
id: "/_auth";
path: "";
fullPath: "/";
preLoaderRoute: typeof AuthRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/register/": {
id: "/register/";
path: "/register";
fullPath: "/register/";
preLoaderRoute: typeof RegisterIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/login/": {
id: "/login/";
path: "/login";
fullPath: "/login/";
preLoaderRoute: typeof LoginIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/404/": {
id: "/404/";
path: "/404";
fullPath: "/404/";
preLoaderRoute: typeof R404IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/_auth/users/": {
id: "/_auth/users/";
path: "/users";
fullPath: "/users/";
preLoaderRoute: typeof AuthUsersIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/dashboard/": {
id: "/_auth/dashboard/";
path: "/dashboard";
fullPath: "/dashboard/";
preLoaderRoute: typeof AuthDashboardIndexRouteImport;
parentRoute: typeof AuthRoute;
};
"/_auth/403/": {
id: "/_auth/403/";
path: "/403";
fullPath: "/403/";
preLoaderRoute: typeof Auth403IndexRouteImport;
parentRoute: typeof AuthRoute;
};
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
id: '/404/'
path: '/404'
fullPath: '/404/'
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/': {
id: '/_auth/profile/'
path: '/profile'
fullPath: '/profile/'
preLoaderRoute: typeof AuthProfileIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_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
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard/'
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
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute;
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute;
AuthUsersIndexRoute: typeof AuthUsersIndexRoute;
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,
};
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren);
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
@@ -193,7 +354,7 @@ const rootRouteChildren: RootRouteChildren = {
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
};
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileTypes<FileRouteTypes>()
+17 -30
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useLayoutEffect } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
@@ -6,34 +6,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { RouteError } from "@/components/RouteError";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import "@/index.css";
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>;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useEffect(() => {
useLayoutEffect(() => {
document.documentElement.lang = "en";
}, []);
@@ -42,18 +28,19 @@ function RootComponent() {
}, [darkMode]);
return (
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
<QueryClientProvider client={queryClient}>
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</App>
</ConfigProvider>
</QueryClientProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
+3 -14
View File
@@ -2,29 +2,18 @@ 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: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
beforeLoad: ({ location }) => {
const { isAuthenticated, user } = 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, nextUser?.permissions)) {
if (!canAccessPath(location.pathname, user?.permissions)) {
throw redirect({ to: "/403" });
}
},
+108
View File
@@ -0,0 +1,108 @@
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>
);
}
@@ -0,0 +1,104 @@
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>
);
}
@@ -0,0 +1,91 @@
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"
/>
);
});
@@ -0,0 +1,364 @@
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>
);
}
@@ -0,0 +1,84 @@
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>
);
}
@@ -197,37 +197,37 @@ function DashboardPage() {
const timelineItems = useMemo(
() => [
{
color: "green" as const,
color: "green",
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" as const,
color: "blue",
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" as const,
color: "gold",
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" as const,
color: "red",
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,11 +288,7 @@ 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
@@ -0,0 +1,440 @@
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>
);
}
@@ -0,0 +1,137 @@
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>
);
}
@@ -0,0 +1,300 @@
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, Select } from "antd";
import { Form, Input } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateUserRequest, User } from "@/api/schemas";
import { BaseFormModal } from "@/components/FormModal";
@@ -39,17 +39,10 @@ export function FormModal({
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
name="mobile"
label="Mobile"
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
+11 -42
View File
@@ -1,9 +1,8 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, UserRound } from "lucide-react";
import { Button, Input, theme } from "antd";
import { Plus } 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 = {
@@ -11,21 +10,12 @@ export type ToolbarProps = {
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,
onCreateClick,
},
{ keywordInput, onKeywordChange, onSearch, onClearSearch, canCreate, onCreateClick },
ref,
) {
const { token } = theme.useToken();
@@ -47,34 +37,8 @@ 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 (
@@ -82,7 +46,12 @@ export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar
ref={ref}
slots={slots}
actions={
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
<Button
type="primary"
icon={<Plus size={token.fontSize} />}
onClick={onCreateClick}
disabled={!canCreate}
>
Create User
</Button>
}
+236 -99
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import { Avatar, Button, Form, App, Dropdown, theme, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useMemo, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { httpClient } from "@/utils/http";
import { USER_ENDPOINTS } from "@/api/user";
import { PaginatedResponseSchema, UserSchema, CreateUserRequestSchema } from "@/api/schemas";
@@ -10,23 +10,19 @@ import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { useAuthStore } from "@/stores/auth";
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().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
sortField: z.string().nullable().optional().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().optional().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,
@@ -34,6 +30,10 @@ 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,23 +44,31 @@ 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;
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",
});
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
@@ -76,6 +84,7 @@ function UsersPage() {
search.sortField,
search.sortOrder,
],
invalidateKey: ["users"],
queryFn: () =>
httpClient.get(USER_ENDPOINTS.list, {
params: {
@@ -92,29 +101,66 @@ 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: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
onSuccess: () => {
message.success({
content: "Created successfully",
key: MESSAGE_KEY_USER_CREATE,
});
setModalOpen(false);
form.resetFields();
},
onError: crudToasts.createLifecycle?.onError,
onError: () => {
message.error({
content: "Create failed",
key: MESSAGE_KEY_USER_CREATE,
});
},
},
updateLifecycle: {
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
onMutate: () => {
message.loading({
content: "Updating…",
key: MESSAGE_KEY_USER_UPDATE,
duration: 0,
});
},
onSuccess: () => {
message.success({
content: "Updated successfully",
key: MESSAGE_KEY_USER_UPDATE,
});
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: crudToasts.updateLifecycle?.onError,
onError: () => {
message.error({
content: "Update failed",
key: MESSAGE_KEY_USER_UPDATE,
});
},
},
deleteLifecycle: {
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
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,
});
},
},
});
@@ -174,27 +220,20 @@ function UsersPage() {
title: "Roles",
dataIndex: "roles",
key: "roles",
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>
),
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 || "—",
},
{
title: "Actions",
@@ -202,11 +241,10 @@ function UsersPage() {
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => (
<Dropdown
menu={{
items: [
{
render: (_: unknown, record: User) => {
const items = [
canEdit
? {
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
@@ -215,40 +253,138 @@ function UsersPage() {
form.setFieldsValue(record);
setModalOpen(true);
},
},
{
}
: null,
canDelete
? {
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
},
],
}}
placement="bottomRight"
>
<Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label="Row actions"
/>
</Dropdown>
),
}
: 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>
);
},
},
];
const applySearch = (keyword: string) => {
void navigate({
search: {
...search,
keyword: keyword.trim(),
offset: 0,
},
});
};
const showPagination = (data?.total ?? 0) > search.limit;
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
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 tablePagination: false | TablePaginationConfig = useMemo(
() =>
@@ -284,22 +420,14 @@ function UsersPage() {
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applyKeyword}
onSearch={applySearch}
onClearSearch={() => {
setKeywordInput("");
setSearch({ ...search, keyword: "", offset: 0 });
applySearch("");
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
void navigate({
search: {
...search,
role,
offset: 0,
},
})
}
canCreate={canCreate}
onCreateClick={() => {
if (!canCreate) return;
setEditingUser(null);
form.resetFields();
setModalOpen(true);
@@ -309,7 +437,7 @@ function UsersPage() {
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={lockScrollHeight}
lockScrollHeight={tableScrollY != null}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
@@ -324,8 +452,9 @@ function UsersPage() {
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
onChange={(_pagination, _filters, sorter) => {
if (Array.isArray(sorter)) return;
const nextSortField = sorter.order ? String(sorter.field) : "username";
const nextSortOrder = sorter.order ? sorter.order : "descend";
// 修正排序回退逻辑:无排序时清空 sortField/sortOrder,否则用当前字段和顺序
const nextSortField = sorter.order ? String(sorter.field) : undefined;
const nextSortOrder = sorter.order ? sorter.order : undefined;
void navigate({
search: {
...search,
@@ -348,8 +477,16 @@ 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,17 +5,3 @@
-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;
}
+19 -17
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Link, createFileRoute, 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,7 +140,12 @@ 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
@@ -166,13 +171,9 @@ function LoginPage() {
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>Auto login</Checkbox>
</Form.Item>
<Typography.Link
href="#"
onClick={(e) => e.preventDefault()}
style={{ fontSize: token.fontSizeSM }}
>
Forgot password?
</Typography.Link>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
Demo: admin/admin or guest/guest
</Typography.Text>
</Flex>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
@@ -186,15 +187,16 @@ 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
+13 -30
View File
@@ -10,7 +10,6 @@ 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/")({
@@ -38,11 +37,11 @@ function RegisterPage() {
await fetchSessionAndApplyToStore();
},
onSuccess: () => {
message.success("Account created successfully");
message.success("Register successful");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Registration failed");
message.error(err instanceof Error ? err.message : "Register failed");
},
});
@@ -78,12 +77,7 @@ 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}
@@ -127,21 +121,20 @@ 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 your username" }]}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input placeholder="new-user" size="large" />
</Form.Item>
<Form.Item
name="email"
label={<span style={{ fontWeight: 500 }}>Email address</span>}
rules={[{ type: "email", message: "Please enter a valid email address" }]}
label={<span style={{ fontWeight: 500 }}>Email (optional)</span>}
rules={[{ type: "email", message: "Please enter a valid email" }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
@@ -150,19 +143,19 @@ function RegisterPage() {
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters long" },
{ required: true, message: "Please enter password" },
{ min: 6, message: "Password must be at least 6 characters" },
]}
>
<Input.Password placeholder="Enter at least 6 characters" size="large" />
<Input.Password placeholder="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 your password" },
{ required: true, message: "Please confirm password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
@@ -173,7 +166,7 @@ function RegisterPage() {
}),
]}
>
<Input.Password placeholder="Re-enter your password" size="large" />
<Input.Password placeholder="Repeat your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
@@ -184,7 +177,7 @@ function RegisterPage() {
block
size="large"
>
Create account
Create Account
</Button>
</Form.Item>
@@ -199,16 +192,6 @@ 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
@@ -0,0 +1,28 @@
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,11 +55,3 @@ 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;
}
+79 -19
View File
@@ -1,5 +1,20 @@
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",
@@ -33,41 +48,74 @@ 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-projects",
id: "g-settings",
kind: "group",
name: "Projects",
name: "Settings",
path: null,
icon: "IconLucideFolderKanban",
icon: "IconLucideSettings",
permissions: ["admin:view"],
sort: 1,
hidden: false,
children: [
{
id: "3",
id: "6",
kind: "item",
name: "Design Engineering",
path: "/design-engineering",
icon: "IconLucideBookOpen",
name: "Menus",
path: "/menus",
icon: "IconLucideSettings",
children: null,
permissions: null,
permissions: ["admin:view"],
sort: 0,
hidden: false,
},
{
id: "4",
id: "7",
kind: "item",
name: "Sales & Marketing",
path: "/sales-marketing",
icon: "IconLucideBriefcase",
name: "AI Settings",
path: "/ai-settings",
icon: "IconLucideSparkles",
children: null,
permissions: ["user:view"],
permissions: ["admin:view"],
sort: 1,
hidden: false,
},
],
permissions: null,
sort: 1,
hidden: false,
},
];
@@ -109,18 +157,30 @@ 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,
"/users": "user:view",
"/design-engineering": null,
"/sales-marketing": "user:view",
"/notifications": null,
"/logs": "admin:view",
"/profile": null,
"/menus": "admin:view",
"/ai-settings": "admin:view",
"/403": null,
};
return map[p] ?? null;
+112
View File
@@ -0,0 +1,112 @@
/**
* 全局异常上报工具
*
* 能力:
* - 分级记录错误(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 };
+254 -97
View File
@@ -1,7 +1,8 @@
import { API_BASE_URL } from "./constants";
import { AUTH_ENDPOINTS } from "@/api/auth";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { reportWarning } from "@/utils/errorReporter";
// ── Error types ────────────────────────────────────────
export class ApiError extends Error {
code: number;
@@ -21,75 +22,127 @@ export class HttpError extends Error {
}
}
type RequestOptions = Omit<RequestInit, "method" | "body"> & {
// ── Types ──────────────────────────────────────────────
export interface RequestOptions extends Omit<RequestInit, "method" | "body" | "signal"> {
params?: Record<string, string | number | null | undefined>;
};
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
/**
* 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;
}
let inflightRefresh: Promise<boolean> | null = null;
// ── Token management ───────────────────────────────────
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);
interface StoredAuth {
state?: {
tokens?: { accessToken: string; refreshToken: string } | null;
};
}
function readAuthFromStorage() {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const raw = localStorage.getItem("auth-storage");
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
function getAccessToken(): string | null {
return readAuthFromStorage()?.state?.tokens?.accessToken ?? null;
}
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) {
logout();
navigateToLogin();
return false;
useAuthStore.getState().logout();
throw new HttpError(res.status, `Token refresh failed: ${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) {
logout();
navigateToLogin();
return false;
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 next = AuthTokensSchema.parse(envelope.data ?? envelope);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
const data = json.data ?? json;
const { accessToken, refreshToken: newRefreshToken } = data as {
accessToken: string;
refreshToken: string;
};
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
writeTokens(accessToken, newRefreshToken);
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
// Also update in-memory store so Zustand subscribers are aware
const authStore = useAuthStore.getState();
authStore.setTokens({ accessToken, refreshToken: newRefreshToken });
return accessToken;
})().finally(() => {
refreshPromise = null;
});
return inflightRefresh;
return refreshPromise;
}
function buildUrl(path: string, params?: RequestOptions["params"]): string {
// ── Helpers ────────────────────────────────────────────
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);
@@ -101,72 +154,176 @@ function buildUrl(path: string, params?: RequestOptions["params"]): string {
function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {};
return token ? { Authorization: `Bearer ${token}` } : {};
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
/** 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 navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Core request function ──────────────────────────────
async function request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions,
isAfterRefresh = false,
): Promise<T> {
const url = buildUrl(path, options?.params);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...getAuthHeaders(),
...(options?.headers as Record<string, string>),
};
const {
params,
retry = 0,
retryBaseMs = 1000,
signal,
skipAuthRefresh = false,
headers: extraHeaders,
...restOptions
} = options ?? {};
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
...options,
});
const url = buildUrl(path, params);
const maxAttempts = retry + 1;
if (res.status === 401 && !isAfterRefresh && path !== AUTH_ENDPOINTS.refresh) {
const refreshed = await refreshSessionTokens();
if (refreshed) {
return request<T>(method, path, body, options, true);
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;
}
throw new HttpError(401, "Unauthorized");
// ── 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;
}
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;
// Should never reach here, but TypeScript needs it
throw new HttpError(0, "Max retries exceeded");
}
// ── 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),
};
+6 -3
View File
@@ -1,17 +1,20 @@
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 { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { MenuItemSchema } from "@/api/schemas";
import { filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
/** Fetches `/api/auth/user` and `/api/auth/permissions`, then updates `user` and sidebar `menus`. */
export async function fetchSessionAndApplyToStore(): Promise<void> {
const [userBase, permissions] = await Promise.all([
const [userBase, permissions, menuTree] = 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(APP_MENU_TREE, permissions);
const menus = filterMenuTreeByPermissions(menuTree, 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
@@ -1,15 +0,0 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
passWithNoTests: false,
},
});
+5 -26
View File
@@ -2,37 +2,16 @@
基于 [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 # 首次或依赖变更后
@@ -50,4 +29,4 @@ cd apps/docs && pnpm install && pnpm dev
## 设计说明
初始实现见 `docs/specs/2026-04-15-apps-docs-nextra-plan.md`。
实现与信息架构见:`docs/specs/2026-04-15-apps-docs-nextra-plan.md`(及同目录 design spec,若存在)。
+31
View File
@@ -0,0 +1,31 @@
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>
);
}
@@ -1,33 +0,0 @@
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
@@ -1,98 +0,0 @@
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
@@ -1,5 +0,0 @@
import { NotFoundPage } from "nextra-theme-docs";
export default function NotFound() {
return <NotFoundPage />;
}
+54
View File
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,17 @@
---
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
@@ -1,13 +0,0 @@
/** @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
@@ -1,4 +0,0 @@
/** @type {import('nextra').MetaRecord} */
export default {
"proxying-api-requests": "Proxying API Requests",
};
@@ -1,14 +0,0 @@
---
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
@@ -1,7 +0,0 @@
/** @type {import('nextra').MetaRecord} */
export default {
"environment-variables": "Environment Variables",
performance: "Performance",
"production-build": "Production Build",
router: "Router",
};
@@ -1,12 +0,0 @@
---
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.
@@ -1,13 +0,0 @@
---
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
@@ -1,19 +0,0 @@
---
title: Production Build
description: Build and validate for production deployment
---
# Production Build
Before deployment:
1. Run production build.
2. Verify lint and tests.
3. Validate key user flows in preview/staging.
Example:
```bash
pnpm --filter ./apps/basic build
pnpm --filter ./apps/with-lingui build
```
-14
View File
@@ -1,14 +0,0 @@
---
title: Router
description: Routing conventions and recommendations
---
# Router
Use clear route grouping and layout boundaries.
Recommendations:
- Keep feature routes close to feature modules
- Avoid deeply coupled route dependencies
- Handle auth/permission checks in a predictable layer
-15
View File
@@ -1,15 +0,0 @@
---
title: Deployment
description: Deployment guidance and rollout checklist
---
# Deployment
Before production rollout:
- Validate environment variables
- Run full build and tests
- Verify critical user journeys
- Set up monitoring and error tracking
Deploy progressively when possible and prepare a rollback plan.
@@ -1,5 +0,0 @@
/** @type {import('nextra').MetaRecord} */
export default {
"bundle-analysis": "Bundle Analysis",
"https-in-development": "HTTPS in Development",
};
@@ -1,16 +0,0 @@
---
title: Bundle Analysis
description: Analyze bundle size and optimize package usage
---
# Bundle Analysis
Use your build tooling and analyzer plugins to inspect bundle composition.
Focus on:
- Heavy dependencies
- Duplicate packages
- Over-imported UI modules
After optimization, rerun build and compare the output artifacts.
@@ -1,10 +0,0 @@
---
title: HTTPS in Development
description: Enable HTTPS for local development when needed
---
# HTTPS in Development
If your scenario requires secure context (for example some browser APIs), enable HTTPS in local dev by configuring your dev server certificates.
Keep certificates out of version control and share setup instructions with your team.

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