优化更新
This commit is contained in:
@@ -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)]"
|
||||
|
||||
Reference in New Issue
Block a user