feat: 初始化 ChatOne 管理后台前端
基于 React + Vite + Ant Design Pro 搭建登录、动态菜单、布局主题与数据总览,并补充按菜单整理的 UI 设计稿。 Co-authored-by: Cursor <cursoragent@cursor.com>
146
.gitignore
vendored
@@ -1,138 +1,30 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.pnpm-store
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
node_modules
|
||||
12
.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false
|
||||
}
|
||||
74
README.md
@@ -1,3 +1,73 @@
|
||||
# chat-one-admin-web
|
||||
# React + TypeScript + Vite
|
||||
|
||||
ChatOne 后台管理前端项目
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x';
|
||||
import reactDom from 'eslint-plugin-react-dom';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
110
docs/ui/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# ChatOne Admin UI 图目录
|
||||
|
||||
按侧栏菜单结构组织。路径相对于 `docs/ui/`。
|
||||
|
||||
## 目录树
|
||||
|
||||
```text
|
||||
docs/ui/
|
||||
├── README.md # 本索引
|
||||
├── layout/ # 全局布局参考
|
||||
├── login/ # 登录
|
||||
├── dashboard/ # 总览
|
||||
│ ├── overview/ # 数据概览
|
||||
│ └── modules/ # 模块示意 / 地图
|
||||
├── llm/ # 大模型管理
|
||||
│ ├── list/ # 模型列表(含用量抽屉)
|
||||
│ ├── form/ # 新增 / 编辑模型
|
||||
│ ├── provider/ # 供应商管理
|
||||
│ ├── usage/ # 用量统计
|
||||
│ ├── usage-detail/ # 用量明细
|
||||
│ └── quota/ # 限流与配额
|
||||
└── system/ # 系统管理(待补图)
|
||||
├── user/
|
||||
├── role/
|
||||
└── permission/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 菜单 ↔ 路由 ↔ UI 图
|
||||
|
||||
| 菜单 | 建议路由 | UI 图 |
|
||||
| --- | --- | --- |
|
||||
| (布局) | — | [layout/chatone-admin-layout-ui.png](./layout/chatone-admin-layout-ui.png) |
|
||||
| 登录 | `/login` | [login/chatone-login-ui.png](./login/chatone-login-ui.png) |
|
||||
| **总览** | `/dashboard` | [dashboard/overview/…](./dashboard/overview/) |
|
||||
| └ 模块示意 | — | [dashboard/modules/…](./dashboard/modules/) |
|
||||
| **大模型管理** | `/llm` | — |
|
||||
| ├ 模型列表 | `/llm/models` | [llm/list/chatone-llm-list-ui.png](./llm/list/chatone-llm-list-ui.png) |
|
||||
| │ └ 用量抽屉 | (列表内 Drawer) | [llm/list/chatone-llm-usage-drawer-ui.png](./llm/list/chatone-llm-usage-drawer-ui.png) |
|
||||
| ├ 新增/编辑模型 | `/llm/models/create` · `/llm/models/:id/edit` | [llm/form/chatone-llm-form-ui.png](./llm/form/chatone-llm-form-ui.png) |
|
||||
| ├ 供应商管理 | `/llm/providers` | [llm/provider/chatone-llm-provider-ui.png](./llm/provider/chatone-llm-provider-ui.png) |
|
||||
| ├ 用量统计 | `/llm/usage` | [llm/usage/chatone-llm-usage-ui.png](./llm/usage/chatone-llm-usage-ui.png) |
|
||||
| ├ 用量明细 | `/llm/usage/details` | [llm/usage-detail/chatone-llm-usage-detail-ui.png](./llm/usage-detail/chatone-llm-usage-detail-ui.png) |
|
||||
| └ 限流配额 | `/llm/quota` | [llm/quota/chatone-llm-quota-ui.png](./llm/quota/chatone-llm-quota-ui.png) |
|
||||
| **系统管理** | `/system` | 待补 |
|
||||
| ├ 用户管理 | `/system/user` | `system/user/` |
|
||||
| ├ 角色管理 | `/system/role` | `system/role/` |
|
||||
| └ 权限配置 | `/system/permission` | `system/permission/` |
|
||||
|
||||
---
|
||||
|
||||
## 大模型管理:功能清单
|
||||
|
||||
### 模型列表 `/llm/models`(你点名的能力)
|
||||
|
||||
- 新增模型
|
||||
- 编辑 / 删除(含批量删除)
|
||||
- 启用 / 禁用(Switch)
|
||||
- 拖拽或排序号调整顺序
|
||||
- 筛选:供应商、状态、关键词
|
||||
- 行内查看今日 Token;入口打开用量抽屉或跳转明细
|
||||
|
||||
### 新增 / 编辑模型(子页)
|
||||
|
||||
- 基础:名称、供应商、Model ID、类型(对话 / 嵌入 / 多模态)
|
||||
- 接入:Base URL、API Key、超时、重试
|
||||
- 计费:输入/输出单价
|
||||
- 能力:上下文长度、最大输出 Token
|
||||
- 运维:排序权重、启用状态、备注
|
||||
|
||||
### 供应商管理(主流补充)
|
||||
|
||||
- 管理 OpenAI / Anthropic / 通义 / Gemini / DeepSeek / 自定义
|
||||
- 凭证配置、连通性测试、关联模型数、启停
|
||||
|
||||
### 用量统计(主流补充)
|
||||
|
||||
- KPI:今日 / 本月 Token、调用次数、预估费用
|
||||
- 趋势图、模型占比、用量排行
|
||||
|
||||
### 用量明细(你点名的明细)
|
||||
|
||||
- 按时间 / 模型 / 用户过滤
|
||||
- 输入/输出/合计 Token、耗时、状态、费用
|
||||
- 导出;可下钻单次请求详情
|
||||
|
||||
### 限流与配额(主流补充)
|
||||
|
||||
- 按模型 / 用户 / 全局:RPM、TPM、日/月 Token 配额
|
||||
- 用量进度、超额策略(拒绝 / 降级 / 告警)
|
||||
|
||||
---
|
||||
|
||||
## 建议的菜单 Mock 结构
|
||||
|
||||
```ts
|
||||
{
|
||||
name: '大模型管理',
|
||||
path: '/llm',
|
||||
icon: 'BlockOutlined',
|
||||
children: [
|
||||
{ name: '模型列表', path: '/llm/models' },
|
||||
{ name: '供应商管理', path: '/llm/providers' },
|
||||
{ name: '用量统计', path: '/llm/usage' },
|
||||
{ name: '用量明细', path: '/llm/usage/details' },
|
||||
{ name: '限流配额', path: '/llm/quota' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/ui/dashboard/modules/chatone-dashboard-modules-map.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/ui/dashboard/overview/chatone-dashboard-overview-ui.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/ui/layout/chatone-admin-layout-ui.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
docs/ui/llm/form/chatone-llm-form-ui.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/ui/llm/list/chatone-llm-list-ui.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/ui/llm/list/chatone-llm-usage-drawer-ui.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/ui/llm/provider/chatone-llm-provider-ui.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/ui/llm/quota/chatone-llm-quota-ui.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/ui/llm/usage-detail/chatone-llm-usage-detail-ui.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/ui/llm/usage/chatone-llm-usage-ui.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/ui/login/chatone-login-ui.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
718
docs/后台管理前端设计方案.md
Normal file
@@ -0,0 +1,718 @@
|
||||
# ChatOne 后台管理前端 — 设计方案
|
||||
|
||||
> 版本:v1.1
|
||||
> 适用范围:中小型后台管理系统(10~30 个业务模块,5~15 人前端团队)
|
||||
> 更新日期:2026-06-04
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
| 维度 | 目标 |
|
||||
| ------------ | ------------------------------------------ |
|
||||
| **开发效率** | 脚手架开箱即用,CRUD 页面可快速复制模板 |
|
||||
| **可维护性** | 分层清晰、类型安全、模块边界明确 |
|
||||
| **可扩展性** | 权限、路由、菜单由后端驱动,新增模块成本低 |
|
||||
| **用户体验** | 统一布局、响应式、加载/错误状态一致 |
|
||||
| **工程质量** | 自动化 lint/test/CI,代码风格统一 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型(2026 主流方案)
|
||||
|
||||
### 2.1 推荐主栈:React 生态
|
||||
|
||||
在国内中小型后台项目中,**React 18 + Vite + TypeScript + Ant Design 5 + Zustand** 是团队规模扩大后最主流、招聘与协作成本最低的组合,参考项目包括 [Ant Design Pro](https://pro.ant.design/)、[Umi Max](https://umijs.org/)。
|
||||
|
||||
| 类别 | 选型 | 说明 |
|
||||
| ---------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| 框架 | **React 18+** | 函数组件 + Hooks 为默认写法 |
|
||||
| 构建 | **Vite 6** | 极速 HMR,原生 ESM,Rollup 生产构建 |
|
||||
| 语言 | **TypeScript 5.x** | 严格模式,接口类型与后端对齐 |
|
||||
| 路由 | **React Router 6** | 支持动态路由、Loader/Guard 模式 |
|
||||
| 状态 | **Zustand** | 轻量、TS 友好,无样板代码 |
|
||||
| 服务端状态 | **TanStack Query (React Query)** | 请求缓存、重试、失效刷新(可选但推荐) |
|
||||
| UI 库 | **Ant Design 5** | 表格/表单/弹窗等后台组件完备 |
|
||||
| 高级组件 | **@ant-design/pro-components** | ProTable、ProForm、ProLayout 开箱即用 |
|
||||
| HTTP | **Axios** | 拦截器统一处理 Token、错误、重试 |
|
||||
| CSS | **CSS Modules + Ant Design Token** | 组件库主题 Token + 局部样式隔离 |
|
||||
| 样式方案(可选) | **Tailwind CSS** | 与 Ant Design 共存,用于布局/间距 |
|
||||
| 图标 | **@ant-design/icons** + **Iconify** | 内置图标 + 按需扩展 |
|
||||
| 工具库 | **ahooks**、**dayjs**、**lodash-es** | Hooks 工具、日期、数据处理 |
|
||||
| 表单校验 | **Ant Design Form**(内置 rules) | 声明式校验规则 |
|
||||
| 图表 | **@ant-design/charts** 或 **ECharts 5** | 与 Ant Design 风格一致 |
|
||||
| 包管理 | **pnpm** | 磁盘占用小、依赖隔离好 |
|
||||
| 代码规范 | **ESLint 9 (flat config) + Prettier + Stylelint** | 统一风格 |
|
||||
| Git 钩子 | **Husky + lint-staged + commitlint** | 提交前自动检查 |
|
||||
| 单元测试 | **Vitest + React Testing Library** | 与 Vite 同源配置 |
|
||||
| E2E 测试 | **Playwright** | 覆盖登录、核心流程 |
|
||||
| API 类型 | **openapi-typescript** 或 **swagger-typescript-api** | 由 OpenAPI 自动生成 TS 类型 |
|
||||
|
||||
### 2.2 备选栈:Vue 生态
|
||||
|
||||
若团队 Vue 经验更丰富,可选用 **Vue 3 + Vite + TypeScript + Element Plus + Pinia**,参考 [Vben Admin](https://github.com/vbenjs/vue-vben-admin)。两套方案在架构层面等价,本文以 React 主栈展开。
|
||||
|
||||
### 2.3 不推荐的选型(中小型场景)
|
||||
|
||||
| 方案 | 原因 |
|
||||
| ------------------------- | -------------------------------------------------- |
|
||||
| Next.js SSR | 后台以 SPA 为主,SSR 收益低、复杂度上升 |
|
||||
| 微前端(qiankun 等) | 模块量 < 30 时过度设计,联调与部署成本高 |
|
||||
| 自研 UI 组件库 | 投入大,Ant Design / ProComponents 已足够 |
|
||||
| Redux Toolkit(单独使用) | 中小型项目 Zustand 更轻量,除非团队已有 Redux 规范 |
|
||||
| JavaScript(无 TS) | 中大型项目维护成本显著上升 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Browser (SPA) │
|
||||
├──────────────┬──────────────────────────────────────────┤
|
||||
│ Pages │ 页面组件(列表 / 表单 / 详情 / 仪表盘) │
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ Components │ 业务组件 + 通用组件(Access、PageContainer)│
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ Hooks │ 可复用逻辑(useTable、useAuth、usePermission)│
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ Stores │ Zustand(user / permission / app / dict)│
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ Router │ 静态路由 + 动态路由(权限驱动) │
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ API Layer │ 按模块划分的 API + 统一 Request 封装 │
|
||||
├──────────────┼──────────────────────────────────────────┤
|
||||
│ Utils │ 工具函数、常量、枚举、类型定义 │
|
||||
└──────────────┴──────────────────────────────────────────┘
|
||||
│
|
||||
▼ HTTP / WebSocket
|
||||
┌──────────────────┐
|
||||
│ Backend API │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 核心设计原则
|
||||
|
||||
1. **单向数据流**:View → Action → Store/API → View,避免组件间隐式耦合。
|
||||
2. **权限后置**:路由与按钮权限均来自后端,前端只做渲染控制。
|
||||
3. **页面薄、逻辑厚**:页面组件负责组装,业务逻辑下沉到 Hooks 和 Stores。
|
||||
4. **约定优于配置**:CRUD 页面遵循 ProComponents 模板,减少决策成本。
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录结构
|
||||
|
||||
```
|
||||
chat-one-admin-web/
|
||||
├── public/ # 静态资源(favicon、静态 JSON)
|
||||
├── src/
|
||||
│ ├── api/ # API 接口(按业务模块划分)
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── user.ts
|
||||
│ │ └── types/ # 接口请求/响应类型
|
||||
│ ├── assets/ # 图片、字体等
|
||||
│ ├── components/ # 全局通用组件
|
||||
│ │ ├── Access/ # 权限控制组件
|
||||
│ │ ├── Icon/ # 图标封装
|
||||
│ │ └── ...
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── useTable.ts
|
||||
│ │ ├── useAuth.ts
|
||||
│ │ └── usePermission.ts
|
||||
│ ├── layouts/ # 布局
|
||||
│ │ ├── BasicLayout/ # 侧边栏 + 顶栏 + 内容区(ProLayout)
|
||||
│ │ │ └── index.tsx
|
||||
│ │ └── BlankLayout/ # 登录页等无框架页面
|
||||
│ │ └── index.tsx
|
||||
│ ├── pages/ # 页面(按业务模块划分,约定式路由)
|
||||
│ │ ├── login/
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ └── index.tsx
|
||||
│ │ ├── system/
|
||||
│ │ │ └── user/
|
||||
│ │ │ ├── index.tsx # 列表页
|
||||
│ │ │ └── components/
|
||||
│ │ │ └── UserForm.tsx
|
||||
│ │ └── exception/
|
||||
│ │ ├── 403.tsx
|
||||
│ │ └── 404.tsx
|
||||
│ ├── router/
|
||||
│ │ ├── index.tsx # 路由入口(createBrowserRouter)
|
||||
│ │ ├── routes.tsx # 静态路由(登录、404)
|
||||
│ │ ├── AuthGuard.tsx # 路由守卫(鉴权、动态路由加载)
|
||||
│ │ └── utils.ts # 菜单 → 路由转换工具
|
||||
│ ├── stores/
|
||||
│ │ ├── user.ts # 用户信息、Token
|
||||
│ │ ├── permission.ts # 动态路由、菜单
|
||||
│ │ ├── app.ts # 侧边栏折叠、主题、语言
|
||||
│ │ └── dict.ts # 数据字典缓存
|
||||
│ ├── styles/
|
||||
│ │ ├── global.css # 全局样式入口
|
||||
│ │ └── theme.ts # Ant Design 主题 Token 配置
|
||||
│ ├── utils/
|
||||
│ │ ├── request.ts # Axios 封装
|
||||
│ │ ├── auth.ts # Token 存取
|
||||
│ │ ├── storage.ts # localStorage 封装
|
||||
│ │ └── index.ts
|
||||
│ ├── App.tsx
|
||||
│ ├── main.tsx
|
||||
│ └── vite-env.d.ts
|
||||
├── .env # 公共环境变量
|
||||
├── .env.development
|
||||
├── .env.production
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── eslint.config.js
|
||||
├── package.json
|
||||
└── docs/ # 项目文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心模块设计
|
||||
|
||||
### 5.1 认证与鉴权
|
||||
|
||||
```
|
||||
登录流程:
|
||||
用户输入账号密码
|
||||
↓
|
||||
POST /api/auth/login → 返回 accessToken + refreshToken
|
||||
↓
|
||||
存储 Token(内存 + localStorage,refreshToken 建议 httpOnly Cookie 由后端设置)
|
||||
↓
|
||||
GET /api/auth/userInfo → 用户信息 + 角色 + 权限码
|
||||
↓
|
||||
GET /api/auth/menus → 动态菜单/路由配置
|
||||
↓
|
||||
生成动态路由并注入 Router → 跳转首页
|
||||
```
|
||||
|
||||
**Token 策略(推荐):**
|
||||
|
||||
| 项目 | 方案 |
|
||||
| ------------- | ----------------------------------------------- |
|
||||
| Access Token | 短期(15~30 min),放 Authorization Header |
|
||||
| Refresh Token | 长期,httpOnly Cookie 或安全存储 |
|
||||
| 过期处理 | 401 拦截 → 尝试 refresh → 失败则跳转登录 |
|
||||
| 多 Tab | 通过 `storage` 事件或 BroadcastChannel 同步登出 |
|
||||
|
||||
**权限模型:RBAC(角色 → 权限 → 资源)**
|
||||
|
||||
- **路由级**:后端返回菜单树,前端转换为 `RouteObject[]` 动态注册。
|
||||
- **按钮级**:权限码字符串(如 `user:create`),通过 `<Access />` 组件或 `usePermission` Hook 控制。
|
||||
- **数据级**:由后端接口过滤,前端不做数据权限逻辑。
|
||||
|
||||
### 5.2 路由设计
|
||||
|
||||
```typescript
|
||||
// 静态路由(无需权限)
|
||||
const staticRoutes: RouteObject[] = [
|
||||
{ path: '/login', element: <LoginPage />, handle: { hidden: true } },
|
||||
{ path: '/404', element: <NotFoundPage />, handle: { hidden: true } },
|
||||
]
|
||||
|
||||
// 动态路由(后端驱动,示例结构)
|
||||
interface MenuRoute {
|
||||
path: string
|
||||
name: string
|
||||
component: string // 组件路径,如 'system/user/index'
|
||||
meta: {
|
||||
title: string
|
||||
icon?: string
|
||||
permissions?: string[] // 页面级权限码
|
||||
keepAlive?: boolean
|
||||
}
|
||||
children?: MenuRoute[]
|
||||
}
|
||||
```
|
||||
|
||||
**路由守卫(AuthGuard)职责:**
|
||||
|
||||
1. 未登录 → 重定向 `/login?redirect=原路径`
|
||||
2. 已登录但未加载动态路由 → 拉取菜单 → 生成路由 → 渲染 `<Outlet />`
|
||||
3. 无权限 → 403 页面
|
||||
4. 切换路由 → NProgress 进度条
|
||||
|
||||
```tsx
|
||||
// router/AuthGuard.tsx 示意
|
||||
function AuthGuard() {
|
||||
const { token } = useUserStore();
|
||||
const { routesLoaded, loadRoutes } = usePermissionStore();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (token && !routesLoaded) loadRoutes();
|
||||
}, [token, routesLoaded]);
|
||||
|
||||
if (!token) return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
|
||||
if (!routesLoaded) return <PageLoading />;
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
```
|
||||
|
||||
**动态路由注入:**
|
||||
|
||||
```tsx
|
||||
// 后端 component 字段 → React.lazy 懒加载
|
||||
const modules = import.meta.glob('../pages/**/index.tsx');
|
||||
|
||||
function resolveComponent(path: string) {
|
||||
const key = `../pages/${path}/index.tsx`;
|
||||
return lazy(modules[key] as () => Promise<{ default: ComponentType }>);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 状态管理(Zustand)
|
||||
|
||||
| Store | 职责 | 持久化 |
|
||||
| -------------------- | -------------------------------- | -------------------------------------- |
|
||||
| `useUserStore` | Token、用户信息、登录/登出 | Token → localStorage(persist 中间件) |
|
||||
| `usePermissionStore` | 动态路由、菜单树、权限码集合 | 否(每次登录重新拉取) |
|
||||
| `useAppStore` | 侧边栏状态、主题、语言、设备类型 | 部分 → localStorage |
|
||||
| `useDictStore` | 数据字典(状态枚举等) | 会话级缓存 |
|
||||
|
||||
```typescript
|
||||
// stores/user.ts 示意
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface UserState {
|
||||
token: string | null;
|
||||
userInfo: UserInfo | null;
|
||||
setToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
set => ({
|
||||
token: null,
|
||||
userInfo: null,
|
||||
setToken: token => set({ token }),
|
||||
logout: () => set({ token: null, userInfo: null }),
|
||||
}),
|
||||
{ name: 'user-store', partialize: s => ({ token: s.token }) },
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**原则:** 只有跨页面共享的状态才进 Store;页面私有状态用 `useState`/`useReducer` 即可。服务端列表数据优先用 TanStack Query 管理。
|
||||
|
||||
### 5.4 API 层
|
||||
|
||||
```typescript
|
||||
// utils/request.ts 核心能力
|
||||
const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 });
|
||||
|
||||
// 请求拦截:注入 Token
|
||||
// 响应拦截:统一错误码处理、Token 刷新、message 提示
|
||||
// 取消重复请求(可选,AbortController)
|
||||
// 下载文件(blob)特殊处理
|
||||
```
|
||||
|
||||
**接口组织规范:**
|
||||
|
||||
```typescript
|
||||
// api/user.ts
|
||||
export function getUserList(params: UserQuery) {
|
||||
return request.get<PageResult<UserVO>>('/users', { params });
|
||||
}
|
||||
export function createUser(data: UserCreateDTO) {
|
||||
return request.post<UserVO>('/users', data);
|
||||
}
|
||||
```
|
||||
|
||||
**与后端协作:**
|
||||
|
||||
- 统一响应格式:`{ code: number, data: T, message: string }`
|
||||
- 业务成功码约定(如 `code === 0` 或 `200`)
|
||||
- 提供 OpenAPI/Swagger 文档,前端用工具自动生成 TS 类型
|
||||
|
||||
### 5.5 布局系统
|
||||
|
||||
推荐使用 **ProLayout**(`@ant-design/pro-components`)作为基础布局:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Logo 面包屑 / 标签页(Tabs) 用户头像 ▼ │ ← Header
|
||||
├────────┬─────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧 │ Main Content │
|
||||
│ 边 │ (Outlet + 页面缓存) │
|
||||
│ 栏 │ │
|
||||
│ │ │
|
||||
├────────┴─────────────────────────────────────────────┤
|
||||
│ 折叠按钮 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**主流交互特性:**
|
||||
|
||||
- 侧边栏可折叠,移动端自动切换为 Drawer
|
||||
- **多标签页(Tabs)**:缓存已访问页面,支持关闭/刷新(`react-activation` 或自研 Tab + 条件渲染)
|
||||
- 面包屑导航(ProLayout 内置)
|
||||
- 全屏、主题切换(亮/暗)、国际化入口(可选)
|
||||
|
||||
```tsx
|
||||
// layouts/BasicLayout/index.tsx 示意
|
||||
<ProLayout
|
||||
title="ChatOne"
|
||||
logo="/logo.svg"
|
||||
route={{ routes: menuRoutes }}
|
||||
location={{ pathname: location.pathname }}
|
||||
menuItemRender={(item, dom) => <Link to={item.path!}>{dom}</Link>}
|
||||
>
|
||||
<TabLayout>
|
||||
<Outlet />
|
||||
</TabLayout>
|
||||
</ProLayout>
|
||||
```
|
||||
|
||||
### 5.6 通用业务组件
|
||||
|
||||
中小型项目的效率关键 — 优先使用 ProComponents,必要时二次封装:
|
||||
|
||||
#### ProTable(表格页模板)
|
||||
|
||||
```
|
||||
┌─ 搜索区(可折叠)──────────────────────────┐
|
||||
│ [关键词] [状态 ▼] [日期范围] [搜索] [重置] │
|
||||
├─ 工具栏 ────────────────────────────────────┤
|
||||
│ [+ 新增] [导出] [批量删除] │
|
||||
├─ 数据表格 ──────────────────────────────────┤
|
||||
│ ☐ 姓名 状态 创建时间 操作 │
|
||||
│ ☐ 张三 启用 2026-01-01 编辑 删除 │
|
||||
├─ 分页 ──────────────────────────────────────┤
|
||||
│ < 1 2 3 ... 10 > │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
配置驱动:`columns`、`request`(返回 `{ data, total, success }`)两个核心 props。
|
||||
|
||||
```tsx
|
||||
// pages/system/user/index.tsx 示意
|
||||
<ProTable<UserVO>
|
||||
columns={columns}
|
||||
request={async params => {
|
||||
const res = await getUserList(params);
|
||||
return { data: res.data.list, total: res.data.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Access key="create" permission="user:create">
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
新增
|
||||
</Button>
|
||||
</Access>,
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
#### ProForm(表单弹窗/抽屉)
|
||||
|
||||
- 支持 `ModalForm` / `DrawerForm` 两种模式
|
||||
- Schema 驱动字段渲染(`ProFormText`、`ProFormSelect` 等)
|
||||
- 内置新增/编辑模式切换
|
||||
|
||||
#### Access(权限组件)
|
||||
|
||||
```tsx
|
||||
// components/Access/index.tsx
|
||||
interface AccessProps {
|
||||
permission: string | string[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Access({ permission, children }: AccessProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
if (!hasPermission(permission)) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 典型 CRUD 页面模式
|
||||
|
||||
每个业务模块的标准文件:
|
||||
|
||||
```
|
||||
pages/system/user/
|
||||
├── index.tsx # 列表页(ProTable)
|
||||
├── components/
|
||||
│ └── UserForm.tsx # 新增/编辑表单(ModalForm / DrawerForm)
|
||||
└── types.ts # 页面级类型(可选)
|
||||
```
|
||||
|
||||
**列表页职责:** 组装 ProTable 配置,处理新增/编辑/删除事件。
|
||||
**表单组件职责:** 接收 `record` prop,提交成功后回调 `onSuccess`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 横切关注点
|
||||
|
||||
### 6.1 错误处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
| -------------- | ----------------------------------- |
|
||||
| 网络错误 | `message.error` 提示 + 可选重试 |
|
||||
| 401 未授权 | 刷新 Token 或跳转登录 |
|
||||
| 403 无权限 | 提示 + 停留当前页 |
|
||||
| 404 接口不存在 | 开发环境 console 详细日志 |
|
||||
| 500 服务端错误 | 友好提示,上报 Sentry(可选) |
|
||||
| 表单校验失败 | Ant Design Form 字段级红色提示 |
|
||||
| React 渲染错误 | `<ErrorBoundary>` 捕获并展示降级 UI |
|
||||
|
||||
### 6.2 加载状态
|
||||
|
||||
- **全局**:路由切换 NProgress 细条
|
||||
- **页面级**:`<Spin>`、`<Skeleton>`(首屏)
|
||||
- **按钮级**:`<Button loading={submitting}>`
|
||||
- **表格级**:ProTable 内置 `loading` 状态
|
||||
|
||||
### 6.3 国际化(可选)
|
||||
|
||||
- 使用 `react-i18next`,语言包按模块拆分
|
||||
- Ant Design `ConfigProvider` 同步切换 locale
|
||||
- 中小型项目若仅中文,可暂不引入,预留 `meta.title` 的 i18n key
|
||||
|
||||
### 6.4 主题
|
||||
|
||||
- 基于 Ant Design 5 **Design Token** + `ConfigProvider` 实现亮/暗主题
|
||||
- 主色、圆角、字体大小通过 `theme.ts` 统一管理
|
||||
- 暗色模式:`algorithm: theme.darkAlgorithm`
|
||||
|
||||
```tsx
|
||||
// App.tsx 示意
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: { colorPrimary: '#1677ff', borderRadius: 6 },
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 开发规范
|
||||
|
||||
### 7.1 命名约定
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
| ----------- | ------------------- | ----------------------------- |
|
||||
| 组件文件 | PascalCase | `UserForm.tsx` |
|
||||
| 页面入口 | `index.tsx` | `pages/system/user/index.tsx` |
|
||||
| 自定义 Hook | camelCase,use 前缀 | `useTable.ts` |
|
||||
| API 函数 | camelCase,动词开头 | `getUserList` |
|
||||
| 常量 | UPPER_SNAKE_CASE | `TOKEN_KEY` |
|
||||
| 路由 path | kebab-case | `/system/user-list` |
|
||||
|
||||
### 7.2 组件编写规范
|
||||
|
||||
- 默认使用函数组件 + TypeScript
|
||||
- Props 使用 `interface` 或 `type` 显式声明
|
||||
- 单文件组件不超过 300 行,超出则拆分
|
||||
- 样式优先 Ant Design Token;局部样式用 CSS Modules
|
||||
- 列表渲染必须提供稳定的 `rowKey`
|
||||
- 避免在 render 中创建内联对象/函数(或使用 `useMemo`/`useCallback`)
|
||||
|
||||
### 7.3 Git 提交规范(Conventional Commits)
|
||||
|
||||
```
|
||||
feat: 新增用户管理页面
|
||||
fix: 修复 Token 刷新后路由丢失
|
||||
refactor: 重构 ProTable 搜索逻辑
|
||||
docs: 更新设计方案文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 环境配置
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_APP_TITLE=ChatOne 管理后台
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_MOCK=false
|
||||
|
||||
# .env.production
|
||||
VITE_APP_TITLE=ChatOne 管理后台
|
||||
VITE_API_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
**Vite 代理(开发环境):**
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 构建与部署
|
||||
|
||||
### 9.1 构建命令
|
||||
|
||||
```bash
|
||||
pnpm dev # 本地开发
|
||||
pnpm build # 生产构建 → dist/
|
||||
pnpm preview # 预览构建产物
|
||||
pnpm lint # 代码检查
|
||||
pnpm test # 单元测试
|
||||
```
|
||||
|
||||
### 9.2 构建优化
|
||||
|
||||
| 策略 | 手段 |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
| 分包 | `manualChunks` 分离 react、antd、echarts |
|
||||
| 按需引入 | Ant Design 5 默认 Tree Shaking;ProComponents 按需导入 |
|
||||
| 路由懒加载 | `React.lazy` + `import.meta.glob` |
|
||||
| 压缩 | gzip / brotli(Nginx 层) |
|
||||
| 缓存 | 文件名带 content hash |
|
||||
| 图片 | 小图 inline,大图 CDN |
|
||||
|
||||
### 9.3 部署架构
|
||||
|
||||
```
|
||||
用户 → CDN/Nginx → 静态文件 (dist/)
|
||||
→ /api 反向代理 → 后端服务
|
||||
```
|
||||
|
||||
- SPA 需要 Nginx `try_files $uri /index.html` 配置
|
||||
- 推荐 Docker 多阶段构建,产物仅含 dist + Nginx
|
||||
|
||||
### 9.4 CI/CD 流水线
|
||||
|
||||
```
|
||||
Push/PR → ESLint + TypeCheck → Vitest → Build → Deploy (staging/prod)
|
||||
```
|
||||
|
||||
推荐 GitHub Actions 或 GitLab CI,PR 必须通过 lint 和 build。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全要点
|
||||
|
||||
| 项 | 措施 |
|
||||
| -------- | --------------------------------------------------------------- |
|
||||
| XSS | React 默认转义;`dangerouslySetInnerHTML` 必须消毒(DOMPurify) |
|
||||
| CSRF | Cookie 方案启用 CSRF Token;Token Header 方案风险较低 |
|
||||
| 敏感信息 | `.env` 不入库;生产环境变量由 CI 注入 |
|
||||
| 权限 | 前端权限仅为 UX 优化,**后端必须独立校验** |
|
||||
| 依赖安全 | 定期 `pnpm audit`,Dependabot 自动 PR |
|
||||
|
||||
---
|
||||
|
||||
## 11. 可选增强(按阶段引入)
|
||||
|
||||
| 阶段 | 能力 | 工具 |
|
||||
| ---- | ------------------ | ------------------------------- |
|
||||
| MVP | Mock 数据 | MSW (Mock Service Worker) |
|
||||
| V1.1 | 操作日志、消息通知 | WebSocket / SSE |
|
||||
| V1.2 | 错误监控 | Sentry |
|
||||
| V1.2 | 页面缓存 | react-activation |
|
||||
| V2.0 | 低代码表单 | ProForm Schema + 自研表单设计器 |
|
||||
| V2.0 | 数据大屏 | ECharts + 自适应方案 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 实施路线图
|
||||
|
||||
### Phase 1 — 基础脚手架(1~2 周)
|
||||
|
||||
- [ ] 初始化 Vite + React 18 + TS 项目
|
||||
- [ ] 集成 Ant Design 5、ProComponents、Zustand、React Router
|
||||
- [ ] 实现 ProLayout 布局(侧边栏 + 顶栏 + 内容区)
|
||||
- [ ] 实现登录页 + Token 认证流程
|
||||
- [ ] Axios 封装 + 环境变量配置
|
||||
- [ ] ESLint + Prettier + Husky 配置
|
||||
|
||||
### Phase 2 — 权限与通用组件(1~2 周)
|
||||
|
||||
- [ ] 动态路由 + 菜单渲染
|
||||
- [ ] Access 权限组件 + usePermission Hook
|
||||
- [ ] 基于 ProTable / ProForm 封装业务模板
|
||||
- [ ] 多标签页(Tabs)+ 页面缓存
|
||||
- [ ] 数据字典 Store
|
||||
|
||||
### Phase 3 — 业务模块开发(持续)
|
||||
|
||||
- [ ] 按后端模块迭代 CRUD 页面
|
||||
- [ ] Dashboard 仪表盘
|
||||
- [ ] 完善错误处理与边界场景
|
||||
|
||||
### Phase 4 — 质量与上线(1 周)
|
||||
|
||||
- [ ] 核心流程 E2E 测试
|
||||
- [ ] 构建优化 + CI/CD
|
||||
- [ ] 部署文档 + 运维手册
|
||||
|
||||
---
|
||||
|
||||
## 13. 参考资源
|
||||
|
||||
| 资源 | 链接 |
|
||||
| -------------- | -------------------------------- |
|
||||
| React 官方文档 | https://react.dev |
|
||||
| Vite 官方文档 | https://vite.dev |
|
||||
| Ant Design 5 | https://ant.design |
|
||||
| Ant Design Pro | https://pro.ant.design |
|
||||
| ProComponents | https://procomponents.ant.design |
|
||||
| Zustand | https://zustand.docs.pmnd.rs |
|
||||
| ahooks | https://ahooks.js.org |
|
||||
| TanStack Query | https://tanstack.com/query |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Vue 技术栈映射表
|
||||
|
||||
| React 主栈 | Vue 等价 |
|
||||
| ----------------- | ----------------------- |
|
||||
| React 18 | Vue 3 |
|
||||
| Zustand | Pinia |
|
||||
| React Router 6 | Vue Router 4 |
|
||||
| Ant Design 5 | Element Plus |
|
||||
| ProComponents | 自研 ProTable / ProForm |
|
||||
| ahooks | VueUse |
|
||||
| 函数组件 + Hooks | `<script setup>` |
|
||||
| `<Access />` 组件 | `v-permission` 指令 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:关键依赖版本参考
|
||||
|
||||
```json
|
||||
{
|
||||
"react": "^18.3",
|
||||
"react-dom": "^18.3",
|
||||
"react-router-dom": "^6.28",
|
||||
"antd": "^5.22",
|
||||
"@ant-design/pro-components": "^2.8",
|
||||
"@ant-design/icons": "^5.5",
|
||||
"zustand": "^5.0",
|
||||
"@tanstack/react-query": "^5.62",
|
||||
"axios": "^1.7",
|
||||
"ahooks": "^3.8",
|
||||
"dayjs": "^1.11",
|
||||
"vite": "^6.0",
|
||||
"typescript": "^5.7",
|
||||
"vitest": "^3.0",
|
||||
"@testing-library/react": "^16.0",
|
||||
"eslint": "^9.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_本文档将随项目演进持续更新。_
|
||||
31
eslint.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import prettierPlugin from 'eslint-plugin-prettier';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
]);
|
||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>chat-one-admin-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "chat-one-admin-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/pro-components": "3.1.14-2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"ahooks": "^3.9.7",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.8",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
4972
pnpm-lock.yaml
generated
Normal file
1
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
32
src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ConfigProvider, App as AntdApp, theme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
import router from './router';
|
||||
import { themeConfig } from './styles/theme';
|
||||
import { useResolvedTheme } from './hooks/useTheme';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function App() {
|
||||
const resolved = useResolvedTheme();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
...themeConfig,
|
||||
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
50
src/api/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ApiResponse } from './types/common';
|
||||
import type { LoginAccountParams, LoginResponse, UserInfo } from './types/auth';
|
||||
|
||||
const delay = (ms = 500) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/** 登录接口 */
|
||||
export async function login(data: LoginAccountParams): Promise<ApiResponse<LoginResponse>> {
|
||||
await delay();
|
||||
|
||||
if (data.username === 'admin' && data.password === '123456') {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
token: 'token-' + Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 401,
|
||||
message: '用户名或密码错误',
|
||||
data: { token: '' },
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
export async function getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||
await delay();
|
||||
return {
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
avatar: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
export async function logout(): Promise<ApiResponse<void>> {
|
||||
await delay();
|
||||
return {
|
||||
code: 200,
|
||||
message: 'ok',
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
51
src/api/menu.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ApiResponse } from './types/common';
|
||||
import type { MenuItem } from './types/menu';
|
||||
|
||||
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/** 多级菜单 Mock */
|
||||
const mockMenus: MenuItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '总览',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '大模型管理',
|
||||
path: '/llm',
|
||||
icon: 'BlockOutlined',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
children: [
|
||||
{
|
||||
id: '3-1',
|
||||
name: '用户管理',
|
||||
path: '/system/user',
|
||||
icon: 'UserOutlined',
|
||||
},
|
||||
{
|
||||
id: '3-2',
|
||||
name: '角色管理',
|
||||
path: '/system/role',
|
||||
icon: 'TeamOutlined',
|
||||
},
|
||||
{
|
||||
id: '3-3',
|
||||
name: '权限配置',
|
||||
path: '/system/permission',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function getMenus(): Promise<ApiResponse<MenuItem[]>> {
|
||||
await delay();
|
||||
console.log(mockMenus);
|
||||
return { code: 200, data: mockMenus, message: 'ok' };
|
||||
}
|
||||
23
src/api/types/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** 登录类型 */
|
||||
export type LoginType = 'account' | 'phone' | 'email';
|
||||
|
||||
/** 登录请求参数 */
|
||||
export interface LoginAccountParams {
|
||||
username: string;
|
||||
password: string;
|
||||
autoLogin?: boolean;
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** 用户信息 */
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
email?: string;
|
||||
}
|
||||
12
src/api/types/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** 后端统一响应格式 */
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
export interface Pagination<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
}
|
||||
14
src/api/types/menu.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** 菜单数据树节点 */
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string;
|
||||
hideInMenu?: boolean;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
export interface MenuResponse {
|
||||
code: number;
|
||||
data: MenuItem[];
|
||||
}
|
||||
BIN
src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
50
src/components/PageSkeleton/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Card, Col, Row, Skeleton, Space } from 'antd';
|
||||
|
||||
/** 内容区占位骨架,贴近总览页结构 */
|
||||
export default function PageSkeleton() {
|
||||
return (
|
||||
<Space vertical size={16} style={{ width: '100%' }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Col key={i} xs={24} sm={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<Skeleton active paragraph={{ rows: 1 }} title={{ width: '40%' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card variant="outlined">
|
||||
<Skeleton active title={{ width: '30%' }} paragraph={false} style={{ marginBottom: 16 }} />
|
||||
<Skeleton.Node active style={{ width: '100%', height: 260 }}>
|
||||
<div />
|
||||
</Skeleton.Node>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card variant="outlined">
|
||||
<Skeleton active title={{ width: '40%' }} paragraph={false} style={{ marginBottom: 16 }} />
|
||||
<Skeleton.Node active style={{ width: '100%', height: 260 }}>
|
||||
<div />
|
||||
</Skeleton.Node>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card variant="outlined">
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card variant="outlined">
|
||||
<Skeleton active paragraph={{ rows: 5 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
26
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppStore, type ResolvedTheme } from '@/stores/app';
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/** 返回当前真正应用的 light/dark */
|
||||
export function useResolvedTheme(): ResolvedTheme {
|
||||
const preference = useAppStore(s => s.themePreference);
|
||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() =>
|
||||
typeof window === 'undefined' ? 'light' : getSystemTheme(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
if (preference === 'system') return systemTheme;
|
||||
return preference;
|
||||
}
|
||||
96
src/layouts/BasicLayout/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { PageContainer, ProLayout } from '@ant-design/pro-components';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { DesktopOutlined, MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { menusToProRoutes } from '@/router/menu';
|
||||
import logo from '@/assets/logo.png';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useResolvedTheme } from '@/hooks/useTheme';
|
||||
import type { ThemePreference } from '@/stores/app';
|
||||
|
||||
export default function BasicLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const menus = usePermissionStore(s => s.menus);
|
||||
const clearMenus = usePermissionStore(s => s.clearMenus);
|
||||
const userInfo = useUserStore(s => s.userInfo);
|
||||
const logout = useUserStore(s => s.logout);
|
||||
const preference = useAppStore(s => s.themePreference);
|
||||
const setThemePreference = useAppStore(s => s.setThemePreference);
|
||||
const resolved = useResolvedTheme();
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 等 ProLayout / 侧栏完成首屏布局后再挂页面(图表)
|
||||
const id = requestAnimationFrame(() => {
|
||||
setContentReady(true);
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const proRoutes = {
|
||||
path: '/',
|
||||
routes: menusToProRoutes(menus),
|
||||
};
|
||||
|
||||
const icon = preference === 'system' ? <DesktopOutlined /> : resolved === 'dark' ? <MoonOutlined /> : <SunOutlined />;
|
||||
|
||||
return (
|
||||
<ProLayout
|
||||
logo={<img src={logo} alt="logo" />}
|
||||
title="ChatOne"
|
||||
layout="mix" // 或 side
|
||||
location={{ pathname: location.pathname }}
|
||||
route={proRoutes}
|
||||
menuProps={{
|
||||
onClick: ({ key }) => {
|
||||
// key 一般是 path,如 /dashboard、/llm
|
||||
if (!key || key.startsWith('http')) return;
|
||||
navigate(key);
|
||||
},
|
||||
}}
|
||||
avatarProps={{
|
||||
title: userInfo?.nickname ?? '用户',
|
||||
render: (_, dom) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [{ key: 'logout', label: '退出登录' }],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
clearMenus();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{dom}
|
||||
</Dropdown>
|
||||
),
|
||||
}}
|
||||
actionsRender={() => [
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'light', label: '亮色', icon: <SunOutlined /> },
|
||||
{ key: 'dark', label: '暗色', icon: <MoonOutlined /> },
|
||||
{ key: 'system', label: '跟随系统', icon: <DesktopOutlined /> },
|
||||
],
|
||||
selectable: true,
|
||||
selectedKeys: [preference],
|
||||
onClick: ({ key }) => setThemePreference(key as ThemePreference),
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={icon} />
|
||||
</Dropdown>,
|
||||
]}
|
||||
>
|
||||
<PageContainer>{contentReady ? <Outlet /> : <PageSkeleton />}</PageContainer>
|
||||
</ProLayout>
|
||||
);
|
||||
}
|
||||
11
src/layouts/BlankLayout/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
export default function BlankLayout() {
|
||||
return (
|
||||
<Suspense fallback={<Spin style={{ margin: '40vh auto', display: 'block' }} />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
15
src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App.tsx';
|
||||
import './styles/global.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
217
src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { ApiOutlined, MessageOutlined, RiseOutlined, TeamOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { Line, Pie } from '@ant-design/charts';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { Col, Progress, Row, Select, Space, Statistic, Table, Tag, Typography, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { themeConfig } from '@/styles/theme';
|
||||
|
||||
import {
|
||||
alertList,
|
||||
conversationTrend,
|
||||
modelShare,
|
||||
overviewStats,
|
||||
topModels,
|
||||
type AlertItem,
|
||||
type OverviewStat,
|
||||
} from './mock';
|
||||
|
||||
const iconMap: Record<OverviewStat['icon'], ReactNode> = {
|
||||
message: <MessageOutlined />,
|
||||
token: <ThunderboltOutlined />,
|
||||
user: <TeamOutlined />,
|
||||
api: <ApiOutlined />,
|
||||
};
|
||||
|
||||
const alertColumns: ColumnsType<AlertItem> = [
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
width: 80,
|
||||
render: (level: AlertItem['level']) => {
|
||||
const color = level === '高' ? 'error' : level === '中' ? 'warning' : 'default';
|
||||
return <Tag color={color}>{level}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '时间', dataIndex: 'time', width: 170 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (status: AlertItem['status']) => <Tag color={status === '已恢复' ? 'success' : 'error'}>{status}</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
const primary = themeConfig.token.colorPrimary;
|
||||
|
||||
export default function Dashboard() {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const lineConfig = {
|
||||
data: conversationTrend,
|
||||
xField: 'date',
|
||||
yField: 'value',
|
||||
height: 280,
|
||||
autoFit: true,
|
||||
// 给曲线/圆点留边,避免贴边裁切
|
||||
padding: [12, 16, 8, 8], // 上 右 下 左,可按观感微调
|
||||
// 或用 inset: 12 / insetRight: 16
|
||||
shapeField: 'smooth',
|
||||
style: {
|
||||
stroke: primary,
|
||||
lineWidth: 2,
|
||||
},
|
||||
area: {
|
||||
style: {
|
||||
fill: `linear-gradient(-90deg, ${primary}00 0%, ${primary}40 100%)`,
|
||||
},
|
||||
},
|
||||
point: {
|
||||
sizeField: 4,
|
||||
shapeField: 'circle',
|
||||
style: {
|
||||
fill: primary,
|
||||
stroke: '#fff',
|
||||
lineWidth: 1,
|
||||
},
|
||||
},
|
||||
axis: {
|
||||
y: {
|
||||
labelFormatter: (v: number) => `${Math.round(v / 1000)}k`,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
items: [{ channel: 'y', name: '对话量' }],
|
||||
},
|
||||
};
|
||||
|
||||
const pieConfig = {
|
||||
data: modelShare,
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
height: 280,
|
||||
innerRadius: 0.6,
|
||||
legend: {
|
||||
color: {
|
||||
position: 'right' as const,
|
||||
itemLabelFontSize: 12,
|
||||
},
|
||||
},
|
||||
label: false,
|
||||
annotations: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
style: {
|
||||
text: '总计\n100%',
|
||||
x: '50%',
|
||||
y: '50%',
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
fill: token.colorTextSecondary,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Space vertical size={16} style={{ width: '100%' }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{overviewStats.map(item => (
|
||||
<Col key={item.key} xs={24} sm={12} md={6}>
|
||||
<ProCard key={item.key} colSpan={{ xs: 24, sm: 12, md: 6 }} variant="outlined">
|
||||
<Statistic
|
||||
title={
|
||||
<Space>
|
||||
<span style={{ color: token.colorPrimary }}>{iconMap[item.icon]}</span>
|
||||
{item.title}
|
||||
</Space>
|
||||
}
|
||||
value={item.value}
|
||||
suffix={
|
||||
<Typography.Text type="success" style={{ fontSize: 13 }}>
|
||||
<RiseOutlined /> 较昨日 ↑ {item.trend}%
|
||||
</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</ProCard>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16} style={{ minWidth: 0 }}>
|
||||
<ProCard
|
||||
title="近7日对话趋势"
|
||||
variant="outlined"
|
||||
styles={{ body: { overflow: 'hidden' } }}
|
||||
extra={
|
||||
<Select
|
||||
defaultValue="7d"
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ label: '近7日', value: '7d' },
|
||||
{ label: '近30日', value: '30d' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Line {...lineConfig} autoFit containerStyle={{ width: '100%', height: 280 }} />
|
||||
</ProCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ProCard title="模型调用占比" variant="outlined">
|
||||
<Pie {...pieConfig} />
|
||||
</ProCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<ProCard title="异常告警" variant="outlined">
|
||||
<Table<AlertItem>
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={alertColumns}
|
||||
dataSource={alertList}
|
||||
pagination={{ pageSize: 5, showTotal: total => `共 ${total} 条` }}
|
||||
/>
|
||||
</ProCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ProCard
|
||||
title="热门模型 TOP5"
|
||||
variant="outlined"
|
||||
extra={
|
||||
<Typography.Link type="secondary" style={{ fontSize: 13 }}>
|
||||
查看更多 >
|
||||
</Typography.Link>
|
||||
}
|
||||
>
|
||||
<Space vertical size={16} style={{ width: '100%' }}>
|
||||
{topModels.map((item, index) => (
|
||||
<div key={item.name}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Typography.Text>
|
||||
{index + 1}. {item.name}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.percent}%</Typography.Text>
|
||||
</div>
|
||||
<Progress percent={item.percent} showInfo={false} strokeColor={token.colorPrimary} size="small" />
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</ProCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
90
src/pages/dashboard/mock.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export interface OverviewStat {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
trend: number;
|
||||
icon: 'message' | 'token' | 'user' | 'api';
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ModelShare {
|
||||
type: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
level: '高' | '中' | '低';
|
||||
time: string;
|
||||
status: '未恢复' | '已恢复';
|
||||
}
|
||||
|
||||
export interface TopModel {
|
||||
name: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export const overviewStats: OverviewStat[] = [
|
||||
{ key: 'messages', title: '今日消息', value: '128,560', trend: 12.5, icon: 'message' },
|
||||
{ key: 'tokens', title: 'Token消耗', value: '2.46亿', trend: 8.7, icon: 'token' },
|
||||
{ key: 'users', title: '活跃用户', value: '18,320', trend: 5.2, icon: 'user' },
|
||||
{ key: 'success', title: '接口成功率', value: '99.23%', trend: 0.1, icon: 'api' },
|
||||
];
|
||||
|
||||
export const conversationTrend: TrendPoint[] = [
|
||||
{ date: '05-10', value: 98000 },
|
||||
{ date: '05-11', value: 112000 },
|
||||
{ date: '05-12', value: 105000 },
|
||||
{ date: '05-13', value: 128000 },
|
||||
{ date: '05-14', value: 141000 },
|
||||
{ date: '05-15', value: 136000 },
|
||||
{ date: '05-16', value: 152000 },
|
||||
];
|
||||
|
||||
export const modelShare: ModelShare[] = [
|
||||
{ type: 'GPT', value: 42 },
|
||||
{ type: 'Claude', value: 27 },
|
||||
{ type: '通义', value: 19 },
|
||||
{ type: '其他', value: 12 },
|
||||
];
|
||||
|
||||
export const alertList: AlertItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: '接口错误',
|
||||
content: '模型服务超时:GPT-4o',
|
||||
level: '高',
|
||||
time: '2024-05-16 10:21:35',
|
||||
status: '未恢复',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: '限流告警',
|
||||
content: 'Claude-3.5 请求限流',
|
||||
level: '中',
|
||||
time: '2024-05-16 09:47:12',
|
||||
status: '未恢复',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: '服务降级',
|
||||
content: '向量库服务降级运行',
|
||||
level: '中',
|
||||
time: '2024-05-16 08:33:09',
|
||||
status: '已恢复',
|
||||
},
|
||||
];
|
||||
|
||||
export const topModels: TopModel[] = [
|
||||
{ name: 'GPT-4o', percent: 42.35 },
|
||||
{ name: 'Claude-3.5-Sonnet', percent: 27.46 },
|
||||
{ name: '通义千问-Max', percent: 18.91 },
|
||||
{ name: 'GPT-4 Turbo', percent: 7.65 },
|
||||
{ name: '其他', percent: 3.63 },
|
||||
];
|
||||
19
src/pages/exception/403.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="403"
|
||||
subTitle="抱歉,您没有权限访问该页面"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/dashboard', { replace: true })}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
src/pages/exception/404.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="抱歉,您访问的页面不存在"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/dashboard', { replace: true })}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
src/pages/llm/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'react';
|
||||
|
||||
function LLM() {
|
||||
return <div>大模型管理</div>;
|
||||
}
|
||||
|
||||
export default LLM;
|
||||
216
src/pages/login/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { LockOutlined, MobileOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
LoginFormPage,
|
||||
ProConfigProvider,
|
||||
ProFormCaptcha,
|
||||
ProFormCheckbox,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { Tabs, message, theme } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { login, getUserInfo } from '@/api/auth';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import type { LoginType } from '@/api/types/auth';
|
||||
import type { LoginAccountParams } from '@/api/types/auth';
|
||||
import logo from '@/assets/logo.png';
|
||||
|
||||
const LoginFrom = () => {
|
||||
const [loginType, setLoginType] = useState<LoginType>('account');
|
||||
const { token } = theme.useToken();
|
||||
const setToken = useUserStore(s => s.setToken);
|
||||
const setUserInfo = useUserStore(s => s.setUserInfo);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const onFinish = async (values: LoginAccountParams) => {
|
||||
console.log(values);
|
||||
if (loginType !== 'account') {
|
||||
message.warning('暂不支持短信登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await login(values);
|
||||
if (res.code !== 200) {
|
||||
message.error(res.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfo = await getUserInfo();
|
||||
if (userInfo.code !== 200) {
|
||||
message.error(userInfo.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setToken(res.data.token);
|
||||
setUserInfo(userInfo.data);
|
||||
|
||||
message.success('登录成功');
|
||||
const redirect = searchParams.get('redirect') || '/dashboard';
|
||||
navigate(redirect, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<LoginFormPage
|
||||
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
|
||||
logo={logo}
|
||||
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||
title="ChatOne"
|
||||
containerStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
subTitle="整合AI聊天管理平台"
|
||||
onFinish={onFinish}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'account', label: '账号密码登录' },
|
||||
{ key: 'phone', label: '手机号登录' },
|
||||
]}
|
||||
onChange={key => setLoginType(key as LoginType)}
|
||||
activeKey={loginType}
|
||||
/>
|
||||
{loginType === 'account' && (
|
||||
<>
|
||||
<ProFormText
|
||||
name="username"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: (
|
||||
<UserOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
placeholder={'用户名: admin or user'}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入用户名!',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="password"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: (
|
||||
<LockOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
placeholder={'密码: ant.design'}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入密码!',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{loginType === 'phone' && (
|
||||
<>
|
||||
<ProFormText
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: (
|
||||
<MobileOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
name="mobile"
|
||||
placeholder={'手机号'}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入手机号!',
|
||||
},
|
||||
{
|
||||
pattern: /^1\d{10}$/,
|
||||
message: '手机号格式错误!',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormCaptcha
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
prefix: (
|
||||
<LockOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
captchaProps={{
|
||||
size: 'large',
|
||||
}}
|
||||
placeholder={'请输入验证码'}
|
||||
captchaTextRender={(timing, count) => {
|
||||
if (timing) {
|
||||
return `${count} ${'获取验证码'}`;
|
||||
}
|
||||
return '获取验证码';
|
||||
}}
|
||||
name="captcha"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入验证码!',
|
||||
},
|
||||
]}
|
||||
onGetCaptcha={async () => {
|
||||
message.success('获取验证码成功!验证码为:1234');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginBlockEnd: 24,
|
||||
}}
|
||||
>
|
||||
<ProFormCheckbox noStyle name="autoLogin">
|
||||
自动登录
|
||||
</ProFormCheckbox>
|
||||
<a
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
>
|
||||
忘记密码
|
||||
</a>
|
||||
</div>
|
||||
</LoginFormPage>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<ProConfigProvider dark>
|
||||
<LoginFrom />
|
||||
</ProConfigProvider>
|
||||
);
|
||||
}
|
||||
30
src/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
|
||||
export default function AuthGuard() {
|
||||
const token = useUserStore(s => s.token);
|
||||
const location = useLocation();
|
||||
const menusLoaded = usePermissionStore(s => s.menusLoaded);
|
||||
const getMenus = usePermissionStore(s => s.getMenus);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && !menusLoaded) {
|
||||
getMenus();
|
||||
}
|
||||
}, [token, getMenus, menusLoaded]);
|
||||
|
||||
if (!token) return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
|
||||
if (!menusLoaded) {
|
||||
return (
|
||||
<div style={{ padding: 48, maxWidth: 480 }}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
6
src/router/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { routes } from './routes';
|
||||
|
||||
const router = createBrowserRouter(routes);
|
||||
|
||||
export default router;
|
||||
26
src/router/menu.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MenuDataItem } from '@ant-design/pro-components';
|
||||
import * as Icons from '@ant-design/icons';
|
||||
import type { MenuItem } from '@/api/types/menu';
|
||||
import { createElement, type ComponentType, type ReactNode } from 'react';
|
||||
|
||||
function resolveIcon(name?: string): ReactNode {
|
||||
if (!name) return undefined;
|
||||
|
||||
// @ant-design/icons 同时导出组件和工具函数(如 setTwoToneColor),不能直接断言为 ComponentType
|
||||
const Candidate = (Icons as unknown as Record<string, unknown>)[name];
|
||||
if (Candidate === undefined) return undefined;
|
||||
|
||||
if (typeof Candidate !== 'function' && typeof Candidate !== 'object') return undefined;
|
||||
|
||||
return createElement(Candidate as ComponentType);
|
||||
}
|
||||
|
||||
/** 后端菜单树 → ProLayout route */
|
||||
export function menusToProRoutes(menus: MenuItem[]): MenuDataItem[] {
|
||||
return menus.map(m => ({
|
||||
path: m.path,
|
||||
name: m.name,
|
||||
icon: resolveIcon(m.icon),
|
||||
children: m.children?.length ? menusToProRoutes(m.children) : undefined,
|
||||
}));
|
||||
}
|
||||
52
src/router/routes.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { lazy } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
|
||||
import AuthGuard from './AuthGuard';
|
||||
import BasicLayout from '@/layouts/BasicLayout';
|
||||
import BlankLayout from '@/layouts/BlankLayout';
|
||||
|
||||
const Login = lazy(() => import('@/pages/login'));
|
||||
const Dashboard = lazy(() => import('@/pages/dashboard'));
|
||||
const LLM = lazy(() => import('@/pages/llm'));
|
||||
const NotFound = lazy(() => import('@/pages/exception/404'));
|
||||
const Forbidden = lazy(() => import('@/pages/exception/403'));
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <BlankLayout />,
|
||||
children: [{ index: true, element: <Login /> }],
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
element: <BlankLayout />,
|
||||
children: [{ index: true, element: <NotFound /> }],
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
element: <BlankLayout />,
|
||||
children: [{ index: true, element: <Forbidden /> }],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <BasicLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <Dashboard /> },
|
||||
{ path: 'llm', element: <LLM /> },
|
||||
{ path: 'system/user', element: <div>用户管理</div> },
|
||||
{ path: 'system/role', element: <div>角色管理</div> },
|
||||
{ path: 'system/permission/menu', element: <div>菜单权限</div> },
|
||||
{ path: 'system/permission/button', element: <div>按钮权限</div> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/404" replace /> },
|
||||
];
|
||||
33
src/stores/app.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
/** 用户偏好:含跟随系统 */
|
||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||
/** 实际生效的明暗 */
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
interface AppState {
|
||||
themePreference: ThemePreference;
|
||||
setThemePreference: (mode: ThemePreference) => void;
|
||||
/** 三态循环:light → dark → system → light */
|
||||
cycleTheme: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
set => ({
|
||||
themePreference: 'system', // 默认跟随系统
|
||||
setThemePreference: themePreference => set({ themePreference }),
|
||||
cycleTheme: () =>
|
||||
set(s => {
|
||||
const order: ThemePreference[] = ['light', 'dark', 'system'];
|
||||
const i = order.indexOf(s.themePreference);
|
||||
return { themePreference: order[(i + 1) % order.length] };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'app-store',
|
||||
partialize: s => ({ themePreference: s.themePreference }),
|
||||
},
|
||||
),
|
||||
);
|
||||
36
src/stores/permission.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand';
|
||||
import { message } from 'antd';
|
||||
import type { MenuItem } from '@/api/types/menu';
|
||||
import { getMenus } from '@/api/menu';
|
||||
|
||||
interface PermissionState {
|
||||
menus: MenuItem[];
|
||||
menusLoaded: boolean;
|
||||
menusLoading: boolean;
|
||||
getMenus: () => Promise<void>;
|
||||
clearMenus: () => void;
|
||||
}
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
menus: [],
|
||||
menusLoaded: false,
|
||||
menusLoading: false,
|
||||
getMenus: async () => {
|
||||
const { menusLoading, menusLoaded } = get();
|
||||
if (menusLoading || menusLoaded) return;
|
||||
set({ menusLoading: true });
|
||||
|
||||
try {
|
||||
const res = await getMenus();
|
||||
if (res.code !== 200) {
|
||||
throw new Error(res.message);
|
||||
}
|
||||
set({ menus: res.data, menusLoaded: true });
|
||||
} catch (error) {
|
||||
message.error((error as Error).message);
|
||||
} finally {
|
||||
set({ menusLoading: false });
|
||||
}
|
||||
},
|
||||
clearMenus: () => set({ menus: [], menusLoaded: false }),
|
||||
}));
|
||||
29
src/stores/user.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { UserInfo } from '@/api/types/auth';
|
||||
|
||||
/** 用户状态 */
|
||||
interface UserState {
|
||||
token: string | null;
|
||||
userInfo: UserInfo | null;
|
||||
setToken: (token: string) => void;
|
||||
setUserInfo: (info: UserInfo) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** 用户存储 */
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
set => ({
|
||||
token: null,
|
||||
userInfo: null,
|
||||
setToken: token => set({ token }),
|
||||
setUserInfo: info => set({ userInfo: info }),
|
||||
logout: () => set({ token: null, userInfo: null }),
|
||||
}),
|
||||
{
|
||||
name: 'user-store',
|
||||
partialize: state => ({ token: state.token }),
|
||||
},
|
||||
),
|
||||
);
|
||||
11
src/styles/global.css
Normal file
@@ -0,0 +1,11 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
8
src/styles/theme.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
sider: {
|
||||
colorBgMenuItemSelected: '#e6f4ff',
|
||||
},
|
||||
},
|
||||
};
|
||||
31
tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Paths */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 8083,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||