优化更新

This commit is contained in:
2026-09-14 09:44:33 +08:00
parent b3a9e21254
commit 5b8e178970
60 changed files with 1243 additions and 1022 deletions
@@ -19,7 +19,7 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
var _hmt = _hmt || []; var _hmt = _hmt || [];
(function() { (function() {
var hm = document.createElement("script"); var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?f2662051bbed33ef4673edf8bfe609fe"; hm.src = "https://hm.baidu.com/hm.js?b4f8d988d60305492a9c37c6805a4fc7";
var s = document.getElementsByTagName("script")[0]; var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s); s.parentNode.insertBefore(hm, s);
})(); })();
@@ -29,7 +29,7 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
themeConfig: { themeConfig: {
editLink: { editLink: {
pattern: "https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue", pattern: "https://gitee.com/djangoadmin/DjangoAdmin_FastAPI_AntdVue",
text: "对本页提出修改建议", text: "对本页提出修改建议",
}, },
@@ -207,8 +207,8 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
link: "https://cn.vuejs.org/", link: "https://cn.vuejs.org/",
}, },
{ {
text: "ElementPlus", text: "AntDesign Vue",
link: "https://element-plus.org/zh-CN/", link: "https://antdv.com/",
}, },
], ],
}, },
@@ -789,7 +789,7 @@ export const zhConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
{ text: "9.2 免责声明", link: "/zh/other/declare.html" }, { text: "9.2 免责声明", link: "/zh/other/declare.html" },
{ {
text: "9.3 开源协议", text: "9.3 开源协议",
link: "https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue/blob/master/LICENSE", link: "https://gitee.com/djangoadmin/DjangoAdmin_FastAPI_AntdVue/blob/master/LICENSE",
}, },
{ text: "9.4 广告赞助", link: "/zh/other/sponsor.html" }, { text: "9.4 广告赞助", link: "/zh/other/sponsor.html" },
], ],
+3 -3
View File
@@ -2,11 +2,11 @@
layout: home layout: home
title: DjangoAdmin文档 title: DjangoAdmin文档
titleTemplate: 前后端分离FastAPI+ElementPlus版 titleTemplate: 前后端分离FastAPI+AntDesign版
hero: hero:
name: DjangoAdmin name: DjangoAdmin
text: "企业级中台解决方案FastAPI+ElementPlus版" text: "企业级中台解决方案FastAPI+AntDesign版"
tagline: "赋能开发者,助力企业发展。\n企业级数据平台底座 · 让数字化触手可及。" tagline: "赋能开发者,助力企业发展。\n企业级数据平台底座 · 让数字化触手可及。"
image: image:
src: /logo.svg src: /logo.svg
@@ -17,7 +17,7 @@ hero:
link: /zh/introduction link: /zh/introduction
- theme: alt - theme: alt
text: 在线演示 text: 在线演示
link: http://manage.fastapi.elevue.djangoadmin.cn link: http://manage.fastapi.antdvue.djangoadmin.cn
- theme: alt - theme: alt
text: 官方授权 text: 官方授权
link: https://www.djangoadmin.cn/goods/detail/2 link: https://www.djangoadmin.cn/goods/detail/2
+1 -1
View File
@@ -165,7 +165,7 @@
### 新增 ### 新增
- **Vue3 升级**:前端框架从 Vue 2 升级至 Vue3.4+,全面拥抱 Composition API - **Vue3 升级**:前端框架从 Vue 2 升级至 Vue3.4+,全面拥抱 Composition API
- **ElementPlus**:UI 组件库适配 Vue3 生态 - **AntDesign Vue**:UI 组件库适配 Vue3 生态,企业级组件丰富
- **Pinia 状态管理**:替代 Vuex,更轻量、更灵活、TypeScript 支持更完善 - **Pinia 状态管理**:替代 Vuex,更轻量、更灵活、TypeScript 支持更完善
- **TypeScript 类型安全**:全量类型定义,IDE 智能提示与编译时类型检查 - **TypeScript 类型安全**:全量类型定义,IDE 智能提示与编译时类型检查
- **Vite 构建工具**:替代 Webpack,开发服务器毫秒级启动,HMR 即时生效 - **Vite 构建工具**:替代 Webpack,开发服务器毫秒级启动,HMR 即时生效
+3 -3
View File
@@ -85,7 +85,7 @@ cp .env.example .env
# 数据库配置 # 数据库配置
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=djangoadmin.fastapi.elevue DB_DATABASE=djangoadmin.fastapi.antdvue
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=your_password DB_PASSWORD=your_password
@@ -112,10 +112,10 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
```bash ```bash
# 1. 登录 MySQL,创建数据库 # 1. 登录 MySQL,创建数据库
mysql -uroot -p mysql -uroot -p
CREATE DATABASE `djangoadmin.fastapi.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE `djangoadmin.fastapi.antdvue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
# 2. 导入数据库脚本(脚本位于 document/mysql/ 目录下) # 2. 导入数据库脚本(脚本位于 document/mysql/ 目录下)
mysql -uroot -p djangoadmin.fastapi.elevue < document/mysql/djangoadmin.fastapi.elevue.sql mysql -uroot -p djangoadmin.fastapi.antdvue < document/mysql/djangoadmin.fastapi.antdvue.sql
``` ```
:::tip 温馨提示 :::tip 温馨提示
+79 -77
View File
@@ -16,9 +16,9 @@
## 软件说明 ## 软件说明
基于 FastAPI、SQLAlchemy、Pydantic、Vue3、TypeScript、Vite、ElementPlus、MySQL 等技术栈构建的企业级前后端分离后台管理系统。 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、TypeScript、Vite、AntDesign Vue、MySQL 等技术栈构建的企业级前后端分离后台管理系统。
后端采用 Python 语言,基于 FastAPI 异步框架、SQLAlchemy ORM、Pydantic v2 数据校验等主流技术栈;前端基于 Vue3、TypeScript、Vite 构建,采用 ElementPlus 框架,遵循 Vue 前后端分离主流设计思想。 后端采用 Python 语言,基于 FastAPI 异步框架、SQLAlchemy ORM、Pydantic v2 数据校验等主流技术栈;前端基于 Vue3、TypeScript、Vite 构建,采用 AntDesign Vue 框架,遵循 Vue 前后端分离主流设计思想。
系统采用完全前后端分离架构,前端通过 API 调用后端服务实现数据交互。内置完善的 RBAC 权限体系,权限颗粒度精细至按钮级别;支持多主题切换,兼容桌面端和平板端。系统预置了用户、角色、菜单、部门、岗位、职级、日志、字典、配置、参数、定时任务、代码生成器等基础功能模块,开箱即用。 系统采用完全前后端分离架构,前端通过 API 调用后端服务实现数据交互。内置完善的 RBAC 权限体系,权限颗粒度精细至按钮级别;支持多主题切换,兼容桌面端和平板端。系统预置了用户、角色、菜单、部门、岗位、职级、日志、字典、配置、参数、定时任务、代码生成器等基础功能模块,开箱即用。
@@ -56,94 +56,96 @@
## 软件出处 ## 软件出处
+ 软件名称:小蚂蚁云前后端分离FastAPI+ElementPlus版本 + 软件名称:小蚂蚁云前后端分离FastAPI+AntDesign版本
+ 软件作者:@小蚂蚁云团队 + 软件作者:@小蚂蚁云团队
+ 软件协议:Apache-2.0 + 软件协议:Apache-2.0
+ 官网网址:[https://www.djangoadmin.cn](https://www.djangoadmin.cn) + 官网网址:[https://www.djangoadmin.cn](https://www.djangoadmin.cn)
+ 文档网址:[http://docs.fastapi.elevue.djangoadmin.cn](http://docs.fastapi.elevue.djangoadmin.cn) + 文档网址:[http://docs.fastapi.antdvue.djangoadmin.cn](http://docs.fastapi.antdvue.djangoadmin.cn)
+ 演示地址:[http://manage.fastapi.elevue.djangoadmin.cn](http://manage.fastapi.elevue.djangoadmin.cn) + 演示地址:[http://manage.fastapi.antdvue.djangoadmin.cn](http://manage.fastapi.antdvue.djangoadmin.cn)
## 软件版本 ## 软件版本
| 版本名称 | 说明 | 地址 | | 版本名称 | 说明 | 地址 |
|:---------|:-----|:-----| |:---------|:-----|:-----|
| Django+Layui混编版 | 基于 Django、Jinja2、Layui、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_Layui) | | Django+Layui混编版 | 基于 Django、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_Layui) |
| Django+EleVue前后端分离版 | 基于 Django、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_EleVue) | | Django+ElementPlus前后端分离版 | 基于 Django、Django ORM、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_EleVue) |
| Django+AntdVue前后端分离版 | 基于 Django、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_AntdVue) | | Django+AntDesign前后端分离版 | 基于 Django、Django ORM、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_AntdVue) |
| Django+NaiveVue前后端分离版 | 基于 Django、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_NaiveVue) | | Django+NaiveUI前后端分离版 | 基于 Django、Django ORM、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Django_NaiveVue) |
| Flask+Layui混编版 | 基于 Flask、Jinja2、Layui、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_Layui) | | Flask+Layui混编版 | 基于 Flask、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_Layui) |
| Flask+EleVue前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue) | | Flask+ElementPlus前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue) |
| Flask+AntdVue前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue) | | Flask+AntDesign前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue) |
| Flask+NaiveVue前后端分离版 | 基于 Flask、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_NaiveVue) | | Flask+NaiveUI前后端分离版 | 基于 Flask、SQLAlchemy、WTForms、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Flask_NaiveVue) |
| FastAPI+Layui混编版 | 基于 FastAPI、Jinja2、Layui、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_Layui) | | FastAPI+Layui混编版 | 基于 FastAPI、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_Layui) |
| FastAPI+EleVue前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue) | | FastAPI+ElementPlus前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue) |
| FastAPI+AntdVue前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_AntdVue) | | FastAPI+AntDesign前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_AntdVue) |
| FastAPI+NaiveVue前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_NaiveVue) | | FastAPI+NaiveUI前后端分离版 | 基于 FastAPI、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_NaiveVue) |
| Tornado+Layui混编版 | 基于 Tornado、Jinja2、Layui、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_Layui) | | Tornado+Layui混编版 | 基于 Tornado、Jinja2、Layui 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_Layui) |
| Tornado+EleVue前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_EleVue) | | Tornado+ElementPlus前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、ElementPlus、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_EleVue) |
| Tornado+AntdVue前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_AntdVue) | | Tornado+AntDesign前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、AntDesign、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_AntdVue) |
| Tornado+NaiveVue前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite、MySQL 等技术栈 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_NaiveVue) | | Tornado+NaiveUI前后端分离版 | 基于 Tornado、SQLAlchemy、Pydantic、Vue3、NaiveUI、TypeScript、Vite 等技术栈,支持 MySQL、PostgreSQL、SQLite、Oracle、SQL Server 等数据库 | [Gitee地址](https://gitee.com/djangoadmin/DjangoAdmin_Tornado_NaiveVue) |
## 模块展示 ## 模块展示
<table> <table>
<tr> <tbody>
<td><img src="/demo/1.png"/></td> <tr>
<td><img src="/demo/2.png"/></td> <td><img src="/demo/1.png"/></td>
</tr> <td><img src="/demo/2.png"/></td>
<tr> </tr>
<td><img src="/demo/3.png"/></td> <tr>
<td><img src="/demo/4.png"/></td> <td><img src="/demo/3.png"/></td>
</tr> <td><img src="/demo/4.png"/></td>
<tr> </tr>
<td><img src="/demo/5.png"/></td> <tr>
<td><img src="/demo/6.png"/></td> <td><img src="/demo/5.png"/></td>
</tr> <td><img src="/demo/6.png"/></td>
<tr> </tr>
<td><img src="/demo/7.png"/></td> <tr>
<td><img src="/demo/8.png"/></td> <td><img src="/demo/7.png"/></td>
</tr> <td><img src="/demo/8.png"/></td>
<tr> </tr>
<td><img src="/demo/9.png"/></td> <tr>
<td><img src="/demo/10.png"/></td> <td><img src="/demo/9.png"/></td>
</tr> <td><img src="/demo/10.png"/></td>
<tr> </tr>
<td><img src="/demo/11.png"/></td> <tr>
<td><img src="/demo/12.png"/></td> <td><img src="/demo/11.png"/></td>
</tr> <td><img src="/demo/12.png"/></td>
<tr> </tr>
<td><img src="/demo/13.png"/></td> <tr>
<td><img src="/demo/14.png"/></td> <td><img src="/demo/13.png"/></td>
</tr> <td><img src="/demo/14.png"/></td>
<tr> </tr>
<td><img src="/demo/15.png"/></td> <tr>
<td><img src="/demo/16.png"/></td> <td><img src="/demo/15.png"/></td>
</tr> <td><img src="/demo/16.png"/></td>
<tr> </tr>
<td><img src="/demo/17.png"/></td> <tr>
<td><img src="/demo/18.png"/></td> <td><img src="/demo/17.png"/></td>
</tr> <td><img src="/demo/18.png"/></td>
<tr> </tr>
<td><img src="/demo/19.png"/></td> <tr>
<td><img src="/demo/20.png"/></td> <td><img src="/demo/19.png"/></td>
</tr> <td><img src="/demo/20.png"/></td>
<tr> </tr>
<td><img src="/demo/21.png"/></td> <tr>
<td><img src="/demo/22.png"/></td> <td><img src="/demo/21.png"/></td>
</tr> <td><img src="/demo/22.png"/></td>
<tr> </tr>
<td><img src="/demo/23.png"/></td> <tr>
<td><img src="/demo/24.png"/></td> <td><img src="/demo/23.png"/></td>
</tr> <td><img src="/demo/24.png"/></td>
<tr> </tr>
<td><img src="/demo/25.png"/></td> <tr>
<td><img src="/demo/26.png"/></td> <td><img src="/demo/25.png"/></td>
</tr> <td><img src="/demo/26.png"/></td>
<tr> </tr>
<td><img src="/demo/27.png"/></td> <tr>
<td><img src="/demo/28.png"/></td> <td><img src="/demo/27.png"/></td>
</tr> <td><img src="/demo/28.png"/></td>
</tr>
</tbody>
</table> </table>
## 开源协议 ## 开源协议
+3 -4
View File
@@ -13,7 +13,7 @@
``` ```
├── src/ // 后端源码 ├── src/ // 后端源码
├── ui/ // 前端源码(Vue3 + ElementPlus) ├── ui/ // 前端源码(Vue3 + AntDesign Vue)
├── document/ // 项目文档(SQL脚本、迁移日志等) ├── document/ // 项目文档(SQL脚本、迁移日志等)
├── migrations/ // Alembic 数据库迁移脚本 ├── migrations/ // Alembic 数据库迁移脚本
├── scripts/ // 脚本工具(init_db、migrate_db、seed 等) ├── scripts/ // 脚本工具(init_db、migrate_db、seed 等)
@@ -123,7 +123,6 @@ src/
│ └── response_body.py // 响应体工具 │ └── response_body.py // 响应体工具
└── constants/ // 常量与枚举 └── constants/ // 常量与枚举
├── constants.py // PAGE_SIZE / DEFAULT_PASSWORD / SERIALIZE_EXCLUDE_FIELDS ├── constants.py // PAGE_SIZE / DEFAULT_PASSWORD / SERIALIZE_EXCLUDE_FIELDS
├── enums.py // 枚举定义
└── pagination.py // 分页参数 └── pagination.py // 分页参数
``` ```
@@ -216,8 +215,8 @@ ui/src/
│ ├── useLockFn.ts // 防重复提交 │ ├── useLockFn.ts // 防重复提交
│ └── Storage.ts // 本地存储 │ └── Storage.ts // 本地存储
├── directives/ // 自定义指令(v-perm 权限指令) ├── directives/ // 自定义指令(v-perm 权限指令)
├── plugins/ // 插件注册(ElementPlus / 自定义组件 / 指令) ├── plugins/ // 插件注册(AntDesign Vue / 自定义组件 / 指令)
├── styles/ // 全局样式(Tailwind CSS / ElementPlus 主题覆盖) ├── styles/ // 全局样式(Tailwind CSS / AntDesign Vue 主题覆盖)
├── enums/ // 枚举定义 ├── enums/ // 枚举定义
└── assets/ // 静态资源 └── assets/ // 静态资源
├── icons/ // 图标 ├── icons/ // 图标
@@ -114,7 +114,7 @@ FASTAPI_DEBUG=True
DB_DRIVER=mysql DB_DRIVER=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=djangoadmin.fastapi.elevue DB_DATABASE=djangoadmin.fastapi.antdvue
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=your_mysql_password # 修改为真实密码 DB_PASSWORD=your_mysql_password # 修改为真实密码
DB_PREFIX=fastapi_ DB_PREFIX=fastapi_
@@ -155,10 +155,10 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
```bash ```bash
# 1. 登录 MySQL,创建数据库 # 1. 登录 MySQL,创建数据库
mysql -uroot -p mysql -uroot -p
CREATE DATABASE `djangoadmin.fastapi.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE `djangoadmin.fastapi.antdvue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
# 2. 导入数据库脚本(脚本位于 document/mysql/ 目录下) # 2. 导入数据库脚本(脚本位于 document/mysql/ 目录下)
mysql -uroot -p djangoadmin.fastapi.elevue < document/mysql/djangoadmin.fastapi.elevue.sql mysql -uroot -p djangoadmin.fastapi.antdvue < document/mysql/djangoadmin.fastapi.antdvue.sql
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -7,15 +7,15 @@
``` ```
document/ document/
├── mysql/ # MySQL 脚本 ├── mysql/ # MySQL 脚本
│ └── djangoadmin.fastapi.elevue.sql │ └── djangoadmin.fastapi.antdvue.sql
├── postgresql/ # PostgreSQL 脚本 ├── postgresql/ # PostgreSQL 脚本
│ └── djangoadmin.fastapi.elevue.sql │ └── djangoadmin.fastapi.antdvue.sql
├── sqlserver/ # SQL Server 脚本 ├── sqlserver/ # SQL Server 脚本
│ └── djangoadmin.fastapi.elevue.sql │ └── djangoadmin.fastapi.antdvue.sql
├── sqlite/ # SQLite 脚本 ├── sqlite/ # SQLite 脚本
│ └── djangoadmin.fastapi.elevue.sql │ └── djangoadmin.fastapi.antdvue.sql
└── oracle/ # Oracle 脚本 └── oracle/ # Oracle 脚本
└── djangoadmin.fastapi.elevue.sql └── djangoadmin.fastapi.antdvue.sql
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -36,7 +36,7 @@ document/
**MySQL**: **MySQL**:
```sql ```sql
CREATE DATABASE `djangoadmin.fastapi.elevue` CREATE DATABASE `djangoadmin.fastapi.antdvue`
DEFAULT CHARACTER SET utf8mb4 DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_general_ci; COLLATE utf8mb4_general_ci;
``` ```
@@ -44,7 +44,7 @@ CREATE DATABASE `djangoadmin.fastapi.elevue`
**PostgreSQL**: **PostgreSQL**:
```sql ```sql
CREATE DATABASE "djangoadmin.fastapi.elevue" CREATE DATABASE "djangoadmin.fastapi.antdvue"
WITH ENCODING 'UTF8' WITH ENCODING 'UTF8'
LC_COLLATE = 'zh_CN.UTF-8' LC_COLLATE = 'zh_CN.UTF-8'
LC_CTYPE = 'zh_CN.UTF-8' LC_CTYPE = 'zh_CN.UTF-8'
@@ -54,7 +54,7 @@ CREATE DATABASE "djangoadmin.fastapi.elevue"
**SQL Server**: **SQL Server**:
```sql ```sql
CREATE DATABASE [djangoadmin.fastapi.elevue] CREATE DATABASE [djangoadmin.fastapi.antdvue]
COLLATE Chinese_PRC_CI_AS; COLLATE Chinese_PRC_CI_AS;
``` ```
@@ -87,27 +87,27 @@ GRANT CONNECT, RESOURCE, CREATE SESSION, CREATE TABLE, CREATE SEQUENCE, CREATE T
```bash ```bash
# 使用 mysql 命令行导入 # 使用 mysql 命令行导入
mysql -uroot -p djangoadmin.fastapi.elevue < document/mysql/djangoadmin.fastapi.elevue.sql mysql -uroot -p djangoadmin.fastapi.antdvue < document/mysql/djangoadmin.fastapi.antdvue.sql
# 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/mysql/djangoadmin.fastapi.elevue.sql # 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/mysql/djangoadmin.fastapi.antdvue.sql
``` ```
**PostgreSQL**: **PostgreSQL**:
```bash ```bash
# 使用 psql 命令行导入 # 使用 psql 命令行导入
psql -U postgres -d djangoadmin.fastapi.elevue -f document/postgresql/djangoadmin.fastapi.elevue.sql psql -U postgres -d djangoadmin.fastapi.antdvue -f document/postgresql/djangoadmin.fastapi.antdvue.sql
# 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/postgresql/djangoadmin.fastapi.elevue.sql # 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/postgresql/djangoadmin.fastapi.antdvue.sql
``` ```
**SQL Server**: **SQL Server**:
```bash ```bash
# 使用 sqlcmd 命令行导入 # 使用 sqlcmd 命令行导入
sqlcmd -S localhost -U sa -P your_password -d djangoadmin.fastapi.elevue -i document/sqlserver/djangoadmin.fastapi.elevue.sql sqlcmd -S localhost -U sa -P your_password -d djangoadmin.fastapi.antdvue -i document/sqlserver/djangoadmin.fastapi.antdvue.sql
# 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/sqlserver/djangoadmin.fastapi.elevue.sql # 或使用 Navicat 等工具:右键数据库 → 运行 SQL 文件 → 选择 document/sqlserver/djangoadmin.fastapi.antdvue.sql
# 或使用 SSMS(SQL Server Management Studio):打开 SQL 文件 → 执行 # 或使用 SSMS(SQL Server Management Studio):打开 SQL 文件 → 执行
``` ```
@@ -115,18 +115,18 @@ sqlcmd -S localhost -U sa -P your_password -d djangoadmin.fastapi.elevue -i docu
```bash ```bash
# 使用 sqlite3 命令行导入 # 使用 sqlite3 命令行导入
sqlite3 ./data/app.db < document/sqlite/djangoadmin.fastapi.elevue.sql sqlite3 ./data/app.db < document/sqlite/djangoadmin.fastapi.antdvue.sql
# 或使用 Navicat 等工具:打开数据库文件 → 运行 SQL 文件 → 选择 document/sqlite/djangoadmin.fastapi.elevue.sql # 或使用 Navicat 等工具:打开数据库文件 → 运行 SQL 文件 → 选择 document/sqlite/djangoadmin.fastapi.antdvue.sql
``` ```
**Oracle**: **Oracle**:
```bash ```bash
# 使用 sqlplus 命令行导入 # 使用 sqlplus 命令行导入
sqlplus djangoadmin/your_password@127.0.0.1:1521/XE @document/oracle/djangoadmin.fastapi.elevue.sql sqlplus djangoadmin/your_password@127.0.0.1:1521/XE @document/oracle/djangoadmin.fastapi.antdvue.sql
# 或使用 Navicat 等工具:打开连接 → 运行 SQL 文件 → 选择 document/oracle/djangoadmin.fastapi.elevue.sql # 或使用 Navicat 等工具:打开连接 → 运行 SQL 文件 → 选择 document/oracle/djangoadmin.fastapi.antdvue.sql
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -150,7 +150,7 @@ Oracle 用户需要有 CREATE TABLE、CREATE SEQUENCE、CREATE TRIGGER 权限。
DB_DRIVER=mysql # 数据库驱动类型 DB_DRIVER=mysql # 数据库驱动类型
DB_HOST=127.0.0.1 # 数据库主机地址 DB_HOST=127.0.0.1 # 数据库主机地址
DB_PORT=3306 # 数据库端口(MySQL 默认 3306) DB_PORT=3306 # 数据库端口(MySQL 默认 3306)
DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称
DB_USERNAME=root # 数据库用户名 DB_USERNAME=root # 数据库用户名
DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
@@ -161,7 +161,7 @@ DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
# DB_DRIVER=postgresql # 数据库驱动类型 # DB_DRIVER=postgresql # 数据库驱动类型
# DB_HOST=127.0.0.1 # 数据库主机地址 # DB_HOST=127.0.0.1 # 数据库主机地址
# DB_PORT=5432 # 数据库端口(PostgreSQL 默认 5432) # DB_PORT=5432 # 数据库端口(PostgreSQL 默认 5432)
# DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 # DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称
# DB_USERNAME=postgres # 数据库用户名 # DB_USERNAME=postgres # 数据库用户名
# DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) # DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
@@ -172,7 +172,7 @@ DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
# DB_DRIVER=mssql # 数据库驱动类型 # DB_DRIVER=mssql # 数据库驱动类型
# DB_HOST=127.0.0.1 # 数据库主机地址 # DB_HOST=127.0.0.1 # 数据库主机地址
# DB_PORT=1433 # 数据库端口(SQL Server 默认 1433) # DB_PORT=1433 # 数据库端口(SQL Server 默认 1433)
# DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 # DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称
# DB_USERNAME=sa # 数据库用户名 # DB_USERNAME=sa # 数据库用户名
# DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) # DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
@@ -257,11 +257,11 @@ $ python scripts/init_db.py --drop-first
[INFO] ============================================================ [INFO] ============================================================
[INFO] 检测数据库驱动: mysql [INFO] 检测数据库驱动: mysql
[INFO] 连接 MySQL 服务器: 127.0.0.1:3306 [INFO] 连接 MySQL 服务器: 127.0.0.1:3306
[INFO] 数据库配置: root@127.0.0.1:3306/djangoadmin.fastapi.elevue [INFO] 数据库配置: root@127.0.0.1:3306/djangoadmin.fastapi.antdvue
[INFO] 数据表前缀: fastapi_ [INFO] 数据表前缀: fastapi_
[INFO] 删除已有数据库: djangoadmin.fastapi.elevue [INFO] 删除已有数据库: djangoadmin.fastapi.antdvue
[INFO] 创建数据库: djangoadmin.fastapi.elevue [INFO] 创建数据库: djangoadmin.fastapi.antdvue
[INFO] 查找数据库脚本: document\mysql\djangoadmin.fastapi.elevue.sql [INFO] 查找数据库脚本: document\mysql\djangoadmin.fastapi.antdvue.sql
[INFO] 导入 SQL 脚本... [INFO] 导入 SQL 脚本...
[INFO] SQL 脚本导入完成: 784510 条语句 [INFO] SQL 脚本导入完成: 784510 条语句
[INFO] 创建数据表... [INFO] 创建数据表...
@@ -396,30 +396,30 @@ alembic stamp head
```bash ```bash
# 备份数据库 # 备份数据库
mysqldump -uroot -p djangoadmin.fastapi.elevue > backup.sql mysqldump -uroot -p djangoadmin.fastapi.antdvue > backup.sql
# 恢复数据库 # 恢复数据库
mysql -uroot -p djangoadmin.fastapi.elevue < backup.sql mysql -uroot -p djangoadmin.fastapi.antdvue < backup.sql
``` ```
+ PostgreSQL 备份 + PostgreSQL 备份
```bash ```bash
# 备份数据库 # 备份数据库
pg_dump -U postgres djangoadmin.fastapi.elevue > backup.sql pg_dump -U postgres djangoadmin.fastapi.antdvue > backup.sql
# 恢复数据库 # 恢复数据库
psql -U postgres djangoadmin.fastapi.elevue < backup.sql psql -U postgres djangoadmin.fastapi.antdvue < backup.sql
``` ```
+ SQL Server 备份 + SQL Server 备份
```bash ```bash
# 备份数据库 # 备份数据库
sqlcmd -S localhost -U sa -P your_password -Q "BACKUP DATABASE [djangoadmin.fastapi.elevue] TO DISK = 'backup.bak'" sqlcmd -S localhost -U sa -P your_password -Q "BACKUP DATABASE [djangoadmin.fastapi.antdvue] TO DISK = 'backup.bak'"
# 恢复数据库 # 恢复数据库
sqlcmd -S localhost -U sa -P your_password -Q "RESTORE DATABASE [djangoadmin.fastapi.elevue] FROM DISK = 'backup.bak'" sqlcmd -S localhost -U sa -P your_password -Q "RESTORE DATABASE [djangoadmin.fastapi.antdvue] FROM DISK = 'backup.bak'"
# 或使用 Navicat 等工具进行备份恢复 # 或使用 Navicat 等工具进行备份恢复
# 或使用 SSMS(SQL Server Management Studio):右键数据库 → 任务 → 备份 / 还原 # 或使用 SSMS(SQL Server Management Studio):右键数据库 → 任务 → 备份 / 还原
@@ -57,7 +57,7 @@ cp .env.example .env
# ============================================================ # ============================================================
DB_HOST=127.0.0.1 # 数据库主机地址(本地容器) DB_HOST=127.0.0.1 # 数据库主机地址(本地容器)
DB_PORT=3306 # 数据库端口(MySQL 默认 3306) DB_PORT=3306 # 数据库端口(MySQL 默认 3306)
DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称
DB_USERNAME=root # 数据库用户名 DB_USERNAME=root # 数据库用户名
DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
@@ -92,7 +92,7 @@ python -c "import secrets; print(secrets.token_urlsafe(48))"
```yaml ```yaml
# +====================================================================== # +======================================================================
# | 模块: Docker Compose 配置 # | 模块: Docker Compose 配置
# | 说明: FastAPI+EleVue 应用服务容器编排 # | 说明: FastAPI+AntdVue 应用服务容器编排
# +====================================================================== # +======================================================================
version: '3.7' version: '3.7'
# ============================================================ # ============================================================
@@ -102,11 +102,11 @@ services:
# ------------------------------------------------------------ # ------------------------------------------------------------
# 后端服务 # 后端服务
# ------------------------------------------------------------ # ------------------------------------------------------------
fastapi_elevue: fastapi_antdvue:
# 构建镜像名称 # 构建镜像名称
image: fastapi_elevue image: fastapi_antdvue
# 容器运行时名称 # 容器运行时名称
container_name: fastapi_elevue container_name: fastapi_antdvue
# 镜像构建配置 # 镜像构建配置
build: build:
context: ./ # 构建上下文路径(项目根目录) context: ./ # 构建上下文路径(项目根目录)
@@ -180,7 +180,7 @@ docker compose up -d
[+] Building 45.2s (12/12) FINISHED [+] Building 45.2s (12/12) FINISHED
[+] Running 2/2 [+] Running 2/2
✔ Network djangoadmin_default Created ✔ Network djangoadmin_default Created
✔ Container fastapi_elevue Started ✔ Container fastapi_antdvue Started
``` ```
:::tip 访问服务 :::tip 访问服务
@@ -214,7 +214,7 @@ docker compose logs -f
docker compose logs --tail 100 docker compose logs --tail 100
# 查看指定服务日志 # 查看指定服务日志
docker compose logs -f fastapi_elevue docker compose logs -f fastapi_antdvue
``` ```
+ 停止与重启 + 停止与重启
@@ -244,10 +244,10 @@ docker compose up --build
```bash ```bash
# 进入运行中的容器终端 # 进入运行中的容器终端
docker compose exec fastapi_elevue bash docker compose exec fastapi_antdvue bash
# 在容器内执行 Python 命令 # 在容器内执行 Python 命令
docker compose exec fastapi_elevue python -c "print('hello')" docker compose exec fastapi_antdvue python -c "print('hello')"
``` ```
## 环境变量注入 ## 环境变量注入
@@ -256,10 +256,10 @@ Docker 模式下,环境变量通过 `env_file` 从 `.env` 文件注入到容
```bash ```bash
# 使用 --env-file 指定环境变量文件 # 使用 --env-file 指定环境变量文件
docker run --env-file .env fastapi_elevue docker run --env-file .env fastapi_antdvue
# 通过 -e 参数覆盖单个变量 # 通过 -e 参数覆盖单个变量
docker run -e FASTAPI_PORT=9090 fastapi_elevue docker run -e FASTAPI_PORT=9090 fastapi_antdvue
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -314,7 +314,7 @@ ports:
```bash ```bash
# 查看容器日志排查原因 # 查看容器日志排查原因
docker compose logs fastapi_elevue docker compose logs fastapi_antdvue
``` ```
常见原因:`JWT_SALT` 未配置、数据库连接失败、Redis 连接失败。 常见原因:`JWT_SALT` 未配置、数据库连接失败、Redis 连接失败。
@@ -12,7 +12,7 @@
| 变量名 | 默认值 | 说明 | | 变量名 | 默认值 | 说明 |
|:----|:----|:----| |:----|:----|:----|
| FASTAPI_NAME | FastAPI+EleVue旗舰版 | 应用名称,用于界面显示和系统标识 | | FASTAPI_NAME | FastAPI+AntdVue旗舰版 | 应用名称,用于界面显示和系统标识 |
| FASTAPI_VERSION | v3.0.0 | 应用版本号 | | FASTAPI_VERSION | v3.0.0 | 应用版本号 |
| FASTAPI_HOST | 0.0.0.0 | 应用监听地址。`127.0.0.1` 仅本地访问;容器/生产建议 `0.0.0.0` | | FASTAPI_HOST | 0.0.0.0 | 应用监听地址。`127.0.0.1` 仅本地访问;容器/生产建议 `0.0.0.0` |
| FASTAPI_PORT | 8031 | 应用监听端口 | | FASTAPI_PORT | 8031 | 应用监听端口 |
@@ -20,7 +20,7 @@
| FASTAPI_ENV | production | 运行环境:`development` / `production` / `testing` | | FASTAPI_ENV | production | 运行环境:`development` / `production` / `testing` |
| FASTAPI_DEBUG | False | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` | | FASTAPI_DEBUG | False | 是否开启调试模式。`True` 时输出详细错误堆栈和 SQL 日志,生产环境务必设为 `False` |
| FASTAPI_DEMO | False | 是否开启演示模式。`True` 时所有写操作(add/update/delete/status/batchDelete)返回"演示环境,暂无操作权限" | | FASTAPI_DEMO | False | 是否开启演示模式。`True` 时所有写操作(add/update/delete/status/batchDelete)返回"演示环境,暂无操作权限" |
| FASTAPI_FILE_URL | http://file.fastapi.elevue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 | | FASTAPI_FILE_URL | http://file.fastapi.antdvue | 文件访问域名,用于拼接上传文件的访问 URL。`BaseService._enrich_file_urls()` 会自动为文件字段补此前缀 |
| UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号)。`upload_size_limit_middleware` 按 `Content-Length` 提前拦截超大请求 | | UPLOAD_ALLOWED_EXTS | .jpg,.jpeg,.png,.gif,...,.zip | 允许上传的文件扩展名,逗号分隔(带点号)。`upload_size_limit_middleware` 按 `Content-Length` 提前拦截超大请求 |
| UPLOAD_MAX_SIZE_MB | 10 | 上传文件大小上限(MB) | | UPLOAD_MAX_SIZE_MB | 10 | 上传文件大小上限(MB) |
@@ -35,7 +35,7 @@
| DB_DRIVER | mysql | 数据库驱动。可选:`mysql` / `postgresql` / `mssql` / `oracle` / `sqlite` | | DB_DRIVER | mysql | 数据库驱动。可选:`mysql` / `postgresql` / `mssql` / `oracle` / `sqlite` |
| DB_HOST | 127.0.0.1 | 数据库地址(`sqlite` 时忽略) | | DB_HOST | 127.0.0.1 | 数据库地址(`sqlite` 时忽略) |
| DB_PORT | 3306 | 数据库端口(`sqlite` 时忽略) | | DB_PORT | 3306 | 数据库端口(`sqlite` 时忽略) |
| DB_DATABASE | djangoadmin.fastapi.elevue | 数据库名称。`sqlite` 时作为数据库文件完整路径 | | DB_DATABASE | djangoadmin.fastapi.antdvue | 数据库名称。`sqlite` 时作为数据库文件完整路径 |
| DB_USERNAME | root | 数据库账号(`sqlite` 时忽略) | | DB_USERNAME | root | 数据库账号(`sqlite` 时忽略) |
| DB_PASSWORD | CHANGE_ME_DB_PASSWORD | 数据库密码,**必填**(`sqlite` 除外) | | DB_PASSWORD | CHANGE_ME_DB_PASSWORD | 数据库密码,**必填**(`sqlite` 除外) |
| DB_PREFIX | fastapi_ | 数据表前缀,所有业务表名自动添加此前缀。模型中通过 `DB_PREFIX + "position"` 拼接表名 | | DB_PREFIX | fastapi_ | 数据表前缀,所有业务表名自动添加此前缀。模型中通过 `DB_PREFIX + "position"` 拼接表名 |
@@ -163,14 +163,14 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001,http://192.168.1.100:8001
# ============================================================ # ============================================================
# 基础服务配置 # 基础服务配置
# ============================================================ # ============================================================
FASTAPI_NAME=FastAPI+EleVue旗舰版 # 应用名称 FASTAPI_NAME=FastAPI+AntdVue旗舰版 # 应用名称
FASTAPI_VERSION=v3.0.0 # 应用版本号 FASTAPI_VERSION=v3.0.0 # 应用版本号
FASTAPI_HOST=127.0.0.1 # 服务监听地址(127.0.0.1 仅本地访问,0.0.0.0 允许外部访问) FASTAPI_HOST=127.0.0.1 # 服务监听地址(127.0.0.1 仅本地访问,0.0.0.0 允许外部访问)
FASTAPI_PORT=8031 # 服务监听端口 FASTAPI_PORT=8031 # 服务监听端口
FASTAPI_ENV=development # 运行环境(development / testing / production) FASTAPI_ENV=development # 运行环境(development / testing / production)
FASTAPI_DEBUG=True # 调试模式开关(True 开启,生产环境请设为 False) FASTAPI_DEBUG=True # 调试模式开关(True 开启,生产环境请设为 False)
FASTAPI_DEMO=False # 演示模式开关(True 开启后将限制部分写操作) FASTAPI_DEMO=False # 演示模式开关(True 开启后将限制部分写操作)
FASTAPI_FILE_URL=http://file.fastapi.elevue # 文件服务地址(用于访问上传的文件) FASTAPI_FILE_URL=http://file.fastapi.antdvue # 文件服务地址(用于访问上传的文件)
# ============================================================ # ============================================================
# 数据库配置 # 数据库配置
@@ -178,7 +178,7 @@ FASTAPI_FILE_URL=http://file.fastapi.elevue # 文件服务地址(用于访问
DB_DRIVER=mysql # 数据库类型(mysql / postgresql / mssql / sqlite / oracle) DB_DRIVER=mysql # 数据库类型(mysql / postgresql / mssql / sqlite / oracle)
DB_HOST=127.0.0.1 # 数据库主机地址 DB_HOST=127.0.0.1 # 数据库主机地址
DB_PORT=3306 # 数据库端口(MySQL 默认 3306) DB_PORT=3306 # 数据库端口(MySQL 默认 3306)
DB_DATABASE=djangoadmin.fastapi.elevue # 数据库名称 DB_DATABASE=djangoadmin.fastapi.antdvue # 数据库名称
DB_USERNAME=root # 数据库用户名 DB_USERNAME=root # 数据库用户名
DB_PASSWORD=your_password # 数据库密码(请修改为实际密码) DB_PASSWORD=your_password # 数据库密码(请修改为实际密码)
DB_PREFIX=fastapi_ # 数据表前缀(用于区分不同应用的同库表) DB_PREFIX=fastapi_ # 数据表前缀(用于区分不同应用的同库表)
@@ -1,6 +1,6 @@
# 环境准备 # 环境准备
本章节介绍在本地搭建项目所需的基础环境和软件安装。项目采用 `Python` + `FastAPI` 后端、`Vue3` + `ElementPlus` 前端的分离架构,本地开发需准备以下软件。 本章节介绍在本地搭建项目所需的基础环境和软件安装。项目采用 `Python` + `FastAPI` 后端、`Vue3` + `AntDesign Vue` 前端的分离架构,本地开发需准备以下软件。
安装软件:`Python(3.12+)`、`MySQL(8.0+)`、`Redis(3.2+)`、`Node.js(22+)`、`pnpm` 等。 安装软件:`Python(3.12+)`、`MySQL(8.0+)`、`Redis(3.2+)`、`Node.js(22+)`、`pnpm` 等。
@@ -211,7 +211,7 @@ mysql>
```sql ```sql
-- 创建数据库(UTF-8 编码) -- 创建数据库(UTF-8 编码)
CREATE DATABASE `djangoadmin.fastapi.elevue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE `djangoadmin.fastapi.antdvue` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 查看数据库 -- 查看数据库
SHOW DATABASES; SHOW DATABASES;
@@ -297,7 +297,7 @@ OK
## 安装 Node.js ## 安装 Node.js
`Node.js` 是前端项目的运行环境,要求 `22` 或更高版本。前端基于 `Vue3` + `ElementPlus` 构建。 `Node.js` 是前端项目的运行环境,要求 `22` 或更高版本。前端基于 `Vue3` + `AntDesign Vue` 构建。
+ 下载 Node.js + 下载 Node.js
@@ -83,7 +83,7 @@ mysql -h127.0.0.1 -P3306 -uroot -p
# 如果是权限问题,授权用户 # 如果是权限问题,授权用户
mysql -uroot -p mysql -uroot -p
GRANT ALL PRIVILEGES ON `djangoadmin.fastapi.elevue`.* TO 'root'@'%' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON `djangoadmin.fastapi.antdvue`.* TO 'root'@'%' IDENTIFIED BY 'your_password';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
@@ -382,7 +382,7 @@ ls static/fonts/Vera.ttf
# .env 中的上传配置 # .env 中的上传配置
UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z
UPLOAD_MAX_SIZE_MB=10 UPLOAD_MAX_SIZE_MB=10
FASTAPI_FILE_URL=http://file.fastapi.elevue FASTAPI_FILE_URL=http://file.fastapi.antdvue
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -1,6 +1,6 @@
# 前端启动 # 前端启动
本章节介绍如何将前端项目安装依赖并启动开发服务器。前端基于 `Vue3` + `ElementPlus` + `TypeScript`+`Vite` 构建,采用组件化开发模式,支持热更新和代理配置。 本章节介绍如何将前端项目安装依赖并启动开发服务器。前端基于 `Vue3` + `AntDesign Vue` + `TypeScript`+`Vite` 构建,采用组件化开发模式,支持热更新和代理配置。
:::tip 温馨提示 :::tip 温馨提示
启动前端前,请确保已完成 [环境准备](/zh/volume1/environment) 章节中的 `Node.js` 和 `pnpm` 安装,并已启动后端服务(参见 [后端启动](/zh/volume1/backend))。 启动前端前,请确保已完成 [环境准备](/zh/volume1/environment) 章节中的 `Node.js` 和 `pnpm` 安装,并已启动后端服务(参见 [后端启动](/zh/volume1/backend))。
+101 -94
View File
@@ -1,6 +1,6 @@
# 前端页面视图 # 前端页面视图
前端页面视图是用户直接交互的界面。项目使用 Vue3 + ElementPlus + Vite 构建。一个完整的 CRUD 页面由 4 个文件组成: 前端页面视图是用户直接交互的界面。项目使用 Vue3 + AntDesign Vue + Vite 构建。一个完整的 CRUD 页面由 4 个文件组成:
``` ```
ui/src/views/system/position/ ui/src/views/system/position/
@@ -19,10 +19,10 @@ ui/src/views/system/position/
```vue ```vue
<template> <template>
<PageWrapper> <PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard"> <a-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" /> <BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card> </a-card>
<el-card :bordered="false" class="proCard"> <a-card :bordered="false" class="proCard">
<BasicTable <BasicTable
:columns="columns" :columns="columns"
:request="loadDataTable" :request="loadDataTable"
@@ -32,30 +32,29 @@ ui/src/views/system/position/
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<template #tableTitle> <template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']"> <a-space>
<template #icon> <a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<el-icon class="el-input__icon"> <template #icon>
<PlusOutlined /> <PlusOutlined />
</el-icon> </template>
</template> 添加岗位
添加岗位 </a-button>
</el-button> <a-button
<el-button type="primary"
type="danger" danger
@click="handleDelete()" @click="handleDelete()"
:disabled="!selectionData.length" :disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']" v-perm="['sys:position:batchDelete']"
> >
<template #icon> <template #icon>
<el-icon class="el-input__icon"> <DeleteOutlined />
<Delete /> </template>
</el-icon> 删除
</template> </a-button>
删除 </a-space>
</el-button>
</template> </template>
</BasicTable> </BasicTable>
</el-card> </a-card>
<editDialog <editDialog
v-if="editVisible" v-if="editVisible"
@@ -68,14 +67,14 @@ ui/src/views/system/position/
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue'; import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
import { ColProps } from 'element-plus'; import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { schemas } from './querySchemas'; import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index'; import { useForm } from '@/components/Form/index';
import { TableAction } from '@/components/Table'; import { TableAction } from '@/components/Table';
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position'; import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
import { columns } from './columns'; import { columns } from './columns';
import { PlusOutlined } from '@vicons/antd'; import { Modal, message } from 'ant-design-vue';
import { message, confirm } from '@/utils/auth';
const editDialog = defineAsyncComponent(() => import('./edit.vue')); const editDialog = defineAsyncComponent(() => import('./edit.vue'));
const positionId = ref(0); const positionId = ref(0);
const editVisible = ref(false); const editVisible = ref(false);
@@ -140,10 +139,15 @@ ui/src/views/system/position/
* 注册搜索表单 * 注册搜索表单
*/ */
const [register, {}] = useForm({ const [register, {}] = useForm({
labelWidth: 80, rowProps: { gutter: [16, 0] },
layout: 'horizontal', colProps: {
colProps: { span: 6 } as ColProps, xs: 24,
submitOnReset: true, sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas, schemas,
}); });
@@ -179,8 +183,8 @@ ui/src/views/system/position/
/** /**
* 执行编辑 * 执行编辑
*/ */
const handleEdit = async (record: Recordable) => { const handleEdit = async (id: number) => {
positionId.value = record.row.id; positionId.value = id;
await nextTick(); await nextTick();
editVisible.value = true; editVisible.value = true;
}; };
@@ -188,15 +192,16 @@ ui/src/views/system/position/
/** /**
* 执行删除(单条 + 批量) * 执行删除(单条 + 批量)
*/ */
async function handleDelete(record: Recordable) { async function handleDelete(id?: number) {
let ids = []; Modal.confirm({
if (!record) { title: '提示',
ids = selectionData.value.map(({ id }) => id); content: '确定要删除?',
} onOk: async () => {
await confirm('确定要删除?'); id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids); message.success('删除成功');
message('删除成功'); reloadTable();
reloadTable(); },
});
} }
/** /**
@@ -210,7 +215,7 @@ ui/src/views/system/position/
### 代码解析 ### 代码解析
**页面结构**:`PageWrapper` 包裹两个 `el-card`,上方是搜索表单,下方是数据表格。 **页面结构**:`PageWrapper` 包裹两个 `a-card`,上方是搜索表单,下方是数据表格。
**搜索表单**:通过 `useForm` 注册,Schema 从 `querySchemas.ts` 导入。`submitOnReset: true` 表示重置时自动触发查询。 **搜索表单**:通过 `useForm` 注册,Schema 从 `querySchemas.ts` 导入。`submitOnReset: true` 表示重置时自动触发查询。
@@ -230,45 +235,49 @@ ui/src/views/system/position/
```vue ```vue
<template> <template>
<el-dialog <a-modal
v-model="props.visible" v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'" :title="props.positionId ? '编辑' : '新增'"
width="500" width="500px"
:close-on-click-modal="false" @cancel="dialogClose"
:before-close="dialogClose"
> >
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px"> <a-form
<el-form-item class="ls-form"
ref="formRef"
:model="formData"
:label-col="{ style: { width: '85px' } }"
>
<a-form-item
label="岗位名称" label="岗位名称"
prop="name" name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }" :rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
> >
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable /> <a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</el-form-item> </a-form-item>
<el-form-item label="岗位状态" prop="status"> <a-form-item label="岗位状态" name="status">
<el-radio-group v-model="formData.status" name="status"> <a-radio-group v-model:value="formData.status" name="status">
<el-radio :value="1">正常</el-radio> <a-radio :value="1">正常</a-radio>
<el-radio :value="2">停用</el-radio> <a-radio :value="2">停用</a-radio>
</el-radio-group> </a-radio-group>
</el-form-item> </a-form-item>
<el-form-item label="排序" prop="sort"> <a-form-item label="排序" name="sort">
<el-input-number v-model="formData.sort" /> <a-input-number v-model:value="formData.sort" />
</el-form-item> </a-form-item>
</el-form> </a-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="dialogClose">取消</el-button> <a-button @click="dialogClose">取消</a-button>
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button> <a-button :loading="subLoading" type="primary" @click="submit"> 确定 </a-button>
</span> </span>
</template> </template>
</el-dialog> </a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'ant-design-vue';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position'; import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { onMounted, reactive, shallowRef } from 'vue'; import { onMounted, reactive, shallowRef } from 'vue';
import { message } from '@/utils/auth'; import { message } from 'ant-design-vue';
import { useLockFn } from '@/utils/useLockFn'; import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']); const emit = defineEmits(['success', 'update:visible']);
@@ -306,7 +315,7 @@ ui/src/views/system/position/
const handleSubmit = async () => { const handleSubmit = async () => {
await formRef.value?.validate(); await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData); props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功'); message.success('操作成功');
emit('update:visible', false); emit('update:visible', false);
emit('success'); emit('success');
}; };
@@ -369,52 +378,50 @@ ui/src/views/system/position/
```typescript ```typescript
import { h } from 'vue'; import { h } from 'vue';
import { ElTag } from 'element-plus'; import { Tag } from 'ant-design-vue';
export const columns = [ export const columns = [
{ {
type: 'selection', type: 'selection',
}, },
{ {
label: 'ID', title: 'ID',
prop: 'id', dataIndex: 'id',
fixed: 'left', fixed: 'left',
width: 50, width: 50,
}, },
{ {
label: '岗位名称', title: '岗位名称',
prop: 'name', dataIndex: 'name',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '岗位状态', title: '岗位状态',
prop: 'status', dataIndex: 'status',
minWidth: 100, minWidth: 100,
render(record) { customRender: ({ record }) => {
return h( return h(
ElTag, Tag,
{ {
type: record.row.status == 1 ? 'success' : 'danger', color: record.status == 1 ? 'success' : 'error',
},
{
default: () => (record.row.status == 1 ? '正常' : '停用'),
}, },
() => (record.status == 1 ? '正常' : '停用'),
); );
}, },
}, },
{ {
label: '排序', title: '排序',
prop: 'sort', dataIndex: 'sort',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '创建人', title: '创建人',
prop: 'createUser', dataIndex: 'createUser',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '创建时间', title: '创建时间',
prop: 'createTime', dataIndex: 'createTime',
width: 180, width: 180,
}, },
]; ];
@@ -426,7 +433,7 @@ export const columns = [
**固定列**:`fixed: 'left'` 固定在左侧,`fixed: 'right'` 固定在右侧(操作列)。 **固定列**:`fixed: 'left'` 固定在左侧,`fixed: 'right'` 固定在右侧(操作列)。
**自定义渲染**:`render(record)` 使用 Vue 的 `h()` 函数创建虚拟 DOM。状态列将 `1/2` 渲染为绿色/红色标签。 **自定义渲染**:`customRender({ record })` 使用 Vue 的 `h()` 函数创建虚拟 DOM。状态列将 `1/2` 渲染为绿色/红色标签。
**宽度设置**: **宽度设置**:
- `width`:固定宽度 - `width`:固定宽度
@@ -455,7 +462,7 @@ export const schemas: FormSchema[] = [
label: '状态', label: '状态',
componentProps: { componentProps: {
placeholder: '请选择状态', placeholder: '请选择状态',
clearable: true, allowClear: true,
options: [ options: [
{ {
label: '正常', label: '正常',
@@ -478,11 +485,11 @@ export const schemas: FormSchema[] = [
| 字段 | 说明 | | 字段 | 说明 |
|:----|:----| |:----|:----|
| `field` | 字段名,与后端查询参数对应 | | `field` | 字段名,与后端查询参数对应 |
| `component` | 表单组件类型:`Input` / `Select` / `DatePicker` 等 | | `component` | 表单组件类型:`Input` / `Select` / `RangePicker` 等 |
| `label` | 标签文本 | | `label` | 标签文本 |
| `componentProps` | 组件属性,透传给 ElementPlus 组件 | | `componentProps` | 组件属性,透传给 AntDesign Vue 组件 |
**Select 组件**:`options` 定义下拉选项,`clearable: true` 允许清空选择。 **Select 组件**:`options` 定义下拉选项,`allowClear: true` 允许清空选择。
## 文件关系图 ## 文件关系图
@@ -61,9 +61,9 @@
| name | varchar(255) | 岗位名称 | | name | varchar(255) | 岗位名称 |
| status | int | 状态:1-在用 2-停用 | | status | int | 状态:1-在用 2-停用 |
| sort | int | 排序号 | | sort | int | 排序号 |
| create_user | varchar(255) | 创建人 | | create_user | varchar(50) | 创建人 |
| create_time | datetime | 创建时间 | | create_time | datetime | 创建时间 |
| update_user | varchar(255) | 更新人 | | update_user | varchar(50) | 更新人 |
| update_time | datetime | 更新时间 | | update_time | datetime | 更新时间 |
| is_delete | int | 软删除标记:0-正常 1-已删除 | | is_delete | int | 软删除标记:0-正常 1-已删除 |
@@ -163,27 +163,25 @@ def __init_subclass__(cls, **kwargs):
### 序列化方法 ### 序列化方法
```python ```python
# ============================================================ # src/core/base_model.py
# 子类钩子:自动将 __table_comment__ 写入 __table_args__ def to_dict(self) -> Dict[str, Any]:
# ============================================================ """将模型实例转换为字典"""
def __init_subclass__(cls, **kwargs): result = {}
super().__init_subclass__(**kwargs) for column in self.__table__.columns:
comment = getattr(cls, '__table_comment__', None) if column.name in SERIALIZE_EXCLUDE_FIELDS:
if not comment: continue
return value = getattr(self, column.name)
# 仅对有 __tablename__ 的具体模型操作(跳过纯抽象中间层) if isinstance(value, datetime):
if not getattr(cls, '__tablename__', None): if value.tzinfo is None:
return # MySQL DATETIME 无时区信息,读回为 naive UTC,统一按 UTC 转本地
existing = getattr(cls, '__table_args__', {}) value = value.replace(tzinfo=timezone.utc)
if isinstance(existing, dict): value = value.astimezone().strftime('%Y-%m-%d %H:%M:%S')
merged = {**existing} elif isinstance(value, date):
elif isinstance(existing, tuple) and existing: value = value.strftime('%Y-%m-%d')
merged = dict(existing[-1]) if isinstance(existing[-1], dict) else {} # 字段名转驼峰
else: camel_key = convert_camel_case(column.name)
merged = {} result[camel_key] = value
if 'comment' not in merged: return result
merged['comment'] = comment
cls.__table_args__ = merged
``` ```
关键设计点: 关键设计点:
@@ -416,7 +414,7 @@ Pydantic Schema 可能用 camelCase(如 `parentId`),而模型列是 snake_
| 方法 | 说明 | 返回值 | | 方法 | 说明 | 返回值 |
|:---|:---|:---| |:---|:---|:---|
| `get_page(request)` | 分页查询 | `R.page(records, total, size, current, pages)` | | `get_page(request)` | 分页查询 | `R.page(data, total, current, size)` |
| `get_list(request)` | 不分页查询 | `R.ok(data=[...])` | | `get_list(request)` | 不分页查询 | `R.ok(data=[...])` |
| `get_detail(obj_id)` | 详情查询 | `dict` 或 `None` | | `get_detail(obj_id)` | 详情查询 | `dict` 或 `None` |
| `add(request, data)` | 新增记录 | `R.ok(msg='添加成功')` | | `add(request, data)` | 新增记录 | `R.ok(msg='添加成功')` |
+128 -85
View File
@@ -46,107 +46,142 @@
| **容量限制** | 受进程内存限制 | 受 Redis 内存限制 | | **容量限制** | 受进程内存限制 | 受 Redis 内存限制 |
| **适用场景** | 高频读取、低变更数据 | 跨进程共享、分布式场景 | | **适用场景** | 高频读取、低变更数据 | 跨进程共享、分布式场景 |
## L1 缓存:进程内字典缓存 ## 进程内字典缓存
### 设计决策
`dict_util` 采用**纯进程内 TTL 缓存**(无 Redis L2),原因:
1. 字典数据几乎不变,读写均落在同步 service 路径,无法使用请求级异步 Redis
2. 字典/字典项增删改时由 service 主动失效,TTL 仅作兜底
3. 多 worker 部署下各进程缓存最多在 TTL 内短暂不一致,属可接受范围
### 实现原理 ### 实现原理
`dict_util` 模块实现了带 TTL 的进程内字典缓存。数据首次查询时从 Redis 加载到进程内存,后续查询直接从内存读取,TTL 过期后自动重新加载。
```python ```python
# src/utils/dict_util.py # src/utils/dict_util.py
import logging
import time import time
import json
from typing import Dict
# 进程内缓存:{cache_key: (data, expire_timestamp)} from modules.dictionary.dict.models import Dict
_cache: Dict[str, tuple] = {} from modules.dictionary.dict_item.models import DictItem
from modules.dictionary.dict.repository import dict_repo
from modules.dictionary.dict_item.repository import dictitem_repo
CACHE_TTL = 300 # 默认 5 分钟 logger = logging.getLogger(__name__)
# 字典缓存 TTL(秒):兜底过期时间,实际变更靠主动失效即时生效
_CACHE_TTL = 60
# 进程内字典缓存:{code: (过期时间戳, 字典项列表)}
_dict_cache: dict = {}
def get_dict_map(dict_code: str) -> Dict[str, str]: def get_dict_items(code):
"""获取数据字典的 {value: label} 映射(进程内缓存 + Redis 二级缓存)""" """按字典编码读取字典项列表 [{label, value, sort}, ...],字典不存在返回空列表"""
cache_key = f"dict:{dict_code}"
now = time.time() now = time.time()
hit = _dict_cache.get(code)
# L1: 进程内缓存命中 if hit and hit[0] > now:
if cache_key in _cache: return hit[1]
data, expire_at = _cache[cache_key] items = _load_dict_items(code)
if now < expire_at: _dict_cache[code] = (now + _CACHE_TTL, items)
return data return items
del _cache[cache_key]
# L2: Redis 缓存
import asyncio
from core.redis_client import get_redis
redis = get_redis()
cached = asyncio.get_event_loop().run_until_complete(redis.get(cache_key))
if cached:
data = json.loads(cached)
_cache[cache_key] = (data, now + CACHE_TTL)
return data
# 数据库查询(L3 回源)
from modules.dictionary.dict_item.repository import dict_item_repo
items = dict_item_repo.get_all(dict_code=dict_code)
data = {str(item.value): item.label for item in items}
# 写回两级缓存
_cache[cache_key] = (data, now + CACHE_TTL)
asyncio.get_event_loop().run_until_complete(
redis.set(cache_key, json.dumps(data, ensure_ascii=False), ex=CACHE_TTL)
)
return data
def invalidate_dict_cache(dict_code: str): def get_dict_map(code):
"""失效指定字典的缓存(L1 + L2)""" """按字典编码读取 {value: label} 映射(value 为字典项存储值,字符串类型)"""
cache_key = f"dict:{dict_code}" return {item['value']: item['label'] for item in get_dict_items(code)}
# 清除 L1(进程内缓存)
_cache.pop(cache_key, None)
# 清除 L2(Redis 缓存) def get_dict_label(code, value, default=None):
from core.redis_client import get_redis """取单个字典项名称:value 自动转字符串匹配存储值,查不到返回 default"""
import asyncio return get_dict_map(code).get(str(value), default)
redis = get_redis()
asyncio.get_event_loop().run_until_complete(redis.delete(cache_key))
def invalidate_dict(code):
"""清除指定字典编码的缓存(None/未缓存时安全无操作)"""
if code:
_dict_cache.pop(code, None)
def invalidate_dicts_by_ids(dict_ids):
"""按字典ID批量清除缓存:字典项变更后据 dict_id 反查 code 再失效"""
if not dict_ids:
return
try:
rows = dict_repo.filter(Dict.id.in_(dict_ids), Dict.is_delete == 0).all()
for row in rows:
invalidate_dict(row.code)
except Exception as e:
logger.warning("字典缓存失效失败(将按 TTL 自动过期): %s", e)
def _load_dict_items(code):
"""从数据库读取字典项(未删除、按 sort+id 升序),字典不存在返回空列表"""
dict_obj = dict_repo.get_one(code=code)
if not dict_obj:
return []
rows = dictitem_repo.filter_by(dict_id=dict_obj.id).all()
rows.sort(key=lambda item: (item.sort, item.id))
return [{'label': item.name, 'value': item.value, 'sort': item.sort} for item in rows]
``` ```
### 公开接口
| 函数 | 签名 | 说明 |
|:---|:---|:---|
| `get_dict_items` | `(code) → [{label, value, sort}]` | 字典项列表(供前端下拉) |
| `get_dict_map` | `(code) → {value: label}` | 值→显示名映射(供后端翻译) |
| `get_dict_label` | `(code, value, default) → str` | 单值翻译 |
| `invalidate_dict` | `(code) → None` | 按字典编码清除缓存 |
| `invalidate_dicts_by_ids` | `(dict_ids) → None` | 按字典 ID 批量清除缓存 |
### 使用场景 ### 使用场景
| 场景 | 调用方式 | 说明 | | 场景 | 调用方式 | 说明 |
|:---|:---|:---| |:---|:---|:---|
| 枚举显示名 | `get_dict_map('status_type')` | 序列化时将值转为显示名 | | 枚举显示名 | `get_dict_map('status_type')` | 序列化时将值转为显示名 |
| 表单下拉 | `get_dict_map('gender')` | 前端获取字典项列表 | | 表单下拉 | `get_dict_items('gender')` | 前端获取字典项列表 |
| 单值翻译 | `get_dict_label('status', 1)` | 将 1 转为 "正常" |
| 业务校验 | `value in get_dict_map('xxx')` | 校验值是否在字典范围内 | | 业务校验 | `value in get_dict_map('xxx')` | 校验值是否在字典范围内 |
### 缓存失效 ### 缓存失效
字典数据变更时(新增/编辑/删除字典项),主动清除两级缓存: 字典数据变更时(新增/编辑/删除字典项),通过 `invalidate_dicts_by_ids` 按字典 ID 批量清除:
```python ```python
# src/modules/dictionary/dict_item/service.py # src/modules/dictionary/dict_item/service.py
class DictItemService(BaseService[DictItem]): class DictItemService(BaseService[DictItem]):
def add(self, request, data) -> R: repo = dictitem_repo
model = DictItem
def add(self, request, data):
"""新增字典项,新增后失效所属字典的缓存"""
result = super().add(request, data) result = super().add(request, data)
if result.code == 0: if data.dictId:
invalidate_dict_cache(data.dict_code) # 新增成功后失效缓存 invalidate_dicts_by_ids({data.dictId})
return result return result
def update(self, request, data) -> R: def update(self, request, data):
"""更新字典项,更新后失效新旧所属字典的缓存"""
old = self.repo.get_by_id(data.id) if data.id else None
result = super().update(request, data) result = super().update(request, data)
if result.code == 0: dict_ids = {data.dictId} if data.dictId else set()
invalidate_dict_cache(data.dict_code) # 更新成功后失效缓存 if old and old.dict_id:
dict_ids.add(old.dict_id)
invalidate_dicts_by_ids(dict_ids)
return result return result
def delete(self, ids) -> R: def delete(self, ids):
# 删除前先获取 dict_code(删除后无法从 data 中获取) """删除字典项,删除后失效所属字典的缓存"""
item = self.repo.get_by_id(ids) from utils.request import parse_id_list
dict_code = item.dict_code if item else None id_list = parse_id_list(str(ids))
dict_ids = set()
if id_list:
for row in dictitem_repo.filter(DictItem.id.in_(id_list), DictItem.is_delete == 0).all():
if row.dict_id:
dict_ids.add(row.dict_id)
result = super().delete(ids) result = super().delete(ids)
if result.code == 0 and dict_code: invalidate_dicts_by_ids(dict_ids)
invalidate_dict_cache(dict_code) # 删除成功后失效缓存
return result return result
``` ```
@@ -178,11 +213,15 @@ def get_redis() -> Redis:
| **JWT 黑名单** | String | `jwt:blacklist:{token_hash}` | Token 剩余有效期 | 登出时将 Token 加入黑名单 | | **JWT 黑名单** | String | `jwt:blacklist:{token_hash}` | Token 剩余有效期 | 登出时将 Token 加入黑名单 |
| **登录失败锁** | String | `login:lock:{username}` | 600 秒 | 连续 5 次失败后锁定 | | **登录失败锁** | String | `login:lock:{username}` | 600 秒 | 连续 5 次失败后锁定 |
| **登录失败计数** | String (Lua) | `login:fail:{username}` | 300 秒 | 原子递增 + 首次设置 TTL | | **登录失败计数** | String (Lua) | `login:fail:{username}` | 300 秒 | 原子递增 + 首次设置 TTL |
| **滑动窗口限流** | String (Lua) | `rate:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器 | | **滑动窗口限流** | String (Lua) | `rl:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器(前缀可配置) |
| **数据字典缓存** | String (JSON) | `dict:{dict_code}` | 300 秒 | 字典项 {value: label} 映射 | | **权限列表缓存** | String (JSON) | `perm:list:{user_id}:{version}` | 7200 秒 | 用户权限字符串列表(版本号策略) |
| **权限列表缓存** | String (JSON) | `perm:user:{user_id}` | 变更时失效 | 用户权限字符串列表 | | **菜单缓存** | String (JSON) | `perm:menu:{user_id}:{version}` | 7200 秒 | 用户菜单树(版本号策略) |
| **在线用户** | String | `online:user:{user_id}` | Token 有效期 | 在线用户 Token 存储 | | **用户有效状态** | String | `user:active:{user_id}` | 300 秒 | login_required 每请求校验 |
| **验证码** | String | `captcha:{uuid}` | 120 秒 | 图形验证码文本 | | **验证码** | String | `captcha:{uuid}` | 300 秒 | 图形验证码文本 |
:::tip 字典缓存不在 Redis 中
数据字典(`dict_util`)采用纯进程内 TTL 缓存,不经过 Redis。原因是字典读取落在同步 service 路径,无法使用请求级异步 Redis 客户端。详见上方「进程内字典缓存」章节。
:::
### Lua 脚本 ### Lua 脚本
@@ -243,14 +282,17 @@ return effective
数据变更时主动清除相关缓存,保证数据一致性: 数据变更时主动清除相关缓存,保证数据一致性:
```python ```python
# 字典项变更 → 清除字典缓存 # 字典项变更 → 按字典 ID 批量清除字典缓存
invalidate_dict_cache(dict_code) invalidate_dicts_by_ids(dict_ids)
# 菜单变更 → 递增全局版本号,所有用户权限缓存自然失效
await invalidate_menu_change()
# 用户角色变更 → 删除该用户的权限和菜单缓存
await invalidate_user_perms(user_id)
# 角色菜单变更 → 清除该角色下所有用户的权限缓存 # 角色菜单变更 → 清除该角色下所有用户的权限缓存
invalidate_perm_cache(user_id) await invalidate_role_perms(role_id)
# 用户角色变更 → 清除该用户的权限缓存
invalidate_perm_cache(user_id)
``` ```
### TTL 自动过期 ### TTL 自动过期
@@ -258,26 +300,27 @@ invalidate_perm_cache(user_id)
对于允许短暂不一致的数据,使用 TTL 自动过期: 对于允许短暂不一致的数据,使用 TTL 自动过期:
``` ```
数据字典缓存:TTL 300 秒(5 分钟) 数据字典缓存:TTL 60 秒(1 分钟,兜底过期,实际变更靠主动失效即时生效)
登录失败计数:TTL 300 秒(5 分钟) 登录失败计数:TTL 300 秒(5 分钟)
滑动窗口限流:TTL 2 倍窗口时长 滑动窗口限流:TTL 2 倍窗口时长
验证码:TTL 120 秒(2 分钟) 验证码:TTL 300 秒(5 分钟)
``` ```
### 缓存穿透防护 ### 缓存穿透防护
:::warning 缓存穿透 :::warning 缓存穿透
当查询一个不存在的数据时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。对于字典查询,如果 `dict_code` 不存在,应缓存空结果(空字典),避免反复查询数据库。 当查询一个不存在的字典编码时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。`dict_util` 通过缓存空列表来防护:即使 `_load_dict_items` 返回空列表,也写入缓存,避免反复查询数据库。
::: :::
```python ```python
def get_dict_map(dict_code: str) -> Dict[str, str]: def get_dict_items(code):
# ... 缓存查询逻辑 ... now = time.time()
items = dict_item_repo.get_all(dict_code=dict_code) hit = _dict_cache.get(code)
data = {str(item.value): item.label for item in items} if hit and hit[0] > now:
# 即使 data 为空字典,也写入缓存,防止穿透 return hit[1]
_cache[cache_key] = (data, now + CACHE_TTL) items = _load_dict_items(code) # 字典不存在时返回 []
return data _dict_cache[code] = (now + _CACHE_TTL, items) # 空列表也缓存,防止穿透
return items
``` ```
## 缓存监控 ## 缓存监控
@@ -55,7 +55,7 @@
| code | VARCHAR(100) | NOT NULL, UNIQUE | 角色编码 | | code | VARCHAR(100) | NOT NULL, UNIQUE | 角色编码 |
| status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 | | status | INT | DEFAULT 1 | 状态:1=正常 2=禁用 |
| sort | INT | DEFAULT 0 | 排序 | | sort | INT | DEFAULT 0 | 排序 |
| remark | VARCHAR(500) | NULLABLE | 备注 | | note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | | | + 通用字段 | | | |
#### 菜单表 `fastapi_menu` #### 菜单表 `fastapi_menu`
@@ -139,7 +139,7 @@
| name | VARCHAR(200) | NOT NULL | 字典名称 | | name | VARCHAR(200) | NOT NULL | 字典名称 |
| code | VARCHAR(200) | NOT NULL, UNIQUE | 字典编码 | | code | VARCHAR(200) | NOT NULL, UNIQUE | 字典编码 |
| status | INT | DEFAULT 1 | 状态 | | status | INT | DEFAULT 1 | 状态 |
| remark | VARCHAR(500) | NULLABLE | 备注 | | note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | | | + 通用字段 | | | |
#### 字典项表 `fastapi_dict_item` #### 字典项表 `fastapi_dict_item`
@@ -152,7 +152,7 @@
| value | VARCHAR(200) | NOT NULL | 字典值 | | value | VARCHAR(200) | NOT NULL | 字典值 |
| sort | INT | DEFAULT 0 | 排序 | | sort | INT | DEFAULT 0 | 排序 |
| status | INT | DEFAULT 1 | 状态 | | status | INT | DEFAULT 1 | 状态 |
| remark | VARCHAR(500) | NULLABLE | 备注 | | note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | | | + 通用字段 | | | |
### 内容管理模块 ### 内容管理模块
@@ -243,7 +243,7 @@
| misfire_policy | INT | DEFAULT 3 | 执行策略 | | misfire_policy | INT | DEFAULT 3 | 执行策略 |
| concurrent | INT | DEFAULT 0 | 是否并发 | | concurrent | INT | DEFAULT 0 | 是否并发 |
| status | INT | DEFAULT 1 | 状态:1=运行 2=暂停 | | status | INT | DEFAULT 1 | 状态:1=运行 2=暂停 |
| remark | VARCHAR(500) | NULLABLE | 备注 | | note | VARCHAR(255) | NULLABLE | 备注 |
| + 通用字段 | | | | | + 通用字段 | | | |
#### 任务日志表 `fastapi_job_log` #### 任务日志表 `fastapi_job_log`
@@ -36,8 +36,7 @@ src/
│ ├── base_db.py # 数据库操作基类:save() 持久化方法 │ ├── base_db.py # 数据库操作基类:save() 持久化方法
│ ├── base_repository.py # 仓库基类:通用 CRUD + 软删除 + 分页 │ ├── base_repository.py # 仓库基类:通用 CRUD + 软删除 + 分页
│ ├── base_service.py # 服务基类:模板方法 CRUD + 差异点钩子 │ ├── base_service.py # 服务基类:模板方法 CRUD + 差异点钩子
│ ├── base_schemas.py # Schema 基类:BaseForm(含可选 id 字段) │ └── base_schemas.py # Schema 基类:BaseForm(含可选 id 字段)
│ └── middleware.py # 中间件注册入口
│ │
├── middleware/ # HTTP 中间件层 ├── middleware/ # HTTP 中间件层
│ ├── __init__.py # 统一导出 │ ├── __init__.py # 统一导出
@@ -45,7 +44,7 @@ src/
│ ├── rate_limit.py # 滑动窗口限流中间件 │ ├── rate_limit.py # 滑动窗口限流中间件
│ ├── db_session.py # 数据库会话中间件(commit/rollback) │ ├── db_session.py # 数据库会话中间件(commit/rollback)
│ ├── operation_log.py # 操作日志中间件 │ ├── operation_log.py # 操作日志中间件
│ ├── upload_size.py # 上传体积限制中间件 │ ├── upload_size_limit.py # 上传体积限制中间件
│ └── logger.py # 请求日志钩子 │ └── logger.py # 请求日志钩子
│ │
├── api/ # 路由装配层 ├── api/ # 路由装配层
@@ -125,7 +124,6 @@ src/
├── constants/ # 常量与枚举 ├── constants/ # 常量与枚举
│ ├── __init__.py # 统一导出 │ ├── __init__.py # 统一导出
│ ├── constants.py # 通用常量:PAGE_SIZE / DEFAULT_PASSWORD / SERIALIZE_EXCLUDE_FIELDS │ ├── constants.py # 通用常量:PAGE_SIZE / DEFAULT_PASSWORD / SERIALIZE_EXCLUDE_FIELDS
│ ├── enums.py # 枚举定义
│ └── pagination.py # 分页参数解析 │ └── pagination.py # 分页参数解析
│ │
├── utils/ # 工具函数 ├── utils/ # 工具函数
@@ -272,7 +272,7 @@ def _normalize_driver(driver: str) -> str:
DB_DRIVER = _normalize_driver(os.getenv('DB_DRIVER', 'mysql')) DB_DRIVER = _normalize_driver(os.getenv('DB_DRIVER', 'mysql'))
DB_HOST = os.getenv('DB_HOST', '127.0.0.1') DB_HOST = os.getenv('DB_HOST', '127.0.0.1')
DB_PORT = int(os.getenv('DB_PORT', '3306')) DB_PORT = int(os.getenv('DB_PORT', '3306'))
DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.fastapi.elevue') DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.fastapi.antdvue')
DB_USERNAME = os.getenv('DB_USERNAME', 'root') DB_USERNAME = os.getenv('DB_USERNAME', 'root')
DB_PASSWORD = os.getenv('DB_PASSWORD', '') DB_PASSWORD = os.getenv('DB_PASSWORD', '')
@@ -8,7 +8,7 @@
``` ```
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ Browser (Vue3 + ElementPlus) │ │ Browser (Vue3 + AntDesign Vue) │
│ Axios HTTP Request / Vite Dev Proxy │ │ Axios HTTP Request / Vite Dev Proxy │
└──────────────────────────────┬──────────────────────────────────────────┘ └──────────────────────────────┬──────────────────────────────────────────┘
│ │
@@ -191,7 +191,7 @@ async def _combined_lifespan(app: FastAPI):
``` ```
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ Frontend (Vue3) │ │ Frontend (Vue3) │
│ ElementPlus · Pinia · Vue Router · Axios │ │ AntDesign Vue · Pinia · Vue Router · Axios │
└──────────────────────────┬───────────────────────────────────┘ └──────────────────────────┬───────────────────────────────────┘
│ HTTP / JSON │ HTTP / JSON
┌──────────────────────────┴───────────────────────────────────┐ ┌──────────────────────────┴───────────────────────────────────┐
@@ -5,7 +5,7 @@
## 请求链路总览 ## 请求链路总览
:::tip 链路概览 :::tip 链路概览
一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS → 限流 → Redis 注入 → DB 会话 → 操作日志 → Token 续签 → 路由匹配 → JWT 认证 → RBAC 鉴权 → Pydantic 校验 → Endpoint → Service → Repository → DB → 响应序列化 → 中间件反向链 → 浏览器 一个典型的 API 请求经过以下阶段:浏览器 → Nginx → CORS → 限流 → 上传体积限制 → 请求计时 → Token 续签 → 操作日志 → DB 会话 → Redis 注入 → 路由匹配 → JWT 认证 → RBAC 鉴权 → Pydantic 校验 → Endpoint → Service → Repository → DB → 响应序列化 → 中间件反向链 → 浏览器
::: :::
以下以 `POST /api/v1/position/add` 添加岗位为例,逐步拆解完整链路。 以下以 `POST /api/v1/position/add` 添加岗位为例,逐步拆解完整链路。
@@ -52,28 +52,19 @@ FastAPI 的中间件按注册顺序形成洋葱模型。请求从最外层进入
### 3. CORS 跨域中间件(最外层) ### 3. CORS 跨域中间件(最外层)
```python ```python
# src/middleware/cors.py # src/middleware/cors.py — 最后注册,位于洋葱最外层
register_cors(app) # 注册 CORS 中间件 register_cors(app)
``` ```
- 检查 `Origin` 头是否在允许列表中 - 检查 `Origin` 头是否在允许列表中
- 预检请求(OPTIONS)直接返回 CORS 响应头 - 预检请求(OPTIONS)直接返回 CORS 响应头
- 普通请求附加 `Access-Control-Allow-*` 响应头 - 普通请求附加 `Access-Control-Allow-*` 响应头
- 确保限流等直接返回的响应也带 CORS 头
### 4. 上传体积限制中间件 ### 4. 请求限流中间件
```python ```python
# src/middleware/upload_size.py # src/middleware/rate_limit.py — 倒数第二注册,仅 CORS 内层
app.middleware("http")(upload_size_limit_middleware)
```
- 按 `Content-Length` 提前拒绝超大请求体
- 避免超大 multipart 落临时盘后再由业务层校验
### 5. 请求限流中间件
```python
# src/middleware/rate_limit.py
async def rate_limit_middleware(request: Request, call_next): async def rate_limit_middleware(request: Request, call_next):
if not RATE_LIMIT_ENABLED: if not RATE_LIMIT_ENABLED:
return await call_next(request) return await call_next(request)
@@ -92,29 +83,73 @@ async def rate_limit_middleware(request: Request, call_next):
- 基于客户端 IP 的滑动窗口计数器 - 基于客户端 IP 的滑动窗口计数器
- 使用 Redis Lua 脚本保证原子性 - 使用 Redis Lua 脚本保证原子性
- 超限直接返回统一 JSON 响应,不进入后续链路 - 超限直接返回 HTTP 200 + `{"code": 1}` 统一 JSON 响应,不进入后续链路
- 被限流的请求不进入 DB 会话与操作日志,避免 DoS 攻击时日志表与 DB 连接不降反增
- Redis 不可用时降级放行(fail-open) - Redis 不可用时降级放行(fail-open)
- CORS 预检请求不计数
### 6. Redis 上下文注入中间件 ### 5. 上传体积限制中间件
```python ```python
# src/core/redis_client.py # src/middleware/upload_size_limit.py — 按 Content-Length 提前拒绝超大请求体
async def redis_middleware(request: Request, call_next): app.middleware("http")(upload_size_limit_middleware)
# 从应用状态中获取 Redis 客户端实例
redis = getattr(request.app.state, 'redis', None)
if redis is not None:
# 将 Redis 客户端注入到 contextvars,供业务层通过依赖注入获取
_redis_ctx.set(redis)
return await call_next(request)
``` ```
- 将 Redis 异步客户端注入 `contextvars` - 按 `Content-Length` 提前拒绝超大请求体
- 后续业务代码通过 `get_redis()` 获取当前请求的 Redis 客户端 - 避免超大 multipart 落临时盘后再由业务层校验
- 位于限流内层,已被限流放行的请求才检查体积
### 7. 数据库会话中间件 ### 6. 请求计时中间件
```python ```python
# src/middleware/db_session.py # src/core/app.py — 先注册→内层,统计业务耗时
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(round(process_time, 5))
return response
```
- 记录请求处理耗时(不含限流/CORS 等外层开销)
- 通过 `X-Process-Time` 响应头返回
### 7. Token 续签中间件
```python
# src/core/app.py — 续签 token 写入响应头
async def token_refresh_middleware(request, call_next):
response = await call_next(request)
refresh_token = getattr(request.state, '_refresh_token', None)
if refresh_token:
response.headers['X-Refresh-Token'] = refresh_token
return response
```
- 检查是否需要续签 Token
- 续签后的 Token 通过 `X-Refresh-Token` 响应头返回
### 8. 操作日志中间件
```python
# src/middleware/operation_log.py — 在 DB 会话外层,记录写操作日志
async def operation_log_middleware(request: Request, call_next):
response = await call_next(request)
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
# 记录操作日志到 fastapi_operation_log 表
# 包括:操作人、操作IP、请求路径、请求参数、响应状态、耗时等
...
return response
```
- 在 DB 会话外层,记录写操作日志
- 包含请求方法、URL、参数、响应码、耗时、操作人等信息
### 9. 数据库会话中间件
```python
# src/middleware/db_session.py — 后注册→外层,DB 会话包裹业务逻辑
async def db_session_middleware(request: Request, call_next): async def db_session_middleware(request: Request, call_next):
# 创建数据库会话 # 创建数据库会话
db = SessionLocal() db = SessionLocal()
@@ -144,52 +179,22 @@ async def db_session_middleware(request: Request, call_next):
- 写操作根据响应体 `code` 字段自动 commit/rollback - 写操作根据响应体 `code` 字段自动 commit/rollback
- 异常时自动 rollback,finally 中关闭会话 - 异常时自动 rollback,finally 中关闭会话
### 8. 操作日志中间件 ### 10. Redis 上下文注入中间件(最内层)
```python ```python
# src/middleware/operation_log.py # src/core/redis_client.py — 先注册→最内层:先注入 Redis 再开 DB 会话
async def operation_log_middleware(request: Request, call_next): async def redis_middleware(request: Request, call_next):
response = await call_next(request) # 从应用状态中获取 Redis 客户端实例
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'): redis = getattr(request.app.state, 'redis', None)
# 记录操作日志到 fastapi_operation_log 表 if redis is not None:
# 包括:操作人、操作IP、请求路径、请求参数、响应状态、耗时等 # 将 Redis 客户端注入到 contextvars,供业务层通过依赖注入获取
... _redis_ctx.set(redis)
return response return await call_next(request)
``` ```
- 在 DB 会话外层,记录写操作日志 - 将 Redis 异步客户端注入 `contextvars`
- 包含请求方法、URL、参数、响应码、耗时、操作人等信息 - 后续业务代码通过 `get_redis()` 获取当前请求的 Redis 客户端
- 位于最内层,确保 DB 会话和操作日志都能使用 Redis
### 9. Token 续签中间件
```python
# src/core/app.py
async def token_refresh_middleware(request: Request, call_next):
response = await call_next(request)
refresh_token = getattr(request.state, '_refresh_token', None)
if refresh_token:
response.headers['X-Refresh-Token'] = refresh_token
return response
```
- 最外层中间件,检查是否需要续签 Token
- 续签后的 Token 通过 `X-Refresh-Token` 响应头返回
### 10. 请求计时中间件
```python
# src/core/app.py
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(round(process_time, 5))
return response
```
- 记录请求处理耗时
- 通过 `X-Process-Time` 响应头返回
## 第三阶段:路由与认证 ## 第三阶段:路由与认证
@@ -301,15 +306,25 @@ def permission_required(permission: str):
```python ```python
# src/core/access_decorators.py # src/core/access_decorators.py
def check_demo(func): def check_demo(func):
"""演示模式拦截装饰器,放在 @permission_required 之后、路由函数之前"""
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
if FASTAPI_DEMO:
return R.failed("演示环境,暂无操作权限")
return await func(*args, **kwargs)
return async_wrapper
@wraps(func) @wraps(func)
async def wrapper(*args, **kwargs): def sync_wrapper(*args, **kwargs):
if FASTAPI_DEMO: if FASTAPI_DEMO:
return R.failed("演示环境,暂无操作权限") return R.failed("演示环境,暂无操作权限")
return await func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return sync_wrapper
``` ```
- `FASTAPI_DEMO=True` 时,所有写操作返回失败响应 - `FASTAPI_DEMO=True` 时,所有写操作返回失败响应
- 根据被装饰函数类型自动选择同步/异步包装
- 保护演示环境数据不被修改 - 保护演示环境数据不被修改
## 第四阶段:参数校验 ## 第四阶段:参数校验
@@ -440,7 +455,7 @@ R.ok(msg='添加成功')
## 完整链路流程图 ## 完整链路流程图
``` ```
请求链路(自上而下) 请求链路(自上而下,洋葱模型外层 → 内层)
[浏览器] [浏览器]
│ POST /api/v1/position/add │ POST /api/v1/position/add
@@ -448,29 +463,41 @@ R.ok(msg='添加成功')
[Nginx] ── 反向代理转发 [Nginx] ── 反向代理转发
│ │
▼ ▼
[CORS 中间件] ── 跨域校验通过 [CORS 中间件] ── 跨域校验通过(最外层)
│ │
▼ ▼
[限流中间件] ── 滑动窗口计数(Redis) [限流中间件] ── 滑动窗口计数(Redis Lua)
│ ├─ 超限 → 直接返回 429 │ ├─ 超限 → 直接返回 HTTP 200 + {code:1, msg:"请求过于频繁"}
│ └─ 通过 ▼ │ └─ 通过 ▼
▼ ▼
[上传体积限制] ── 按 Content-Length 拦截超大请求
│
▼
[请求计时] ── 记录起始时间,响应时附加 X-Process-Time
│
▼
[Token 续签] ── 检查是否需要续签,响应时附加 X-Refresh-Token
│
▼
[操作日志中间件] ── 异步记录写操作(不影响主流程)
│
▼
[DB 会话中间件] ── 创建 SessionLocal(),注入 contextvars [DB 会话中间件] ── 创建 SessionLocal(),注入 contextvars
│ │
▼ ▼
[操作日志中间件] ── 异步记录写操作(不影响主流程) [Redis 注入中间件] ── 将 Redis 客户端注入 contextvars(最内层)
│ │
▼ ▼
[路由层] ── 匹配 /api/v1/position/add [路由层] ── 匹配 /api/v1/position/add
│ │
▼ ▼
[认证依赖 login_required] ── JWT 验证 + 黑名单检查(Redis) [认证依赖 login_required] ── JWT 验证 + 黑名单检查(Redis)
│ ├─ 失败 → 返回 401 │ ├─ 失败 → 返回 HTTP 200 + {code:401, msg:"..."}
│ └─ 通过 → 解析 user_id/username 注入 request.state ▼ │ └─ 通过 → 解析 user_id/username 注入 request.state ▼
▼ ▼
[Endpoint 层] [Endpoint 层]
├─ @permission_required ── 权限校验(同步直查DB / 异步Redis缓存)
├─ @check_demo ── 演示模式拦截 ├─ @check_demo ── 演示模式拦截
├─ @permission_required ── 权限校验(Redis 缓存)
└─ 调用 Service ▼ └─ 调用 Service ▼
▼ ▼
[Service 层] [Service 层]
@@ -485,14 +512,14 @@ R.ok(msg='添加成功')
▼ ▼
[数据库] ── 执行 INSERT,返回自增 ID [数据库] ── 执行 INSERT,返回自增 ID
│ │
▼ 逐层返回响应 ▼ 逐层返回响应(内层 → 外层)
[Repository] → [Service] → [Endpoint] → [Router] → [OpLog] → [DBSession] [Redis] → [DBSession] → [OpLog] → [Token续签] → [请求计时] → [上传体积] → [限流] → [CORS]
│ │
├─ 业务成功(code=0)→ commit 提交事务 ├─ 业务成功(code=0)→ DB 会话 commit 提交事务
└─ 业务失败(code≠0)→ rollback 回滚事务 └─ 业务失败(code≠0)→ DB 会话 rollback 回滚事务
│ │
▼ ▼
[限流] → [CORS] → [Nginx] [Nginx]
│ │
▼ ▼
[浏览器] ← JSON 响应({ code, data, msg, ok }) [浏览器] ← JSON 响应({ code, data, msg, ok })
@@ -502,7 +529,7 @@ R.ok(msg='添加成功')
当请求链路中发生异常时,全局异常处理器统一捕获并返回标准响应: 当请求链路中发生异常时,全局异常处理器统一捕获并返回标准响应:
| 异常类型 | 触发场景 | 响应 | | 异常类型 | 触发场景 | 响应(均为 HTTP 200) |
|:---|:---|:---| |:---|:---|:---|
| `AuthorizationException` | JWT 过期 / 无权限 | `{"code": 401, "msg": "..."}` | | `AuthorizationException` | JWT 过期 / 无权限 | `{"code": 401, "msg": "..."}` |
| `BusinessException` | 业务逻辑错误 | `{"code": 1, "msg": "..."}` | | `BusinessException` | 业务逻辑错误 | `{"code": 1, "msg": "..."}` |
@@ -511,7 +538,7 @@ R.ok(msg='添加成功')
| `Exception` | 未捕获异常 | `{"code": 1, "msg": "服务器内部错误"}` | | `Exception` | 未捕获异常 | `{"code": 1, "msg": "服务器内部错误"}` |
:::warning 异常处理原则 :::warning 异常处理原则
所有异常均返回 HTTP 200 状态码,通过 `code` 字段区分业务成功(0)与失败(1)。这是前后端约定,前端统一按 `code` 判断,无需处理 HTTP 状态码差异。 所有异常均返回 **HTTP 200** 状态码,通过响应体 `code` 字段区分业务成功(0)与失败(1/401 等)。注意:`AuthorizationException` 的 `code=401` 是响应体中的业务码,不是 HTTP 状态码。这是前后端约定,前端统一按 `code` 判断,无需处理 HTTP 状态码差异。
::: :::
## 总结 ## 总结
@@ -471,37 +471,73 @@ def permission_required(permission: str):
### 权限缓存 ### 权限缓存
权限缓存采用「用户级缓存 + 全局版本号」双层策略(`src/utils/perm_cache.py`):
```python ```python
# src/modules/system/menu/service.py # src/utils/perm_cache.py
async def get_permissions_list(user_id: int) -> list: _VERSION_KEY = 'perm:version' # 全局版本号
"""获取用户权限列表(异步,走 Redis 缓存)""" _PERM_PREFIX = 'perm:list:' # 用户权限列表前缀
cache_key = f"perm:user:{user_id}" _MENU_PREFIX = 'perm:menu:' # 用户菜单树前缀
_CACHE_TTL = 7200 # 缓存过期时间(2小时)
async def get_cached_permissions(user_id: int):
"""获取缓存的用户权限列表,未命中返回 None"""
redis = get_redis() redis = get_redis()
cached = await redis.get(cache_key) ver = await _get_version()
if cached: key = f'{_PERM_PREFIX}{user_id}:{ver}'
return json.loads(cached) data = await redis.get(key)
# 从数据库查询 if data is not None:
permissions = query_user_permissions(user_id) return json.loads(data)
await redis.set(cache_key, json.dumps(permissions), ex=3600) return None
return permissions
def get_permissions_list_sync(user_id: int) -> list:
"""获取用户权限列表(同步,直查DB,供线程池场景使用)"""
return query_user_permissions(user_id)
def invalidate_perm_cache(user_id: int):
"""失效用户权限缓存(角色/菜单变更时调用)"""
from core.redis_client import get_redis
import asyncio
async def set_cached_permissions(user_id: int, perm_list: list):
"""缓存用户权限列表(空列表不缓存,避免缓存空壳后无法感知后续数据变更)"""
if not perm_list:
return
redis = get_redis() redis = get_redis()
asyncio.get_event_loop().run_until_complete( ver = await _get_version()
redis.delete(f"perm:user:{user_id}") key = f'{_PERM_PREFIX}{user_id}:{ver}'
) await redis.set(key, json.dumps(perm_list), ex=_CACHE_TTL)
``` ```
缓存失效策略:
```python
# src/utils/perm_cache.py
async def invalidate_menu_change():
"""菜单增删改时调用,递增全局版本号使所有用户缓存自然失效"""
await _incr_version()
async def invalidate_user_perms(user_id: int):
"""用户角色变更时调用,删除该用户的权限和菜单缓存"""
redis = get_redis()
ver = await _get_version()
keys = [
f'{_PERM_PREFIX}{user_id}:{ver}',
f'{_MENU_PREFIX}{user_id}:{ver}',
]
await redis.delete(*keys)
async def invalidate_role_perms(role_id: int):
"""角色菜单变更时调用,清理该角色下所有用户的权限和菜单缓存"""
# 查询该角色下所有用户 ID
user_ids = await asyncio.to_thread(_query_user_ids, role_id)
if not user_ids:
return
redis = get_redis()
ver = await _get_version()
keys = []
for uid in user_ids:
keys.append(f'{_PERM_PREFIX}{uid}:{ver}')
keys.append(f'{_MENU_PREFIX}{uid}:{ver}')
if keys:
await redis.delete(*keys)
```
:::tip 版本号策略的优势
传统方案在菜单变更时需要遍历所有用户删除缓存,版本号方案只需递增一个全局 key,所有用户的旧版本缓存自然失效(key 中包含版本号,读取时用新版本号拼 key,旧 key 不再被访问,等 TTL 过期自动清除)。
:::
### check_demo 装饰器 ### check_demo 装饰器
```python ```python
@@ -600,7 +636,7 @@ def sanitize_html(content: str) -> str:
### 上传体积限制 ### 上传体积限制
```python ```python
# src/middleware/upload_size.py # src/middleware/upload_size_limit.py
async def upload_size_limit_middleware(request: Request, call_next): async def upload_size_limit_middleware(request: Request, call_next):
content_length = request.headers.get('content-length') content_length = request.headers.get('content-length')
if content_length and int(content_length) > MAX_UPLOAD_SIZE: if content_length and int(content_length) > MAX_UPLOAD_SIZE:
@@ -695,7 +731,7 @@ async def operation_log_middleware(request: Request, call_next):
| 配置项 | 环境变量 | 默认值 | 说明 | | 配置项 | 环境变量 | 默认值 | 说明 |
|:---|:---|:---|:---| |:---|:---|:---|:---|
| JWT 密钥 | `JWT_SALT` | 必须配置 | HS256 签名密钥 | | JWT 密钥 | `JWT_SALT` | 必须配置 | HS256 签名密钥 |
| JWT 过期时间 | `JWT_EXPIRE_MINUTES` | 120 | Token 有效期(分钟) | | JWT 过期时间 | `JWT_EXPIRE_MINUTES` | 20 | Token 有效期(分钟) |
| 密码错误锁定 | — | 5 次/10 分钟 | 连续失败后锁定 | | 密码错误锁定 | — | 5 次/10 分钟 | 连续失败后锁定 |
| 验证码有效期 | — | 120 秒 | 验证码 Redis TTL | | 验证码有效期 | — | 120 秒 | 验证码 Redis TTL |
| 限流开关 | `RATE_LIMIT_ENABLED` | False | 滑动窗口限流 | | 限流开关 | `RATE_LIMIT_ENABLED` | False | 滑动窗口限流 |
@@ -63,13 +63,13 @@ SQLAlchemy 是 Python 生态中最成熟、功能最完备的 ORM 框架,2.0
## 前端技术栈 ## 前端技术栈
### Vue3 + Vite + ElementPlus ### Vue3 + Vite + AntDesign Vue
| 组件 | 版本 | 选型理由 | | 组件 | 版本 | 选型理由 |
|:---|:---|:---| |:---|:---|:---|
| **Vue3** | 3.x | Composition API、更好的 TypeScript 支持、Teleport/Suspense 等新特性 | | **Vue3** | 3.x | Composition API、更好的 TypeScript 支持、Teleport/Suspense 等新特性 |
| **Vite** | 5.x | 极速 HMR、ESM 原生支持、开箱即用的 TypeScript 支持 | | **Vite** | 5.x | 极速 HMR、ESM 原生支持、开箱即用的 TypeScript 支持 |
| **ElementPlus** | 2.x | Vue3 生态最成熟的 UI 组件库,企业级后台管理系统首选 | | **AntDesign Vue** | 4.x | Vue3 生态成熟的企业级 UI 组件库,设计规范完善 |
| **Pinia** | 2.x | Vue3 官方推荐的状态管理方案,替代 Vuex | | **Pinia** | 2.x | Vue3 官方推荐的状态管理方案,替代 Vuex |
| **Vue Router** | 4.x | Vue3 官方路由,支持动态路由、路由守卫 | | **Vue Router** | 4.x | Vue3 官方路由,支持动态路由、路由守卫 |
| **Axios** | 1.x | HTTP 客户端,支持请求/响应拦截器、取消请求 | | **Axios** | 1.x | HTTP 客户端,支持请求/响应拦截器、取消请求 |
@@ -229,4 +229,4 @@ Redis 在中承担多个关键职责:JWT 令牌黑名单、登录失败锁定
## 总结 ## 总结
项目的技术选型以「高性能、类型安全、可扩展」为核心原则。FastAPI + SQLAlchemy 2.0 + Pydantic v2 构成后端铁三角,Vue3 + Vite + ElementPlus 提供现代化前端体验,Redis 承担缓存与中间件职责。各组件版本经过充分测试验证,形成稳定的技术底座。选型时优先考虑组件的独立性和可替换性,避免强耦合,为未来的架构演进预留空间。 项目的技术选型以「高性能、类型安全、可扩展」为核心原则。FastAPI + SQLAlchemy 2.0 + Pydantic v2 构成后端铁三角,Vue3 + Vite + AntDesign Vue 提供现代化前端体验,Redis 承担缓存与中间件职责。各组件版本经过充分测试验证,形成稳定的技术底座。选型时优先考虑组件的独立性和可替换性,避免强耦合,为未来的架构演进预留空间。
@@ -10,8 +10,8 @@ API 接口速查表,按模块分组列出所有 RESTful 接口的请求方法
| 方法 | 路径 | 说明 | 权限 | | 方法 | 路径 | 说明 | 权限 |
|:----|:----|:----|:----| |:----|:----|:----|:----|
| GET | `/api/captcha` | 获取图形验证码 | 无需登录 | | GET | `/api/v1/captcha` | 获取图形验证码 | 无需登录 |
| POST | `/api/login` | 用户登录 | 无需登录 | | POST | `/api/v1/login` | 用户登录 | 无需登录 |
| GET | `/api/v1/logout` | 退出登录 | 需登录 | | GET | `/api/v1/logout` | 退出登录 | 需登录 |
## 用户管理 ## 用户管理
@@ -104,14 +104,14 @@ def get_tree(data, parent_id):
## 前端使用 ## 前端使用
分类选择通常使用 el-tree 或 el-cascader 组件: 分类选择通常使用 a-tree-select 或 a-cascader 组件:
```vue ```vue
<!-- 树形选择 --> <!-- 树形选择 -->
<el-tree-select <a-tree-select
v-model="form.category_id" v-model:value="form.category_id"
:data="categoryTree" :tree-data="categoryTree"
:props="{ label: 'name', value: 'id' }" :field-names="{ label: 'name', value: 'id' }"
/> />
``` ```
@@ -124,6 +124,6 @@ def get_tree(data, parent_id):
2. 通用设计:适用于文章分类、产品分类等场景 2. 通用设计:适用于文章分类、产品分类等场景
3. 分类编码:category_code 字段支持程序化标识 3. 分类编码:category_code 字段支持程序化标识
4. 排序控制:sort 字段控制分类显示顺序 4. 排序控制:sort 字段控制分类显示顺序
5. 前端集成:el-tree-select 或 el-cascader 组件 5. 前端集成:a-tree-select 或 a-cascader 组件
6. 继承 BaseService:声明 page_like_fields/page_eq_fields 即可获得分页能力 6. 继承 BaseService:声明 page_like_fields/page_eq_fields 即可获得分页能力
``` ```
@@ -170,11 +170,11 @@ async def delete_role(role_id):
角色管理页面通常包含菜单树组件,用于勾选分配权限: 角色管理页面通常包含菜单树组件,用于勾选分配权限:
```vue ```vue
<el-tree <a-tree
ref="menuTree" ref="menuTree"
:data="menuTreeData" :tree-data="menuTreeData"
show-checkbox checkable
node-key="id" :field-names="{ title: 'name', key: 'id' }"
:default-checked-keys="checkedMenuIds" :default-checked-keys="checkedMenuIds"
/> />
``` ```
@@ -39,7 +39,7 @@
### 请求 ### 请求
``` ```
GET /api/captcha GET /api/v1/captcha
``` ```
### 响应 ### 响应
@@ -89,7 +89,7 @@ async def captcha(request: Request):
### 请求 ### 请求
``` ```
POST /api/login POST /api/v1/login
Content-Type: application/json Content-Type: application/json
{ {
@@ -140,7 +140,7 @@ PUT /api/v1/config/item/update
| 用途 | 枚举值(下拉选项) | 系统参数(配置值) | | 用途 | 枚举值(下拉选项) | 系统参数(配置值) |
| 值类型 | 简单值(字符串) | 可存储较长的配置内容 | | 值类型 | 简单值(字符串) | 可存储较长的配置内容 |
| 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 | | 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 |
| 前端用法 | el-select 选项 | 配置表单 | | 前端用法 | a-select 选项 | 配置表单 |
## 总结 ## 总结
@@ -88,20 +88,41 @@ class TrainingService(BaseService[Training]):
效果:列表/详情返回数据中自动添加 `trainingMethod` 和 `trainingMethodName` 字段。 效果:列表/详情返回数据中自动添加 `trainingMethod` 和 `trainingMethodName` 字段。
### 方式 2:手动获取字典值 ### 方式 2:通过 dict_util 获取(推荐后端使用)
```python ```python
from modules.dictionary.dict_item.repository import dict_item_repo from utils.dict_util import get_dict_map, get_dict_label, get_dict_items
# 获取字典项列表 # 获取 {value: label} 映射
items = dict_item_repo.get_all(dict_code='training_method', status=1) options = get_dict_map('training_method')
# 结果:{'1': '线上', '2': '线下', '3': '混合'}
# 转为 {value: label} 字典 # 单值翻译
options = {item.value: item.label for item in items} label = get_dict_label('training_method', '1')
# 结果:'线上'
# 获取字典项列表(含排序信息)
items = get_dict_items('training_method')
# 结果:[{'label': '线上', 'value': '1', 'sort': 1}, ...]
```
### 方式 3:通过 Repository 直接查询
```python
from modules.dictionary.dict.repository import dict_repo
from modules.dictionary.dict_item.repository import dictitem_repo
# 先查字典类型获取 dict_id
dict_obj = dict_repo.get_one(code='training_method')
# 再查字典项
items = dictitem_repo.filter_by(dict_id=dict_obj.id).all()
# 转为 {value: name} 字典
options = {item.value: item.name for item in items}
# 结果:{'1': '线上', '2': '线下', '3': '混合'} # 结果:{'1': '线上', '2': '线下', '3': '混合'}
``` ```
### 方式 3:通过参数模块获取(适合系统配置) ### 方式 4:通过参数模块获取(适合系统配置)
```python ```python
from modules.param.service import param_service from modules.param.service import param_service
@@ -116,14 +137,15 @@ timeout = param_service.get_value_by_code('REQUEST_TIMEOUT', '30')
```vue ```vue
<template> <template>
<el-select v-model="form.trainingMethod" placeholder="请选择培训方式"> <a-select v-model:value="form.trainingMethod" placeholder="请选择培训方式">
<el-option <a-select-option
v-for="item in dictOptions" v-for="item in dictOptions"
:key="item.value" :key="item.value"
:label="item.label"
:value="item.value" :value="item.value"
/> >
</el-select> {{ item.label }}
</a-select-option>
</a-select>
</template> </template>
<script setup> <script setup>
@@ -141,22 +163,22 @@ onMounted(async () => {
### 方式 2:表格列显示字典文本 ### 方式 2:表格列显示字典文本
在 `columns.ts` 中使用 `render` 函数: 在 `columns.ts` 中使用 `customRender` 函数:
```typescript ```typescript
import { h } from 'vue'; import { h } from 'vue';
import { ElTag } from 'element-plus'; import { Tag } from 'ant-design-vue';
export const columns = [ export const columns = [
{ {
label: '培训方式', title: '培训方式',
prop: 'trainingMethod', dataIndex: 'trainingMethod',
render(record) { customRender: ({ record }) => {
const map = { 1: '线上', 2: '线下', 3: '混合' }; const map = { 1: '线上', 2: '线下', 3: '混合' };
const typeMap = { 1: 'success', 2: 'warning', 3: 'info' }; const colorMap = { 1: 'success', 2: 'warning', 3: 'default' };
return h(ElTag, { type: typeMap[record.row.trainingMethod] }, { return h(Tag, { color: colorMap[record.trainingMethod] }, () =>
default: () => map[record.row.trainingMethod] || '-' map[record.trainingMethod] || '-'
}); );
}, },
}, },
]; ];
@@ -174,7 +196,7 @@ export const schemas: FormSchema[] = [
label: '培训方式', label: '培训方式',
componentProps: { componentProps: {
placeholder: '请选择培训方式', placeholder: '请选择培训方式',
clearable: true, allowClear: true,
options: [ options: [
{ label: '线上', value: '1' }, { label: '线上', value: '1' },
{ label: '线下', value: '2' }, { label: '线下', value: '2' },
@@ -94,18 +94,25 @@ class DictItem(base_model, base_db):
## 使用示例 ## 使用示例
### 后端获取字典 ### 后端获取字典(推荐使用 dict_util)
```python ```python
# 通过字典编码获取字典项列表 from utils.dict_util import get_dict_items, get_dict_map, get_dict_label
from modules.dictionary.dict_item import service as dict_item_service
# 获取状态字典 # 获取字典项列表(自动缓存,TTL 60秒兜底)
status_list = await dict_item_service.get_by_dict_code("sys_status") items = get_dict_items("sys_status")
# 返回: [{"label": "正常", "value": "1"}, {"label": "停用", "value": "2"}] # 返回: [{"label": "正常", "value": "1", "sort": 0}, {"label": "停用", "value": "2", "sort": 1}]
# 获取 {value: label} 映射
status_map = get_dict_map("sys_status")
# 返回: {"1": "正常", "2": "停用"}
# 单值翻译(value 自动转字符串匹配)
label = get_dict_label("sys_status", 1, default="未知")
# 返回: "正常"
``` ```
### dict_util 缓存工具 ### dict_util 缓存工具详解
项目提供 `dict_util` 工具(`src/utils/dict_util.py`),内置进程内 TTL 缓存。由于字典数据几乎不变,且读写均落在同步 service 路径(无法使用请求级异步 Redis),因此采用进程内缓存而非 Redis 缓存: 项目提供 `dict_util` 工具(`src/utils/dict_util.py`),内置进程内 TTL 缓存。由于字典数据几乎不变,且读写均落在同步 service 路径(无法使用请求级异步 Redis),因此采用进程内缓存而非 Redis 缓存:
@@ -168,15 +175,16 @@ const dictStore = useDictStore()
const statusOptions = dictStore.getDict('sys_status') const statusOptions = dictStore.getDict('sys_status')
// [{label: "正常", value: "1"}, {label: "停用", value: "2"}] // [{label: "正常", value: "1"}, {label: "停用", value: "2"}]
// 在 el-select 中使用 // 在 a-select 中使用
<el-select v-model="form.status"> <a-select v-model:value="form.status">
<el-option <a-select-option
v-for="item in statusOptions" v-for="item in statusOptions"
:key="item.value" :key="item.value"
:label="item.label"
:value="item.value" :value="item.value"
/> >
</el-select> {{ item.label }}
</a-select-option>
</a-select>
``` ```
## 常用字典示例 ## 常用字典示例
@@ -195,7 +203,7 @@ const statusOptions = dictStore.getDict('sys_status')
``` ```
1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理 1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理
2. 进程内缓存:dict_util 内置 TTL 缓存(60秒兜底),变更时主动失效 2. 进程内缓存:dict_util 内置 TTL 缓存(60秒兜底),变更时主动失效
3. 前端集成:useDictStore 统一管理,el-select 直接绑定 3. 前端集成:useDictStore 统一管理,a-select 直接绑定
4. 值为字符串:字典项 value 统一为字符串,get_dict_label 自动转字符串匹配 4. 值为字符串:字典项 value 统一为字符串,get_dict_label 自动转字符串匹配
5. 避免硬编码:枚举值统一走字典,修改无需改代码 5. 避免硬编码:枚举值统一走字典,修改无需改代码
6. 三种取值:get_dict_items(列表)、get_dict_map(映射)、get_dict_label(单值翻译) 6. 三种取值:get_dict_items(列表)、get_dict_map(映射)、get_dict_label(单值翻译)
@@ -10,11 +10,12 @@
```javascript ```javascript
import { useLockFn } from '@/utils/lock' import { useLockFn } from '@/utils/lock'
import { message } from 'ant-design-vue'
// 包装提交函数,防止重复点击 // 包装提交函数,防止重复点击
const handleSubmit = useLockFn(async () => { const handleSubmit = useLockFn(async () => {
await axios.post('/api/v1/level/add', formData) await axios.post('/api/v1/level/add', formData)
ElMessage.success('添加成功') message.success('添加成功')
}) })
``` ```
@@ -37,25 +38,27 @@ function useLockFn(fn) {
### 按钮 loading ### 按钮 loading
ElementPlus 的按钮组件支持 `loading` 属性,请求期间禁用按钮: AntDesign Vue 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
```vue ```vue
<el-button <a-button
type="primary" type="primary"
:loading="submitting" :loading="submitting"
@click="handleSubmit" @click="handleSubmit"
> >
提交 提交
</el-button> </a-button>
<script setup> <script setup>
import { message } from 'ant-design-vue'
const submitting = ref(false) const submitting = ref(false)
async function handleSubmit() { async function handleSubmit() {
submitting.value = true submitting.value = true
try { try {
await axios.post('/api/v1/level/add', formData) await axios.post('/api/v1/level/add', formData)
ElMessage.success('添加成功') message.success('添加成功')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -185,10 +185,10 @@ def permission_required(permission: str):
```html ```html
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 --> <!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button> <a-button v-perm="'sys:user:add'" type="primary">新增</a-button>
<!-- 多个权限,满足其一即可 --> <!-- 多个权限,满足其一即可 -->
<el-button v-perm="['sys:user:update', 'sys:user:delete']">操作</el-button> <a-button v-perm="['sys:user:update', 'sys:user:delete']">操作</a-button>
``` ```
### 路由守卫 ### 路由守卫
@@ -136,7 +136,7 @@ class BaseForm(BaseModel):
``` ```
:::warning 前端空字符串 :::warning 前端空字符串
前端某些组件(如 el-select)在未选择时可能传空字符串 `""`,BaseForm 的 `empty_str_to_none` 验证器会自动将其转为 `None`,避免整数字段解析报错。 前端某些组件(如 a-select)在未选择时可能传空字符串 `""`,BaseForm 的 `empty_str_to_none` 验证器会自动将其转为 `None`,避免整数字段解析报错。
::: :::
## 唯一性校验 ## 唯一性校验
@@ -208,17 +208,20 @@ transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
// 业务失败(code === 1) // 业务失败(code === 1)
if (code === ResultEnum.ERROR) { if (code === ResultEnum.ERROR) {
ElMessage.error(msg); message.error(msg);
throw new Error(msg); throw new Error(msg);
} }
// 登录超时(code === 401) // 登录超时(code === 401)
if (code === ResultEnum.TIMEOUT) { if (code === ResultEnum.TIMEOUT) {
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { ... }) Modal.confirm({
.then(() => { title: '提示',
content: '登录身份已失效,请重新登录!',
onOk: () => {
storage.clear(); storage.clear();
window.location.href = '/login'; window.location.href = '/login';
}); },
});
throw new Error(msg); throw new Error(msg);
} }
}, },
@@ -291,7 +294,7 @@ const loadDataTable = async (res: any) => {
const handleSubmit = async () => { const handleSubmit = async () => {
await formRef.value?.validate(); await formRef.value?.validate();
await positionAdd(formData); await positionAdd(formData);
message('操作成功'); message.success('操作成功');
emit('success'); emit('success');
}; };
``` ```
@@ -304,20 +307,28 @@ const handleSubmit = async () => {
错误处理分两层: 错误处理分两层:
1. **业务错误**(code !== 0):拦截器统一弹出 `ElMessage.error(msg)`,并 `throw new Error` 1. **业务错误**(code !== 0):拦截器统一弹出 `message.error(msg)`,并 `throw new Error`
2. **HTTP 错误**(网络异常、超时等):`responseInterceptorsCatch` 处理 2. **HTTP 错误**(网络异常、超时等):`responseInterceptorsCatch` 处理
```typescript ```typescript
import { message, Modal } from 'ant-design-vue';
responseInterceptorsCatch: (error: any) => { responseInterceptorsCatch: (error: any) => {
const { response, code } = error || {}; const { response, code } = error || {};
// 超时 // 超时
if (code === 'ECONNABORTED') { if (code === 'ECONNABORTED') {
ElMessage.error('接口请求超时,请刷新页面重试!'); message.error('接口请求超时,请刷新页面重试!');
return; return;
} }
// 网络异常 // 网络异常
if (error.toString().includes('Network Error')) { if (error.toString().includes('Network Error')) {
ElMessageBox.confirm('请检查您的网络连接是否正常', '网络异常', { ... }); Modal.confirm({
title: '网络异常',
content: '请检查您的网络连接是否正常',
onOk: () => {
// 处理确认操作
},
});
return Promise.reject(error); return Promise.reject(error);
} }
// 其他 HTTP 状态码 // 其他 HTTP 状态码
@@ -66,8 +66,8 @@ Vite 按 mode 加载环境变量,后面的文件覆盖前面的:
|------|------|--------|------| |------|------|--------|------|
| `VITE_PORT` | `number` | `8001` | 开发服务器端口 | | `VITE_PORT` | `number` | `8001` | 开发服务器端口 |
| `VITE_PUBLIC_PATH` | `string` | `/` | 部署基础路径(子目录部署时修改) | | `VITE_PUBLIC_PATH` | `string` | `/` | 部署基础路径(子目录部署时修改) |
| `VITE_GLOB_APP_TITLE` | `string` | `FastAPI+ElementPlus版` | 应用标题(注入 HTML) | | `VITE_GLOB_APP_TITLE` | `string` | `FastAPI+AntDesign版` | 应用标题(注入 HTML) |
| `VITE_GLOB_APP_SHORT_NAME` | `string` | `FastAPI+ElementPlus版` | 应用短名(用于生成运行时配置变量名) | | `VITE_GLOB_APP_SHORT_NAME` | `string` | `FastAPI+AntDesign版` | 应用短名(用于生成运行时配置变量名) |
| `VITE_GLOB_API_URL` | `string` | 空 | API 基础路径(留空使用相对路径) | | `VITE_GLOB_API_URL` | `string` | 空 | API 基础路径(留空使用相对路径) |
| `VITE_GLOB_API_URL_PREFIX` | `string` | `/api` | API 前缀 | | `VITE_GLOB_API_URL_PREFIX` | `string` | `/api` | API 前缀 |
| `VITE_GLOB_FILE_URL` | `string` | - | 文件访问域名(拼接上传文件 URL) | | `VITE_GLOB_FILE_URL` | `string` | - | 文件访问域名(拼接上传文件 URL) |
@@ -175,7 +175,7 @@ import { defineStore } from 'pinia'; // 自动
```html ```html
<!-- 构建后的 index.html --> <!-- 构建后的 index.html -->
<title>FastAPI+ElementPlus版</title> <title>FastAPI+AntDesign版</title>
<script src="/app.config.js?v=1.0.0-1725000000000"></script> <script src="/app.config.js?v=1.0.0-1725000000000"></script>
``` ```
@@ -185,14 +185,14 @@ import { defineStore } from 'pinia'; // 自动
```javascript ```javascript
// dist/app.config.js(构建后自动生成,不可手动修改) // dist/app.config.js(构建后自动生成,不可手动修改)
window.__PRODUCTION_FASTAPI_ELEMENTPLUS___CONF__ = { window.__PRODUCTION_FASTAPI_ANTDESIGN___CONF__ = {
"VITE_GLOB_API_URL": "", "VITE_GLOB_API_URL": "",
"VITE_GLOB_API_URL_PREFIX": "/api", "VITE_GLOB_API_URL_PREFIX": "/api",
"VITE_GLOB_FILE_URL": "http://file.fastapi.elevue", "VITE_GLOB_FILE_URL": "http://file.fastapi.antdvue",
"VITE_GLOB_APP_TITLE": "FastAPI+ElementPlus版", "VITE_GLOB_APP_TITLE": "FastAPI+AntDesign版",
// ... // ...
}; };
Object.freeze(window.__PRODUCTION_FASTAPI_ELEMENTPLUS___CONF__); Object.freeze(window.__PRODUCTION_FASTAPI_ANTDESIGN___CONF__);
``` ```
:::warning 为什么需要运行时配置? :::warning 为什么需要运行时配置?
@@ -249,7 +249,7 @@ dist/
├── app.config.js # 运行时配置(VITE_GLOB_* 变量) ├── app.config.js # 运行时配置(VITE_GLOB_* 变量)
├── favicon.ico # 网站图标 ├── favicon.ico # 网站图标
└── assets/ └── assets/
├── index-[hash].js # 主包(Vue、ElementPlus、Pinia 等) ├── index-[hash].js # 主包(Vue、AntDesign Vue、Pinia 等)
├── index-[hash].css # 全局样式 ├── index-[hash].css # 全局样式
├── [name]-[hash].js # 路由懒加载 chunk(按视图分割) ├── [name]-[hash].js # 路由懒加载 chunk(按视图分割)
└── ... # 字体、图片等静态资源 └── ... # 字体、图片等静态资源
@@ -276,7 +276,7 @@ Vite 自动将 `node_modules` 中的库分割为独立 chunk:
| chunk | 内容 | | chunk | 内容 |
|-------|------| |-------|------|
| `vendor-vue.js` | vue + vue-router + pinia | | `vendor-vue.js` | vue + vue-router + pinia |
| `vendor-element.js` | element-plus | | `vendor-antd.js` | ant-design-vue |
| `vendor-echarts.js` | echarts(如果使用) | | `vendor-echarts.js` | echarts(如果使用) |
| `vendor-*.js` | 其他大型依赖 | | `vendor-*.js` | 其他大型依赖 |
@@ -286,7 +286,7 @@ Vite 自动将 `node_modules` 中的库分割为独立 chunk:
```typescript ```typescript
optimizeDeps: { optimizeDeps: {
include: ['dayjs', '@vicons/ionicons5', '@vicons/antd', '@element-plus/icons-vue'], include: ['dayjs', '@vicons/ionicons5', '@vicons/antd', '@ant-design/icons-vue'],
} }
``` ```
@@ -300,13 +300,13 @@ optimizeDeps: {
async function bootstrap() { async function bootstrap() {
const app = createApp(App); const app = createApp(App);
// 1. 全局注册 ElementPlus 图标 // 1. 全局注册 AntDesign Vue 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(AntdIconsVue)) {
app.component(key, component); app.component(key, component);
} }
// 2. 全局引入 ElementPlus(中文包) // 2. 全局引入 AntDesign Vue(中文包)
setupElement(app); setupAntd(app);
// 3. 全局注册自定义组件(BasicTable、BasicForm 等) // 3. 全局注册自定义组件(BasicTable、BasicForm 等)
setupCustomComponents(app); setupCustomComponents(app);
@@ -332,7 +332,7 @@ async function bootstrap() {
| 注册项 | 文件 | 说明 | | 注册项 | 文件 | 说明 |
|--------|------|------| |--------|------|------|
| ElementPlus | `element.ts` | 全局引入 + 中文包 | | AntDesign Vue | `antd.ts` | 全局引入 + 中文包 |
| 自定义组件 | `customComponents.ts` | BasicTable、BasicForm、PageWrapper 等 | | 自定义组件 | `customComponents.ts` | BasicTable、BasicForm、PageWrapper 等 |
| 自定义指令 | `directives.ts` | `v-perm`、`v-perms`、`v-scrollBar` | | 自定义指令 | `directives.ts` | `v-perm`、`v-perms`、`v-scrollBar` |
@@ -497,7 +497,7 @@ pnpm report
**优化手段**: **优化手段**:
1. 路由懒加载(已默认启用) 1. 路由懒加载(已默认启用)
2. 按需引入 ElementPlus 组件(当前为全量引入) 2. 按需引入 AntDesign Vue 组件(当前为全量引入)
3. 大型依赖(echarts、wangeditor)按需引入 3. 大型依赖(echarts、wangeditor)按需引入
4. 开启 gzip 压缩(`VITE_BUILD_COMPRESS=gzip`) 4. 开启 gzip 压缩(`VITE_BUILD_COMPRESS=gzip`)
@@ -1,7 +1,7 @@
# 公共组件库 # 公共组件库
:::tip 说明 :::tip 说明
项目提供丰富的公共组件,位于 `ui/src/components/` 目录,基于 ElementPlus 二次封装,覆盖表格、表单、弹窗、上传、富文本编辑等常见场景。组件通过 `src/plugins/` 中的 `setupCustomComponents` 全局注册,任何页面直接使用,无需单独 import。 项目提供丰富的公共组件,位于 `ui/src/components/` 目录,基于 AntDesign Vue 二次封装,覆盖表格、表单、弹窗、上传、富文本编辑等常见场景。组件通过 `src/plugins/` 中的 `setupCustomComponents` 全局注册,任何页面直接使用,无需单独 import。
::: :::
## 组件总览 ## 组件总览
@@ -17,7 +17,7 @@
| CropperImage | `components/Cropper/` | 图片裁剪(圆形/矩形、实时预览) | | CropperImage | `components/Cropper/` | 图片裁剪(圆形/矩形、实时预览) |
| QrCode | `components/Qrcode/` | 二维码生成(canvas/img、内嵌 logo) | | QrCode | `components/Qrcode/` | 二维码生成(canvas/img、内嵌 logo) |
| ChinaArea | `components/ChinaArea/` | 省市区级联(懒加载、二/三级联动) | | ChinaArea | `components/ChinaArea/` | 省市区级联(懒加载、二/三级联动) |
| Icon | `components/icon/` | 图标(支持 ElementPlus 图标 + SVG 图标) | | Icon | `components/icon/` | 图标(支持 AntDesign 图标 + SVG 图标) |
| BasicSelect | `components/Select/` | 增强下拉(远程请求、本地缓存) | | BasicSelect | `components/Select/` | 增强下拉(远程请求、本地缓存) |
| CountTo | `components/CountTo/` | 数字动画(前缀/后缀、千分位、缓动曲线) | | CountTo | `components/CountTo/` | 数字动画(前缀/后缀、千分位、缓动曲线) |
| Password | `components/Password/` | 密码强度(强度指示器、重复确认、复杂度校验) | | Password | `components/Password/` | 密码强度(强度指示器、重复确认、复杂度校验) |
@@ -38,7 +38,7 @@
## BasicTable 表格 ## BasicTable 表格
表格组件是使用频率最高的组件,基于 `el-table` + `el-pagination` 封装,详见 [BasicTable 表格](/zh/volume4/frontend/table) 章节。 表格组件是使用频率最高的组件,基于 `a-table` 封装,详见 [BasicTable 表格](/zh/volume4/frontend/table) 章节。
### Props ### Props
@@ -129,18 +129,23 @@
<template> <template>
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef"> <BasicTable :columns="columns" :request="loadDataTable" ref="tableRef">
<template #tableTitle> <template #tableTitle>
<el-button v-perm="['sys:level:add']" type="primary" @click="handleAdd"> <a-space>
<el-icon><Plus /></el-icon>新增 <a-button v-perm="['sys:level:add']" type="primary" @click="handleAdd">
</el-button> <template #icon><PlusOutlined /></template>
新增
</a-button>
</a-space>
</template> </template>
</BasicTable> </BasicTable>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue';
const columns = [ const columns = [
{ label: '名称', prop: 'name', width: 200 }, { title: '名称', dataIndex: 'name', width: 200 },
{ label: '状态', prop: 'status', width: 100 }, { title: '状态', dataIndex: 'status', width: 100 },
{ label: '创建时间', prop: 'createTime', width: 180 }, { title: '创建时间', dataIndex: 'createTime', width: 180 },
]; ];
const actionColumn = { const actionColumn = {
@@ -287,8 +292,8 @@ const [registerForm] = useForm({
{ {
field: 'dateRange', field: 'dateRange',
label: '日期范围', label: '日期范围',
component: 'DatePicker', component: 'RangePicker',
componentProps: { type: 'daterange' }, componentProps: { valueFormat: 'YYYY-MM-DD' },
}, },
], ],
}); });
@@ -299,7 +304,7 @@ const [registerForm] = useForm({
## BasicModal 弹窗 ## BasicModal 弹窗
弹窗组件基于 `el-dialog` 封装,支持拖拽、自定义按钮,详见 [Modal 弹窗](/zh/volume4/frontend/modal) 章节。 弹窗组件基于 `a-modal` 封装,支持拖拽、自定义按钮,详见 [Modal 弹窗](/zh/volume4/frontend/modal) 章节。
### Props ### Props
@@ -356,7 +361,7 @@ function handleEdit(record) {
## BasicUpload 上传 ## BasicUpload 上传
文件/图片上传组件,基于 `el-upload` 封装,支持卡片/列表模式、数量限制、图片预览。 文件/图片上传组件,基于 `a-upload` 封装,支持卡片/列表模式、数量限制、图片预览。
### Props ### Props
@@ -698,10 +703,10 @@ async function getDeptOptions() {
<BasicTable :columns="columns" :request="loadData" /> <BasicTable :columns="columns" :request="loadData" />
<template #leftFooter> <template #leftFooter>
<el-button @click="handleCancel">取消</el-button> <a-button @click="handleCancel">取消</a-button>
</template> </template>
<template #rightFooter> <template #rightFooter>
<el-button type="primary" @click="handleSubmit">提交</el-button> <a-button type="primary" @click="handleSubmit">提交</a-button>
</template> </template>
</PageWrapper> </PageWrapper>
</template> </template>
@@ -724,7 +729,7 @@ async function getDeptOptions() {
```vue ```vue
<template> <template>
<Authority :value="['sys:user:add']"> <Authority :value="['sys:user:add']">
<el-button type="primary">新增用户</el-button> <a-button type="primary">新增用户</a-button>
</Authority> </Authority>
</template> </template>
``` ```
@@ -732,7 +737,7 @@ async function getDeptOptions() {
:::tip v-perm 指令 :::tip v-perm 指令
除了 `Authority` 组件,项目还提供 `v-perm` 指令,功能相同但更简洁: 除了 `Authority` 组件,项目还提供 `v-perm` 指令,功能相同但更简洁:
```vue ```vue
<el-button v-perm="['sys:user:add']">新增用户</el-button> <a-button v-perm="['sys:user:add']">新增用户</a-button>
``` ```
::: :::
@@ -740,7 +745,7 @@ async function getDeptOptions() {
## Icon 图标 ## Icon 图标
统一图标组件,支持 ElementPlus 图标和 SVG 图标。 统一图标组件,支持 AntDesign 图标和 SVG 图标。
### Props ### Props
@@ -754,8 +759,8 @@ async function getDeptOptions() {
```vue ```vue
<template> <template>
<!-- ElementPlus 图标 --> <!-- AntDesign 图标 -->
<Icon name="el-icon-Plus" size="18px" /> <Icon name="ant-design:plus-outlined" size="18px" />
<!-- SVG 图标 --> <!-- SVG 图标 -->
<Icon name="svg-icon-user" /> <Icon name="svg-icon-user" />
</template> </template>
@@ -836,4 +841,4 @@ setupCustomComponents(app); // 注册所有自定义组件
## 总结 ## 总结
公共组件库基于 ElementPlus 二次封装,核心三件套 BasicTable(表格)、BasicForm(表单)、BasicModal(弹窗)覆盖列表页标准场景。辅以 Upload、Editor、Cropper 等业务组件,以及 Authority、v-perm 权限体系。通过全局注册,任何页面直接使用,无需单独 import。优先使用公共组件,避免重复开发。 公共组件库基于 AntDesign Vue 二次封装,核心三件套 BasicTable(表格)、BasicForm(表单)、BasicModal(弹窗)覆盖列表页标准场景。辅以 Upload、Editor、Cropper 等业务组件,以及 Authority、v-perm 权限体系。通过全局注册,任何页面直接使用,无需单独 import。优先使用公共组件,避免重复开发。
@@ -1,7 +1,7 @@
# BasicForm 表单 # BasicForm 表单
:::tip 说明 :::tip 说明
`BasicForm` 位于 `src/components/Form/src/BasicForm.vue`,基于 ElementPlus 的 `el-form` 二次封装,通过 schemas 配置化定义表单字段,支持查询表单和业务表单两种模式。 `BasicForm` 位于 `src/components/Form/src/BasicForm.vue`,基于 AntDesign Vue 的 `a-form` 二次封装,通过 schemas 配置化定义表单字段,支持查询表单和业务表单两种模式。
::: :::
## 组件架构 ## 组件架构
@@ -24,9 +24,9 @@ src/components/Form/
列表页的搜索区域使用 `useForm` 注册表单: 列表页的搜索区域使用 `useForm` 注册表单:
```vue ```vue
<el-card :bordered="false" class="pt-3 mb-3 proCard"> <a-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" /> <BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card> </a-card>
``` ```
### querySchemas 定义 ### querySchemas 定义
@@ -51,7 +51,7 @@ export const schemas: FormSchema[] = [
label: '状态', label: '状态',
componentProps: { componentProps: {
placeholder: '请选择状态', placeholder: '请选择状态',
clearable: true, allowClear: true,
options: [ options: [
{ label: '正常', value: '1' }, { label: '正常', value: '1' },
{ label: '禁用', value: '2' }, { label: '禁用', value: '2' },
@@ -66,15 +66,19 @@ export const schemas: FormSchema[] = [
```typescript ```typescript
import { schemas } from './querySchemas'; import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index'; import { useForm } from '@/components/Form/index';
import { ColProps } from 'element-plus';
const formParams = reactive({ name: '', status: '' }); const formParams = reactive({ name: '', status: '' });
const [register, {}] = useForm({ const [register, {}] = useForm({
labelWidth: 80, rowProps: { gutter: [16, 0] },
layout: 'horizontal', colProps: {
colProps: { span: 6 } as ColProps, xs: 24,
submitOnReset: true, sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas, schemas,
}); });
@@ -93,73 +97,80 @@ function handleReset() {
## 业务表单(编辑弹窗) ## 业务表单(编辑弹窗)
编辑弹窗中直接使用 `el-form` + `el-form-item` 构建表单: 编辑弹窗中直接使用 `a-form` + `a-form-item` 构建表单:
```vue ```vue
<!-- src/views/system/position/edit.vue --> <!-- src/views/system/position/edit.vue -->
<template> <template>
<el-dialog v-model="props.visible" :title="props.positionId ? '编辑' : '新增'" width="500"> <a-modal v-model:visible="props.visible" :title="props.positionId ? '编辑' : '新增'" width="500px">
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px"> <a-form
<el-form-item class="ls-form"
ref="formRef"
:model="formData"
:label-col="{ style: { width: '85px' } }"
>
<a-form-item
label="岗位名称" label="岗位名称"
prop="name" name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }" :rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
> >
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable /> <a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</el-form-item> </a-form-item>
<el-form-item label="岗位状态" prop="status"> <a-form-item label="岗位状态" name="status">
<el-radio-group v-model="formData.status"> <a-radio-group v-model:value="formData.status">
<el-radio :value="1">正常</el-radio> <a-radio :value="1">正常</a-radio>
<el-radio :value="2">停用</el-radio> <a-radio :value="2">停用</a-radio>
</el-radio-group> </a-radio-group>
</el-form-item> </a-form-item>
<el-form-item label="排序" prop="sort"> <a-form-item label="排序" name="sort">
<el-input-number v-model="formData.sort" /> <a-input-number v-model:value="formData.sort" />
</el-form-item> </a-form-item>
</el-form> </a-form>
</el-dialog> </a-modal>
</template> </template>
``` ```
## BasicForm 内部渲染机制 ## BasicForm 内部渲染机制
`BasicForm.vue` 根据 `schema.component` 动态渲染对应的 ElementPlus 组件: `BasicForm.vue` 根据 `schema.component` 动态渲染对应的 AntDesign Vue 组件:
```vue ```vue
<!-- src/components/Form/src/BasicForm.vue 核心逻辑 --> <!-- src/components/Form/src/BasicForm.vue 核心逻辑 -->
<template v-for="schema in getSchema" :key="schema.field"> <template v-for="schema in getSchema" :key="schema.field">
<!-- Checkbox --> <!-- Checkbox -->
<template v-if="schema.component === 'Checkbox'"> <template v-if="schema.component === 'Checkbox'">
<el-checkbox-group v-model="formModel[schema.field]"> <a-checkbox-group v-model:value="formModel[schema.field]">
<el-checkbox v-for="item in schema.componentProps?.options" :value="item.value"> <a-checkbox v-for="item in schema.componentProps?.options" :value="item.value">
{{ item.label }} {{ item.label }}
</el-checkbox> </a-checkbox>
</el-checkbox-group> </a-checkbox-group>
</template> </template>
<!-- RadioGroup --> <!-- RadioGroup -->
<template v-else-if="schema.component === 'RadioGroup'"> <template v-else-if="schema.component === 'RadioGroup'">
<el-radio-group v-model="formModel[schema.field]"> <a-radio-group v-model:value="formModel[schema.field]">
<el-radio v-for="item in schema.componentProps?.options" :value="item.value"> <a-radio v-for="item in schema.componentProps?.options" :value="item.value">
{{ item.label }} {{ item.label }}
</el-radio> </a-radio>
</el-radio-group> </a-radio-group>
</template> </template>
<!-- Select --> <!-- Select -->
<template v-else-if="schema.component === 'Select'"> <template v-else-if="schema.component === 'Select'">
<el-select v-model="formModel[schema.field]"> <a-select v-model:value="formModel[schema.field]">
<el-option v-for="item in schema.componentProps?.options" :value="item.value" :label="item.label" /> <a-select-option v-for="item in schema.componentProps?.options" :value="item.value">
</el-select> {{ item.label }}
</a-select-option>
</a-select>
</template> </template>
<!-- Daterange --> <!-- RangePicker -->
<template v-else-if="schema.component === 'Daterange'"> <template v-else-if="schema.component === 'RangePicker'">
<el-date-picker v-model="formModel[schema.field]" type="daterange" value-format="YYYY-MM-DD" /> <a-range-picker v-model:value="formModel[schema.field]" valueFormat="YYYY-MM-DD" />
</template> </template>
<!-- 其他组件:动态渲染 el-{component} --> <!-- 其他组件:动态渲染 a-{component} -->
<component v-else :is="`el-${schema.component}`" v-model="formModel[schema.field]" /> <component v-else :is="`a-${schema.component}`" v-model:value="formModel[schema.field]" />
</template> </template>
``` ```
@@ -170,7 +181,7 @@ interface FormSchema {
field: string; // 字段名 field: string; // 字段名
label: string; // 标签文本 label: string; // 标签文本
component: string; // 组件类型:Input / Select / RadioGroup / Checkbox / DatePicker 等 component: string; // 组件类型:Input / Select / RadioGroup / Checkbox / DatePicker 等
componentProps?: any; // 组件属性(placeholder、options、clearable 等) componentProps?: any; // 组件属性(placeholder、options、allowClear 等)
required?: boolean; // 是否必填 required?: boolean; // 是否必填
rules?: any[]; // 自定义校验规则 rules?: any[]; // 自定义校验规则
defaultValue?: any; // 默认值 defaultValue?: any; // 默认值
@@ -237,4 +248,4 @@ onMounted(() => {
## 总结 ## 总结
BasicForm 通过 schemas 配置化定义表单字段,内部根据 `component` 动态渲染对应的 ElementPlus 组件。查询表单使用 `useForm` 注册并绑定 `@submit`/`@reset` 事件,业务表单直接使用 `el-form` + `reactive` 管理数据。支持多种组件类型、表单校验、数据绑定、条件隐藏等能力。 BasicForm 通过 schemas 配置化定义表单字段,内部根据 `component` 动态渲染对应的 AntDesign Vue 组件。查询表单使用 `useForm` 注册并绑定 `@submit`/`@reset` 事件,业务表单直接使用 `a-form` + `reactive` 管理数据。支持多种组件类型、表单校验、数据绑定、条件隐藏等能力。
@@ -116,11 +116,12 @@ export function useLockFn(fn: (...args: any[]) => Promise<any>) {
```typescript ```typescript
// src/views/system/position/edit.vue // src/views/system/position/edit.vue
import { useLockFn } from '@/utils/useLockFn'; import { useLockFn } from '@/utils/useLockFn';
import { message } from 'ant-design-vue';
const handleSubmit = async () => { const handleSubmit = async () => {
await formRef.value?.validate(); await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData); props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功'); message.success('操作成功');
emit('update:visible', false); emit('update:visible', false);
emit('success'); emit('success');
}; };
@@ -130,7 +131,7 @@ const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
``` ```
```vue ```vue
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button> <a-button :loading="subLoading" type="primary" @click="submit">确定</a-button>
``` ```
## useAsync ## useAsync
@@ -1,22 +1,22 @@
# 国际化 # 国际化
:::tip 说明 :::tip 说明
当前项目仅支持中文界面,ElementPlus 使用中文语言包(`zh-cn`),所有界面文本直接硬编码在组件中。如需扩展多语言支持,可基于 vue-i18n 实现。 当前项目仅支持中文界面,AntDesign Vue 使用中文语言包(`zh-cn`),所有界面文本直接硬编码在组件中。如需扩展多语言支持,可基于 vue-i18n 实现。
::: :::
## 当前状态 ## 当前状态
项目当前为中文单语言版本: 项目当前为中文单语言版本:
- ElementPlus 全局配置中文语言包:`app.use(ElementPlus, { locale: zhCn })` - AntDesign Vue 全局配置中文语言包:`app.use(Antd, { locale: zhCn })`
- 所有界面文本直接硬编码在组件中:`<el-button>添加岗位</el-button>` - 所有界面文本直接硬编码在组件中:`<a-button>添加岗位</a-button>`
- 后端返回的 `msg` 字段为中文:`"操作成功"`、`"岗位名称不能重复"` - 后端返回的 `msg` 字段为中文:`"操作成功"`、`"岗位名称不能重复"`
```vue ```vue
<template> <template>
<el-button type="primary">添加岗位</el-button> <a-button type="primary">添加岗位</a-button>
<el-table-column label="岗位名称" prop="name" /> <a-table-column title="岗位名称" data-index="name" />
<el-table-column label="创建时间" prop="createTime" /> <a-table-column title="创建时间" data-index="createTime" />
</template> </template>
``` ```
@@ -65,8 +65,8 @@ export default i18n;
```vue ```vue
<template> <template>
<el-button type="primary">{{ $t('common.add') }}</el-button> <a-button type="primary">{{ $t('common.add') }}</a-button>
<el-table-column :label="$t('position.name')" prop="name" /> <a-table-column :title="$t('position.name')" data-index="name" />
</template> </template>
<script setup> <script setup>
@@ -75,13 +75,13 @@ import { useI18n } from 'vue-i18n';
</script> </script>
``` ```
### ElementPlus 国际化 ### AntDesign Vue 国际化
```typescript ```typescript
import zhCn from 'element-plus/es/locale/lang/zh-cn'; import zhCN from 'ant-design-vue/es/locale/zh_CN';
import en from 'element-plus/es/locale/lang/en'; import enUS from 'ant-design-vue/es/locale/en_US';
app.use(ElementPlus, { locale: zhCn }); app.use(Antd, { locale: zhCN });
``` ```
:::tip 温馨提示 :::tip 温馨提示
@@ -90,4 +90,4 @@ app.use(ElementPlus, { locale: zhCn });
## 总结 ## 总结
当前项目为中文单语言版本,ElementPlus 使用中文语言包。如需扩展多语言支持,可基于 vue-i18n 实现前端国际化,配合 ElementPlus 内置语言包和后端消息 code 实现全链路多语言。 当前项目为中文单语言版本,AntDesign Vue 使用中文语言包。如需扩展多语言支持,可基于 vue-i18n 实现前端国际化,配合 AntDesign Vue 内置语言包和后端消息 code 实现全链路多语言。
@@ -1,7 +1,7 @@
# Modal 弹窗 # Modal 弹窗
:::tip 说明 :::tip 说明
项目提供 `BasicModal` 组件(`src/components/Modal/src/BasicModal.vue`)和原生 `el-dialog` 两种弹窗用法。编辑弹窗通过 `defineAsyncComponent` 懒加载,减少首屏渲染负担。 项目提供 `BasicModal` 组件(`src/components/Modal/src/BasicModal.vue`)和原生 `a-modal` 两种弹窗用法。编辑弹窗通过 `defineAsyncComponent` 懒加载,减少首屏渲染负担。
::: :::
## 组件架构 ## 组件架构
@@ -9,7 +9,7 @@
``` ```
src/components/Modal/ src/components/Modal/
├── src/ ├── src/
│ ├── BasicModal.vue # 主组件(基于 el-dialog) │ ├── BasicModal.vue # 主组件(基于 a-modal)
│ ├── props.ts # 属性定义 │ ├── props.ts # 属性定义
│ ├── type.ts # 类型定义(ModalProps、ModalMethods) │ ├── type.ts # 类型定义(ModalProps、ModalMethods)
│ └── hooks/ │ └── hooks/
@@ -19,7 +19,7 @@ src/components/Modal/
## BasicModal 组件 ## BasicModal 组件
`BasicModal` 基于 `el-dialog` 封装,提供全屏切换、自定义头部、提交按钮等能力: `BasicModal` 基于 `a-modal` 封装,提供全屏切换、自定义头部、提交按钮等能力:
```vue ```vue
<BasicModal :title="'编辑'" :width="600" @register="registerModal" @ok="handleSubmit"> <BasicModal :title="'编辑'" :width="600" @register="registerModal" @ok="handleSubmit">
@@ -36,7 +36,7 @@ src/components/Modal/
## 实际用法(position 模块) ## 实际用法(position 模块)
以岗位管理为例,实际使用原生 `el-dialog` + `defineAsyncComponent` 懒加载: 以岗位管理为例,实际使用原生 `a-modal` + `defineAsyncComponent` 懒加载:
### 父组件(index.vue) ### 父组件(index.vue)
@@ -57,8 +57,8 @@ const handleAdd = async () => {
}; };
// 编辑 // 编辑
const handleEdit = async (record: Recordable) => { const handleEdit = async (id: number) => {
positionId.value = record.row.id; positionId.value = id;
await nextTick(); await nextTick();
editVisible.value = true; editVisible.value = true;
}; };
@@ -81,41 +81,47 @@ const handleEdit = async (record: Recordable) => {
```vue ```vue
<!-- src/views/system/position/edit.vue --> <!-- src/views/system/position/edit.vue -->
<template> <template>
<el-dialog <a-modal
v-model="props.visible" v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'" :title="props.positionId ? '编辑' : '新增'"
width="500" width="500px"
:close-on-click-modal="false" @cancel="dialogClose"
:before-close="dialogClose"
> >
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px"> <a-form
<el-form-item class="ls-form"
ref="formRef"
:model="formData"
:label-col="{ style: { width: '85px' } }"
>
<a-form-item
label="岗位名称" label="岗位名称"
prop="name" name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }" :rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
> >
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable /> <a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</el-form-item> </a-form-item>
<el-form-item label="岗位状态" prop="status"> <a-form-item label="岗位状态" name="status">
<el-radio-group v-model="formData.status"> <a-radio-group v-model:value="formData.status">
<el-radio :value="1">正常</el-radio> <a-radio :value="1">正常</a-radio>
<el-radio :value="2">停用</el-radio> <a-radio :value="2">停用</a-radio>
</el-radio-group> </a-radio-group>
</el-form-item> </a-form-item>
<el-form-item label="排序" prop="sort"> <a-form-item label="排序" name="sort">
<el-input-number v-model="formData.sort" /> <a-input-number v-model:value="formData.sort" />
</el-form-item> </a-form-item>
</el-form> </a-form>
<template #footer> <template #footer>
<el-button @click="dialogClose">取消</el-button> <a-button @click="dialogClose">取消</a-button>
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button> <a-button :loading="subLoading" type="primary" @click="submit">确定</a-button>
</template> </template>
</el-dialog> </a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position'; import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { useLockFn } from '@/utils/useLockFn'; import { useLockFn } from '@/utils/useLockFn';
import { message } from 'ant-design-vue';
const emit = defineEmits(['success', 'update:visible']); const emit = defineEmits(['success', 'update:visible']);
const formRef = shallowRef<FormInstance>(); const formRef = shallowRef<FormInstance>();
@@ -131,7 +137,7 @@ const props = defineProps({
const handleSubmit = async () => { const handleSubmit = async () => {
await formRef.value?.validate(); await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData); props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功'); message.success('操作成功');
emit('update:visible', false); emit('update:visible', false);
emit('success'); emit('success');
}; };
@@ -161,7 +167,7 @@ onMounted(() => {
## 弹窗数据流 ## 弹窗数据流
``` ```
父组件点击编辑 → positionId = record.row.id → editVisible = true 父组件点击编辑 → positionId = id → editVisible = true
↓ ↓
edit.vue onMounted → positionId != 0 → setFormData() → 获取详情填充表单 edit.vue onMounted → positionId != 0 → setFormData() → 获取详情填充表单
↓ ↓
@@ -172,25 +178,25 @@ emit('success') → 父组件 reloadTable() → 表格刷新
## 确认弹窗 ## 确认弹窗
删除等危险操作使用 `confirm` 封装函数(来自 `src/utils/auth.ts`): 删除等危险操作使用 `Modal.confirm` 方法(来自 `ant-design-vue`):
```typescript ```typescript
import { message, confirm } from '@/utils/auth'; import { Modal, message } from 'ant-design-vue';
async function handleDelete(record: Recordable) { async function handleDelete(id?: number) {
let ids = []; Modal.confirm({
if (!record) { title: '提示',
ids = selectionData.value.map(({ id }) => id); content: '确定要删除?',
} onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
await confirm('确定要删除?'); message.success('删除成功');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids); reloadTable();
message('删除成功'); },
reloadTable(); });
} }
``` ```
`confirm` 内部调用 `ElMessageBox.confirm`,返回 Promise,用户取消时抛出异常中断后续逻辑。 `Modal.confirm` 返回 Promise,用户取消时不会执行 `onOk` 回调。
## v-model:visible 双向绑定 ## v-model:visible 双向绑定
@@ -213,4 +219,4 @@ const dialogClose = () => { emit('update:visible', false); };
## 总结 ## 总结
编辑弹窗使用 `el-dialog` + `defineAsyncComponent` 懒加载,通过 `v-model:visible` 双向绑定显隐状态,`useLockFn` 防重复提交。父组件通过 `positionId` prop 传递编辑行 ID,子组件在 `onMounted` 中判断并加载详情数据。操作成功后 `emit('success')` 通知父组件刷新表格。 编辑弹窗使用 `a-modal` + `defineAsyncComponent` 懒加载,通过 `v-model:visible` 双向绑定显隐状态,`useLockFn` 防重复提交。父组件通过 `positionId` prop 传递编辑行 ID,子组件在 `onMounted` 中判断并加载详情数据。操作成功后 `emit('success')` 通知父组件刷新表格。
@@ -90,15 +90,15 @@ export function usePermission() {
### v-perm(满足任一权限) ### v-perm(满足任一权限)
```vue ```vue
<el-button v-perm="['sys:position:add']">添加岗位</el-button> <a-button v-perm="['sys:position:add']">添加岗位</a-button>
<el-button v-perm="['sys:position:batchDelete']">删除</el-button> <a-button v-perm="['sys:position:batchDelete']">删除</a-button>
<el-button v-perm="['sys:position:export']">导出</el-button> <a-button v-perm="['sys:position:export']">导出</a-button>
``` ```
### v-perms(满足所有权限) ### v-perms(满足所有权限)
```vue ```vue
<el-button v-perms="['sys:user:add', 'sys:user:import']">高级操作</el-button> <a-button v-perms="['sys:user:add', 'sys:user:import']">高级操作</a-button>
``` ```
### 与 v-if 结合 ### 与 v-if 结合
@@ -113,7 +113,7 @@ const showAddBtn = computed(() => hasPermission(['sys:position:add']));
``` ```
```vue ```vue
<el-button v-if="showAddBtn">添加岗位</el-button> <a-button v-if="showAddBtn">添加岗位</a-button>
``` ```
## 实际用法(position 模块) ## 实际用法(position 模块)
@@ -125,15 +125,17 @@ const showAddBtn = computed(() => hasPermission(['sys:position:add']));
<template> <template>
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef" :actionColumn="actionColumn"> <BasicTable :columns="columns" :request="loadDataTable" ref="tableRef" :actionColumn="actionColumn">
<template #tableTitle> <template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']"> <a-space>
<template #icon><el-icon><PlusOutlined /></el-icon></template> <a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
添加岗位 <template #icon><PlusOutlined /></template>
</el-button> 添加岗位
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length" </a-button>
v-perm="['sys:position:batchDelete']"> <a-button type="primary" danger @click="handleDelete()" :disabled="!selectionData.length"
<template #icon><el-icon><Delete /></el-icon></template> v-perm="['sys:position:batchDelete']">
删除 <template #icon><DeleteOutlined /></template>
</el-button> 删除
</a-button>
</a-space>
</template> </template>
</BasicTable> </BasicTable>
</template> </template>
@@ -202,7 +204,7 @@ def add(request: Request, data: PositionForm):
```vue ```vue
<!-- 前端 --> <!-- 前端 -->
<el-button v-perm="['sys:position:add']">添加岗位</el-button> <a-button v-perm="['sys:position:add']">添加岗位</a-button>
``` ```
## 超级管理员 ## 超级管理员
@@ -1,7 +1,7 @@
# 前端环境与结构 # 前端环境与结构
:::tip 说明 :::tip 说明
前端基于 Vue3 + ElementPlus 构建,使用 pnpm 作为包管理工具,Vite 作为构建工具,TypeScript 作为开发语言。 前端基于 Vue3 + AntDesign Vue 构建,使用 pnpm 作为包管理工具,Vite 作为构建工具,TypeScript 作为开发语言。
::: :::
## 技术栈 ## 技术栈
@@ -9,7 +9,7 @@
| 技术 | 版本 | 说明 | | 技术 | 版本 | 说明 |
|------|------|------| |------|------|------|
| Vue | 3.x | 渐进式 JavaScript 框架,组合式 API | | Vue | 3.x | 渐进式 JavaScript 框架,组合式 API |
| ElementPlus | 2.x | Vue3 组件库 | | AntDesign Vue | 4.x | Vue3 组件库 |
| Vite | 3.x | 下一代前端构建工具 | | Vite | 3.x | 下一代前端构建工具 |
| Pinia | 2.x | 状态管理(Vuex 替代方案) | | Pinia | 2.x | 状态管理(Vuex 替代方案) |
| Vue Router | 4.x | 路由管理 | | Vue Router | 4.x | 路由管理 |
@@ -140,24 +140,24 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
```typescript ```typescript
import './styles/tailwindbase.css'; import './styles/tailwindbase.css';
import 'element-plus/dist/index.css'; import 'ant-design-vue/dist/reset.css';
import './styles/index.scss'; import './styles/index.scss';
import * as ElementPlusIconsVue from '@element-plus/icons-vue'; import * as AntdIconsVue from '@ant-design/icons-vue';
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import router, { setupRouter } from './router'; import router, { setupRouter } from './router';
import { setupStore } from '@/store'; import { setupStore } from '@/store';
import { setupElement, setupDirectives, setupCustomComponents } from '@/plugins'; import { setupAntd, setupDirectives, setupCustomComponents } from '@/plugins';
async function bootstrap() { async function bootstrap() {
const app = createApp(App); const app = createApp(App);
// 注册所有 ElementPlus 图标 // 注册所有 AntDesign Vue 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(AntdIconsVue)) {
app.component(key, component); app.component(key, component);
} }
setupElement(app); // 全局引入 ElementPlus 组件 setupAntd(app); // 全局引入 AntDesign Vue 组件
setupCustomComponents(app); // 注册自定义组件 setupCustomComponents(app); // 注册自定义组件
setupDirectives(app); // 注册自定义指令(v-perm 等) setupDirectives(app); // 注册自定义指令(v-perm 等)
setupStore(app); // 挂载 Pinia 状态管理 setupStore(app); // 挂载 Pinia 状态管理
@@ -170,7 +170,7 @@ async function bootstrap() {
void bootstrap(); void bootstrap();
``` ```
初始化顺序:样式加载 -> ElementPlus -> 自定义组件 -> 自定义指令 -> Pinia Store -> Router -> 挂载。 初始化顺序:样式加载 -> AntDesign Vue -> 自定义组件 -> 自定义指令 -> Pinia Store -> Router -> 挂载。
## 核心目录说明 ## 核心目录说明
@@ -180,7 +180,7 @@ API 接口定义目录,按模块组织文件。公共接口在 `api/common/`
### components/ ### components/
公共组件目录,基于 ElementPlus 二次封装,包含 `Table`(BasicTable 表格)、`Form`(BasicForm 表单)、`Modal`(BasicModal 弹窗)、`Upload`、`Editor`、`Cropper`、`Select` 等。 公共组件目录,基于 AntDesign Vue 二次封装,包含 `Table`(BasicTable 表格)、`Form`(BasicForm 表单)、`Modal`(BasicModal 弹窗)、`Upload`、`Editor`、`Cropper`、`Select` 等。
### directives/ ### directives/
@@ -212,4 +212,4 @@ Pinia 状态管理目录,核心模块:
## 总结 ## 总结
前端项目基于 Vue3 + ElementPlus + Vite + TypeScript + Pinia 构建,使用 pnpm 管理依赖,Tailwind CSS + SCSS 提供样式能力。通过 `main.ts` 的 `bootstrap()` 函数按顺序初始化所有基础设施,目录结构按功能分层组织,便于维护和扩展。 前端项目基于 Vue3 + AntDesign Vue + Vite + TypeScript + Pinia 构建,使用 pnpm 管理依赖,Tailwind CSS + SCSS 提供样式能力。通过 `main.ts` 的 `bootstrap()` 函数按顺序初始化所有基础设施,目录结构按功能分层组织,便于维护和扩展。
@@ -1,7 +1,7 @@
# 样式与主题 # 样式与主题
:::tip 说明 :::tip 说明
前端样式方案采用 Tailwind CSS + SCSS + ElementPlus 主题覆盖,支持深色模式切换。全局 SCSS 变量通过 Vite 的 `additionalData` 注入。 前端样式方案采用 Tailwind CSS + SCSS + AntDesign Vue 主题覆盖,支持深色模式切换。全局 SCSS 变量通过 Vite 的 `additionalData` 注入。
::: :::
## 样式架构 ## 样式架构
@@ -11,7 +11,7 @@ src/styles/
├── var.scss # SCSS 全局变量(通过 Vite additionalData 注入) ├── var.scss # SCSS 全局变量(通过 Vite additionalData 注入)
├── index.scss # 入口文件,引入所有样式 ├── index.scss # 入口文件,引入所有样式
├── tailwindbase.css # Tailwind CSS 基础样式 ├── tailwindbase.css # Tailwind CSS 基础样式
└── element-override.css # ElementPlus 主题覆盖(可选) └── ant-override.css # AntDesign Vue 主题覆盖(可选)
``` ```
## SCSS 全局变量 ## SCSS 全局变量
@@ -56,36 +56,33 @@ import './styles/tailwindbase.css';
<div class="w-full md:w-1/2 lg:w-1/3">响应式宽度</div> <div class="w-full md:w-1/2 lg:w-1/3">响应式宽度</div>
``` ```
## ElementPlus 主题 ## AntDesign Vue 主题
ElementPlus 通过 CSS 变量支持主题定制: AntDesign Vue 通过 CSS 变量和 ConfigProvider 支持主题定制:
```typescript ```typescript
// main.ts // main.ts
import 'element-plus/dist/index.css'; import 'ant-design-vue/dist/reset.css';
import 'element-plus/theme-chalk/display.css';
import 'element-plus/theme-chalk/dark/css-vars.css'; // 深色模式 CSS 变量
``` ```
### ElementPlus 全局引入 ### AntDesign Vue 全局引入
```typescript ```typescript
// src/plugins/setupElement.ts // src/plugins/setupAntd.ts
import ElementPlus from 'element-plus'; import Antd from 'ant-design-vue';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
export function setupElement(app) { export function setupAntd(app) {
app.use(ElementPlus, { locale: zhCn, size: 'default' }); app.use(Antd);
} }
``` ```
## 深色模式 ## 深色模式
通过 ElementPlus 的 `dark/css-vars.css` 和 Tailwind 的 `dark` 模式实现深色主题: 通过 AntDesign Vue 的 ConfigProvider 和 Tailwind 的 `dark` 模式实现深色主题:
```vue ```vue
<template> <template>
<el-switch v-model="isDark" @change="toggleDark" /> <a-switch v-model:checked="isDark" @change="toggleDark" />
</template> </template>
<script setup> <script setup>
@@ -100,9 +97,9 @@ const toggleDark = (val) => {
```css ```css
.dark { .dark {
--el-bg-color: #1d1e1f; --ant-color-bg-base: #1d1e1f;
--el-text-color-primary: #e5eaf3; --ant-color-text: #e5eaf3;
--el-border-color: #4c4d4f; --ant-color-border: #4c4d4f;
} }
``` ```
@@ -130,12 +127,12 @@ export function useDesignSetting() {
```vue ```vue
<template> <template>
<PageWrapper> <PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard"> <a-card :bordered="false" class="pt-3 mb-3 proCard">
<!-- 搜索区域 --> <!-- 搜索区域 -->
</el-card> </a-card>
<el-card :bordered="false" class="proCard"> <a-card :bordered="false" class="proCard">
<!-- 表格区域 --> <!-- 表格区域 -->
</el-card> </a-card>
</PageWrapper> </PageWrapper>
</template> </template>
``` ```
@@ -150,4 +147,4 @@ export function useDesignSetting() {
## 总结 ## 总结
样式方案采用 Tailwind CSS 提供原子化样式 + SCSS 全局变量(Vite `additionalData` 注入)+ ElementPlus 主题覆盖,支持深色模式切换。`designSetting` store 管理主题配置,`PageWrapper` 组件提供统一页面容器。 样式方案采用 Tailwind CSS 提供原子化样式 + SCSS 全局变量(Vite `additionalData` 注入)+ AntDesign Vue 主题覆盖,支持深色模式切换。`designSetting` store 管理主题配置,`PageWrapper` 组件提供统一页面容器。
@@ -1,7 +1,7 @@
# BasicTable 表格 # BasicTable 表格
:::tip 说明 :::tip 说明
`BasicTable` 位于 `src/components/Table/src/Table.vue`,基于 ElementPlus 的 `el-table` 二次封装,提供列定义、数据请求、分页、操作列、多选、全屏、密度调整、列设置等能力。 `BasicTable` 位于 `src/components/Table/src/Table.vue`,基于 AntDesign Vue 的 `a-table` 二次封装,提供列定义、数据请求、分页、操作列、多选、全屏、密度调整、列设置等能力。
::: :::
## 组件架构 ## 组件架构
@@ -38,40 +38,45 @@ src/components/Table/
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<template #tableTitle> <template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">添加岗位</el-button> <a-space>
<a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon><PlusOutlined /></template>
添加岗位
</a-button>
</a-space>
</template> </template>
</BasicTable> </BasicTable>
``` ```
## 列定义 ## 列定义
`columns` 定义表格列,支持 `render` 函数自定义渲染: `columns` 定义表格列,支持 `customRender` 函数自定义渲染:
```typescript ```typescript
// src/views/system/position/columns.ts // src/views/system/position/columns.ts
import { h } from 'vue'; import { h } from 'vue';
import { ElTag } from 'element-plus'; import { Tag } from 'ant-design-vue';
export const columns = [ export const columns = [
{ type: 'selection' }, { type: 'selection' },
{ label: 'ID', prop: 'id', fixed: 'left', width: 50 }, { title: 'ID', dataIndex: 'id', fixed: 'left', width: 50 },
{ label: '岗位名称', prop: 'name', minWidth: 100 }, { title: '岗位名称', dataIndex: 'name', minWidth: 100 },
{ {
label: '岗位状态', title: '岗位状态',
prop: 'status', dataIndex: 'status',
minWidth: 100, minWidth: 100,
render(record) { customRender: ({ record }) => {
return h( return h(
ElTag, Tag,
{ type: record.row.status == 1 ? 'success' : 'danger' }, { color: record.status == 1 ? 'success' : 'error' },
{ default: () => (record.row.status == 1 ? '正常' : '停用') }, () => (record.status == 1 ? '正常' : '停用'),
); );
}, },
}, },
{ label: '排序', prop: 'sort', minWidth: 100 }, { title: '排序', dataIndex: 'sort', minWidth: 100 },
{ label: '创建人', prop: 'createUser', minWidth: 100 }, { title: '创建人', dataIndex: 'createUser', minWidth: 100 },
{ label: '创建时间', prop: 'createTime', width: 180 }, { title: '创建时间', dataIndex: 'createTime', width: 180 },
]; ];
``` ```
@@ -79,17 +84,17 @@ export const columns = [
| 属性 | 类型 | 说明 | | 属性 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `prop` | string | 字段名 | | `dataIndex` | string | 字段名 |
| `label` | string | 列标题 | | `title` | string | 列标题 |
| `width` | number | 固定宽度 | | `width` | number | 固定宽度 |
| `minWidth` | number | 最小宽度 | | `minWidth` | number | 最小宽度 |
| `type` | string | 列类型:`selection` / `index` | | `type` | string | 列类型:`selection` / `index` |
| `fixed` | string | 固定列:`left` / `right` | | `fixed` | string | 固定列:`left` / `right` |
| `render` | function | 自定义渲染函数,接收 `{ row, column, index }` | | `customRender` | function | 自定义渲染函数,接收 `{ record, text, index }` |
| `isSlot` | boolean | 是否使用插槽渲染 | | `isSlot` | boolean | 是否使用插槽渲染 |
| `ellipsis` | boolean | 是否溢出省略 | | `ellipsis` | boolean | 是否溢出省略 |
`render` 函数中 `record.row` 是行数据,`record.$index` 是行索引。 `customRender` 函数中 `record` 是行数据,`text` 是当前单元格值,`index` 是行索引。
## 数据请求 ## 数据请求
@@ -154,16 +159,16 @@ function onSelectionChange(value) {
} }
// 批量删除 // 批量删除
async function handleDelete(record) { async function handleDelete(id?: number) {
let ids = []; Modal.confirm({
if (!record) { title: '提示',
ids = selectionData.value.map(({ id }) => id); content: '确定要删除?',
} onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
await confirm('确定要删除?'); message.success('删除成功');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids); reloadTable();
message('删除成功'); },
reloadTable(); });
} }
``` ```
@@ -182,7 +187,7 @@ function reloadTable(noRefresh = '') {
// 操作成功后刷新 // 操作成功后刷新
const handleAdd = async () => { const handleAdd = async () => {
await positionAdd(data); await positionAdd(data);
message('操作成功'); message.success('操作成功');
reloadTable(); reloadTable();
}; };
``` ```
@@ -216,10 +221,15 @@ BasicTable 内置右侧工具栏,支持:
const formParams = reactive({ name: '', status: '' }); const formParams = reactive({ name: '', status: '' });
const [register, {}] = useForm({ const [register, {}] = useForm({
labelWidth: 80, rowProps: { gutter: [16, 0] },
layout: 'horizontal', colProps: {
colProps: { span: 6 } as ColProps, xs: 24,
submitOnReset: true, sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas, // querySchemas.ts 中定义 schemas, // querySchemas.ts 中定义
}); });
@@ -258,9 +268,9 @@ const loadDataTable = async (res: any) => {
| `setLoading(loading)` | 设置 loading | | `setLoading(loading)` | 设置 loading |
:::tip 温馨提示 :::tip 温馨提示
BasicTable 内部自动处理分页参数(pageNo/pageSize),调用方只需提供请求函数。操作列的权限控制通过 `TableAction` 的 `auth` 属性实现,与后端 `@permission_required` 对应。`render` 函数使用 Vue 的 `h()` 函数创建虚拟 DOM。 BasicTable 内部自动处理分页参数(pageNo/pageSize),调用方只需提供请求函数。操作列的权限控制通过 `TableAction` 的 `auth` 属性实现,与后端 `@permission_required` 对应。`customRender` 函数使用 Vue 的 `h()` 函数创建虚拟 DOM。
::: :::
## 总结 ## 总结
BasicTable 封装了列定义(含 `render` 函数自定义渲染)、数据请求(自动分页和 loading)、操作列(`TableAction` + 权限控制)、多选、全屏、密度、列设置等表格常用能力。通过 `columns` 配置化定义列,`request` 函数自动管理数据加载,`actionColumn` 配置操作按钮。 BasicTable 封装了列定义(含 `customRender` 函数自定义渲染)、数据请求(自动分页和 loading)、操作列(`TableAction` + 权限控制)、多选、全屏、密度、列设置等表格常用能力。通过 `columns` 配置化定义列,`request` 函数自动管理数据加载,`actionColumn` 配置操作按钮。
@@ -9,6 +9,8 @@
认证与通用工具函数,位于 `src/utils/auth.ts`: 认证与通用工具函数,位于 `src/utils/auth.ts`:
```typescript ```typescript
import { Modal, message as antMessage } from 'ant-design-vue';
// 外部链接判断 // 外部链接判断
export function isExternal(path: string) { export function isExternal(path: string) {
return /^(https?:|mailto:|tel:)/.test(path); return /^(https?:|mailto:|tel:)/.test(path);
@@ -22,17 +24,22 @@ export function getNormalPath(path: string) {
} }
// 确认弹窗封装 // 确认弹窗封装
export function confirm(msg: string, type: 'warning') { export function confirm(msg: string) {
return ElMessageBox.confirm(msg, '温馨提示', { return new Promise((resolve, reject) => {
confirmButtonText: '确定', Modal.confirm({
cancelButtonText: '取消', title: '温馨提示',
type: type, content: msg,
okText: '确定',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => reject(new Error('取消')),
});
}); });
} }
// 消息提示封装 // 消息提示封装
export function message(msg: string, type = 'success') { export function message(msg: string, type = 'success') {
ElMessage[type](msg); antMessage[type](msg);
} }
// 文件流下载 // 文件流下载
@@ -233,9 +240,9 @@ export enum PageEnum {
``` ```
:::tip 温馨提示 :::tip 温馨提示
`Storage` 工具类自动将键名转为大写(如 `access-token` → `ACCESS-TOKEN`),存储时包含过期时间,读取时自动检查是否过期。`auth.ts` 中的 `confirm` 和 `message` 函数是对 ElementPlus 的简封装,在业务组件中直接调用即可。 `Storage` 工具类自动将键名转为大写(如 `access-token` → `ACCESS-TOKEN`),存储时包含过期时间,读取时自动检查是否过期。`auth.ts` 中的 `confirm` 和 `message` 函数是对 AntDesign Vue 的简封装,在业务组件中直接调用即可。
::: :::
## 总结 ## 总结
工具函数库提供了 Storage(本地存储,支持过期)、auth(认证管理、树结构操作、文件下载)、dateUtil(日期格式化)、httpEnum/pageEnum(枚举定义)等通用能力。`confirm` 和 `message` 是 ElementPlus 的简封装,`arrayToTree`/`treeToArray` 处理菜单数据转换。 工具函数库提供了 Storage(本地存储,支持过期)、auth(认证管理、树结构操作、文件下载)、dateUtil(日期格式化)、httpEnum/pageEnum(枚举定义)等通用能力。`confirm` 和 `message` 是 AntDesign Vue 的简封装,`arrayToTree`/`treeToArray` 处理菜单数据转换。
@@ -32,7 +32,7 @@ src/views/
| 文件 | 职责 | 说明 | | 文件 | 职责 | 说明 |
|------|------|------| |------|------|------|
| `index.vue` | 列表页 | 搜索表单 + 工具栏 + BasicTable + 编辑弹窗引用 | | `index.vue` | 列表页 | 搜索表单 + 工具栏 + BasicTable + 编辑弹窗引用 |
| `edit.vue` | 编辑弹窗 | el-dialog + el-form,懒加载 | | `edit.vue` | 编辑弹窗 | a-modal + a-form,懒加载 |
| `columns.ts` | 表格列定义 | 定义列 prop、label、render 函数等 | | `columns.ts` | 表格列定义 | 定义列 prop、label、render 函数等 |
| `querySchemas.ts` | 搜索表单定义 | FormSchema 数组,定义搜索字段 | | `querySchemas.ts` | 搜索表单定义 | FormSchema 数组,定义搜索字段 |
@@ -44,10 +44,10 @@ src/views/
<!-- src/views/system/position/index.vue --> <!-- src/views/system/position/index.vue -->
<template> <template>
<PageWrapper> <PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard"> <a-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" /> <BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card> </a-card>
<el-card :bordered="false" class="proCard"> <a-card :bordered="false" class="proCard">
<BasicTable <BasicTable
:columns="columns" :columns="columns"
:request="loadDataTable" :request="loadDataTable"
@@ -57,30 +57,29 @@ src/views/
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<template #tableTitle> <template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']"> <a-space>
<template #icon> <a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<el-icon class="el-input__icon"> <template #icon>
<PlusOutlined /> <PlusOutlined />
</el-icon> </template>
</template> 添加岗位
添加岗位 </a-button>
</el-button> <a-button
<el-button type="primary"
type="danger" danger
@click="handleDelete()" @click="handleDelete()"
:disabled="!selectionData.length" :disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']" v-perm="['sys:position:batchDelete']"
> >
<template #icon> <template #icon>
<el-icon class="el-input__icon"> <DeleteOutlined />
<Delete /> </template>
</el-icon> 删除
</template> </a-button>
删除 </a-space>
</el-button>
</template> </template>
</BasicTable> </BasicTable>
</el-card> </a-card>
<editDialog <editDialog
v-if="editVisible" v-if="editVisible"
@@ -93,14 +92,14 @@ src/views/
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue'; import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
import { ColProps } from 'element-plus'; import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { schemas } from './querySchemas'; import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index'; import { useForm } from '@/components/Form/index';
import { TableAction } from '@/components/Table'; import { TableAction } from '@/components/Table';
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position'; import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
import { columns } from './columns'; import { columns } from './columns';
import { PlusOutlined } from '@vicons/antd'; import { Modal, message } from 'ant-design-vue';
import { message, confirm } from '@/utils/auth';
const editDialog = defineAsyncComponent(() => import('./edit.vue')); const editDialog = defineAsyncComponent(() => import('./edit.vue'));
const positionId = ref(0); const positionId = ref(0);
const editVisible = ref(false); const editVisible = ref(false);
@@ -167,10 +166,15 @@ src/views/
* 注册 * 注册
*/ */
const [register, {}] = useForm({ const [register, {}] = useForm({
labelWidth: 80, rowProps: { gutter: [16, 0] },
layout: 'horizontal', colProps: {
colProps: { span: 6 } as ColProps, xs: 24,
submitOnReset: true, sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas, schemas,
}); });
@@ -205,27 +209,28 @@ src/views/
/** /**
* 执行编辑 * 执行编辑
* @param record 参数 * @param id 参数
*/ */
const handleEdit = async (record: Recordable) => { const handleEdit = async (id: number) => {
positionId.value = record.row.id; positionId.value = id;
await nextTick(); await nextTick();
editVisible.value = true; editVisible.value = true;
}; };
/** /**
* 执行删除 * 执行删除
* @param record 参数 * @param id 参数
*/ */
async function handleDelete(record: Recordable) { async function handleDelete(id?: number) {
let ids = []; Modal.confirm({
if (!record) { title: '提示',
ids = selectionData.value.map(({ id }) => id); content: '确定要删除?',
} onOk: async () => {
await confirm('确定要删除?'); id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids); message.success('删除成功');
message('删除成功'); reloadTable();
reloadTable(); },
});
} }
/** /**
@@ -245,44 +250,48 @@ src/views/
```vue ```vue
<!-- src/views/system/position/edit.vue --> <!-- src/views/system/position/edit.vue -->
<template> <template>
<el-dialog <a-modal
v-model="props.visible" v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'" :title="props.positionId ? '编辑' : '新增'"
width="500" width="500px"
:close-on-click-modal="false" @cancel="dialogClose"
:before-close="dialogClose"
> >
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px"> <a-form
<el-form-item class="ls-form"
ref="formRef"
:model="formData"
:label-col="{ style: { width: '85px' } }"
>
<a-form-item
label="岗位名称" label="岗位名称"
prop="name" name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }" :rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
> >
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable /> <a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</el-form-item> </a-form-item>
<el-form-item label="岗位状态" prop="status"> <a-form-item label="岗位状态" name="status">
<el-radio-group v-model="formData.status" name="status"> <a-radio-group v-model:value="formData.status" name="status">
<el-radio :value="1">正常</el-radio> <a-radio :value="1">正常</a-radio>
<el-radio :value="2">停用</el-radio> <a-radio :value="2">停用</a-radio>
</el-radio-group> </a-radio-group>
</el-form-item> </a-form-item>
<el-form-item label="排序" prop="sort"> <a-form-item label="排序" name="sort">
<el-input-number v-model="formData.sort" /> <a-input-number v-model:value="formData.sort" />
</el-form-item> </a-form-item>
</el-form> </a-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="dialogClose">取消</el-button> <a-button @click="dialogClose">取消</a-button>
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button> <a-button :loading="subLoading" type="primary" @click="submit"> 确定 </a-button>
</span> </span>
</template> </template>
</el-dialog> </a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { FormInstance } from 'element-plus'; import type { FormInstance } from 'ant-design-vue';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position'; import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { onMounted, reactive, shallowRef } from 'vue'; import { onMounted, reactive, shallowRef } from 'vue';
import { message } from '@/utils/auth'; import { message } from 'ant-design-vue';
import { useLockFn } from '@/utils/useLockFn'; import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']); const emit = defineEmits(['success', 'update:visible']);
@@ -320,7 +329,7 @@ src/views/
const handleSubmit = async () => { const handleSubmit = async () => {
await formRef.value?.validate(); await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData); props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功'); message.success('操作成功');
emit('update:visible', false); emit('update:visible', false);
emit('success'); emit('success');
}; };
@@ -356,8 +365,6 @@ src/views/
} }
}); });
</script> </script>
</script>
``` ```
## 数据流 ## 数据流
@@ -375,52 +382,50 @@ src/views/
```typescript ```typescript
// src/views/system/position/columns.ts // src/views/system/position/columns.ts
import { h } from 'vue'; import { h } from 'vue';
import { ElTag } from 'element-plus'; import { Tag } from 'ant-design-vue';
export const columns = [ export const columns = [
{ {
type: 'selection', type: 'selection',
}, },
{ {
label: 'ID', title: 'ID',
prop: 'id', dataIndex: 'id',
fixed: 'left', fixed: 'left',
width: 50, width: 50,
}, },
{ {
label: '岗位名称', title: '岗位名称',
prop: 'name', dataIndex: 'name',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '岗位状态', title: '岗位状态',
prop: 'status', dataIndex: 'status',
minWidth: 100, minWidth: 100,
render(record) { customRender: ({ record }) => {
return h( return h(
ElTag, Tag,
{ {
type: record.row.status == 1 ? 'success' : 'danger', color: record.status == 1 ? 'success' : 'error',
},
{
default: () => (record.row.status == 1 ? '正常' : '停用'),
}, },
() => (record.status == 1 ? '正常' : '停用'),
); );
}, },
}, },
{ {
label: '排序', title: '排序',
prop: 'sort', dataIndex: 'sort',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '创建人', title: '创建人',
prop: 'createUser', dataIndex: 'createUser',
minWidth: 100, minWidth: 100,
}, },
{ {
label: '创建时间', title: '创建时间',
prop: 'createTime', dataIndex: 'createTime',
width: 180, width: 180,
}, },
]; ];
@@ -446,7 +451,7 @@ export const schemas: FormSchema[] = [
label: '状态', label: '状态',
componentProps: { componentProps: {
placeholder: '请选择状态', placeholder: '请选择状态',
clearable: true, allowClear: true,
options: [ options: [
{ {
label: '正常', label: '正常',
@@ -463,7 +468,7 @@ export const schemas: FormSchema[] = [
``` ```
:::tip 温馨提示 :::tip 温馨提示
`columns.ts` 和 `querySchemas.ts` 独立为配置文件,便于维护和复用。`columns` 中的 `render` 函数使用 Vue 的 `h()` 创建虚拟 DOM,可实现任意复杂的自定义渲染。`querySchemas` 中的 `options` 硬编码或通过字典 API 动态获取。 `columns.ts` 和 `querySchemas.ts` 独立为配置文件,便于维护和复用。`columns` 中的 `customRender` 函数使用 Vue 的 `h()` 创建虚拟 DOM,可实现任意复杂的自定义渲染。`querySchemas` 中的 `options` 硬编码或通过字典 API 动态获取。
::: :::
## 总结 ## 总结
@@ -88,6 +88,8 @@ return R.failed("参数错误")
```typescript ```typescript
// src/utils/http/axios/index.ts // src/utils/http/axios/index.ts
import { message, Modal } from 'ant-design-vue';
transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => { transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
const { code, data, msg } = res.data; const { code, data, msg } = res.data;
@@ -98,19 +100,21 @@ transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
// 业务失败(code === 1):弹出错误提示 // 业务失败(code === 1):弹出错误提示
if (code === ResultEnum.ERROR) { if (code === ResultEnum.ERROR) {
ElMessage.error(msg); message.error(msg);
throw new Error(msg); throw new Error(msg);
} }
// 登录超时(code === 401):弹出确认框,跳转登录页 // 登录超时(code === 401):弹出确认框,跳转登录页
if (code === ResultEnum.TIMEOUT) { if (code === ResultEnum.TIMEOUT) {
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { Modal.confirm({
confirmButtonText: '确定', title: '提示',
showCancelButton: false, content: '登录身份已失效,请重新登录!',
type: 'warning', okText: '确定',
}).then(() => { cancelText: '取消',
storage.clear(); onOk: () => {
window.location.href = '/login'; storage.clear();
window.location.href = '/login';
},
}); });
throw new Error(msg); throw new Error(msg);
} }
@@ -144,22 +148,24 @@ const result = await getPositionList(params);
| code | 说明 | 前端行为 | | code | 说明 | 前端行为 |
|------|------|----------| |------|------|----------|
| 0 | 成功 | 返回 `data` | | 0 | 成功 | 返回 `data` |
| 1 | 业务失败 | `ElMessage.error(msg)` + `throw` | | 1 | 业务失败 | `message.error(msg)` + `throw` |
| 401 | 未登录或 Token 过期 | 弹出确认框 → 跳转登录页 | | 401 | 未登录或 Token 过期 | 弹出确认框 → 跳转登录页 |
HTTP 状态码始终为 200,业务成功/失败通过 `code` 字段区分。 HTTP 状态码始终为 200,业务成功/失败通过 `code` 字段区分。
## 前端错误展示 ## 前端错误展示
业务错误通过 `ElMessage.error` 弹出提示,无需组件手动处理: 业务错误通过 `message.error` 弹出提示,无需组件手动处理:
```typescript ```typescript
import { message } from 'ant-design-vue';
// 组件中直接调用,错误由拦截器处理 // 组件中直接调用,错误由拦截器处理
try { try {
await positionAdd(formData); await positionAdd(formData);
message('操作成功'); message.success('操作成功');
} catch (e) { } catch (e) {
// 拦截器已弹出 ElMessage.error,此处可选处理 // 拦截器已弹出 message.error,此处可选处理
} }
``` ```
@@ -145,25 +145,25 @@ class ParamService(BaseService[Param]):
```typescript ```typescript
// src/views/system/param/columns.ts // src/views/system/param/columns.ts
{ {
label: '参数类型', title: '参数类型',
prop: 'type', dataIndex: 'type',
width: 100, width: 100,
// 方式一:直接使用后端返回的 typeText 字段 // 方式一:直接使用后端返回的 typeText 字段
render(record) { customRender: ({ record }) => {
return h(ElTag, return h(Tag,
{ type: record.row.type == 0 ? 'primary' : 'warning' }, { color: record.type == 0 ? 'processing' : 'warning' },
{ default: () => record.row.typeText }); () => record.typeText);
}, },
}, },
{ {
label: '状态', title: '状态',
prop: 'status', dataIndex: 'status',
width: 100, width: 100,
// 方式二:直接使用后端返回的 statusText 字段 // 方式二:直接使用后端返回的 statusText 字段
render(record) { customRender: ({ record }) => {
return h(ElTag, return h(Tag,
{ type: record.row.status == 1 ? 'success' : 'danger' }, { color: record.status == 1 ? 'success' : 'error' },
{ default: () => record.row.statusText }); () => record.statusText);
}, },
}, },
``` ```
@@ -205,7 +205,7 @@ export const schemas: FormSchema[] = [
label: '参数类型', label: '参数类型',
componentProps: { componentProps: {
placeholder: '请选择参数类型', placeholder: '请选择参数类型',
clearable: true, allowClear: true,
options: [ options: [
{ label: '系统', value: 0 }, { label: '系统', value: 0 },
{ label: '业务', value: 1 }, { label: '业务', value: 1 },
@@ -218,7 +218,7 @@ export const schemas: FormSchema[] = [
label: '状态', label: '状态',
componentProps: { componentProps: {
placeholder: '请选择状态', placeholder: '请选择状态',
clearable: true, allowClear: true,
options: [ options: [
{ label: '正常', value: 1 }, { label: '正常', value: 1 },
{ label: '禁用', value: 2 }, { label: '禁用', value: 2 },
@@ -309,8 +309,8 @@ const options = [
// 业务字段为数字 // 业务字段为数字
formData.type = 0; formData.type = 0;
// el-option 绑定时需要转换 // a-select-option 绑定时需要转换
<el-option :value="Number(item.value)" /> <a-select-option :value="Number(item.value)" />
``` ```
搜索表单中的 `options` 的 `value` 为字符串 `'0'`/`'1'`,后端 `page_eq_fields` 中的 `type` 字段期望数字。前端 API 传参时自动转换。 搜索表单中的 `options` 的 `value` 为字符串 `'0'`/`'1'`,后端 `page_eq_fields` 中的 `type` 字段期望数字。前端 API 传参时自动转换。
@@ -353,7 +353,7 @@ biz_ —— 业务类字典
``` ```
1. 后台「字典管理」中新增字典类型和字典项 1. 后台「字典管理」中新增字典类型和字典项
2. 后端 service 设置 serialize_maps(字段名 → 字典编码) 2. 后端 service 设置 serialize_maps(字段名 → 字典编码)
3. 前端 columns.ts 使用 render 函数渲染状态标签(直接用 xxxText 字段) 3. 前端 columns.ts 使用 customRender 函数渲染状态标签(直接用 xxxText 字段)
4. 前端 querySchemas.ts 中配置搜索选项(硬编码或 API 获取) 4. 前端 querySchemas.ts 中配置搜索选项(硬编码或 API 获取)
5. 表单提交时 value 为字符串,后端存储时注意类型处理 5. 表单提交时 value 为字符串,后端存储时注意类型处理
``` ```
@@ -362,4 +362,4 @@ biz_ —— 业务类字典
## 总结 ## 总结
字典数据联调通过后端字典 API + `serialize_maps` 映射显示名 + 前端 `render` 函数渲染标签实现。`serialize_maps` 的 value 为数据字典编码,BaseService 自动查询字典并以 `xxxText` 格式输出。字典值统一为字符串,前端使用时注意类型转换。简单场景硬编码选项,动态场景通过 API 获取。 字典数据联调通过后端字典 API + `serialize_maps` 映射显示名 + 前端 `customRender` 函数渲染标签实现。`serialize_maps` 的 value 为数据字典编码,BaseService 自动查询字典并以 `xxxText` 格式输出。字典值统一为字符串,前端使用时注意类型转换。简单场景硬编码选项,动态场景通过 API 获取。
@@ -51,13 +51,17 @@ def add(request: Request, data: PositionForm):
```vue ```vue
<!-- src/views/system/position/index.vue --> <!-- src/views/system/position/index.vue -->
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']"> <a-space>
添加岗位 <a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
</el-button> <template #icon><PlusOutlined /></template>
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length" 添加岗位
v-perm="['sys:position:batchDelete']"> </a-button>
删除 <a-button type="primary" danger @click="handleDelete()" :disabled="!selectionData.length"
</el-button> v-perm="['sys:position:batchDelete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
``` ```
### TableAction auth 属性 ### TableAction auth 属性
@@ -27,7 +27,7 @@
|:----|:----|:----| |:----|:----|:----|
| `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) | | `UPLOAD_ALLOWED_EXTS` | `.jpg,.jpeg,.png,.gif,...,.zip` | 允许上传的文件扩展名(逗号分隔,带点号) |
| `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) | | `UPLOAD_MAX_SIZE_MB` | `10` | 上传文件大小上限(MB) |
| `FASTAPI_FILE_URL` | `http://file.fastapi.elevue` | 文件访问域名 | | `FASTAPI_FILE_URL` | `http://file.fastapi.antdvue` | 文件访问域名 |
| `FASTAPI_UPLOAD_DIR` | `uploads` | 上传文件存储目录 | | `FASTAPI_UPLOAD_DIR` | `uploads` | 上传文件存储目录 |
## 上传接口 ## 上传接口
@@ -173,19 +173,20 @@ Service 层的 `BaseService._enrich_file_urls()` 会自动为文件字段补此
```vue ```vue
<template> <template>
<el-upload <a-upload
:action="uploadUrl" :action="uploadUrl"
:headers="headers" :headers="headers"
:on-success="handleSuccess" @change="handleChange"
:before-upload="beforeUpload" :before-upload="beforeUpload"
> >
<el-button type="primary">上传文件</el-button> <a-button type="primary">上传文件</a-button>
</el-upload> </a-upload>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { useUserStore } from '@/store/modules/user'; import { useUserStore } from '@/store/modules/user';
import { message } from 'ant-design-vue';
const uploadUrl = '/api/v1/upload/uploadFile'; const uploadUrl = '/api/v1/upload/uploadFile';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -196,15 +197,18 @@ const headers = {
function beforeUpload(file) { function beforeUpload(file) {
const isLt10M = file.size / 1024 / 1024 < 10; const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) { if (!isLt10M) {
ElMessage.error('文件大小不能超过 10MB'); message.error('文件大小不能超过 10MB');
} }
return isLt10M; return isLt10M;
} }
function handleSuccess(response) { function handleChange(info) {
if (response.code === 0) { if (info.file.status === 'done') {
// 保存 fileUrl 到业务字段 const response = info.file.response;
form.value.cover = response.data.fileUrl; if (response.code === 0) {
// 保存 fileUrl 到业务字段
form.value.cover = response.data.fileUrl;
}
} }
} }
</script> </script>
@@ -214,12 +218,13 @@ function handleSuccess(response) {
```vue ```vue
<template> <template>
<el-image <a-image
v-if="form.cover" v-if="form.cover"
:src="getFileUrl(form.cover)" :src="getFileUrl(form.cover)"
:preview-src-list="[getFileUrl(form.cover)]" :preview="{ src: getFileUrl(form.cover) }"
:width="100"
:height="100"
fit="cover" fit="cover"
style="width: 100px; height: 100px"
/> />
</template> </template>
@@ -187,12 +187,12 @@ pip install -r requirements.txt
# 干跑:核对源库表和行数 # 干跑:核对源库表和行数
python scripts/migrate_db.py --dry-run \ python scripts/migrate_db.py --dry-run \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-user=root --src-pass=root --src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root --src-db=djangoadmin.fastapi.antdvue
# 或使用 Make 命令 # 或使用 Make 命令
make db-migrate-dry-run ARGS="\ make db-migrate-dry-run ARGS="\
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-user=root --src-pass=root --src-db=djangoadmin.fastapi.elevue" --src-user=root --src-pass=root --src-db=djangoadmin.fastapi.antdvue"
``` ```
### MySQL → PostgreSQL ### MySQL → PostgreSQL
@@ -200,9 +200,9 @@ make db-migrate-dry-run ARGS="\
```bash ```bash
python scripts/migrate_db.py \ python scripts/migrate_db.py \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \ --src-db=djangoadmin.fastapi.antdvue --src-user=root --src-pass=root \
--dst-driver=postgresql --dst-host=localhost --dst-port=5432 \ --dst-driver=postgresql --dst-host=localhost --dst-port=5432 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=postgres --dst-pass=你的密码 \ --dst-db=djangoadmin.fastapi.antdvue --dst-user=postgres --dst-pass=你的密码 \
--drop-target-first --drop-target-first
``` ```
@@ -210,12 +210,12 @@ python scripts/migrate_db.py \
``` ```
============================================================== ==============================================================
[迁移开始] 源: mysql:djangoadmin.fastapi.elevue → 目标: postgresql:djangoadmin.fastapi.elevue [迁移开始] 源: mysql:djangoadmin.fastapi.antdvue → 目标: postgresql:djangoadmin.fastapi.antdvue
[迁移开始] 待迁移 23 张表 [迁移开始] 待迁移 23 张表
============================================================== ==============================================================
[·] [1/5] 建目标库... [·] [1/5] 建目标库...
· 正在连接 postgres 维护库... · 正在连接 postgres 维护库...
· 正在创建目标库 "djangoadmin.fastapi.elevue"(UTF8)... · 正在创建目标库 "djangoadmin.fastapi.antdvue"(UTF8)...
[✓] [1/5] 建目标库 完成(耗时 947ms) [✓] [1/5] 建目标库 完成(耗时 947ms)
[·] [2/5] 建表结构(23 张表 create_all)... [·] [2/5] 建表结构(23 张表 create_all)...
[✓] [2/5] 建表结构 完成(耗时 1.1s),23 张表 [✓] [2/5] 建表结构 完成(耗时 1.1s),23 张表
@@ -248,9 +248,9 @@ python scripts/migrate_db.py \
```bash ```bash
python scripts/migrate_db.py \ python scripts/migrate_db.py \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \ --src-db=djangoadmin.fastapi.antdvue --src-user=root --src-pass=root \
--dst-driver=mssql --dst-host=localhost --dst-port=1433 \ --dst-driver=mssql --dst-host=localhost --dst-port=1433 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=sa --dst-pass=你的密码 \ --dst-db=djangoadmin.fastapi.antdvue --dst-user=sa --dst-pass=你的密码 \
--drop-target-first --drop-target-first
``` ```
@@ -262,13 +262,13 @@ python scripts/migrate_db.py \
``` ```
============================================================== ==============================================================
[迁移开始] 源: mysql → 目标: mssql:djangoadmin.fastapi.elevue [迁移开始] 源: mysql → 目标: mssql:djangoadmin.fastapi.antdvue
[迁移开始] 待迁移 23 张表 [迁移开始] 待迁移 23 张表
============================================================== ==============================================================
[·] [1/5] 建目标库... [·] [1/5] 建目标库...
· 正在连接 SQL Server(master 维护库)... · 正在连接 SQL Server(master 维护库)...
· 正在删除旧目标库 [djangoadmin.fastapi.elevue](SINGLE_USER + DROP)... · 正在删除旧目标库 [djangoadmin.fastapi.antdvue](SINGLE_USER + DROP)...
· 正在创建目标库 [djangoadmin.fastapi.elevue]... · 正在创建目标库 [djangoadmin.fastapi.antdvue]...
[✓] [1/5] 建目标库 完成(耗时 304ms) [✓] [1/5] 建目标库 完成(耗时 304ms)
[·] [2/5] 建表结构(23 张表 create_all)... [·] [2/5] 建表结构(23 张表 create_all)...
[✓] [2/5] 建表结构 完成(耗时 743ms),23 张表 [✓] [2/5] 建表结构 完成(耗时 743ms),23 张表
@@ -291,8 +291,8 @@ python scripts/migrate_db.py \
```bash ```bash
python scripts/migrate_db.py \ python scripts/migrate_db.py \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \ --src-db=djangoadmin.fastapi.antdvue --src-user=root --src-pass=root \
--dst-driver=sqlite --dst-db=D:/data/djangoadmin.fastapi.elevue.db \ --dst-driver=sqlite --dst-db=D:/data/djangoadmin.fastapi.antdvue.db \
--drop-target-first --drop-target-first
``` ```
@@ -306,7 +306,7 @@ python scripts/migrate_db.py \
``` ```
============================================================== ==============================================================
[迁移开始] 源: mysql → 目标: sqlite:D:/data/djangoadmin.fastapi.elevue.db [迁移开始] 源: mysql → 目标: sqlite:D:/data/djangoadmin.fastapi.antdvue.db
[迁移开始] 待迁移 23 张表 [迁移开始] 待迁移 23 张表
============================================================== ==============================================================
[·] [1/5] 建目标库... [·] [1/5] 建目标库...
@@ -334,7 +334,7 @@ python scripts/migrate_db.py \
```bash ```bash
python scripts/migrate_db.py \ python scripts/migrate_db.py \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \ --src-db=djangoadmin.fastapi.antdvue --src-user=root --src-pass=root \
--dst-driver=oracle --dst-host=localhost --dst-port=1521 \ --dst-driver=oracle --dst-host=localhost --dst-port=1521 \
--dst-db=ORCLPDB1 --dst-user=fastapi --dst-pass=你的密码 \ --dst-db=ORCLPDB1 --dst-user=fastapi --dst-pass=你的密码 \
--drop-target-first --drop-target-first
@@ -29,7 +29,7 @@
**数据库**: **数据库**:
- [ ] 安装 MySQL 8.0+ - [ ] 安装 MySQL 8.0+
- [ ] 创建数据库:`CREATE DATABASE djangoadmin.fastapi.elevue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;` - [ ] 创建数据库:`CREATE DATABASE djangoadmin.fastapi.antdvue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;`
- [ ] 创建数据库用户并授权 - [ ] 创建数据库用户并授权
- [ ] 修改 `.env` 中的数据库配置 - [ ] 修改 `.env` 中的数据库配置
- [ ] 初始化数据库:`python scripts/init_db.py` - [ ] 初始化数据库:`python scripts/init_db.py`
@@ -138,7 +138,7 @@ server {
### 数据库迁移 ### 数据库迁移
- [ ] 备份数据库:`mysqldump -uroot -p djangoadmin.fastapi.elevue > backup.sql` - [ ] 备份数据库:`mysqldump -uroot -p djangoadmin.fastapi.antdvue > backup.sql`
- [ ] 执行迁移:`alembic upgrade head` - [ ] 执行迁移:`alembic upgrade head`
- [ ] 验证迁移结果 - [ ] 验证迁移结果
@@ -198,7 +198,7 @@ cp -r /opt/backup/dist /opt/project/ui/dist
```bash ```bash
# 从备份恢复 # 从备份恢复
mysql -uroot -p djangoadmin.fastapi.elevue < backup.sql mysql -uroot -p djangoadmin.fastapi.antdvue < backup.sql
``` ```
## 总结 ## 总结
@@ -58,9 +58,9 @@ Dockerfile 将 `requirements.txt` 和源码分两步 COPY,利用 Docker 层缓
version: '3.7' version: '3.7'
services: services:
fastapi_elevue: # 服务名称 fastapi_antdvue: # 服务名称
image: fastapi_elevue # 镜像名称 image: fastapi_antdvue # 镜像名称
container_name: fastapi_elevue # 容器名称 container_name: fastapi_antdvue # 容器名称
build: # 构建配置 build: # 构建配置
context: ./ # 构建上下文路径 context: ./ # 构建上下文路径
dockerfile: Dockerfile # Dockerfile 文件名 dockerfile: Dockerfile # Dockerfile 文件名
@@ -116,26 +116,26 @@ docker-compose build
docker-compose ps docker-compose ps
# 查看日志 # 查看日志
docker-compose logs -f fastapi_elevue docker-compose logs -f fastapi_antdvue
# 停止服务 # 停止服务
docker-compose down docker-compose down
# 重启服务 # 重启服务
docker-compose restart fastapi_elevue docker-compose restart fastapi_antdvue
# 进入容器 # 进入容器
docker exec -it fastapi_elevue bash docker exec -it fastapi_antdvue bash
``` ```
### 查看容器资源 ### 查看容器资源
```bash ```bash
# 查看容器资源占用 # 查看容器资源占用
docker stats fastapi_elevue docker stats fastapi_antdvue
# 查看容器日志大小 # 查看容器日志大小
docker inspect --format='{{.LogPath}}' fastapi_elevue docker inspect --format='{{.LogPath}}' fastapi_antdvue
``` ```
## 环境变量注入 ## 环境变量注入
@@ -186,7 +186,7 @@ logging:
```yaml ```yaml
services: services:
fastapi_elevue: fastapi_antdvue:
# ... 后端配置 ... # ... 后端配置 ...
nginx: nginx:
@@ -198,7 +198,7 @@ services:
- ./nginx.conf:/etc/nginx/conf.d/default.conf # Nginx 配置文件 - ./nginx.conf:/etc/nginx/conf.d/default.conf # Nginx 配置文件
- ./dist:/usr/share/nginx/html # 前端构建产物 - ./dist:/usr/share/nginx/html # 前端构建产物
depends_on: # 依赖服务 depends_on: # 依赖服务
- fastapi_elevue # 等待后端服务启动 - fastapi_antdvue # 等待后端服务启动
``` ```
## 总结 ## 总结
@@ -249,10 +249,10 @@ python -m memory_profiler src/main.py
```bash ```bash
# 1. 查看容器日志 # 1. 查看容器日志
docker-compose logs fastapi_elevue docker-compose logs fastapi_antdvue
# 2. 进入容器调试 # 2. 进入容器调试
docker run -it --rm fastapi_elevue bash docker run -it --rm fastapi_antdvue bash
# 3. 检查 .env 文件 # 3. 检查 .env 文件
cat .env cat .env
@@ -133,7 +133,7 @@ query = query.filter(self._soft_col() == 0)
``` ```
document/ # 补充文档 document/ # 补充文档
├── 数据库迁移_runbook.md # 数据库迁移操作手册 ├── 数据库迁移_runbook.md # 数据库迁移操作手册
└── djangoadmin.fastapi.elevue.sql # 数据库结构 SQL └── djangoadmin.fastapi.antdvue.sql # 数据库结构 SQL
wiki/ # VitePress 文档站 wiki/ # VitePress 文档站
├── zh/ # 中文文档 ├── zh/ # 中文文档
@@ -2,11 +2,11 @@
## 概述 ## 概述
前端基于 Vue3 + ElementPlus + 自定义组件库构建。页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套,分页由 `BasicTable` 内置处理,无需单独引入分页组件。 前端基于 Vue3 + AntDesign Vue + 自定义组件库构建。页面统一使用 `PageWrapper` + `BasicForm` + `BasicTable` + `TableAction` 四件套,分页由 `BasicTable` 内置处理,无需单独引入分页组件。
:::tip 技术栈 :::tip 技术栈
- Vue3(Composition API + `<script setup>`) - Vue3(Composition API + `<script setup>`)
- ElementPlus - AntDesign Vue 4.x
- 自定义组件(BasicTable / BasicForm / TableAction / PageWrapper) - 自定义组件(BasicTable / BasicForm / TableAction / PageWrapper)
- Pinia 状态管理 - Pinia 状态管理
- Vue Router 路由管理 - Vue Router 路由管理
@@ -56,7 +56,7 @@ ui/src/
├── utils/ # 工具函数 ├── utils/ # 工具函数
├── hooks/ # 组合式函数 ├── hooks/ # 组合式函数
├── styles/ # 全局样式 ├── styles/ # 全局样式
├── plugins/ # 插件注册(ElementPlus、自定义组件、指令) ├── plugins/ # 插件注册(AntDesign Vue、自定义组件、指令)
└── main.ts # 入口文件 └── main.ts # 入口文件
``` ```
@@ -79,12 +79,12 @@ ui/src/
<template> <template>
<PageWrapper> <PageWrapper>
<!-- 搜索表单 --> <!-- 搜索表单 -->
<el-card :bordered="false" class="pt-3 mb-3 proCard"> <a-card :bordered="false" class="pt-3 mb-3 proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" /> <BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
</el-card> </a-card>
<!-- 数据表格 --> <!-- 数据表格 -->
<el-card :bordered="false" class="proCard"> <a-card :bordered="false" class="proCard">
<BasicTable <BasicTable
:columns="columns" :columns="columns"
:request="loadDataTable" :request="loadDataTable"
@@ -94,22 +94,25 @@ ui/src/
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<template #tableTitle> <template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:param:add']"> <a-space>
<el-icon><PlusOutlined /></el-icon> <a-button type="primary" @click="handleAdd" v-perm="['sys:param:add']">
添加参数 <template #icon><PlusOutlined /></template>
</el-button> 添加参数
<el-button </a-button>
type="danger" <a-button
@click="handleDelete()" type="primary"
:disabled="!selectionData.length" danger
v-perm="['sys:param:batchDelete']" @click="handleDelete()"
> :disabled="!selectionData.length"
<el-icon><Delete /></el-icon> v-perm="['sys:param:batchDelete']"
删除 >
</el-button> <template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template> </template>
</BasicTable> </BasicTable>
</el-card> </a-card>
<!-- 编辑弹窗(懒加载) --> <!-- 编辑弹窗(懒加载) -->
<editDialog <editDialog
@@ -133,56 +136,56 @@ ui/src/
```typescript ```typescript
// views/data/param/columns.ts // views/data/param/columns.ts
import { h } from 'vue'; import { h } from 'vue';
import { ElTag } from 'element-plus'; import { Tag } from 'ant-design-vue';
export const columns = [ export const columns = [
{ type: 'selection' }, // 多选列 { type: 'selection' }, // 多选列
{ label: 'ID', prop: 'id', width: 50 }, // 固定列 { title: 'ID', dataIndex: 'id', width: 50 }, // 固定列
{ label: '参数名称', prop: 'name', minWidth: 200 }, { title: '参数名称', dataIndex: 'name', minWidth: 200 },
{ label: '参数编码', prop: 'code', minWidth: 250 }, { title: '参数编码', dataIndex: 'code', minWidth: 250 },
{ label: '参数值', prop: 'value', minWidth: 200 }, { title: '参数值', dataIndex: 'value', minWidth: 200 },
{ {
label: '参数类型', title: '参数类型',
prop: 'type', dataIndex: 'type',
minWidth: 100, minWidth: 100,
render(record) { customRender: ({ record }) => {
return h(ElTag, return h(Tag,
{ type: record.row.type == 0 ? 'primary' : 'warning' }, { color: record.type == 0 ? 'processing' : 'warning' },
{ default: () => (record.row.type == 0 ? '系统' : '业务') } () => (record.type == 0 ? '系统' : '业务')
); );
}, },
}, },
{ {
label: '状态', title: '状态',
prop: 'status', dataIndex: 'status',
minWidth: 100, minWidth: 100,
render(record) { customRender: ({ record }) => {
return h(ElTag, return h(Tag,
{ type: record.row.status == 1 ? 'success' : 'danger' }, { color: record.status == 1 ? 'success' : 'error' },
{ default: () => (record.row.status == 1 ? '正常' : '禁用') } () => (record.status == 1 ? '正常' : '禁用')
); );
}, },
}, },
{ label: '创建时间', prop: 'createTime', width: 180 }, { title: '创建时间', dataIndex: 'createTime', width: 180 },
]; ];
``` ```
### render 函数自定义渲染 ### customRender 函数自定义渲染
```typescript ```typescript
// 状态标签 // 状态标签
render(record) { customRender: ({ record }) => {
return h(ElTag, return h(Tag,
{ type: record.row.status == 1 ? 'success' : 'danger' }, { color: record.status == 1 ? 'success' : 'error' },
{ default: () => record.row.statusText } // 使用后端 serialize_maps 返回的 xxxText () => record.statusText // 使用后端 serialize_maps 返回的 xxxText
); );
} }
// 链接 // 链接
render(record) { customRender: ({ record }) => {
return h('a', return h('a',
{ href: record.row.url, target: '_blank', class: 'text-blue-500' }, { href: record.url, target: '_blank', class: 'text-blue-500' },
{ default: () => record.row.url } () => record.url
); );
} }
``` ```
@@ -206,7 +209,7 @@ export const schemas: FormSchema[] = [
label: '参数类型', label: '参数类型',
componentProps: { componentProps: {
placeholder: '请选择参数类型', placeholder: '请选择参数类型',
clearable: true, allowClear: true,
options: [ options: [
{ label: '系统', value: '0' }, { label: '系统', value: '0' },
{ label: '业务', value: '1' }, { label: '业务', value: '1' },
@@ -219,7 +222,7 @@ export const schemas: FormSchema[] = [
label: '状态', label: '状态',
componentProps: { componentProps: {
placeholder: '请选择状态', placeholder: '请选择状态',
clearable: true, allowClear: true,
options: [ options: [
{ label: '正常', value: '1' }, { label: '正常', value: '1' },
{ label: '禁用', value: '2' }, { label: '禁用', value: '2' },
@@ -349,8 +352,8 @@ const handleAdd = async () => {
}; };
// 编辑 // 编辑
const handleEdit = async (record: Recordable) => { const handleEdit = async (id: number) => {
paramId.value = record.row.id; paramId.value = id;
await nextTick(); await nextTick();
editVisible.value = true; editVisible.value = true;
}; };
@@ -359,16 +362,18 @@ const handleEdit = async (record: Recordable) => {
## 删除(单条 + 批量) ## 删除(单条 + 批量)
```typescript ```typescript
async function handleDelete(record: Recordable) { import { Modal, message } from 'ant-design-vue';
let ids = [];
if (!record) { async function handleDelete(id?: number) {
// 批量删除:取多选行的 ID Modal.confirm({
ids = selectionData.value.map(({ id }) => id); title: '提示',
} content: '确定要删除?',
await confirm('确定要删除?'); onOk: async () => {
record ? await paramDelete(record.row.id) : await paramBatchDelete(ids); id ? await paramDelete(id) : await paramBatchDelete(selectionData.value);
message('删除成功'); message.success('删除成功');
reloadTable(); reloadTable();
},
});
} }
// 多选变化 // 多选变化
@@ -73,6 +73,9 @@ position_service = PositionService()
| `serialize_maps` | dict | 枚举显示名映射 | `{'status': {1: '在用', 2: '停用'}}` | | `serialize_maps` | dict | 枚举显示名映射 | `{'status': {1: '在用', 2: '停用'}}` |
| `file_fields` | tuple | 文件字段 | `('cover', 'image')` | | `file_fields` | tuple | 文件字段 | `('cover', 'image')` |
| `rich_text_fields` | tuple | 富文本字段 | `('content',)` | | `rich_text_fields` | tuple | 富文本字段 | `('content',)` |
| `file_dir` | str | 文件存储子目录名 | 默认从表名去掉 `DB_PREFIX` 推导 |
| `serialize_extra` | Callable | 列表/详情共用序列化钩子,返回需合并进字典的额外字段 | — |
| `detail_serialize` | Callable | 详情专用序列化钩子,仅在 `get_detail` 时合并 | — |
:::tip 单例模式 :::tip 单例模式
每个模块在文件末尾创建 Service 实例(如 `position_service`),供 Endpoint 层使用。 每个模块在文件末尾创建 Service 实例(如 `position_service`),供 Endpoint 层使用。
+1 -1
View File
@@ -105,7 +105,7 @@ service.py → 业务逻辑层
| 层级 | 技术 | | 层级 | 技术 |
|------|------| |------|------|
| 后端 | FastAPI + SQLAlchemy + Pydantic | | 后端 | FastAPI + SQLAlchemy + Pydantic |
| 前端 | Vue3 + ElementPlus + TypeScript + Vite | | 前端 | Vue3 + AntDesign Vue + TypeScript + Vite |
| 基础设施 | MySQL / PostgreSQL / Redis / Docker | | 基础设施 | MySQL / PostgreSQL / Redis / Docker |
详细版本号和技术选型说明见 [技术选型](/zh/volume3/tech-stack)。 详细版本号和技术选型说明见 [技术选型](/zh/volume3/tech-stack)。