优化更新
This commit is contained in:
Binary file not shown.
@@ -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" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 即时生效
|
||||||
|
|||||||
@@ -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 温馨提示
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|||||||
@@ -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))。
|
||||||
|
|||||||
@@ -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='添加成功')` |
|
||||||
|
|||||||
@@ -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 层使用。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
Reference in New Issue
Block a user