diff --git a/.gitignore b/.gitignore index 2309cc8..f7c320d 100644 --- a/.gitignore +++ b/.gitignore @@ -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? diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..723d257 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist +pnpm-lock.yaml +node_modules \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b729bb4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "jsxSingleQuote": false +} diff --git a/README.md b/README.md index 76e435c..e54697d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,73 @@ -# chat-one-admin-web +# React + TypeScript + Vite -ChatOne 后台管理前端项目 \ No newline at end of file +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... + }, + }, +]); +``` diff --git a/docs/ui/README.md b/docs/ui/README.md new file mode 100644 index 0000000..72fdf45 --- /dev/null +++ b/docs/ui/README.md @@ -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' }, + ], +} +``` diff --git a/docs/ui/dashboard/modules/chatone-dashboard-modules-antd-default.png b/docs/ui/dashboard/modules/chatone-dashboard-modules-antd-default.png new file mode 100644 index 0000000..84d6780 Binary files /dev/null and b/docs/ui/dashboard/modules/chatone-dashboard-modules-antd-default.png differ diff --git a/docs/ui/dashboard/modules/chatone-dashboard-modules-map.png b/docs/ui/dashboard/modules/chatone-dashboard-modules-map.png new file mode 100644 index 0000000..38c3182 Binary files /dev/null and b/docs/ui/dashboard/modules/chatone-dashboard-modules-map.png differ diff --git a/docs/ui/dashboard/overview/chatone-dashboard-overview-antd-default.png b/docs/ui/dashboard/overview/chatone-dashboard-overview-antd-default.png new file mode 100644 index 0000000..0e16f86 Binary files /dev/null and b/docs/ui/dashboard/overview/chatone-dashboard-overview-antd-default.png differ diff --git a/docs/ui/dashboard/overview/chatone-dashboard-overview-ui.png b/docs/ui/dashboard/overview/chatone-dashboard-overview-ui.png new file mode 100644 index 0000000..fe88e94 Binary files /dev/null and b/docs/ui/dashboard/overview/chatone-dashboard-overview-ui.png differ diff --git a/docs/ui/layout/chatone-admin-layout-ui.png b/docs/ui/layout/chatone-admin-layout-ui.png new file mode 100644 index 0000000..498ddf5 Binary files /dev/null and b/docs/ui/layout/chatone-admin-layout-ui.png differ diff --git a/docs/ui/llm/form/chatone-llm-form-ui.png b/docs/ui/llm/form/chatone-llm-form-ui.png new file mode 100644 index 0000000..332e46f Binary files /dev/null and b/docs/ui/llm/form/chatone-llm-form-ui.png differ diff --git a/docs/ui/llm/list/chatone-llm-list-ui.png b/docs/ui/llm/list/chatone-llm-list-ui.png new file mode 100644 index 0000000..9d812a6 Binary files /dev/null and b/docs/ui/llm/list/chatone-llm-list-ui.png differ diff --git a/docs/ui/llm/list/chatone-llm-usage-drawer-ui.png b/docs/ui/llm/list/chatone-llm-usage-drawer-ui.png new file mode 100644 index 0000000..1b688c8 Binary files /dev/null and b/docs/ui/llm/list/chatone-llm-usage-drawer-ui.png differ diff --git a/docs/ui/llm/provider/chatone-llm-provider-ui.png b/docs/ui/llm/provider/chatone-llm-provider-ui.png new file mode 100644 index 0000000..3a143ed Binary files /dev/null and b/docs/ui/llm/provider/chatone-llm-provider-ui.png differ diff --git a/docs/ui/llm/quota/chatone-llm-quota-ui.png b/docs/ui/llm/quota/chatone-llm-quota-ui.png new file mode 100644 index 0000000..82a3f35 Binary files /dev/null and b/docs/ui/llm/quota/chatone-llm-quota-ui.png differ diff --git a/docs/ui/llm/usage-detail/chatone-llm-usage-detail-ui.png b/docs/ui/llm/usage-detail/chatone-llm-usage-detail-ui.png new file mode 100644 index 0000000..d6c6b10 Binary files /dev/null and b/docs/ui/llm/usage-detail/chatone-llm-usage-detail-ui.png differ diff --git a/docs/ui/llm/usage/chatone-llm-usage-ui.png b/docs/ui/llm/usage/chatone-llm-usage-ui.png new file mode 100644 index 0000000..bcdcb29 Binary files /dev/null and b/docs/ui/llm/usage/chatone-llm-usage-ui.png differ diff --git a/docs/ui/login/chatone-login-ui.png b/docs/ui/login/chatone-login-ui.png new file mode 100644 index 0000000..fcdf9dc Binary files /dev/null and b/docs/ui/login/chatone-login-ui.png differ diff --git a/docs/后台管理前端设计方案.md b/docs/后台管理前端设计方案.md new file mode 100644 index 0000000..a8456ec --- /dev/null +++ b/docs/后台管理前端设计方案.md @@ -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`),通过 `` 组件或 `usePermission` Hook 控制。 +- **数据级**:由后端接口过滤,前端不做数据权限逻辑。 + +### 5.2 路由设计 + +```typescript +// 静态路由(无需权限) +const staticRoutes: RouteObject[] = [ + { path: '/login', element: , handle: { hidden: true } }, + { path: '/404', element: , 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. 已登录但未加载动态路由 → 拉取菜单 → 生成路由 → 渲染 `` +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 ; + if (!routesLoaded) return ; + + return ; +} +``` + +**动态路由注入:** + +```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()( + 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>('/users', { params }); +} +export function createUser(data: UserCreateDTO) { + return request.post('/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 示意 + {dom}} +> + + + + +``` + +### 5.6 通用业务组件 + +中小型项目的效率关键 — 优先使用 ProComponents,必要时二次封装: + +#### ProTable(表格页模板) + +``` +┌─ 搜索区(可折叠)──────────────────────────┐ +│ [关键词] [状态 ▼] [日期范围] [搜索] [重置] │ +├─ 工具栏 ────────────────────────────────────┤ +│ [+ 新增] [导出] [批量删除] │ +├─ 数据表格 ──────────────────────────────────┤ +│ ☐ 姓名 状态 创建时间 操作 │ +│ ☐ 张三 启用 2026-01-01 编辑 删除 │ +├─ 分页 ──────────────────────────────────────┤ +│ < 1 2 3 ... 10 > │ +└─────────────────────────────────────────────┘ +``` + +配置驱动:`columns`、`request`(返回 `{ data, total, success }`)两个核心 props。 + +```tsx +// pages/system/user/index.tsx 示意 + + columns={columns} + request={async params => { + const res = await getUserList(params); + return { data: res.data.list, total: res.data.total, success: true }; + }} + toolBarRender={() => [ + + + , + ]} +/> +``` + +#### 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 渲染错误 | `` 捕获并展示降级 UI | + +### 6.2 加载状态 + +- **全局**:路由切换 NProgress 细条 +- **页面级**:``、``(首屏) +- **按钮级**:` + } + /> + ); +} diff --git a/src/pages/exception/404.tsx b/src/pages/exception/404.tsx new file mode 100644 index 0000000..0c233b9 --- /dev/null +++ b/src/pages/exception/404.tsx @@ -0,0 +1,19 @@ +import { Button, Result } from 'antd'; +import { useNavigate } from 'react-router-dom'; + +export default function NotFoundPage() { + const navigate = useNavigate(); + + return ( + navigate('/dashboard', { replace: true })}> + 返回首页 + + } + /> + ); +} diff --git a/src/pages/llm/index.tsx b/src/pages/llm/index.tsx new file mode 100644 index 0000000..7fb28d4 --- /dev/null +++ b/src/pages/llm/index.tsx @@ -0,0 +1,7 @@ +import 'react'; + +function LLM() { + return
大模型管理
; +} + +export default LLM; diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx new file mode 100644 index 0000000..0845c0a --- /dev/null +++ b/src/pages/login/index.tsx @@ -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('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 ( +
+ + setLoginType(key as LoginType)} + activeKey={loginType} + /> + {loginType === 'account' && ( + <> + + ), + }} + placeholder={'用户名: admin or user'} + rules={[ + { + required: true, + message: '请输入用户名!', + }, + ]} + /> + + ), + }} + placeholder={'密码: ant.design'} + rules={[ + { + required: true, + message: '请输入密码!', + }, + ]} + /> + + )} + {loginType === 'phone' && ( + <> + + ), + }} + name="mobile" + placeholder={'手机号'} + rules={[ + { + required: true, + message: '请输入手机号!', + }, + { + pattern: /^1\d{10}$/, + message: '手机号格式错误!', + }, + ]} + /> + + ), + }} + captchaProps={{ + size: 'large', + }} + placeholder={'请输入验证码'} + captchaTextRender={(timing, count) => { + if (timing) { + return `${count} ${'获取验证码'}`; + } + return '获取验证码'; + }} + name="captcha" + rules={[ + { + required: true, + message: '请输入验证码!', + }, + ]} + onGetCaptcha={async () => { + message.success('获取验证码成功!验证码为:1234'); + }} + /> + + )} +
+ + 自动登录 + + + 忘记密码 + +
+
+
+ ); +}; + +export default function Login() { + return ( + + + + ); +} diff --git a/src/router/AuthGuard.tsx b/src/router/AuthGuard.tsx new file mode 100644 index 0000000..11c7050 --- /dev/null +++ b/src/router/AuthGuard.tsx @@ -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 ; + if (!menusLoaded) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/router/index.tsx b/src/router/index.tsx new file mode 100644 index 0000000..313caf9 --- /dev/null +++ b/src/router/index.tsx @@ -0,0 +1,6 @@ +import { createBrowserRouter } from 'react-router-dom'; +import { routes } from './routes'; + +const router = createBrowserRouter(routes); + +export default router; diff --git a/src/router/menu.tsx b/src/router/menu.tsx new file mode 100644 index 0000000..ae0c0fc --- /dev/null +++ b/src/router/menu.tsx @@ -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)[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, + })); +} diff --git a/src/router/routes.tsx b/src/router/routes.tsx new file mode 100644 index 0000000..76d5301 --- /dev/null +++ b/src/router/routes.tsx @@ -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: , + children: [{ index: true, element: }], + }, + { + path: '/404', + element: , + children: [{ index: true, element: }], + }, + { + path: '/403', + element: , + children: [{ index: true, element: }], + }, + { + path: '/', + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: 'dashboard', element: }, + { path: 'llm', element: }, + { path: 'system/user', element:
用户管理
}, + { path: 'system/role', element:
角色管理
}, + { path: 'system/permission/menu', element:
菜单权限
}, + { path: 'system/permission/button', element:
按钮权限
}, + { path: '*', element: }, + ], + }, + ], + }, + { path: '*', element: }, +]; diff --git a/src/stores/app.ts b/src/stores/app.ts new file mode 100644 index 0000000..0e4dc7c --- /dev/null +++ b/src/stores/app.ts @@ -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()( + 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 }), + }, + ), +); diff --git a/src/stores/permission.ts b/src/stores/permission.ts new file mode 100644 index 0000000..acef422 --- /dev/null +++ b/src/stores/permission.ts @@ -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; + clearMenus: () => void; +} + +export const usePermissionStore = create((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 }), +})); diff --git a/src/stores/user.ts b/src/stores/user.ts new file mode 100644 index 0000000..b7289d6 --- /dev/null +++ b/src/stores/user.ts @@ -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()( + 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 }), + }, + ), +); diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..224ad80 --- /dev/null +++ b/src/styles/global.css @@ -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'; +} diff --git a/src/styles/theme.ts b/src/styles/theme.ts new file mode 100644 index 0000000..4ba25dd --- /dev/null +++ b/src/styles/theme.ts @@ -0,0 +1,8 @@ +export const themeConfig = { + token: { + colorPrimary: '#1677ff', + sider: { + colorBgMenuItemSelected: '#e6f4ff', + }, + }, +}; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..54d9aaa --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f46248d --- /dev/null +++ b/vite.config.ts @@ -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, + }, + }, + }, +});