优化更新

This commit is contained in:
2026-09-13 21:53:16 +08:00
parent 8548fe9ea1
commit 3f8626fb10
84 changed files with 2058 additions and 2594 deletions
@@ -105,14 +105,14 @@ def get_tree(data, parent_id):
## 前端使用
分类选择通常使用 el-tree 或 el-cascader 组件:
分类选择通常使用 a-tree-select 或 a-cascader 组件:
```vue
<!-- 树形选择 -->
<el-tree-select
v-model="form.category_id"
:data="categoryTree"
:props="{ label: 'name', value: 'id' }"
<a-tree-select
v-model:value="form.category_id"
:tree-data="categoryTree"
:fieldNames="{ children: 'children', label: 'name', value: 'id' }"
/>
```
@@ -125,6 +125,6 @@ def get_tree(data, parent_id):
2. 通用设计:适用于文章分类、产品分类等场景
3. 分类编码:category_code 字段支持程序化标识
4. 排序控制:sort 字段控制分类显示顺序
5. 前端集成:el-tree-select 或 el-cascader 组件
5. 前端集成:a-tree-select 或 a-cascader 组件
6. 继承 BaseService:声明 repository/form/model_name 即可获得完整 CRUD 能力
```
@@ -173,12 +173,13 @@ class RoleService(BaseService):
角色管理页面通常包含菜单树组件,用于勾选分配权限:
```vue
<el-tree
ref="menuTree"
:data="menuTreeData"
show-checkbox
node-key="id"
:default-checked-keys="checkedMenuIds"
<a-tree
:tree-data="menuTree"
:fieldNames="{ children: 'children', title: 'name', key: 'id' }"
:height="600"
v-model:checkedKeys="checkedKeys"
v-model:expandedKeys="expandedKeys"
checkable
/>
```
@@ -1,5 +1,5 @@
:::tip 用户管理
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。与简单 CRUD 模块不同,用户管理使用自定义 service 函数而非继承 BaseService 的类方法。
用户管理是系统的核心模块,属于复杂业务模块。包含用户 CRUD、角色关联、密码加密、行政区划、Excel 导入导出等功能。用户管理继承 `BaseService` 获得标准 CRUD 能力,并通过覆写钩子方法实现头像保存、密码加密、角色关联等定制逻辑。
:::
## 模块结构
@@ -237,7 +237,7 @@ def _enrich_user_records(records):
role_map = user_role.get_user_roles_batch(user_ids)
for data in records:
data["genderName"] = gender_map.get(str(data.get("gender")))
data["genderText"] = gender_map.get(str(data.get("gender")))
data["deptName"] = dept_map.get(data.get("deptId"))
data["levelName"] = level_map.get(data.get("levelId"))
data["positionName"] = position_map.get(data.get("positionId"))
@@ -117,15 +117,13 @@ Content-Type: application/json
"code": 0,
"msg": "登录成功",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"must_change_password": false
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
},
"ok": true
}
```
- `access_token`:JWT 令牌,后续请求需携带
- `must_change_password`:是否需要修改密码(检测到默认密码时为 `true`)
### 失败响应
@@ -134,10 +132,10 @@ Content-Type: application/json
{"code": 1, "msg": "验证码不正确", "data": null, "ok": false}
// 账号或密码错误
{"code": 1, "msg": "用户名或密码不正确", "data": null, "ok": false}
{"code": 1, "msg": "用户不存在", "data": null, "ok": false}
// 账号被锁定
{"code": 1, "msg": "登录失败次数过多,请280秒后再试", "data": null, "ok": false}
{"code": 1, "msg": "登录失败次数过多,请5分钟后再试", "data": null, "ok": false}
```
### 登录流程详解
@@ -132,7 +132,7 @@ PUT /api/v1/config/item/update
| 用途 | 枚举值(下拉选项) | 系统参数(配置值) |
| 值类型 | 简单值(字符串) | 可存储较长的配置内容 |
| 典型场景 | 状态、性别、类型 | SMTP配置、站点信息 |
| 前端用法 | el-select 选项 | 配置表单 |
| 前端用法 | a-select 选项 | 配置表单 |
## 总结
@@ -1,96 +1,118 @@
:::tip CORS跨域
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。中间件位于 `src/middleware/cors.py`。
# 跨域资源共享(CORS)
:::tip CORS 跨域
CORS(Cross-Origin Resource Sharing)跨域资源共享是前后端分离架构必须处理的问题。项目通过 `flask-cors` 扩展实现 CORS 支持,配置入口在 `src/app.py` 的 `create_app()` 中,通过 `CORS_ALLOWED_ORIGINS` 环境变量配置允许的跨域源。
:::
## 配置项
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `CORS_ALLOWED_ORIGINS` | 空(允许所有源) | 允许的跨域源(逗号分隔) |
| `TRUSTED_PROXY_COUNT` | `0` | 可信代理层数 |
| `TRUSTED_PROXY_IPS` | 空 | 可信代理 IP 白名单(逗号分隔) |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:8001` | 允许的跨域源(逗号分隔),留空则允许所有源 |
| `PROXY_NUM` | `0` | 可信代理层数。`0` = 不信任 X-Forwarded-For;部署在 Nginx 后设为 `1` |
## 实现原理
CORS 配置位于 `src/app.py` 的 `create_app()` 中:
```python
# src/app.py
from flask_cors import CORS
from config import CORS_ALLOWED_ORIGINS
# ============================================================
# 跨域资源共享配置
# ============================================================
if CORS_ALLOWED_ORIGINS:
allowed_origins = [origin.strip() for origin in CORS_ALLOWED_ORIGINS.split(',') if origin.strip()]
CORS(
app,
resources={r"/*": {"origins": allowed_origins}},
supports_credentials=True
)
else:
CORS(
app,
resources={r"/*": {"origins": "*"}},
supports_credentials=False
)
```
- 配置了 `CORS_ALLOWED_ORIGINS` 时:仅允许指定源,支持携带凭证(Cookie 等)
- 未配置时:允许所有源,但不携带凭证
## 配置示例
```bash
# .env
# 允许所有源(开发环境)
CORS_ALLOWED_ORIGINS=
# 开发环境:允许前端开发服务器
CORS_ALLOWED_ORIGINS=http://localhost:8001
# 允许指定源(生产环境)
# 生产环境:允许指定域名(多个用逗号分隔)
CORS_ALLOWED_ORIGINS=https://admin.example.com,https://www.example.com
# 信任一层反向代理(Nginx)
TRUSTED_PROXY_COUNT=1
TRUSTED_PROXY_IPS=127.0.0.1
```
:::warning 生产环境
生产环境务必配置 `CORS_ALLOWED_ORIGINS` 为具体域名,避免允许所有源带来的安全风险。
:::
## CORS 中间件注册
## 可信代理配置
Flask 使用 `flask-cors` 扩展实现 CORS 支持:
当应用部署在 Nginx 等反向代理后,需要配置代理信任以获取真实客户端 IP:
```python
# src/middleware/cors.py
from flask_cors import CORS
from config.app import CORS_ALLOWED_ORIGINS
# src/app.py
from werkzeug.middleware.proxy_fix import ProxyFix
def register_cors(app):
if CORS_ALLOWED_ORIGINS:
origins = [o.strip() for o in CORS_ALLOWED_ORIGINS.split(',') if o.strip()]
CORS(app, origins=origins, supports_credentials=True)
else:
# 未配置时允许所有源但不携带凭证
CORS(app, origins="*")
# ============================================================
# 可信代理配置(反向代理环境下获取真实客户端IP)
# ============================================================
proxy_num = int(os.getenv('PROXY_NUM', 0))
if proxy_num > 0:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)
```
## 中间件顺序
`PROXY_NUM` 说明:
CORS 中间件在 `src/app.py` 中注册为最外层:
| 值 | 说明 |
|----|------|
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
| `1` | 信任一层反向代理(如 Nginx) |
| `2` | 信任两层代理 |
:::tip 安全提示
仅当应用部署在反向代理后才设置 `PROXY_NUM>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过安全校验。
:::
## 中间件注册顺序
CORS 在 `src/app.py` 中最先注册,确保所有响应都带 CORS 头:
```python
# 最先注册→最外层:确保限流等直接返回的响应也带 CORS 头
register_cors(app)
# src/app.py - create_app() 中间件注册顺序
# 1. CORS 配置(最先注册→最外层)
# 2. ProxyFix 可信代理
# 3. register_operation_log(app) # 操作日志
# 4. register_transaction(app) # AOP 事务
# 5. register_v1(app) # 路由注册
# 6. register_extends(app) # 扩展初始化
# 7. register_middleware(app) # 认证中间件
# 8. register_error_handlers(app) # 异常处理
```
中间件执行顺序(从外到内):
```
CORS → 限流 → 上传体积限制 → 操作日志 → DB会话 → Redis → 业务逻辑
CORS → 操作日志 → AOP 事务 → 认证 → 路由 → 业务逻辑
```
## 代理头处理
当应用部署在 Nginx 等反向代理后,需要配置代理信任:
```bash
# 信任一层代理
TRUSTED_PROXY_COUNT=1
TRUSTED_PROXY_IPS=127.0.0.1
```
`TRUSTED_PROXY_COUNT` 说明:
| 值 | 说明 |
|----|------|
| `0` | 不信任 X-Forwarded-For,使用 request.remote_addr |
| `1` | 信任一层反向代理 |
| `2` | 信任两层代理 |
:::tip 安全提示
仅当应用部署在反向代理后才设置 `TRUSTED_PROXY_COUNT>=1`。如果应用被直接暴露(如 Docker bridge 映射端口),设置代理信任会导致客户端伪造 XFF 绕过限流。
:::
## Flask-CORS 配置选项
```python
from flask_cors import CORS
CORS(app,
CORS(app,
origins=["https://admin.example.com"], # 允许的源
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], # 允许的方法
allow_headers=["Content-Type", "Authorization"], # 允许的头
@@ -107,14 +129,24 @@ CORS(app,
| `supports_credentials` | 允许携带凭证 | `False` |
| `max_age` | 预检请求缓存时间 | `None` |
## 常见问题
+ 跨域请求被拒绝
```
1. 检查 .env 中 CORS_ALLOWED_ORIGINS 是否包含前端地址
2. 检查前端请求的 Origin 头是否与配置一致
3. 生产环境检查 Nginx 是否正确传递 Origin 头
```
+ 携带凭证(Cookie)失败
```
1. CORS_ALLOWED_ORIGINS 必须配置具体域名,不能用 *
2. 前端请求需设置 withCredentials: true
3. 确保 supports_credentials=True
```
## 总结
CORS 跨域配置具备以下特点:
```
1. 环境变量配置:通过 CORS_ALLOWED_ORIGINS 灵活配置允许的源
2. 开发/生产分离:开发环境允许所有源,生产环境指定域名
3. 代理头支持:TRUSTED_PROXY_COUNT 配置代理信任层数
4. 安全默认:未配置时允许所有源但不携带凭证
5. 最外层注册:确保所有响应都带 CORS 头
```
CORS 跨域配置通过 `flask-cors` 扩展在 `src/app.py` 中实现,通过 `CORS_ALLOWED_ORIGINS` 环境变量灵活配置允许的源。开发环境允许前端开发服务器(`http://localhost:8001`),生产环境应配置具体域名。反向代理场景通过 `PROXY_NUM` 配置代理信任层数,确保获取真实客户端 IP。
@@ -40,7 +40,7 @@ def add():
```python
from functools import wraps
from flask import current_app
from src.core.response import R
from src.core import response as R
def check_demo(func):
"""演示模式拦截装饰器,放在 @check_permission 之后、路由函数之前"""
@@ -116,14 +116,15 @@ timeout = get_param_value('REQUEST_TIMEOUT', '30')
```vue
<template>
<el-select v-model="form.trainingMethod" placeholder="请选择培训方式">
<el-option
<a-select v-model:value="form.trainingMethod" placeholder="请选择培训方式">
<a-select-option
v-for="item in dictOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
>
{{ item.label }}
</a-select-option>
</a-select>
</template>
<script setup>
@@ -141,17 +142,17 @@ onMounted(async () => {
### 方式 2:表格列显示字典文本
在 `columns.ts` 中使用 `render` 函数:
在 `columns.ts` 中使用 `customRender` 函数:
```typescript
import { h } from 'vue';
import { ElTag } from 'element-plus';
import { Tag } from 'ant-design-vue';
export const columns = [
{
label: '培训方式',
prop: 'trainingMethod',
render(record) {
title: '培训方式',
dataIndex: 'trainingMethod',
customRender({ record }) {
const map = { 1: '线上', 2: '线下', 3: '混合' };
const typeMap = { 1: 'success', 2: 'warning', 3: 'info' };
return h(ElTag, { type: typeMap[record.row.trainingMethod] }, {
@@ -160,15 +160,16 @@ const dictStore = useDictStore()
const statusOptions = dictStore.getDict('sys_status')
// [{label: "正常", value: "1"}, {label: "停用", value: "2"}]
// 在 el-select 中使用
<el-select v-model="form.status">
<el-option
// 在 a-select 中使用
<a-select v-model:value="form.status">
<a-select-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
>
{{ item.label }}
</a-select-option>
</a-select>
```
## 常用字典示例
@@ -187,7 +188,7 @@ const statusOptions = dictStore.getDict('sys_status')
```
1. 两级结构:字典类型(分组)+ 字典项(具体值),清晰管理
2. 进程内缓存:dict_util 内置 TTL 缓存(60秒兜底),变更时主动失效
3. 前端集成:useDictStore 统一管理,el-select 直接绑定
3. 前端集成:useDictStore 统一管理,a-select 直接绑定
4. 值为字符串:字典项 value 统一为字符串,get_dict_label 自动转字符串匹配
5. 避免硬编码:枚举值统一走字典,修改无需改代码
6. 三种取值:get_dict_items(列表)、get_dict_map(映射)、get_dict_label(单值翻译)
@@ -7,7 +7,7 @@
`src/core/response.py` 提供统一的响应生成函数:
```python
from src.core.response import R
from src.core import response as R
# 成功响应
R.ok(data={"id": 1}, msg="操作成功")
@@ -9,53 +9,58 @@
前端提供 `useLockFn` 钩子函数,实现请求级别的防重复提交:
```javascript
import { useLockFn } from '@/utils/lock'
import { useLockFn } from '@/utils/useLockFn'
// 包装提交函数,防止重复点击
const handleSubmit = useLockFn(async () => {
const { lockFn: handleSubmit } = useLockFn(async () => {
await axios.post('/api/v1/level/add', formData)
ElMessage.success('添加成功')
message.success('添加成功')
})
```
`useLockFn` 原理:
```javascript
function useLockFn(fn) {
let isLocked = false
return async (...args) => {
if (isLocked) return
isLocked = true
import { ref } from 'vue'
export function useLockFn(fn) {
const isLock = ref(false)
const lockFn = async (...args) => {
if (isLock.value) return
isLock.value = true
try {
return await fn(...args)
await fn(...args)
} finally {
isLocked = false
isLock.value = false
}
}
return { isLock, lockFn }
}
```
### 按钮 loading
ElementPlus 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
AntDesign Vue 的按钮组件支持 `loading` 属性,请求期间禁用按钮:
```vue
<el-button
<a-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
提交
</el-button>
</a-button>
<script setup>
import { message } from 'ant-design-vue';
const submitting = ref(false)
async function handleSubmit() {
submitting.value = true
try {
await axios.post('/api/v1/level/add', formData)
ElMessage.success('添加成功')
message.success('添加成功')
} finally {
submitting.value = false
}
@@ -214,7 +214,7 @@ def login(data):
return R.ok(msg="登录成功", data={
"access_token": access_token,
"must_change_password": is_default_pwd
"access_token": access_token
})
```
@@ -17,50 +17,42 @@
以下路径不记录操作日志:
```python
_EXCLUDE_PATHS = (
'/api/v1/login',
'/api/v1/logout',
'/api/v1/captcha',
'/api/v1/upload',
'/api/v1/loginlog',
'/api/v1/operationlog',
'/api/v1/index/menu',
'/api/v1/index/user',
)
# src/middleware/operation_log.py
_EXCLUDED_PATH_SEGMENTS = ("/login", "/captcha", "/logout", "/sendSms")
```
排除逻辑使用 `endswith` 匹配路径末段,而非精确匹配完整路径。
## 操作类型自动推断
中间件根据 URL 路径末段自动推断操作类型,映射表定义在 `_TYPE_MAP` 中:
中间件根据 HTTP 方法和 URL 路径自动推断操作类型:
```python
_TYPE_MAP = {
'add': 1, # 新增
'update': 2, # 修改
'delete': 3, # 删除
'batchdelete': 4, # 批量删除
'status': 5, # 状态设置
'resetpwd': 6, # 重置密码
'updatepassword': 7, # 修改密码
'import': 8, # 导入
'export': 9, # 导出
'kickout': 10, # 强退
'generate': 11, # 生成代码
'clear': 12, # 清空数据
'auth': 13, # 授权
'refresh': 14, # 缓存
'openaccount': 15, # 开账号
# src/middleware/operation_log.py
# HTTP 方法 → 日志类型
_METHOD_LOG_TYPE = {
"POST": 3, # 新增
"PUT": 4, # 修改
"DELETE": 5, # 删除
}
def _infer_type(request) -> int:
"""从 URL 路径末段推断操作类型,匹配不到返回 0(其它)"""
path = request.path.rstrip('/')
last_segment = path.split('/')[-1].lower()
# 跳过纯数字路径参数(如 /delete/123 → 取 delete)
if last_segment.isdigit():
parts = path.split('/')
last_segment = parts[-2].lower() if len(parts) >= 2 else ''
return _TYPE_MAP.get(last_segment, 0)
# URL 路径关键字 → 日志类型(优先级高于方法)
_PATH_ACTION_TYPE = {
"status": 4, # 状态变更
"import": 3, # 导入
"export": 5, # 导出
"batchDelete": 5, # 批量删除
}
def _infer_log_type(request) -> int:
"""从 HTTP 方法和 URL 路径推断操作类型"""
# 先按路径关键字匹配
for keyword, log_type in _PATH_ACTION_TYPE.items():
if keyword in request.path:
return log_type
# 再按 HTTP 方法匹配
return _METHOD_LOG_TYPE.get(request.method, 18) # 18=其它
```
| 路径末段 | 操作类型 | 类型ID |
@@ -136,7 +128,7 @@ def register_operation_log(app):
return response
# 排除特定路径
if request.path in _EXCLUDE_PATHS:
if any(seg in request.path for seg in _EXCLUDED_PATH_SEGMENTS):
return response
# 计算耗时
@@ -1,145 +0,0 @@
:::tip 请求限流
请求限流中间件基于 Redis 实现 IP 级别的滑动窗口限流,防止恶意请求和 DoS 攻击。默认关闭,可通过环境变量开启。源码位于 `src/middleware/rate_limit.py`,滑动窗口 Lua 脚本位于 `src/core/redis_client.py`。
:::
## 配置项
| 环境变量 | 默认值 | 说明 |
|---------|--------|------|
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每个 IP 最大请求数 |
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 统计窗口时长(秒) |
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis 计数 key 前缀 |
:::warning 生产环境建议
生产环境建议开启限流(`RATE_LIMIT_ENABLED=True`),根据实际流量调整 `RATE_LIMIT_LIMIT` 和 `RATE_LIMIT_WINDOW_SECONDS`。
:::
## 工作原理
```
1. 请求进入 → 检查 RATE_LIMIT_ENABLED 开关
2. 获取客户端 IP(支持 X-Forwarded-For 代理头)
3. 使用滑动窗口算法计算当前窗口请求数
4. 超过限制 → 返回 {"code":1, "msg":"请求过于频繁,请 X 秒后再试"}
5. 未超过 → 放行请求
```
## 滑动窗口算法
采用滑动窗口计数器,按当前窗口与上一窗口的计数时间加权,消除固定窗口边界处的 2x 突发问题。
### 中间件实现
```python
# src/middleware/rate_limit.py
from flask import request, g
import time
def register_rate_limit(app):
"""注册限流中间件"""
@app.before_request
def rate_limit():
"""按客户端 IP 做滑动窗口限流,超限直接返回统一 200 + code 响应"""
from flask import current_app
if not current_app.config.get('RATE_LIMIT_ENABLED'):
return
# CORS 预检请求不计数,避免浏览器预检占用限流额度
if request.method == 'OPTIONS':
return
# Redis 未就绪时降级放行
redis = getattr(g, 'redis', None)
if redis is None:
return
client_ip = get_client_ip()
now = time.time()
window_index = int(now // RATE_LIMIT_WINDOW_SECONDS)
current_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index}"
prev_key = f"{RATE_LIMIT_KEY_PREFIX}{client_ip}:{window_index - 1}"
# 原子滑动窗口计数
try:
count = sliding_window_incr(
redis, current_key, prev_key, RATE_LIMIT_WINDOW_SECONDS, window_index, now)
except Exception as e:
app.logger.warning(f"限流计数异常,降级放行: {e}")
return
# 直接返回而非抛异常
if count > RATE_LIMIT_LIMIT:
try:
ttl = redis.ttl(current_key)
except Exception:
ttl = RATE_LIMIT_WINDOW_SECONDS
from src.core.response import R
return R.failed(msg=f"请求过于频繁,请 {ttl} 秒后再试")
```
### Lua 脚本
滑动窗口的核心在 `src/core/redis_client.py` 中的 Lua 脚本:
```python
_SLIDING_WINDOW_LUA = """
local cur = redis.call('incr', KEYS[1])
if cur == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
local prev = tonumber(redis.call('get', KEYS[2]) or '0')
local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[2]) * tonumber(ARGV[1])
local f = elapsed / tonumber(ARGV[1])
if f < 0 then f = 0 elseif f > 1 then f = 1 end
local effective = prev * (1 - f) + cur
return effective
"""
def sliding_window_incr(redis, current_key, prev_key,
window_seconds, window_index, now) -> float:
"""滑动窗口限流计数:返回当前滑动窗口内的加权请求数(近似精确)"""
return redis.eval(
_SLIDING_WINDOW_LUA, 2, current_key, prev_key,
window_seconds, window_index, now)
```
算法原理:当前窗口计数 `cur` + 上一窗口计数 `prev` 按时间比例加权(`prev * (1 - f)`),窗口边界处请求数平滑过渡,消除固定窗口在边界整体重置导致的 2x 突发。
## 降级策略
```
1. Redis 未连接:直接放行,不阻塞请求
2. Redis 运行期故障:降级放行,记录警告日志
3. 限流计数异常:降级放行,避免全站不可用
```
:::tip fail-open 策略
限流采用 fail-open 策略:Redis 不可用时放行所有请求,优先保证服务可用性。这是安全与可用性的权衡。
:::
## 中间件位置
限流中间件在 `src/app.py` 中注册为最外层(CORS 内层):
```python
# 最外层:限流(被限流的请求不进入 DB 会话与操作日志)
register_rate_limit(app)
# CORS(确保限流响应也带 CORS 头)
register_cors(app)
```
## 总结
请求限流模块具备以下特点:
```
1. 滑动窗口:消除固定窗口边界处的 2x 突发问题
2. IP 维度:按客户端 IP 限流,支持代理头
3. 默认关闭:opt-in 模式,按需开启
4. 降级策略:Redis 故障时 fail-open 放行
5. 最外层拦截:限流请求不进入 DB 会话和操作日志
6. Lua 原子操作:计数和过期设置原子执行,避免并发问题
```
@@ -118,59 +118,108 @@ def add():
校验逻辑(完整实现):
```python
# src/core/permission.py
import fnmatch
import hmac
import logging
from functools import wraps
from flask import request, g
from src.core.response import R
from src.core.security import get_user_id
from typing import List, Protocol
def check_permission(permission: str):
"""节点权限鉴权装饰器,userId==1(管理员)直接放行"""
from flask import request, g, current_app
from src.core import response as R
from src.core.security import get_user_id
from config import PERMISSION_ENABLED, INTERNAL_API_KEY, INTERNAL_ALLOWED_IPS
logger = logging.getLogger(__name__)
class PermissionProvider(Protocol):
"""权限数据提供方接口:core 只认接口,不认实现。"""
def get_user_permissions(self, user_id: int) -> List[str]:
"""返回用户拥有的权限节点列表。"""
def get_provider() -> "PermissionProvider":
"""获取注入的权限提供方(应用工厂注入)。"""
return current_app.extensions.get("permission_provider")
def check_permission(permission: str, log: bool = False):
"""
权限验证装饰器
权限验证规则:
1. 定时任务等内部调用 → 跳过权限验证,直接放行
2. 超级管理员(user_id=1) → 拥有所有权限
3. 普通用户 → 通过注入的 PermissionProvider 验证权限
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 获取当前用户ID
user_id = get_user_id()
# 定时任务等内部调用:跳过权限验证
if is_internal_call():
return func(*args, **kwargs)
# 管理员免检
# 获取用户ID
user_id = getattr(g, "user_id", None)
if user_id is None:
try:
user_id = get_user_id()
except Exception:
user_id = None
# 超级管理员放行(user_id=1 为系统内置管理员)
if user_id == 1:
return func(*args, **kwargs)
# 获取用户权限列表
from src.modules.system.menu.service import get_permissions_list
permission_list = get_permissions_list(user_id)
# 普通用户权限验证
if user_id:
provider = get_provider()
if provider is None:
return R.failed(msg="权限验证失败", code=500)
# 检查权限
if permission not in permission_list:
return R.failed("权限不足")
permission_list = provider.get_user_permissions(user_id)
if permission not in permission_list:
return R.failed(msg="权限不足!", code=403)
else:
return R.failed(msg="请先登录", code=401)
return func(*args, **kwargs)
return wrapper
return decorator
```
:::warning Request 提取
装饰器通过 `flask.request` 获取当前请求对象,无需显式传递 request 参数。
:::tip 依赖注入设计
权限校验采用依赖注入设计(解决 core → modules 依赖倒置):
- core 定义 `PermissionProvider` 协议,不 import 任何业务模块
- 应用工厂启动时注入 menu 服务的实现(见 `src/app.py` 的 `_inject_dependencies`)
- `check_permission` 通过 `current_app.extensions['permission_provider']` 获取权限数据
:::
:::tip 管理员免检
用户ID为1的管理员账号(admin)自动跳过所有权限校验,拥有全部权限。
:::
### 权限缓存
### 权限数据注入
权限列表通过 Redis 缓存,提高查询性能:
权限数据通过应用工厂注入,避免 core 层直接依赖 modules 层:
```python
# 获取用户的权限节点列表(带 Redis 缓存)
def get_permissions_list(user_id: int) -> list:
# 1. 先查 Redis 缓存
cache_key = f"user:permissions:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# src/app.py - _inject_dependencies()
from src.modules.system.menu.service import get_user_permissions
# 2. 缓存未命中 → 查 DB(user → role → menu where type=1)
permission_list = _query_permissions_from_db(user_id)
class MenuPermissionProvider:
"""权限数据提供方(适配 menu 服务)。"""
def get_user_permissions(self, user_id):
return get_user_permissions(user_id)
app.extensions['permission_provider'] = MenuPermissionProvider()
```
### 权限缓存
权限列表通过 Redis 缓存,提高查询性能。缓存在角色菜单变更时主动失效。
# 3. 写入缓存
redis.set(cache_key, json.dumps(permission_list), ex=3600)
@@ -186,10 +235,10 @@ def get_permissions_list(user_id: int) -> list:
```html
<!-- 只有拥有 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>
```
### 路由守卫
@@ -98,7 +98,7 @@ def add_position(data):
### 批量操作
```python
def batch_delete_positions(id_list):
def delete_positions(id_list):
"""批量删除岗位"""
# 批量软删除(仅 flush,不 commit)
PositionRepository.batch_delete(id_list)
@@ -123,7 +123,7 @@ from wtforms import Form, IntegerField
from wtforms.validators import Optional
class BaseForm(Form):
class BaseForm(FlaskForm):
"""
表单基类
@@ -135,7 +135,7 @@ class BaseForm(Form):
```
:::warning 前端空字符串
前端某些组件(如 el-select)在未选择时可能传空字符串 `""`,`parse_request_body` 函数会自动将其转为 `None`,避免整数字段解析报错。
前端某些组件(如 a-select)在未选择时可能传空字符串 `""`,`parse_request_body` 函数会自动将其转为 `None`,避免整数字段解析报错。
:::
## 唯一性校验
@@ -208,13 +208,13 @@ transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
// 业务失败(code === 1)
if (code === ResultEnum.ERROR) {
ElMessage.error(msg);
message.error(msg);
throw new Error(msg);
}
// 登录超时(code === 401)
if (code === ResultEnum.TIMEOUT) {
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { ... })
Modal.confirm({ title: '提示', content: '登录身份已失效,请重新登录!', ... })
.then(() => {
storage.clear();
window.location.href = '/login';
@@ -291,7 +291,7 @@ const loadDataTable = async (res: any) => {
const handleSubmit = async () => {
await formRef.value?.validate();
await positionAdd(formData);
message('操作成功');
message.success('操作成功');
emit('success');
};
```
@@ -304,7 +304,7 @@ const handleSubmit = async () => {
错误处理分两层:
1. **业务错误**(code !== 0):拦截器统一弹出 `ElMessage.error(msg)`,并 `throw new Error`
1. **业务错误**(code !== 0):拦截器统一弹出 `message.error(msg)`,并 `throw new Error`
2. **HTTP 错误**(网络异常、超时等):`responseInterceptorsCatch` 处理
```typescript
@@ -312,12 +312,12 @@ responseInterceptorsCatch: (error: any) => {
const { response, code } = error || {};
// 超时
if (code === 'ECONNABORTED') {
ElMessage.error('接口请求超时,请刷新页面重试!');
message.error('接口请求超时,请刷新页面重试!');
return;
}
// 网络异常
if (error.toString().includes('Network Error')) {
ElMessageBox.confirm('请检查您的网络连接是否正常', '网络异常', { ... });
Modal.confirm({ title: '网络异常', content: '请检查您的网络连接是否正常', ... });
return Promise.reject(error);
}
// 其他 HTTP 状态码
@@ -66,8 +66,8 @@ Vite 按 mode 加载环境变量,后面的文件覆盖前面的:
|------|------|--------|------|
| `VITE_PORT` | `number` | `8001` | 开发服务器端口 |
| `VITE_PUBLIC_PATH` | `string` | `/` | 部署基础路径(子目录部署时修改) |
| `VITE_GLOB_APP_TITLE` | `string` | `Flask+ElementPlus版` | 应用标题(注入 HTML) |
| `VITE_GLOB_APP_SHORT_NAME` | `string` | `Flask+ElementPlus版` | 应用短名(用于生成运行时配置变量名) |
| `VITE_GLOB_APP_TITLE` | `string` | `Flask+AntDesign版` | 应用标题(注入 HTML) |
| `VITE_GLOB_APP_SHORT_NAME` | `string` | `Flask+AntDesign版` | 应用短名(用于生成运行时配置变量名) |
| `VITE_GLOB_API_URL` | `string` | 空 | API 基础路径(留空使用相对路径) |
| `VITE_GLOB_API_URL_PREFIX` | `string` | `/api` | API 前缀 |
| `VITE_GLOB_FILE_URL` | `string` | - | 文件访问域名(拼接上传文件 URL) |
@@ -141,10 +141,10 @@ dist/
```javascript
window['<%- appName %>'] = {
VITE_GLOB_APP_TITLE: 'Flask+ElementPlus版',
VITE_GLOB_APP_TITLE: 'Flask+AntDesign版',
VITE_GLOB_API_URL: '',
VITE_GLOB_API_URL_PREFIX: '/api',
VITE_GLOB_FILE_URL: 'http://file.flask.elevue',
VITE_GLOB_FILE_URL: 'http://file.flask.antdvue',
};
```
@@ -1,7 +1,7 @@
# 公共组件库
:::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/` | 图片裁剪(圆形/矩形、实时预览) |
| QrCode | `components/Qrcode/` | 二维码生成(canvas/img、内嵌 logo) |
| ChinaArea | `components/ChinaArea/` | 省市区级联(懒加载、二/三级联动) |
| Icon | `components/icon/` | 图标(支持 ElementPlus 图标 + SVG 图标) |
| Icon | `components/icon/` | 图标(支持 AntDesign 图标 + SVG 图标) |
| BasicSelect | `components/Select/` | 增强下拉(远程请求、本地缓存) |
| CountTo | `components/CountTo/` | 数字动画(前缀/后缀、千分位、缓动曲线) |
| Password | `components/Password/` | 密码强度(强度指示器、重复确认、复杂度校验) |
@@ -29,16 +29,14 @@
| Websocket | `components/Websocket/` | WebSocket 连接 |
| importFile | `components/importFile/` | 通用文件导入 |
| numberInput | `components/numberInput/` | 数字输入框 |
| priceInput | `components/priceInput/` | 金额输入框 |
| Region | `components/Region/` | 区域选择 |
| TableSelect | `components/TableSelect/` | 表格选择器 |
| pagination | `components/pagination/` | 分页组件 |
---
## BasicTable 表格
表格组件是使用频率最高的组件,基于 `el-table` + `el-pagination` 封装,详见 [BasicTable 表格](/zh/volume4/frontend/table) 章节。
表格组件是使用频率最高的组件,基于 `a-table` + `a-pagination` 封装,详见 [BasicTable 表格](/zh/volume4/frontend/table) 章节。
### Props
@@ -47,21 +45,20 @@
| `columns` | `BasicColumn[]` | `[]`(必填) | 列定义 |
| `request` | `Function` | `null` | 数据请求函数,自动管理分页和 loading |
| `dataSource` | `Function \| Array` | `[]` | 数据源(与 request 二选一) |
| `actionColumn` | `BasicColumn` | `null` | 操作列配置 |
| `title` | `string` | `null` | 表格标题 |
| `titleTooltip` | `string` | `null` | 标题提示 |
| `pagination` | `Object \| Boolean` | `{}` | 分页配置,`false` 隐藏分页 |
| `rowKey` | `string \| Function` | `undefined` | 行数据的唯一标识 |
| `border` | `Boolean` | `false` | 是否显示边框 |
| `striped` | `Boolean` | `false` | 斑马纹 |
| `size` | `string` | `'default'` | 表格尺寸:`small` / `default` / `large` |
| `tableHeight` | `Number \| String` | `null` | 固定表格高度 |
| `size` | `string` | `'default'` | 表格尺寸:`small` / `middle` / `default` |
| `maxHeight` | `Number` | - | 最大高度 |
| `canResize` | `Boolean` | `true` | 是否自适应窗口高度 |
| `resizeHeightOffset` | `Number` | `0` | 高度偏移量 |
| `showTableSetting` | `Boolean` | `true` | 是否显示工具栏 |
| `tableSetting` | `TableSetting` | 见下方 | 工具栏配置 |
| `isKeepRowKeys` | `Boolean` | `false` | 翻页时是否保留选中行 |
| `scroll` | `Object` | - | 滚动配置(`x` / `y`) |
| `rowSelection` | `Object` | - | 多选配置 |
| `virtualScroll` | `Boolean` | `false` | 是否启用虚拟滚动(大数据量优化) |
### tableSetting 工具栏配置
@@ -77,13 +74,13 @@
| 属性 | 类型 | 说明 |
|------|------|------|
| `label` | `string` | 列标题 |
| `prop` | `string` | 字段名 |
| `title` | `string` | 列标题 |
| `dataIndex` | `string` | 字段名 |
| `key` | `string` | 列唯一标识 |
| `width` | `number` | 列宽 |
| `align` | `string` | 对齐方式 |
| `type` | `string` | 类型:`index` / `selection` |
| `render` | `Function` | 自定义渲染函数 `(column, row, index) => VNode` |
| `isSlot` | `boolean` | 是否使用插槽 |
| `fixed` | `string` | 固定列:`left` / `right` |
| `customRender` | `Function` | 自定义渲染函数 `({ text, record, index, column }) => VNode` |
| `ellipsis` | `boolean` | 超出省略(tooltip) |
| `auth` | `string[]` | 权限编码,控制列是否显示 |
| `ifShow` | `boolean \| Function` | 业务控制是否显示 |
@@ -94,9 +91,7 @@
|------|------|------|
| `fetch-success` | `(data, result)` | 数据请求成功 |
| `fetch-error` | `(error)` | 数据请求失败 |
| `selection-change` | `(rows)` | 多选变化 |
| `columns-change` | `(columns)` | 列设置变化 |
| `checked-row-change` | `(rowKeys)` | 选中行变化 |
### Slots
@@ -104,7 +99,7 @@
|------|------|
| `tableTitle` | 表格标题左侧区域(常放添加按钮) |
| `toolbar` | 工具栏右侧区域 |
| `{prop}` | 列名对应插槽,自定义列渲染 |
| `bodyCell` | 自定义单元格渲染(操作列) |
### Expose 方法
@@ -112,15 +107,11 @@
|------|------|
| `reload(opt?)` | 刷新表格(保留分页) |
| `restReload(opt?)` | 重置到第一页并刷新 |
| `reloadTable(opt?)` | 内部刷新(清空选中行) |
| `getDataSource()` | 获取当前数据 |
| `setTableData(data)` | 设置表格数据 |
| `updateTableDataRecord(index, record)` | 更新指定行 |
| `deleteTableDataRecord(index)` | 删除指定行 |
| `updateTableData(index, record)` | 更新指定行 |
| `getColumns()` | 获取列配置 |
| `setColumns(columns)` | 设置列配置 |
| `clearSelection()` | 清空多选 |
| `setCheckedRowKeys(keys)` | 设置选中行 |
| `redoHeight()` | 重新计算高度 |
### 基础用法
@@ -129,72 +120,43 @@
<template>
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef">
<template #tableTitle>
<el-button v-perm="['sys:level:add']" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<a-space>
<a-button v-perm="['sys:level:add']" type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:level:update']">编辑</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:level:delete']">删除</a-button>
</a-space>
</template>
</template>
</BasicTable>
</template>
<script setup lang="ts">
const columns = [
{ label: '名称', prop: 'name', width: 200 },
{ label: '状态', prop: 'status', width: 100 },
{ label: '创建时间', prop: 'createTime', width: 180 },
];
import { PlusOutlined } from '@ant-design/icons-vue';
const actionColumn = {
width: 200,
actions: [
{ label: '编辑', auth: ['sys:level:update'], onClick: handleEdit },
{
label: '删除',
auth: ['sys:level:delete'],
isConfirm: true,
popConfirm: { title: '确认删除?' },
onClick: handleDelete,
},
],
};
const columns = [
{ title: '名称', dataIndex: 'name', width: 200 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createTime', width: 180 },
{ title: '操作', dataIndex: 'action', key: 'action', fixed: 'right', width: 200 },
];
async function loadDataTable({ page, pageSize }) {
const res = await getListApi({ pageNo: page, pageSize });
return { items: res.data, total: res.count };
return { data: res.data, count: res.count };
}
</script>
```
---
## TableAction 操作列
操作列按钮组件,支持权限控制和下拉折叠。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `actions` | `ActionItem[]` | (必填) | 操作按钮列表 |
| `dropDownActions` | `ActionItem[]` | `null` | 下拉折叠操作列表 |
| `dropDownProps` | `ActionItem` | `{ label: '更多' }` | 下拉按钮配置 |
| `select` | `Function` | `() => {}` | 下拉选择回调 |
### ActionItem 配置
| 属性 | 类型 | 说明 |
|------|------|------|
| `label` | `string` | 按钮文字 |
| `auth` | `string[]` | 权限编码 |
| `onClick` | `Function` | 点击回调 |
| `icon` | `Component` | 图标组件 |
| `type` | `string` | 按钮类型 |
| `disabled` | `boolean` | 是否禁用 |
| `ifShow` | `boolean \| Function` | 是否显示 |
| `isConfirm` | `boolean` | 是否需要确认 |
| `popConfirm` | `object` | 气泡确认框配置 |
---
## BasicForm 表单
表单组件通过 schemas 配置化定义表单字段,支持动态渲染、折叠展开,详见 [BasicForm 表单](/zh/volume4/frontend/form) 章节。
@@ -206,27 +168,23 @@ async function loadDataTable({ page, pageSize }) {
| `schemas` | `FormSchema[]` | `[]` | 表单字段配置 |
| `model` | `Object` | - | 表单数据对象 |
| `rules` | `FormRules` | - | 表单验证规则 |
| `inline` | `Boolean` | `false` | 行内表单 |
| `layout` | `string` | `'vertical'` | 布局方式 |
| `labelPosition` | `string` | `'right'` | 标签位置 |
| `labelWidth` | `Number \| String` | `80` | 标签宽度 |
| `labelCol` | `Object` | - | 标签栅格配置 |
| `size` | `string` | `'default'` | 表单尺寸 |
| `disabled` | `Boolean` | `false` | 禁用全部组件 |
| `isFull` | `Boolean` | `true` | 组件是否 100% 宽度 |
| `showActionButtonGroup` | `Boolean` | `true` | 显示操作按钮组 |
| `showSubmitButton` | `Boolean` | `true` | 显示查询按钮 |
| `showResetButton` | `Boolean` | `true` | 显示重置按钮 |
| `showAdvancedButton` | `Boolean` | `true` | 显示展开/收起按钮 |
| `submitButtonText` | `string` | `'查询'` | 查询按钮文字 |
| `resetButtonText` | `string` | `'重置'` | 重置按钮文字 |
| `collapsed` | `Boolean` | `true` | 是否默认折叠 |
| `collapsedRows` | `Number` | `1` | 折叠时显示行数 |
| `rowProps` | `Object` | - | 行栅格配置(gutter 等) |
| `colProps` | `Object` | - | 列栅格配置(span 等) |
### FormSchema 字段配置
| 属性 | 类型 | 说明 |
|------|------|------|
| `field` | `string` | 字段名(必填) |
| `name` | `string` | 字段名(必填) |
| `label` | `string` | 标签文字 |
| `component` | `ComponentType` | 渲染组件:`Input` / `Select` / `DatePicker` 等 |
| `componentProps` | `object` | 组件属性 |
@@ -234,9 +192,9 @@ async function loadDataTable({ page, pageSize }) {
| `rules` | `object \| object[]` | 字段校验规则 |
| `slot` | `string` | 自定义插槽名 |
| `hidden` | `boolean \| Function` | 是否隐藏 |
| `colProps` | `ColProps` | 栅格配置 |
| `colProps` | `Object` | 栅格配置 |
| `labelMessage` | `string` | 标签提示信息 |
| `suffix` | `string` | 后缀文字 |
| `suffix` | `string` | 后缀插槽名 |
### Events
@@ -268,13 +226,13 @@ async function loadDataTable({ page, pageSize }) {
const [registerForm] = useForm({
schemas: [
{
field: 'name',
name: 'name',
label: '名称',
component: 'Input',
componentProps: { placeholder: '请输入名称' },
},
{
field: 'status',
name: 'status',
label: '状态',
component: 'Select',
componentProps: {
@@ -284,12 +242,6 @@ const [registerForm] = useForm({
],
},
},
{
field: 'dateRange',
label: '日期范围',
component: 'DatePicker',
componentProps: { type: 'daterange' },
},
],
});
</script>
@@ -299,7 +251,7 @@ const [registerForm] = useForm({
## BasicModal 弹窗
弹窗组件基于 `el-dialog` 封装,支持拖拽、自定义按钮,详见 [Modal 弹窗](/zh/volume4/frontend/modal) 章节。
弹窗组件基于 `a-modal` 封装,支持拖拽、自定义按钮,详见 [Modal 弹窗](/zh/volume4/frontend/modal) 章节。
### Props
@@ -309,23 +261,22 @@ const [registerForm] = useForm({
| `width` | `Number` | `446` | 弹窗宽度 |
| `draggable` | `Boolean` | `true` | 是否可拖拽 |
| `hideFooter` | `Boolean` | `false` | 隐藏底部按钮 |
| `confirmButText` | `string` | `'确认'` | 确认按钮文字 |
| `cancelButText` | `string` | `'取消'` | 取消按钮文字 |
| `confirmButProps` | `Object` | - | 确认按钮属性 |
| `cancelButProps` | `Object` | - | 取消按钮属性 |
| `okText` | `string` | `'确认'` | 确认按钮文字 |
| `cancelText` | `string` | `'取消'` | 取消按钮文字 |
### Events
| 事件 | 说明 |
|------|------|
| `ok` | 点击确认 |
| `cancel` | 点击取消 |
| `register` | 弹窗注册回调 |
### Expose 方法
| 方法 | 说明 |
|------|------|
| `setModalProps(props)` | 设置弹窗属性 |
| `setProps(props)` | 设置弹窗属性 |
| `openModal()` | 打开弹窗 |
| `closeModal()` | 关闭弹窗 |
@@ -339,12 +290,11 @@ const [registerForm] = useForm({
</template>
<script setup lang="ts">
const [registerModal, { openModal, closeModal, setModalProps }] = useModal();
const [registerModal, { openModal, closeModal, setProps }] = useModal();
function handleEdit(record) {
setModalProps({ title: '编辑用户' });
setProps({ title: '编辑用户' });
openModal();
// 回填表单数据
nextTick(() => {
setFieldsValue(record);
});
@@ -356,7 +306,7 @@ function handleEdit(record) {
## BasicUpload 上传
文件/图片上传组件,基于 `el-upload` 封装,支持卡片/列表模式、数量限制、图片预览。
文件/图片上传组件,支持卡片/列表模式、数量限制、图片预览。
### Props
@@ -370,7 +320,6 @@ function handleEdit(record) {
| `circle` | `Boolean` | `false` | 圆形裁剪 |
| `uploadTitle` | `string` | `'上传图片'` | 列表模式按钮文字 |
| `helpText` | `string` | `''` | 帮助提示文字 |
| `width` | `Number` | `104` | 卡片宽度(px) |
### Events
@@ -409,7 +358,6 @@ function handleEdit(record) {
| `mode` | `string` | `'default'` | 模式:`default` / `simple` |
| `disable` | `Boolean` | `false` | 是否禁用 |
| `placeholder` | `string` | `'请输入内容...'` | 占位文字 |
| `uploadFileUrl` | `string` | `'/file/upload'` | 图片上传接口 |
### 基础用法
@@ -429,26 +377,6 @@ function handleEdit(record) {
基于 [cropperjs](https://github.com/fengyuanchen/cropperjs) 封装,支持圆形/矩形裁剪、实时预览。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `src` | `string` | (必填) | 图片地址 |
| `alt` | `string` | - | 图片描述 |
| `circled` | `Boolean` | `false` | 圆形裁剪 |
| `realTimePreview` | `Boolean` | `true` | 实时预览 |
| `height` | `string \| Number` | `'360px'` | 裁剪区域高度 |
| `imageStyle` | `CSSProperties` | `{}` | 图片样式 |
| `options` | `Object` | `{}` | cropperjs 配置 |
### Events
| 事件 | 参数 | 说明 |
|------|------|------|
| `cropend` | `{ imgBase64, imgInfo }` | 裁剪完成 |
| `ready` | `(cropper)` | 裁剪器就绪 |
| `cropendError` | - | 裁剪出错 |
### 基础用法
```vue
@@ -463,8 +391,6 @@ function handleEdit(record) {
<script setup lang="ts">
function handleCropend({ imgBase64, imgInfo }) {
// imgBase64: base64 格式的裁剪结果
// imgInfo: 裁剪区域坐标和尺寸
formData.value.avatar = imgBase64;
}
</script>
@@ -472,95 +398,10 @@ function handleCropend({ imgBase64, imgInfo }) {
---
## QrCode 二维码生成
基于 [qrcode](https://github.com/soldair/node-qrcode) 封装,支持 canvas/img 渲染、内嵌 logo。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `value` | `string \| Array` | (必填) | 二维码内容 |
| `width` | `Number` | `200` | 宽度(px) |
| `tag` | `string` | `'canvas'` | 渲染方式:`canvas` / `img` |
| `logo` | `string \| Object` | `''` | 中间 logo |
| `options` | `Object` | `{}` | qrcode 配置项 |
### Events
| 事件 | 参数 | 说明 |
|------|------|------|
| `done` | `{ url, ctx }` | 生成完成 |
| `error` | `(error)` | 生成失败 |
### Expose 方法
| 方法 | 说明 |
|------|------|
| `download(fileName?)` | 下载二维码图片 |
### 基础用法
```vue
<template>
<QrCode value="https://www.example.com" :width="200" :logo="logoUrl" />
</template>
```
---
## ChinaArea 省市区级联
省市区三级联动选择器,基于后端城市接口懒加载。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `modelValue` | `String \| Array` | - | 双向绑定(区划编码数组) |
| `type` | `Number` | `3` | 联动级别:`2`(省市) / `3`(省市区) |
| `disabled` | `Boolean` | `false` | 是否禁用 |
### Events
| 事件 | 参数 | 说明 |
|------|------|------|
| `update:modelValue` | `(codes)` | 值变化 |
| `change` | `(text)` | 选中文本(如"浙江省杭州市西湖区") |
### 基础用法
```vue
<template>
<ChinaArea v-model="formData.cityCode" :type="3" @change="handleCityChange" />
</template>
```
---
## BasicSelect 增强下拉
远程数据下拉选择器,支持请求函数和本地缓存。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `request` | `Function` | (必填) | 获取选项数据的异步函数 |
| `options` | `Array` | `[]` | 静态选项(与 request 二选一) |
| `modelValue` | `String \| Number` | - | 双向绑定 |
| `cache` | `Boolean` | `false` | 是否缓存到 localStorage |
| `cacheKey` | `string` | `''` | 缓存 key(开启缓存时必填) |
| `width` | `Number` | `150` | 宽度(px) |
| `block` | `Boolean` | `false` | 100% 父容器宽度 |
### Expose 方法
| 方法 | 说明 |
|------|------|
| `fetch()` | 重新请求数据 |
| `getData()` | 获取当前选项数据 |
### 基础用法
```vue
@@ -584,112 +425,10 @@ async function getDeptOptions() {
---
## CountTo 数字动画
数字动态递增/递减动画,支持前缀/后缀、千分位、缓动曲线。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `startVal` | `Number` | `0` | 起始值 |
| `endVal` | `Number` | `2021` | 结束值 |
| `duration` | `Number` | `1500` | 动画时长(ms) |
| `autoplay` | `Boolean` | `true` | 自动播放 |
| `decimals` | `Number` | `0` | 小数位数 |
| `prefix` | `string` | `''` | 前缀 |
| `suffix` | `string` | `''` | 后缀 |
| `separator` | `string` | `','` | 千分位分隔符 |
| `decimal` | `string` | `'.'` | 小数点符号 |
| `color` | `string` | - | 字体颜色 |
| `useEasing` | `Boolean` | `true` | 是否使用缓动 |
| `transition` | `string` | `'linear'` | 缓动曲线 |
### Events
| 事件 | 说明 |
|------|------|
| `onStarted` | 动画开始 |
| `onFinished` | 动画结束 |
### 基础用法
```vue
<template>
<CountTo :endVal="12345" :duration="2000" prefix="¥" separator="," />
</template>
```
---
## Password 密码强度
密码输入框,内置强度指示器、重复确认、复杂度校验。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `value` | `string` | - | 密码值 |
| `repeat` | `Boolean` | `false` | 是否显示重复确认框 |
| `required` | `Boolean` | `true` | 是否必填 |
| `minLength` | `Number` | `6` | 最小长度 |
| `maxLength` | `Number` | `32` | 最大长度 |
| `complexity` | `Boolean` | `false` | 是否启用复杂度校验 |
| `complexityTip` | `string` | - | 复杂度不满足时的提示 |
| `width` | `Number` | `300` | 弹出框宽度 |
| `block` | `Boolean` | `false` | 100% 宽度 |
### 强度等级
| 等级 | 条件 | 说明 |
|------|------|------|
| 0 | 无 | 极弱 |
| 1 | 含小写或大写 | 弱 |
| 2 | 含小写 + 大写,或含数字 | 中 |
| 3 | 含小写 + 大写 + 数字 | 强 |
| 4 | 含小写 + 大写 + 数字 + 特殊字符 | 极强 |
### Expose 方法
| 方法 | 说明 |
|------|------|
| `isValidator()` | 是否通过校验 |
| `showValidator()` | 触发校验显示 |
### 基础用法
```vue
<template>
<Password v-model:value="formData.password" :repeat="true" :complexity="true" />
</template>
```
---
## PageWrapper 页面容器
页面容器组件,提供标题卡片和固定底部区域。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `title` | `string` | `''` | 页面标题 |
| `content` | `string` | `''` | 标题下方描述文字 |
| `contentStyle` | `Object` | `{}` | 主体区域样式 |
| `contentClass` | `string` | `''` | 主体区域 class |
| `showFooter` | `Boolean` | `true` | 是否显示底部 |
### Slots
| 插槽 | 说明 |
|------|------|
| `headerContent` | 标题卡片内容区域 |
| `default` | 页面主体内容 |
| `leftFooter` | 底部左侧(常放操作按钮) |
| `rightFooter` | 底部右侧 |
### 基础用法
```vue
@@ -698,10 +437,10 @@ async function getDeptOptions() {
<BasicTable :columns="columns" :request="loadData" />
<template #leftFooter>
<el-button @click="handleCancel">取消</el-button>
<a-button @click="handleCancel">取消</a-button>
</template>
<template #rightFooter>
<el-button type="primary" @click="handleSubmit">提交</el-button>
<a-button type="primary" @click="handleSubmit">提交</a-button>
</template>
</PageWrapper>
</template>
@@ -713,18 +452,12 @@ async function getDeptOptions() {
按权限码控制子元素是否渲染,类似 `v-perm` 指令的组件形式。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `value` | `string[]` | `[]` | 权限编码数组 |
### 基础用法
```vue
<template>
<Authority :value="['sys:user:add']">
<el-button type="primary">新增用户</el-button>
<a-button type="primary">新增用户</a-button>
</Authority>
</template>
```
@@ -732,7 +465,7 @@ async function getDeptOptions() {
:::tip v-perm 指令
除了 `Authority` 组件,项目还提供 `v-perm` 指令,功能相同但更简洁:
```vue
<el-button v-perm="['sys:user:add']">新增用户</el-button>
<a-button v-perm="['sys:user:add']">新增用户</a-button>
```
:::
@@ -740,22 +473,16 @@ async function getDeptOptions() {
## Icon 图标
统一图标组件,支持 ElementPlus 图标和 SVG 图标。
### Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `name` | `string` | (必填) | 图标名称 |
| `size` | `string \| Number` | `'14px'` | 图标大小 |
| `color` | `string` | `'inherit'` | 图标颜色 |
统一图标组件,支持 AntDesign 图标和 SVG 图标。
### 基础用法
```vue
<template>
<!-- ElementPlus 图标 -->
<Icon name="el-icon-Plus" size="18px" />
<!-- AntDesign 图标 -->
<PlusOutlined />
<DeleteOutlined />
<EditOutlined />
<!-- SVG 图标 -->
<Icon name="svg-icon-user" />
</template>
@@ -836,4 +563,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 表单
:::tip 说明
`BasicForm` 位于 `src/components/Form/src/BasicForm.vue`,基于 ElementPlus 的 `el-form` 二次封装,通过 schemas 配置化定义表单字段,支持查询表单和业务表单两种模式。
`BasicForm` 位于 `src/components/Form/src/BasicForm.vue`,基于 AntDesign Vue 的 `a-form` 二次封装,通过 schemas 配置化定义表单字段,支持查询表单和业务表单两种模式。
:::
## 组件架构
@@ -9,13 +9,13 @@
```
src/components/Form/
├── src/
│ ├── BasicForm.vue # 主组件
│ ├── props.ts # 属性定义
│ ├── helper.ts # 工具函数(placeholder 生成)
│ ├── types/form.ts # 类型定义(FormSchema、FormProps)
│ └── hooks/
│ ├── useFormEvents.ts # 表单事件(submit、reset、validate)
│ └── useFormValues.ts # 表单值处理(默认值初始化)
│ ├── BasicForm.vue # 主组件
│ ├── props.ts # 属性定义
│ ├── helper.ts # 工具函数(placeholder 生成)
│ ├── types/form.ts # 类型定义(FormSchema、FormProps)
│ └── hooks/
│ ├── useFormEvents.ts # 表单事件(submit、reset、validate)
│ └── useFormValues.ts # 表单值处理(默认值初始化)
└── index.ts # 导出 useForm、FormSchema 等
```
@@ -24,9 +24,9 @@ src/components/Form/
列表页的搜索区域使用 `useForm` 注册表单:
```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" />
</el-card>
</a-card>
```
### querySchemas 定义
@@ -38,7 +38,7 @@ import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
name: 'name',
component: 'Input',
label: '岗位名称',
componentProps: {
@@ -46,7 +46,7 @@ export const schemas: FormSchema[] = [
},
},
{
field: 'status',
name: 'status',
component: 'Select',
label: '状态',
componentProps: {
@@ -66,26 +66,37 @@ export const schemas: FormSchema[] = [
```typescript
import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index';
import { ColProps } from 'element-plus';
const formParams = reactive({ name: '', status: '' });
const [register, {}] = useForm({
labelWidth: 80,
layout: 'horizontal',
colProps: { span: 6 } as ColProps,
submitOnReset: true,
rowProps: { gutter: [16, 0] },
colProps: {
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas,
});
function handleSubmit(values: Recordable) {
handleReset();
for (const key in values) { formParams[key] = values[key]; }
for (const key in formParams) {
formParams[key] = '';
}
for (const key in values) {
formParams[key] = values[key];
}
reloadTable();
}
function handleReset() {
for (const key in formParams) { formParams[key] = ''; }
for (const key in formParams) {
formParams[key] = '';
}
reloadTable();
}
```
@@ -93,73 +104,75 @@ function handleReset() {
## 业务表单(编辑弹窗)
编辑弹窗中直接使用 `el-form` + `el-form-item` 构建表单:
编辑弹窗中直接使用 `a-form` + `a-form-item` 构建表单:
```vue
<!-- src/views/system/position/edit.vue -->
<template>
<el-dialog v-model="props.visible" :title="props.positionId ? '编辑' : '新增'" width="500">
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
<el-form-item
<a-modal v-model:visible="props.visible" :title="props.positionId ? '编辑' : '新增'" width="500px" @cancel="dialogClose">
<a-form class="ls-form" ref="formRef" :model="formData" :label-col="{ style: { width: '85px' } }">
<a-form-item
label="岗位名称"
prop="name"
name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
>
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
<el-form-item label="岗位状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" />
</el-form-item>
</el-form>
</el-dialog>
<a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</a-form-item>
<a-form-item label="岗位状态" name="status">
<a-radio-group v-model:value="formData.status" name="status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="2">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" />
</a-form-item>
</a-form>
</a-modal>
</template>
```
## BasicForm 内部渲染机制
`BasicForm.vue` 根据 `schema.component` 动态渲染对应的 ElementPlus 组件:
`BasicForm.vue` 根据 `schema.component` 动态渲染对应的 AntDesign Vue 组件:
```vue
<!-- src/components/Form/src/BasicForm.vue 核心逻辑 -->
<template v-for="schema in getSchema" :key="schema.field">
<template v-for="schema in getSchema" :key="schema.name">
<!-- Checkbox -->
<template v-if="schema.component === 'Checkbox'">
<el-checkbox-group v-model="formModel[schema.field]">
<el-checkbox v-for="item in schema.componentProps?.options" :value="item.value">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
<a-checkbox-group v-bind="getSpecComponentProps(schema)" v-model:value="formModel[schema.name]">
<a-space>
<a-checkbox v-for="item in schema.componentProps?.options" :key="item.value" :value="item.value">
{{ item.label }}
</a-checkbox>
</a-space>
</a-checkbox-group>
</template>
<!-- RadioGroup -->
<template v-else-if="schema.component === 'RadioGroup'">
<el-radio-group v-model="formModel[schema.field]">
<el-radio v-for="item in schema.componentProps?.options" :value="item.value">
{{ item.label }}
</el-radio>
</el-radio-group>
<a-radio-group v-bind="getSpecComponentProps(schema)" v-model:value="formModel[schema.name]">
<a-space>
<a-radio v-for="item in schema.componentProps?.options" :key="item.value" :value="item.value">
{{ item.label }}
</a-radio>
</a-space>
</a-radio-group>
</template>
<!-- Select -->
<template v-else-if="schema.component === 'Select'">
<el-select v-model="formModel[schema.field]">
<el-option v-for="item in schema.componentProps?.options" :value="item.value" :label="item.label" />
</el-select>
<!-- BasicSelect -->
<template v-else-if="schema.component === 'BasicSelect'">
<BasicSelect v-model:value="formModel[schema.name]" v-bind="schema.componentProps" />
</template>
<!-- Daterange -->
<template v-else-if="schema.component === 'Daterange'">
<el-date-picker v-model="formModel[schema.field]" type="daterange" value-format="YYYY-MM-DD" />
</template>
<!-- 其他组件:动态渲染 el-{component} -->
<component v-else :is="`el-${schema.component}`" v-model="formModel[schema.field]" />
<!-- 其他组件:动态渲染 a-{component} -->
<component
v-else
v-bind="getComponentProps(schema)"
:is="`a-${schema.component}`"
v-model:value="formModel[schema.name]"
/>
</template>
```
@@ -167,19 +180,18 @@ function handleReset() {
```typescript
interface FormSchema {
field: string; // 字段名
label: string; // 标签文本
component: string; // 组件类型:Input / Select / RadioGroup / Checkbox / DatePicker 等
componentProps?: any; // 组件属性(placeholder、options、clearable 等)
required?: boolean; // 是否必填
rules?: any[]; // 自定义校验规则
defaultValue?: any; // 默认值
hidden?: boolean | (({ schema, values, model }) => boolean); // 是否隐藏
slot?: string; // 自定义插槽名
suffix?: string; // 后缀插槽名
labelMessage?: string; // 标签提示信息
isFull?: boolean; // 是否全宽
type?: string; // 组件子类型
name: string; // 字段名
label: string; // 标签文本
component?: string; // 组件类型:Input / Select / RadioGroup / Checkbox / DatePicker 等
componentProps?: any; // 组件属性(placeholder、options、clearable 等)
rules?: object | object[]; // 自定义校验规则
defaultValue?: any; // 默认值
hidden?: boolean | (({ schema, values, model }) => boolean); // 是否隐藏
slot?: string; // 自定义插槽名
suffix?: string; // 后缀插槽名
labelMessage?: string; // 标签提示信息
isFull?: boolean; // 是否全宽
colProps?: object; // 栅格配置
}
```
@@ -196,11 +208,11 @@ const handleSubmit = async () => {
formRef.value?.clearValidate();
```
Schema 中的 `required: true` 会自动生成必填校验规则,也可以通过 `rules` 自定义:
Schema 中的 `rules` 支持自定义校验规则:
```typescript
{
field: 'name',
name: 'name',
label: '岗位名称',
component: 'Input',
rules: [
@@ -237,4 +249,4 @@ onMounted(() => {
## 总结
BasicForm 通过 schemas 配置化定义表单字段,内部根据 `component` 动态渲染对应的 ElementPlus 组件。查询表单使用 `useForm` 注册并绑定 `@submit`/`@reset` 事件,业务表单直接使用 `el-form` + `reactive` 管理数据。支持多种组件类型、表单校验、数据绑定、条件隐藏等能力。
BasicForm 通过 schemas 配置化定义表单字段,内部根据 `component` 动态渲染对应的 AntDesign Vue 组件。查询表单使用 `useForm` 注册并绑定 `@submit`/`@reset` 事件,业务表单直接使用 `a-form` + `reactive` 管理数据。支持多种组件类型、表单校验、数据绑定、条件隐藏等能力。
@@ -120,7 +120,7 @@ import { useLockFn } from '@/utils/useLockFn';
const handleSubmit = async () => {
await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功');
message.success('操作成功');
emit('update:visible', false);
emit('success');
};
@@ -130,7 +130,7 @@ const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
```
```vue
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button>
<a-button :loading="subLoading" type="primary" @click="submit">确定</a-button>
```
## useAsync
@@ -1,22 +1,22 @@
# 国际化
:::tip 说明
当前项目仅支持中文界面,ElementPlus 使用中文语言包(`zh-cn`),所有界面文本直接硬编码在组件中。如需扩展多语言支持,可基于 vue-i18n 实现。
当前项目仅支持中文界面,AntDesign Vue 默认中文,所有界面文本直接硬编码在组件中。如需扩展多语言支持,可基于 vue-i18n 实现。
:::
## 当前状态
项目当前为中文单语言版本:
- ElementPlus 全局配置中文语言包:`app.use(ElementPlus, { locale: zhCn })`
- 所有界面文本直接硬编码在组件中:`<el-button>添加岗位</el-button>`
- AntDesign Vue 默认中文,无需额外配置语言包
- 所有界面文本直接硬编码在组件中:`<a-button>添加岗位</a-button>`
- 后端返回的 `msg` 字段为中文:`"操作成功"`、`"岗位名称不能重复"`
```vue
<template>
<el-button type="primary">添加岗位</el-button>
<el-table-column label="岗位名称" prop="name" />
<el-table-column label="创建时间" prop="createTime" />
<a-button type="primary">添加岗位</a-button>
<a-table-column title="岗位名称" dataIndex="name" />
<a-table-column title="创建时间" dataIndex="createTime" />
</template>
```
@@ -53,11 +53,11 @@ export default i18n;
```json
// src/i18n/locales/zh-CN.json
{
"common": {
"add": "新增", "edit": "编辑", "delete": "删除",
"search": "搜索", "reset": "重置", "confirm": "确定", "cancel": "取消"
},
"position": { "name": "岗位名称", "status": "状态", "sort": "排序" }
"common": {
"add": "新增", "edit": "编辑", "delete": "删除",
"search": "搜索", "reset": "重置", "confirm": "确定", "cancel": "取消"
},
"position": { "name": "岗位名称", "status": "状态", "sort": "排序" }
}
```
@@ -65,23 +65,26 @@ export default i18n;
```vue
<template>
<el-button type="primary">{{ $t('common.add') }}</el-button>
<el-table-column :label="$t('position.name')" prop="name" />
<a-button type="primary">{{ $t('common.add') }}</a-button>
<a-table-column :title="$t('position.name')" dataIndex="name" />
</template>
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { t } = useI18n();
</script>
```
### ElementPlus 国际化
### AntDesign Vue 国际化
```typescript
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import enUS from 'ant-design-vue/es/locale/en_US';
app.use(ElementPlus, { locale: zhCn });
// 在 ConfigProvider 中使用
<a-config-provider :locale="zhCN">
<App />
</a-config-provider>
```
:::tip 温馨提示
@@ -90,4 +93,4 @@ app.use(ElementPlus, { locale: zhCn });
## 总结
当前项目为中文单语言版本,ElementPlus 使用中文语言包。如需扩展多语言支持,可基于 vue-i18n 实现前端国际化,配合 ElementPlus 内置语言包和后端消息 code 实现全链路多语言。
当前项目为中文单语言版本,AntDesign Vue 默认中文。如需扩展多语言支持,可基于 vue-i18n 实现前端国际化,配合 AntDesign Vue 内置语言包和后端消息 code 实现全链路多语言。
@@ -1,7 +1,7 @@
# Modal 弹窗
:::tip 说明
项目提供 `BasicModal` 组件(`src/components/Modal/src/BasicModal.vue`)和原生 `el-dialog` 两种弹窗用法。编辑弹窗通过 `defineAsyncComponent` 懒加载,减少首屏渲染负担。
项目提供 `BasicModal` 组件(`src/components/Modal/src/basicModal.vue`)和原生 `a-modal` 两种弹窗用法。编辑弹窗通过 `defineAsyncComponent` 懒加载,减少首屏渲染负担。
:::
## 组件架构
@@ -9,17 +9,17 @@
```
src/components/Modal/
├── src/
│ ├── BasicModal.vue # 主组件(基于 el-dialog)
│ ├── props.ts # 属性定义
│ ├── type.ts # 类型定义(ModalProps、ModalMethods)
│ └── hooks/
│ └── useModalContext.ts # 弹窗上下文
│ ├── basicModal.vue # 主组件(基于 a-modal)
│ ├── props.ts # 属性定义
│ ├── type.ts # 类型定义(ModalProps、ModalMethods)
│ └── hooks/
│ └── useModal.ts # 弹窗 Hook
└── index.ts # 导出 useModal 等
```
## BasicModal 组件
`BasicModal` 基于 `el-dialog` 封装,提供全屏切换、自定义头部、提交按钮等能力:
`BasicModal` 基于 `a-modal` 封装,提供全屏切换、自定义头部、提交按钮等能力:
```vue
<BasicModal :title="'编辑'" :width="600" @register="registerModal" @ok="handleSubmit">
@@ -36,7 +36,7 @@ src/components/Modal/
## 实际用法(position 模块)
以岗位管理为例,实际使用原生 `el-dialog` + `defineAsyncComponent` 懒加载:
以岗位管理为例,实际使用原生 `a-modal` + `defineAsyncComponent` 懒加载:
### 父组件(index.vue)
@@ -57,8 +57,8 @@ const handleAdd = async () => {
};
// 编辑
const handleEdit = async (record: Recordable) => {
positionId.value = record.row.id;
const handleEdit = async (id) => {
positionId.value = id;
await nextTick();
editVisible.value = true;
};
@@ -81,40 +81,41 @@ const handleEdit = async (record: Recordable) => {
```vue
<!-- src/views/system/position/edit.vue -->
<template>
<el-dialog
v-model="props.visible"
<a-modal
v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'"
width="500"
:close-on-click-modal="false"
:before-close="dialogClose"
width="500px"
@cancel="dialogClose"
>
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
<el-form-item
<a-form class="ls-form" ref="formRef" :model="formData" :label-col="{ style: { width: '85px' } }">
<a-form-item
label="岗位名称"
prop="name"
name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
>
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
<el-form-item label="岗位状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" />
</el-form-item>
</el-form>
<a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</a-form-item>
<a-form-item label="岗位状态" name="status">
<a-radio-group v-model:value="formData.status" name="status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="2">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" />
</a-form-item>
</a-form>
<template #footer>
<el-button @click="dialogClose">取消</el-button>
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button>
<a-button @click="dialogClose">取消</a-button>
<a-button :loading="subLoading" type="primary" @click="submit">确定</a-button>
</template>
</el-dialog>
</a-modal>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
import { message } from 'ant-design-vue';
import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']);
@@ -131,7 +132,7 @@ const props = defineProps({
const handleSubmit = async () => {
await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功');
message.success('操作成功');
emit('update:visible', false);
emit('success');
};
@@ -161,7 +162,7 @@ onMounted(() => {
## 弹窗数据流
```
父组件点击编辑 → positionId = record.row.id → editVisible = true
父组件点击编辑 → positionId = id → editVisible = true
↓
edit.vue onMounted → positionId != 0 → setFormData() → 获取详情填充表单
↓
@@ -172,25 +173,25 @@ emit('success') → 父组件 reloadTable() → 表格刷新
## 确认弹窗
删除等危险操作使用 `confirm` 封装函数(来自 `src/utils/auth.ts`):
删除等危险操作使用 `Modal.confirm` 方法(来自 `ant-design-vue`):
```typescript
import { message, confirm } from '@/utils/auth';
import { Modal, message } from 'ant-design-vue';
async function handleDelete(record: Recordable) {
let ids = [];
if (!record) {
ids = selectionData.value.map(({ id }) => id);
}
await confirm('确定要删除?');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
message('删除成功');
reloadTable();
async function handleDelete(id) {
Modal.confirm({
title: '提示',
content: '确定要删除?',
onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
message.success('删除成功');
reloadTable();
},
});
}
```
`confirm` 内部调用 `ElMessageBox.confirm`,返回 Promise,用户取消时抛出异常中断后续逻辑。
`Modal.confirm` 返回 Promise,用户点击取消时不会执行 `onOk` 中的逻辑。
## v-model:visible 双向绑定
@@ -213,4 +214,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(满足任一权限)
```vue
<el-button v-perm="['sys:position:add']">添加岗位</el-button>
<el-button v-perm="['sys:position:batchDelete']">删除</el-button>
<el-button v-perm="['sys:position:export']">导出</el-button>
<a-button v-perm="['sys:position:add']">添加岗位</a-button>
<a-button v-perm="['sys:position:batchDelete']">删除</a-button>
<a-button v-perm="['sys:position:export']">导出</a-button>
```
### v-perms(满足所有权限)
```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 结合
@@ -113,7 +113,7 @@ const showAddBtn = computed(() => hasPermission(['sys:position:add']));
```
```vue
<el-button v-if="showAddBtn">添加岗位</el-button>
<a-button v-if="showAddBtn">添加岗位</a-button>
```
## 实际用法(position 模块)
@@ -123,52 +123,39 @@ const showAddBtn = computed(() => hasPermission(['sys:position:add']));
```vue
<!-- src/views/system/position/index.vue -->
<template>
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef" :actionColumn="actionColumn">
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef">
<template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon><el-icon><PlusOutlined /></el-icon></template>
添加岗位
</el-button>
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']">
<template #icon><el-icon><Delete /></el-icon></template>
删除
</el-button>
<a-space>
<a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon><PlusOutlined /></template>
新建
</a-button>
<a-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:position:update']">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:position:delete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</BasicTable>
</template>
```
操作列中的权限控制通过 `auth` 属性实现:
```typescript
const actionColumn = reactive({
width: 200,
label: '操作',
fixed: 'right',
render(record) {
return h(TableAction, {
style: 'button',
actions: [
{
label: '编辑',
icon: 'Edit',
type: 'warning',
onClick: handleEdit.bind(null, record),
auth: ['sys:position:update'], // 权限节点
},
{
label: '删除',
icon: 'Delete',
type: 'danger',
onClick: handleDelete.bind(null, record),
auth: ['sys:position:delete'], // 权限节点
},
],
});
},
});
```
操作列中的权限控制通过 `v-perm` 指令实现,与按钮级权限一致。
## 权限节点命名规范
@@ -202,7 +189,7 @@ def add(request: Request, data: PositionForm):
```vue
<!-- 前端 -->
<el-button v-perm="['sys:position:add']">添加岗位</el-button>
<a-button v-perm="['sys:position:add']">添加岗位</a-button>
```
## 超级管理员
@@ -1,7 +1,7 @@
# 前端环境与结构
:::tip 说明
前端基于 Vue3 + ElementPlus 构建,使用 pnpm 作为包管理工具,Vite 作为构建工具,TypeScript 作为开发语言。
前端基于 Vue3 + AntDesign Vue 构建,使用 pnpm 作为包管理工具,Vite 作为构建工具,TypeScript 作为开发语言。
:::
## 技术栈
@@ -9,14 +9,14 @@
| 技术 | 版本 | 说明 |
|------|------|------|
| Vue | 3.x | 渐进式 JavaScript 框架,组合式 API |
| ElementPlus | 2.x | Vue3 组件库 |
| AntDesign Vue | 4.x | Vue3 组件库 |
| Vite | 3.x | 下一代前端构建工具 |
| Pinia | 2.x | 状态管理(Vuex 替代方案) |
| Vue Router | 4.x | 路由管理 |
| Axios | 1.x | HTTP 请求库 |
| Axios | 0.27.x | HTTP 请求库 |
| TypeScript | 5.x | 类型安全的 JavaScript 超集 |
| Tailwind CSS | 3.x | 原子化 CSS 框架 |
| SCSS | - | CSS 预处理器 |
| Less | - | CSS 预处理器 |
## 目录结构
@@ -37,7 +37,7 @@ ui/
│ ├── router/ # 路由配置(generator-routers、router-guards)
│ ├── store/ # Pinia 状态管理
│ │ └── modules/ # Store 模块(user、asyncRoute、projectSetting 等)
│ ├── styles/ # 全局样式(SCSS 变量、Tailwind)
│ ├── styles/ # 全局样式(Less 变量、Tailwind)
│ ├── utils/ # 工具函数(http、auth、Storage、dateUtil)
│ │ └── http/axios/ # Axios 封装(VAxios 类、拦截器、取消请求)
│ └── views/ # 页面视图
@@ -107,8 +107,8 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
plugins: createVitePlugins(viteEnv, isBuild, prodMock),
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "src/styles/var.scss";`,
less: {
additionalData: `@import "src/styles/var.less";`,
},
},
},
@@ -130,7 +130,7 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
关键配置说明:
- `@` 别名指向 `src/` 目录,代码中可用 `@/api/system/position` 引入模块
- SCSS 全局变量通过 `additionalData` 注入,无需每个文件手动 `@import`
- Less 全局变量通过 `additionalData` 注入,无需每个文件手动 `@import`
- 代理配置通过 `VITE_PROXY` 环境变量传入,由 `createProxy()` 工具函数解析
- 构建产物输出到 `OUTPUT_DIR`(默认 `dist/`)
@@ -140,24 +140,18 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
```typescript
import './styles/tailwindbase.css';
import 'element-plus/dist/index.css';
import './styles/index.scss';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'ant-design-vue/dist/reset.css';
import './styles/index.less';
import { createApp } from 'vue';
import App from './App.vue';
import router, { setupRouter } from './router';
import { setupStore } from '@/store';
import { setupElement, setupDirectives, setupCustomComponents } from '@/plugins';
import { setupAntd, setupDirectives, setupCustomComponents } from '@/plugins';
async function bootstrap() {
const app = createApp(App);
// 注册所有 ElementPlus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
setupElement(app); // 全局引入 ElementPlus 组件
setupAntd(app); // 全局引入 AntDesign Vue 组件
setupCustomComponents(app); // 注册自定义组件
setupDirectives(app); // 注册自定义指令(v-perm 等)
setupStore(app); // 挂载 Pinia 状态管理
@@ -170,7 +164,7 @@ async function bootstrap() {
void bootstrap();
```
初始化顺序:样式加载 -> ElementPlus -> 自定义组件 -> 自定义指令 -> Pinia Store -> Router -> 挂载。
初始化顺序:样式加载 -> AntDesign Vue -> 自定义组件 -> 自定义指令 -> Pinia Store -> Router -> 挂载。
## 核心目录说明
@@ -180,7 +174,7 @@ API 接口定义目录,按模块组织文件。公共接口在 `api/common/`
### 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/
@@ -212,4 +206,4 @@ Pinia 状态管理目录,核心模块:
## 总结
前端项目基于 Vue3 + ElementPlus + Vite + TypeScript + Pinia 构建,使用 pnpm 管理依赖,Tailwind CSS + SCSS 提供样式能力。通过 `main.ts` 的 `bootstrap()` 函数按顺序初始化所有基础设施,目录结构按功能分层组织,便于维护和扩展。
前端项目基于 Vue3 + AntDesign Vue + Vite + TypeScript + Pinia 构建,使用 pnpm 管理依赖,Tailwind CSS + Less 提供样式能力。通过 `main.ts` 的 `bootstrap()` 函数按顺序初始化所有基础设施,目录结构按功能分层组织,便于维护和扩展。
@@ -1,35 +1,39 @@
# 样式与主题
:::tip 说明
前端样式方案采用 Tailwind CSS + SCSS + ElementPlus 主题覆盖,支持深色模式切换。全局 SCSS 变量通过 Vite 的 `additionalData` 注入。
前端样式方案采用 Tailwind CSS + Less + AntDesign Vue 主题覆盖,支持深色模式切换。全局 Less 变量通过 Vite 的 `additionalData` 注入。
:::
## 样式架构
```
src/styles/
├── var.scss # SCSS 全局变量(通过 Vite additionalData 注入)
├── index.scss # 入口文件,引入所有样式
├── index.less # 入口文件,引入所有样式
├── tailwindbase.css # Tailwind CSS 基础样式
└── element-override.css # ElementPlus 主题覆盖(可选)
├── common.less # 公共样式
├── color.less # 颜色变量
├── config.less # 配置样式
└── theme/
├── theme-default.less # 默认主题
└── theme-dark.less # 深色主题
```
## SCSS 全局变量
## Less 全局变量
Vite 配置中通过 `additionalData` 注入全局 SCSS 变量:
Vite 配置中通过 `additionalData` 注入全局 Less 变量:
```typescript
// vite.config.ts
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "src/styles/var.scss";`,
less: {
additionalData: `@import "src/styles/var.less";`,
},
},
},
```
这意味着 `var.scss` 中定义的变量在所有 `.vue` 和 `.scss` 文件中自动可用,无需手动 `@import`。
这意味着 `var.less` 中定义的变量在所有 `.vue` 和 `.less` 文件中自动可用,无需手动 `@import`。
## Tailwind CSS
@@ -56,36 +60,33 @@ import './styles/tailwindbase.css';
<div class="w-full md:w-1/2 lg:w-1/3">响应式宽度</div>
```
## ElementPlus 主题
## AntDesign Vue 主题
ElementPlus 通过 CSS 变量支持主题定制:
AntDesign Vue 通过 CSS 变量和 Less 变量支持主题定制:
```typescript
// main.ts
import 'element-plus/dist/index.css';
import 'element-plus/theme-chalk/display.css';
import 'element-plus/theme-chalk/dark/css-vars.css'; // 深色模式 CSS 变量
import 'ant-design-vue/dist/reset.css';
```
### ElementPlus 全局引入
### AntDesign Vue 全局引入
```typescript
// src/plugins/setupElement.ts
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
// src/plugins/antdv.ts
import Antd from 'ant-design-vue';
export function setupElement(app) {
app.use(ElementPlus, { locale: zhCn, size: 'default' });
export function setupAntd(app) {
app.use(Antd);
}
```
## 深色模式
通过 ElementPlus 的 `dark/css-vars.css` 和 Tailwind 的 `dark` 模式实现深色主题:
通过 AntDesign Vue 的主题配置和 Tailwind 的 `dark` 模式实现深色主题:
```vue
<template>
<el-switch v-model="isDark" @change="toggleDark" />
<a-switch v-model:checked="isDark" @change="toggleDark" />
</template>
<script setup>
@@ -100,9 +101,9 @@ const toggleDark = (val) => {
```css
.dark {
--el-bg-color: #1d1e1f;
--el-text-color-primary: #e5eaf3;
--el-border-color: #4c4d4f;
--ant-background: #1d1e1f;
--ant-text-color: #e5eaf3;
--ant-border-color: #4c4d4f;
}
```
@@ -130,12 +131,12 @@ export function useDesignSetting() {
```vue
<template>
<PageWrapper>
<el-card :bordered="false" class="pt-3 mb-3 proCard">
<a-card :bordered="false" class="pt-3 mb-3 proCard">
<!-- 搜索区域 -->
</el-card>
<el-card :bordered="false" class="proCard">
</a-card>
<a-card :bordered="false" class="proCard">
<!-- 表格区域 -->
</el-card>
</a-card>
</PageWrapper>
</template>
```
@@ -145,9 +146,9 @@ export function useDesignSetting() {
`proCard` 是项目定义的卡片样式类,用于统一卡片组件的间距和边框。
:::tip 温馨提示
优先使用 Tailwind 原子类实现样式,避免编写自定义 CSS。需要全局覆盖时使用 CSS 变量。SCSS 变量通过 Vite `additionalData` 自动注入,无需每个文件手动引入。
优先使用 Tailwind 原子类实现样式,避免编写自定义 CSS。需要全局覆盖时使用 CSS 变量。Less 变量通过 Vite `additionalData` 自动注入,无需每个文件手动引入。
:::
## 总结
样式方案采用 Tailwind CSS 提供原子化样式 + SCSS 全局变量(Vite `additionalData` 注入)+ ElementPlus 主题覆盖,支持深色模式切换。`designSetting` store 管理主题配置,`PageWrapper` 组件提供统一页面容器。
样式方案采用 Tailwind CSS 提供原子化样式 + Less 全局变量(Vite `additionalData` 注入)+ AntDesign Vue 主题覆盖,支持深色模式切换。`designSetting` store 管理主题配置,`PageWrapper` 组件提供统一页面容器。
@@ -1,7 +1,7 @@
# BasicTable 表格
:::tip 说明
`BasicTable` 位于 `src/components/Table/src/Table.vue`,基于 ElementPlus 的 `el-table` 二次封装,提供列定义、数据请求、分页、操作列、多选、全屏、密度调整、列设置等能力。
`BasicTable` 位于 `src/components/Table/src/Table.vue`,基于 AntDesign Vue 的 `a-table` 二次封装,提供列定义、数据请求、分页、多选、全屏、密度调整、列设置等能力。
:::
## 组件架构
@@ -9,21 +9,20 @@
```
src/components/Table/
├── src/
│ ├── Table.vue # 主组件
│ ├── props.ts # 属性定义
│ ├── types/table.ts # 类型定义
│ ├── hooks/
│ │ ├── useLoading.ts # loading 状态
│ │ ├── useColumns.ts # 列管理
│ │ ├── useDataSource.ts # 数据源管理
│ │ ├── usePagination.ts # 分页管理
│ │ └── useTableContext.ts # 表格上下文
│ └── components/
│ ├── Render.tsx # 渲染函数组件
│ └── settings/
│ └── ColumnSetting.vue # 列设置面板
├── index.ts # 导出 TableAction 等
└── src/TableAction.vue # 操作列按钮组件
│ ├── Table.vue # 主组件
│ ├── props.ts # 属性定义
│ ├── types/table.ts # 类型定义
│ ├── hooks/
│ │ ├── useLoading.ts # loading 状态
│ │ ├── useColumns.ts # 列管理
│ │ ├── useDataSource.ts # 数据源管理
│ │ ├── usePagination.ts # 分页管理
│ │ └── useTableContext.ts # 表格上下文
│ └── components/
│ ├── TableAction.vue # 操作列按钮组件
│ └── settings/
│ └── ColumnSetting.vue # 列设置面板
└── index.ts # 导出 TableAction 等
```
## 基本用法
@@ -34,44 +33,94 @@ src/components/Table/
:request="loadDataTable"
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
scroll-x="1200"
:row-selection="{ onChange: onSelectionChange }"
>
<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-button type="danger" @click="handleDelete()" :disabled="!selectionData.length" v-perm="['sys:position:batchDelete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:position:update']">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:position:delete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</BasicTable>
```
## 列定义
`columns` 定义表格列,支持 `render` 函数自定义渲染:
`columns` 定义表格列,支持 `customRender` 函数自定义渲染:
```typescript
// src/views/system/position/columns.ts
import { h } from 'vue';
import { ElTag } from 'element-plus';
import { Tag } from 'ant-design-vue';
export const columns = [
{ type: 'selection' },
{ label: 'ID', prop: 'id', fixed: 'left', width: 50 },
{ label: '岗位名称', prop: 'name', minWidth: 100 },
{
label: '岗位状态',
prop: 'status',
minWidth: 100,
render(record) {
title: 'ID',
dataIndex: 'id',
fixed: 'left',
width: 50,
},
{
title: '岗位名称',
dataIndex: 'name',
width: 100,
},
{
title: '岗位状态',
dataIndex: 'status',
width: 100,
customRender({ record }) {
return h(
ElTag,
{ type: record.row.status == 1 ? 'success' : 'danger' },
{ default: () => (record.row.status == 1 ? '正常' : '停用') },
Tag,
{ color: record.status == 1 ? 'success' : 'error' },
{ default: () => (record.status == 1 ? '正常' : '停用') },
);
},
},
{ label: '排序', prop: 'sort', minWidth: 100 },
{ label: '创建人', prop: 'createUser', minWidth: 100 },
{ label: '创建时间', prop: 'createTime', width: 180 },
{
title: '排序',
dataIndex: 'sort',
width: 100,
},
{
title: '创建人',
dataIndex: 'createUser',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createTime',
width: 180,
},
{
title: '操作',
fixed: 'right',
dataIndex: 'action',
key: 'action',
width: 200,
},
];
```
@@ -79,17 +128,15 @@ export const columns = [
| 属性 | 类型 | 说明 |
|------|------|------|
| `prop` | string | 字段名 |
| `label` | string | 列标题 |
| `dataIndex` | string | 字段名 |
| `title` | string | 列标题 |
| `width` | number | 固定宽度 |
| `minWidth` | number | 最小宽度 |
| `type` | string | 列类型:`selection` / `index` |
| `fixed` | string | 固定列:`left` / `right` |
| `render` | function | 自定义渲染函数,接收 `{ row, column, index }` |
| `isSlot` | boolean | 是否使用插槽渲染 |
| `key` | string | 列唯一标识(操作列需要) |
| `customRender` | function | 自定义渲染函数,接收 `{ text, record, index, column }` |
| `ellipsis` | boolean | 是否溢出省略 |
`render` 函数中 `record.row` 是行数据,`record.$index` 是行索引。
`customRender` 函数中 `record` 是行数据,`text` 是当前单元格值。
## 数据请求
@@ -106,64 +153,49 @@ const loadDataTable = async (res: any) => {
## 操作列
`actionColumn` 使用 `TableAction` 组件渲染行操作按钮,支持权限控制:
操作列通过 `bodyCell` 插槽实现,使用 `v-perm` 指令控制权限:
```typescript
import { TableAction } from '@/components/Table';
const actionColumn = reactive({
width: 200,
label: '操作',
prop: 'action',
fixed: 'right',
render(record) {
return h(TableAction, {
style: 'button',
actions: [
{
label: '编辑',
icon: 'Edit',
type: 'warning',
onClick: handleEdit.bind(null, record),
auth: ['sys:position:update'], // 权限节点
},
{
label: '删除',
icon: 'Delete',
type: 'danger',
onClick: handleDelete.bind(null, record),
auth: ['sys:position:delete'], // 权限节点
},
],
});
},
});
```vue
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:position:update']">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:position:delete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
```
`TableAction` 的 `auth` 属性接受权限数组,内部通过 `usePermission` Hook 检查,无权限时自动隐藏按钮。
`v-perm` 指令接受权限数组,内部通过 `usePermission` Hook 检查,无权限时自动隐藏按钮。
## 多选
通过 `selection-change` 事件获取选中行:
通过 `row-selection` 属性启用多选,`onChange` 回调获取选中行:
```typescript
const selectionData = ref([]);
function onSelectionChange(value) {
selectionData.value = value;
function onSelectionChange(selectedRowKeys) {
selectionData.value = selectedRowKeys;
}
// 批量删除
async function handleDelete(record) {
let ids = [];
if (!record) {
ids = selectionData.value.map(({ id }) => id);
}
await confirm('确定要删除?');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
message('删除成功');
reloadTable();
async function handleDelete(id) {
Modal.confirm({
title: '提示',
content: '确定要删除?',
onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
message.success('删除成功');
reloadTable();
},
});
}
```
@@ -181,9 +213,9 @@ function reloadTable(noRefresh = '') {
// 操作成功后刷新
const handleAdd = async () => {
await positionAdd(data);
message('操作成功');
reloadTable();
positionId.value = 0;
await nextTick();
editVisible.value = true;
};
```
@@ -192,7 +224,7 @@ const handleAdd = async () => {
BasicTable 内置右侧工具栏,支持:
- 刷新按钮(redo)
- 密度切换(small / default / large)
- 密度切换(small / middle / default)
- 列设置面板(ColumnSetting)
- 全屏切换
- 斑马纹开关
@@ -216,23 +248,35 @@ BasicTable 内置右侧工具栏,支持:
const formParams = reactive({ name: '', status: '' });
const [register, {}] = useForm({
labelWidth: 80,
layout: 'horizontal',
colProps: { span: 6 } as ColProps,
submitOnReset: true,
schemas, // querySchemas.ts 中定义
rowProps: { gutter: [16, 0] },
colProps: {
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas,
});
// 表单提交:更新查询参数,刷新表格
function handleSubmit(values: Recordable) {
handleReset();
for (const key in values) { formParams[key] = values[key]; }
for (const key in formParams) {
formParams[key] = '';
}
for (const key in values) {
formParams[key] = values[key];
}
reloadTable();
}
// 表单重置:清空查询参数,刷新表格
function handleReset() {
for (const key in formParams) { formParams[key] = ''; }
for (const key in formParams) {
formParams[key] = '';
}
reloadTable();
}
// 数据请求:合并表单参数和分页参数
@@ -250,17 +294,17 @@ const loadDataTable = async (res: any) => {
|------|------|
| `reload(opt?)` | 刷新表格(可指定 pageNo) |
| `restReload()` | 重置后刷新 |
| `reloadTable(opt?)` | 刷新表格(清空选中行) |
| `clearSelection()` | 清空多选 |
| `getDataSource()` | 获取当前数据源 |
| `setTableData(data)` | 设置表格数据 |
| `updateTableData(index, record)` | 更新指定行 |
| `setColumns(columns)` | 设置列 |
| `setLoading(loading)` | 设置 loading |
| `redoHeight()` | 重新计算高度 |
:::tip 温馨提示
BasicTable 内部自动处理分页参数(pageNo/pageSize),调用方只需提供请求函数。操作列的权限控制通过 `TableAction` 的 `auth` 属性实现,与后端 `@permission_required` 对应。`render` 函数使用 Vue 的 `h()` 函数创建虚拟 DOM。
BasicTable 内部自动处理分页参数(pageNo/pageSize),调用方只需提供请求函数。操作列通过 `bodyCell` 插槽实现,配合 `v-perm` 指令控制权限,与后端 `@permission_required` 对应。`customRender` 函数使用 Vue 的 `h()` 函数创建虚拟 DOM。
:::
## 总结
BasicTable 封装了列定义(含 `render` 函数自定义渲染)、数据请求(自动分页和 loading)、操作列(`TableAction` + 权限控制)、多选、全屏、密度、列设置等表格常用能力。通过 `columns` 配置化定义列,`request` 函数自动管理数据加载,`actionColumn` 配置操作按钮。
BasicTable 封装了列定义(含 `customRender` 函数自定义渲染)、数据请求(自动分页和 loading)、操作列(`bodyCell` 插槽 + `v-perm` 权限控制)、多选、全屏、密度、列设置等表格常用能力。通过 `columns` 配置化定义列,`request` 函数自动管理数据加载,`bodyCell` 插槽配置操作按钮。
@@ -22,17 +22,20 @@ export function getNormalPath(path: string) {
}
// 确认弹窗封装
export function confirm(msg: string, type: 'warning') {
return ElMessageBox.confirm(msg, '温馨提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: type,
export function confirm(msg: string) {
return new Promise((resolve, reject) => {
Modal.confirm({
title: '提示',
content: msg,
onOk: resolve,
onCancel: reject,
});
});
}
// 消息提示封装
export function message(msg: string, type = 'success') {
ElMessage[type](msg);
antMessage[type](msg);
}
// 文件流下载
@@ -233,9 +236,9 @@ export enum PageEnum {
```
:::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` 处理菜单数据转换。
@@ -9,21 +9,21 @@
```
src/views/
├── system/
│ ├── position/
│ │ ├── index.vue # 岗位列表页
│ │ ├── edit.vue # 岗位编辑弹窗
│ │ ├── columns.ts # 表格列定义
│ │ └── querySchemas.ts # 搜索表单定义
│ ├── user/
│ │ ├── index.vue
│ │ └── edit.vue
│ ├── role/
│ │ ├── index.vue
│ │ └── edit.vue
│ └── ...
│ ├── position/
│ │ ├── index.vue # 岗位列表页
│ │ ├── edit.vue # 岗位编辑弹窗
│ │ ├── columns.ts # 表格列定义
│ │ └── querySchemas.ts # 搜索表单定义
│ ├── user/
│ │ ├── index.vue
│ │ └── edit.vue
│ ├── role/
│ │ ├── index.vue
│ │ └── edit.vue
│ └── ...
├── cms/
│ ├── article/
│ └── ...
│ ├── article/
│ └── ...
└── ...
```
@@ -32,8 +32,8 @@ src/views/
| 文件 | 职责 | 说明 |
|------|------|------|
| `index.vue` | 列表页 | 搜索表单 + 工具栏 + BasicTable + 编辑弹窗引用 |
| `edit.vue` | 编辑弹窗 | el-dialog + el-form,懒加载 |
| `columns.ts` | 表格列定义 | 定义列 prop、label、render 函数等 |
| `edit.vue` | 编辑弹窗 | a-modal + a-form,懒加载 |
| `columns.ts` | 表格列定义 | 定列 dataIndex、title、customRender 函数等 |
| `querySchemas.ts` | 搜索表单定义 | FormSchema 数组,定义搜索字段 |
## index.vue 列表页
@@ -44,43 +44,51 @@ src/views/
<!-- src/views/system/position/index.vue -->
<template>
<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" />
</el-card>
<el-card :bordered="false" class="proCard">
</a-card>
<a-card :bordered="false" class="proCard">
<BasicTable
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
scroll-x="1200"
:row-selection="{ onChange: onSelectionChange }"
>
<template #tableTitle>
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon>
<el-icon class="el-input__icon">
<PlusOutlined />
</el-icon>
</template>
添加岗位
</el-button>
<el-button
type="danger"
@click="handleDelete()"
:disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']"
>
<template #icon>
<el-icon class="el-input__icon">
<Delete />
</el-icon>
</template>
删除
</el-button>
<a-space>
<a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<template #icon><PlusOutlined /></template>
新建
</a-button>
<a-button
type="danger"
@click="handleDelete()"
:disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']"
>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:position:update']">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:position:delete']">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-space>
</template>
</template>
</BasicTable>
</el-card>
</a-card>
<editDialog
v-if="editVisible"
@@ -92,15 +100,14 @@ src/views/
</template>
<script lang="ts" setup>
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
import { ColProps } from 'element-plus';
import { reactive, ref, nextTick, defineAsyncComponent } from 'vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { schemas } from './querySchemas';
import { useForm } from '@/components/Form/index';
import { TableAction } from '@/components/Table';
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
import { columns } from './columns';
import { PlusOutlined } from '@vicons/antd';
import { message, confirm } from '@/utils/auth';
import { Modal, message } from 'ant-design-vue';
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
const positionId = ref(0);
const editVisible = ref(false);
@@ -115,42 +122,11 @@ src/views/
status: '',
});
/**
* 定义操作栏
*/
const actionColumn = reactive({
width: 200,
label: '操作',
prop: 'action',
fixed: 'right',
render(record) {
return h(TableAction, {
style: 'button',
actions: [
{
label: '编辑',
icon: 'Edit',
type: 'warning',
onClick: handleEdit.bind(null, record),
auth: ['sys:position:update'],
},
{
label: '删除',
icon: 'Delete',
type: 'danger',
onClick: handleDelete.bind(null, record),
auth: ['sys:position:delete'],
},
],
});
},
});
/**
* 加载数据列表
* @param res 参数
*/
const loadDataTable = async (res: any) => {
const loadDataTable = async (res) => {
const result = await getPositionList({ ...formParams, ...res });
return result;
};
@@ -167,18 +143,25 @@ src/views/
* 注册
*/
const [register, {}] = useForm({
labelWidth: 80,
layout: 'horizontal',
colProps: { span: 6 } as ColProps,
submitOnReset: true,
rowProps: { gutter: [16, 0] },
colProps: {
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
},
labelCol: { span: 6, offset: 0 },
schemas,
});
/**
* 执行提交表单
*/
function handleSubmit(values: Recordable) {
handleReset();
function handleSubmit(values) {
for (const key in formParams) {
formParams[key] = '';
}
for (const key in values) {
formParams[key] = values[key];
}
@@ -192,6 +175,7 @@ src/views/
for (const key in formParams) {
formParams[key] = '';
}
reloadTable();
}
/**
@@ -205,27 +189,28 @@ src/views/
/**
* 执行编辑
* @param record 参数
* @param id 参数
*/
const handleEdit = async (record: Recordable) => {
positionId.value = record.row.id;
async function handleEdit(id) {
positionId.value = id;
await nextTick();
editVisible.value = true;
};
}
/**
* 执行删除
* @param record 参数
* @param id 参数
*/
async function handleDelete(record: Recordable) {
let ids = [];
if (!record) {
ids = selectionData.value.map(({ id }) => id);
}
await confirm('确定要删除?');
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
message('删除成功');
reloadTable();
async function handleDelete(id) {
Modal.confirm({
title: '提示',
content: '确定要删除?',
onOk: async () => {
id ? await positionDelete(id) : await positionBatchDelete(selectionData.value);
message.success('删除成功');
reloadTable();
},
});
}
/**
@@ -237,7 +222,7 @@ src/views/
}
</script>
<style lang="scss" scoped></style>
<style lang="less" scoped></style>
```
## edit.vue 编辑弹窗
@@ -245,44 +230,43 @@ src/views/
```vue
<!-- src/views/system/position/edit.vue -->
<template>
<el-dialog
v-model="props.visible"
<a-modal
v-model:visible="props.visible"
:title="props.positionId ? '编辑' : '新增'"
width="500"
:close-on-click-modal="false"
:before-close="dialogClose"
width="500px"
@cancel="dialogClose"
>
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
<el-form-item
<a-form class="ls-form" ref="formRef" :model="formData" :label-col="{ style: { width: '85px' } }">
<a-form-item
label="岗位名称"
prop="name"
name="name"
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
>
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
<el-form-item label="岗位状态" prop="status">
<el-radio-group v-model="formData.status" name="status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" />
</el-form-item>
</el-form>
<a-input v-model:value="formData.name" placeholder="请输入岗位名称" allow-clear />
</a-form-item>
<a-form-item label="岗位状态" name="status">
<a-radio-group v-model:value="formData.status" name="status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="2">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" />
</a-form-item>
</a-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogClose">取消</el-button>
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button>
<a-button @click="dialogClose">取消</a-button>
<a-button :loading="subLoading" type="primary" @click="submit"> 确定 </a-button>
</span>
</template>
</el-dialog>
</a-modal>
</template>
<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 { onMounted, reactive, shallowRef } from 'vue';
import { message } from '@/utils/auth';
import { message } from 'ant-design-vue';
import { useLockFn } from '@/utils/useLockFn';
const emit = defineEmits(['success', 'update:visible']);
@@ -320,7 +304,7 @@ src/views/
const handleSubmit = async () => {
await formRef.value?.validate();
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
message('操作成功');
message.success('操作成功');
emit('update:visible', false);
emit('success');
};
@@ -356,8 +340,6 @@ src/views/
}
});
</script>
</script>
```
## 数据流
@@ -367,7 +349,7 @@ src/views/
用户搜索 → handleSubmit → 更新 formParams → reloadTable → 重新请求
用户新增 → handleAdd → positionId=0 → editVisible=true → edit.vue 加载 → 表单填写 → 提交
用户编辑 → handleEdit → positionId=id → editVisible=true → edit.vue 加载详情 → 修改 → 提交
用户删除 → confirm → API → reloadTable
用户删除 → Modal.confirm → API → reloadTable
```
## columns.ts
@@ -375,54 +357,54 @@ src/views/
```typescript
// src/views/system/position/columns.ts
import { h } from 'vue';
import { ElTag } from 'element-plus';
import { Tag } from 'ant-design-vue';
export const columns = [
{
type: 'selection',
},
{
label: 'ID',
prop: 'id',
title: 'ID',
dataIndex: 'id',
fixed: 'left',
width: 50,
},
{
label: '岗位名称',
prop: 'name',
minWidth: 100,
title: '岗位名称',
dataIndex: 'name',
width: 100,
},
{
label: '岗位状态',
prop: 'status',
minWidth: 100,
render(record) {
title: '岗位状态',
dataIndex: 'status',
width: 100,
customRender({ record }) {
return h(
ElTag,
{
type: record.row.status == 1 ? 'success' : 'danger',
},
{
default: () => (record.row.status == 1 ? '正常' : '停用'),
},
Tag,
{ color: record.status == 1 ? 'success' : 'error' },
{ default: () => (record.status == 1 ? '正常' : '停用') },
);
},
},
{
label: '排序',
prop: 'sort',
minWidth: 100,
title: '排序',
dataIndex: 'sort',
width: 100,
},
{
label: '创建人',
prop: 'createUser',
minWidth: 100,
title: '创建人',
dataIndex: 'createUser',
width: 100,
},
{
label: '创建时间',
prop: 'createTime',
title: '创建时间',
dataIndex: 'createTime',
width: 180,
},
{
title: '操作',
fixed: 'right',
dataIndex: 'action',
key: 'action',
width: 200,
},
];
```
@@ -433,7 +415,7 @@ export const columns = [
import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
name: 'name',
component: 'Input',
label: '岗位名称',
componentProps: {
@@ -441,7 +423,7 @@ export const schemas: FormSchema[] = [
},
},
{
field: 'status',
name: 'status',
component: 'Select',
label: '状态',
componentProps: {
@@ -463,9 +445,9 @@ export const schemas: FormSchema[] = [
```
:::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 动态获取。
:::
## 总结
页面视图遵循 `index.vue` + `edit.vue` + `columns.ts` + `querySchemas.ts` 标准模式。列表页使用 `BasicForm` 搜索 + `BasicTable` 展示数据 + `TableAction` 操作按钮,编辑弹窗使用 `defineAsyncComponent` 懒加载 + `useLockFn` 防重复提交。数据流清晰:搜索更新参数 → 刷新表格,操作成功 → emit 事件 → 刷新表格。
页面视图遵循 `index.vue` + `edit.vue` + `columns.ts` + `querySchemas.ts` 标准模式。列表页使用 `BasicForm` 搜索 + `BasicTable` 展示数据 + `bodyCell` 插槽操作按钮,编辑弹窗使用 `defineAsyncComponent` 懒加载 + `useLockFn` 防重复提交。数据流清晰:搜索更新参数 → 刷新表格,操作成功 → emit 事件 → 刷新表格。
@@ -74,7 +74,7 @@ class R:
### 使用示例
```python
from src.core.response import R
from src.core import response as R
# 成功
return R.ok(data=user_info)
@@ -102,16 +102,15 @@ transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
// 业务失败(code === 1):弹出错误提示
if (code === ResultEnum.ERROR) {
ElMessage.error(msg);
message.error(msg);
throw new Error(msg);
}
// 登录超时(code === 401):弹出确认框,跳转登录页
if (code === ResultEnum.TIMEOUT) {
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', {
confirmButtonText: '确定',
showCancelButton: false,
type: 'warning',
Modal.confirm({
title: '提示',
content: '登录身份已失效,请重新登录!',
}).then(() => {
storage.clear();
window.location.href = '/login';
@@ -148,14 +147,14 @@ const result = await getPositionList(params);
| code | 说明 | 前端行为 |
|------|------|----------|
| 0 | 成功 | 返回 `data` |
| 1 | 业务失败 | `ElMessage.error(msg)` + `throw` |
| 1 | 业务失败 | `message.error(msg)` + `throw` |
| 401 | 未登录或 Token 过期 | 弹出确认框 → 跳转登录页 |
HTTP 状态码始终为 200,业务成功/失败通过 `code` 字段区分。
## 前端错误展示
业务错误通过 `ElMessage.error` 弹出提示,无需组件手动处理:
业务错误通过 `message.error` 弹出提示,无需组件手动处理:
```typescript
// 组件中直接调用,错误由拦截器处理
@@ -163,7 +162,7 @@ try {
await positionAdd(formData);
message('操作成功');
} catch (e) {
// 拦截器已弹出 ElMessage.error,此处可选处理
// 拦截器已弹出 message.error,此处可选处理
}
```
@@ -307,8 +307,8 @@ const options = [
// 业务字段为数字
formData.type = 0;
// el-option 绑定时需要转换
<el-option :value="Number(item.value)" />
// Select 绑定时需要转换
<a-select-option :value="Number(item.value)" />
```
搜索表单中的 `options` 的 `value` 为字符串 `'0'`/`'1'`,后端 `filters` 中的 `type` 字段期望数字。前端 API 传参时自动转换。
@@ -53,13 +53,13 @@ def add():
```vue
<!-- src/views/system/position/index.vue -->
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
<a-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
添加岗位
</el-button>
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
</a-button>
<a-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
v-perm="['sys:position:batchDelete']">
删除
</el-button>
</a-button>
```
### TableAction auth 属性
@@ -1,7 +1,7 @@
# IP/UA 解析
:::tip 说明
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、限流、登录锁定等场景提供客户端身份信息。
项目提供客户端 IP 获取、IP 地理位置查询和 User-Agent 解析三大能力。IP 获取位于 `src/common/utils/ip.py`,地理位置查询基于 ip2region 离线数据库(`src/common/utils/ip2region.py`),UA 解析位于 `src/common/utils/user_agent.py`。三者组合使用,为登录日志、操作日志、登录锁定等场景提供客户端身份信息。
:::
## 架构总览
@@ -21,8 +21,8 @@
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌──────────────────────────────────┐
│ 限流 │ │ get_ip_region(ip) │
│ 登录锁定 │ │ ├─ 内网/保留 IP → "内网IP" │
│ 登录锁定 │ │ get_ip_region(ip) │
│ 验证码防刷│ │ ├─ 内网/保留 IP → "内网IP" │
│ 验证码防刷│ │ ├─ ip2region.xdb 离线查询 │
└─────────┘ │ └─ 异常降级 → "未知" │
└──────────┬───────────────────────┘
@@ -49,49 +49,40 @@
### get_client_ip()
```python
def get_client_ip() -> str:
# src/common/utils/ip.py
def get_client_ip():
"""
获取客户端真实IP地址
仅当直连对端命中 TRUSTED_PROXY_IPS 白名单且 TRUSTED_PROXY_COUNT>0 时才信任
X-Forwarded-For 头:
- TRUSTED_PROXY_COUNT==0 或 TRUSTED_PROXY_IPS 未配置 / 对端不命中:不信任代理头,
直接使用 request.remote_addr(fail-closed,防止被直接暴露时伪造 XFF)
- 命中白名单:从 X-Forwarded-For 右侧取倒数第 N 个 IP 作为真实客户端IP
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
request.remote_addr为真实客户端IP。未配置ProxyFix时,
request.remote_addr即为直连IP,避免客户端伪造。
"""
from flask import request
return request.remote_addr
```
**返回值**:客户端 IP 地址字符串(如 `"1.2.3.4"`)。
### 代理链解析原理
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。此时需要从 `X-Forwarded-For`(XFF)头提取真实 IP。
当应用部署在 Nginx/HAProxy 等反向代理之后,`request.remote_addr` 拿到的是代理 IP 而非真实客户端 IP。项目通过 Flask 的 `ProxyFix` 中间件自动处理,在 `src/app.py` 中通过 `PROXY_NUM` 配置:
```
客户端 (1.2.3.4)
│
▼
外层代理 (10.0.0.1) ← 追加 XFF: 1.2.3.4
│
▼
内层代理 (10.0.0.2) ← 追加 XFF: 1.2.3.4, 10.0.0.1
│
▼
Flask 应用
request.remote_addr = 10.0.0.2
X-Forwarded-For = "1.2.3.4, 10.0.0.1"
```
```python
# src/app.py - create_app()
from werkzeug.middleware.proxy_fix import ProxyFix
**取值逻辑**:XFF 最右侧由受信代理追加,客户端可控的伪造条目只可能出现在左侧。跳过最右侧 `TRUSTED_PROXY_COUNT` 个代理条目,取真实客户端 IP。
proxy_num = int(os.getenv('PROXY_NUM', 0))
if proxy_num > 0:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_num, x_proto=proxy_num)
```
### 安全设计:fail-closed
| 场景 | 行为 |
|------|------|
| `TRUSTED_PROXY_COUNT=0` | 不信任任何代理头,直接返回 `request.remote_addr` |
| `TRUSTED_PROXY_IPS` 未配置或为空 | 同上,fail-closed |
| 直连对端不在白名单中 | 同上(防止应用被直接暴露时客户端伪造 XFF) |
| 对端命中白名单 + XFF 条目少于代理层数 | 回退到 XFF 最右侧条目(避免拿到代理 IP) |
| `PROXY_NUM=0`(默认) | 不信任任何代理头,直接返回 `request.remote_addr` |
| `PROXY_NUM>=1` | 信任 N 层代理,从 XFF 右侧取真实客户端 IP |
| `remote_addr` 为 None | 回退到 `127.0.0.1` |
### 代理配置
@@ -100,15 +91,15 @@ Flask 应用
```bash
# 信任的代理层数(0 = 不信任代理头)
TRUSTED_PROXY_COUNT=1
PROXY_NUM=1
# 可信代理IP白名单(逗号分隔)
TRUSTED_PROXY_IPS=127.0.0.1
PROXY_NUM=127.0.0.1
```
**常见部署配置**:
| 部署方式 | TRUSTED_PROXY_COUNT | TRUSTED_PROXY_IPS |
| 部署方式 | PROXY_NUM | PROXY_NUM |
|:---------|:----|:----|
| 直连(无代理) | `0` | 留空 |
| 单层 Nginx | `1` | `127.0.0.1`(本机)或 Nginx 内网 IP |
@@ -116,7 +107,7 @@ TRUSTED_PROXY_IPS=127.0.0.1
| Docker bridge 映射端口 | `0` | 留空(容器外为客户端直连) |
:::warning 安全提示
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `TRUSTED_PROXY_COUNT=0` 或 `TRUSTED_PROXY_IPS` 留空。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定和请求限流。
如果应用被直接暴露在公网(如 Docker bridge 映射端口、未走 Nginx),务必保持 `PROXY_NUM=0`。否则客户端可随意伪造 XFF 首部,绕过基于 IP 的登录锁定。
:::
## 二、IP 地理位置查询
@@ -339,29 +330,7 @@ os_name, browser_name = parse_user_agent(ua)
| `os` | `VARCHAR(100)` | 操作系统 |
| `browser` | `VARCHAR(100)` | 浏览器 |
### 3. 请求限流
基于 IP 的 Redis 滑动窗口限流,防止接口被刷:
```python
# src/middleware/rate_limit.py
client_ip = get_client_ip()
current_key = f"rl:{client_ip}:{window_index}"
count = sliding_window_incr(redis, current_key, ...)
if count > RATE_LIMIT_LIMIT:
return R.failed(msg="请求过于频繁,请 N 秒后再试")
```
**配置项**(`.env`):
| 变量 | 默认值 | 说明 |
|:-----|:-------|:-----|
| `RATE_LIMIT_ENABLED` | `False` | 是否启用限流 |
| `RATE_LIMIT_LIMIT` | `100` | 单窗口内每 IP 最大请求数 |
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | 窗口时长(秒) |
| `RATE_LIMIT_KEY_PREFIX` | `rl:` | Redis key 前缀 |
### 4. 登录失败锁定
### 3. 登录失败锁定
按 IP + 用户名维度统计登录失败次数,超过阈值自动锁定:
@@ -380,7 +349,7 @@ if locked:
| 失败计数 | `login:fail:{ip}:{username}` | 累计失败次数 |
| 锁定状态 | `login:lock:{ip}:{username}` | 锁定时自动设置 TTL |
### 5. 验证码防刷
### 4. 验证码防刷
按 IP 统计验证码请求频率,超过阈值拒绝生成新验证码:
@@ -402,21 +371,7 @@ if count and int(count) >= CAPTCHA_RATE_LIMIT:
# 可信代理配置
# ============================================================
# 信任的代理层数(0 = 不信任代理头,直接使用 request.remote_addr)
TRUSTED_PROXY_COUNT=1
# 可信代理IP白名单(逗号分隔,仅白名单内的直连对端才信任 XFF)
TRUSTED_PROXY_IPS=127.0.0.1
# ============================================================
# 请求限流配置
# ============================================================
# 是否启用请求限流(默认关闭)
RATE_LIMIT_ENABLED=False
# 单窗口内每个 IP 允许的最大请求数
RATE_LIMIT_LIMIT=100
# 统计窗口时长(秒)
RATE_LIMIT_WINDOW_SECONDS=60
# Redis 计数 key 前缀
RATE_LIMIT_KEY_PREFIX=rl:
PROXY_NUM=1
```
## 六、常见问题
@@ -425,7 +380,7 @@ RATE_LIMIT_KEY_PREFIX=rl:
**原因**:本地直连无代理,`request.remote_addr` 就是 `127.0.0.1`。
**说明**:这是正常行为。生产环境配置 `TRUSTED_PROXY_COUNT` 和 `TRUSTED_PROXY_IPS` 后即可获取真实客户端 IP。
**说明**:这是正常行为。生产环境配置 `PROXY_NUM` 后即可获取真实客户端 IP。
### 地理位置显示为"未知"
@@ -443,14 +398,6 @@ RATE_LIMIT_KEY_PREFIX=rl:
**说明**:常见于开发环境或内网部署。生产环境通过反代获取真实外网 IP 即可。
### 限流不生效
**排查步骤**:
1. 检查 `.env` 中 `RATE_LIMIT_ENABLED=True`
2. 检查 Redis 连接是否正常
3. 检查 `TRUSTED_PROXY_COUNT` 配置——如果限流基于 `request.remote_addr`(代理 IP),所有请求会共享同一 IP,导致限流提前触发或形同虚设
## 七、总结
| 模块 | 文件 | 核心能力 | 降级策略 |
@@ -459,4 +406,4 @@ RATE_LIMIT_KEY_PREFIX=rl:
| 地理位置 | `src/common/utils/ip2region.py` | 基于 ip2region.xdb 离线查询,零网络依赖 | 异常/畸形/内网统一降级返回 `"未知"` 或 `"内网IP"` |
| UA 解析 | `src/common/utils/user_agent.py` | 正则匹配提取操作系统和浏览器 | UA 为空时返回 `('', '')` |
三者组合为登录日志、操作日志、限流、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
三者组合为登录日志、操作日志、登录锁定、验证码防刷等场景提供完整的客户端身份信息链路。所有接口均采用降级策略,保证日志落库等核心流程不受异常影响。
@@ -1,124 +0,0 @@
# 日志系统
:::tip 说明
项目通过 Python 标准库 `logging` 实现结构化日志,支持控制台 + 文件双输出,日志文件按大小自动轮转。配置入口位于 `src/core/logger.py`。
:::
## 配置入口
日志配置位于 `src/core/logger.py`:
```python
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 日志目录
LOG_DIR = Path(__file__).resolve().parents[2] / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# 日志格式
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
# 轮转配置
LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
LOG_BACKUP_COUNT = 5
```
## setup_logging()
```python
def setup_logging(level: int = logging.INFO, log_file: str = "app.log") -> None:
"""
配置全局日志:root logger 输出到控制台与文件
Args:
level: 日志级别(默认 INFO)
log_file: 日志文件名(位于 logs/ 目录下)
"""
```
### 控制台输出
```python
console = logging.StreamHandler(sys.stdout)
console.setFormatter(formatter)
root.addHandler(console)
```
### 文件输出(按大小轮转)
```python
file_handler = RotatingFileHandler(
LOG_DIR / log_file,
maxBytes=LOG_MAX_BYTES, # 单文件上限 10MB
backupCount=LOG_BACKUP_COUNT, # 保留 5 份备份
encoding="utf-8",
)
```
## 日志轮转
当日志文件达到 10MB 时自动轮转:
```
logs/app.log ← 当前日志
logs/app.log.1 ← 最近一次轮转
logs/app.log.2 ← 倒数第二次
logs/app.log.3
logs/app.log.4
logs/app.log.5 ← 最早的备份(超过自动删除)
```
## 日志格式
```
2025-03-06 14:30:25,123 | INFO | modules.system.user.service | 用户登录成功
```
格式说明:
| 字段 | 说明 |
|------|------|
| `asctime` | 时间戳 |
| `levelname` | 日志级别(右对齐7字符) |
| `name` | 模块名称 |
| `message` | 日志内容 |
## 使用方式
在各模块中获取 logger:
```python
import logging
logger = logging.getLogger(__name__)
logger.info("操作成功")
logger.warning("配置缺失")
logger.error(f"异常信息: {str(e)}")
```
## 框架日志集成
Flask 和 SQLAlchemy 的默认日志统一走 root logger:
```python
for name in ("flask", "sqlalchemy"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
```
:::tip 温馨提示
`setup_logging()` 会检查是否已添加 handler(通过 `_djangoadmin` 标记),避免重复添加。可在应用启动时调用一次。
:::
## 操作日志
除了应用日志外,还通过 `src/middleware/operation_log.py` 中间件自动记录用户的写操作日志,存储到数据库 `flask_operation_log` 表中。详见 [操作日志](/zh/volume4/core/operation-log) 章节。
## 总结
日志系统通过 `RotatingFileHandler` 实现按大小轮转,单文件上限 10MB 保留 5 份备份,防止日志无限增长撑爆磁盘。控制台和文件双输出,格式统一包含时间戳、级别、模块名和消息内容。
@@ -4,22 +4,22 @@
`src/common/utils/request.py` 提供请求数据解析相关的工具函数,包括批量 ID 解析、SQL LIKE 安全处理、分页参数解析等。这些工具函数在各业务模块中广泛使用。
:::
## parse_id_list
## parse_batch_ids
从请求体解析批量删除的 ID 数组,校验并转为整数列表。
```python
from flask import request
from src.common.utils.request import parse_id_list
from src.common.utils.request import parse_batch_ids
def parse_id_list(request) -> list:
def parse_batch_ids(request) -> list:
"""
从请求体解析 ID 数组
请求体格式: [1, 2, 3]
返回: [1, 2, 3]
示例:
>>> parse_id_list(request)
>>> parse_batch_ids(request)
[1, 2, 3]
"""
ids = request.get_json()
@@ -38,9 +38,9 @@ def parse_id_list(request) -> list:
@position_blue.route('/batchDelete', methods=['DELETE'])
@check_permission('sys:position:batchDelete')
def batch_delete():
from src.modules.system.position.service import batch_delete_positions
id_list = parse_id_list(request)
return batch_delete_positions(id_list)
from src.modules.system.position.service import delete_positions
id_list = parse_batch_ids(request)
return delete_positions(id_list)
```
## parse_request_body
@@ -207,4 +207,4 @@ def get_client_ip() -> str:
## 总结
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_id_list` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
请求解析工具函数覆盖了批量操作、模糊查询安全、分页参数等常见场景。`parse_batch_ids` 和 `parse_pagination` 通过防御性校验避免畸形参数导致 500 错误,`like_escape` 防止 LIKE 通配符注入,`parse_request_body` 使用 WTForms 进行参数校验。
@@ -173,19 +173,20 @@ Service 层的 `BaseService._enrich_file_urls()` 会自动为文件字段补此
```vue
<template>
<el-upload
<a-upload
:action="uploadUrl"
:headers="headers"
:on-success="handleSuccess"
@change="handleChange"
:before-upload="beforeUpload"
>
<el-button type="primary">上传文件</el-button>
</el-upload>
<a-button type="primary">上传文件</a-button>
</a-upload>
</template>
<script setup>
import { ref } from 'vue';
import { useUserStore } from '@/store/modules/user';
import { message } from 'ant-design-vue';
const uploadUrl = '/api/v1/upload/uploadFile';
const userStore = useUserStore();
@@ -196,7 +197,7 @@ const headers = {
function beforeUpload(file) {
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
ElMessage.error('文件大小不能超过 10MB');
message.error('文件大小不能超过 10MB');
}
return isLt10M;
}
@@ -214,7 +215,7 @@ function handleSuccess(response) {
```vue
<template>
<el-image
<a-image
v-if="form.cover"
:src="getFileUrl(form.cover)"
:preview-src-list="[getFileUrl(form.cover)]"