mirror of
https://github.com/zuiidea/antd-admin.git
synced 2026-10-20 08:16:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95110511f9 |
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`。
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
@@ -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."
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
vp staged
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const LOG_ENDPOINTS = {
|
||||
list: "/api/logs",
|
||||
} as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MenuItem } from "./schemas";
|
||||
|
||||
export const MENU_ENDPOINTS = {
|
||||
list: "/api/menus",
|
||||
update: "/api/menus",
|
||||
} as const;
|
||||
|
||||
export type { MenuItem };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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" });
|
||||
}),
|
||||
];
|
||||
@@ -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
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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't have an account?{" "}
|
||||
<Link to="/register" style={{ color: token.colorPrimary }}>
|
||||
Sign up
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
<Flex
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,若存在)。
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { NotFoundPage } from "nextra-theme-docs";
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`(若存在于仓库中)。
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user