签入版本
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# 接口响应规范
|
||||
|
||||
:::tip 说明
|
||||
后端所有接口统一返回 `R.ok()` / `R.failed()` 格式的 JSON 响应,前端通过 `VAxios` 的 `transformRequestData` 拦截器统一解包处理。
|
||||
:::
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { "id": 1, "name": "测试岗位", "status": 1 },
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": null,
|
||||
"msg": "岗位名称不能重复",
|
||||
"ok": false
|
||||
}
|
||||
```
|
||||
|
||||
### 分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{ "id": 1, "name": "岗位1", "status": 1, "status_name": "正常" },
|
||||
{ "id": 2, "name": "岗位2", "status": 2, "status_name": "停用" }
|
||||
],
|
||||
"count": 100,
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## 后端实现
|
||||
|
||||
位于 `src/core/response.py`:
|
||||
|
||||
```python
|
||||
class R:
|
||||
@staticmethod
|
||||
def ok(data=None, msg="操作成功", **kwargs):
|
||||
return JSONResponse({
|
||||
"code": 0,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": True,
|
||||
**kwargs, # 支持额外字段,如 count
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def failed(msg="操作失败", data=None):
|
||||
return JSONResponse({
|
||||
"code": 1,
|
||||
"data": data,
|
||||
"msg": msg,
|
||||
"ok": False,
|
||||
})
|
||||
```
|
||||
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
# 成功
|
||||
return R.ok(data=user_info)
|
||||
return R.ok(data=list, count=total) # 分页
|
||||
return R.ok(msg="添加成功")
|
||||
|
||||
# 失败
|
||||
return R.failed(msg="用户名已存在")
|
||||
return R.failed("参数错误")
|
||||
```
|
||||
|
||||
## 前端拦截器
|
||||
|
||||
`VAxios` 的 `transformRequestData` 统一解包响应:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
|
||||
const { code, data, msg } = res.data;
|
||||
|
||||
// 业务成功(code === 0):直接返回 data
|
||||
if (code === ResultEnum.SUCCESS) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 业务失败(code === 1):弹出错误提示
|
||||
if (code === ResultEnum.ERROR) {
|
||||
ElMessage.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// 登录超时(code === 401):弹出确认框,跳转登录页
|
||||
if (code === ResultEnum.TIMEOUT) {
|
||||
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
showCancelButton: false,
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
storage.clear();
|
||||
window.location.href = '/login';
|
||||
});
|
||||
throw new Error(msg);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
解包后调用方拿到的是 `data` 字段内容:
|
||||
|
||||
```typescript
|
||||
// 普通接口:data 就是业务数据
|
||||
const result = await positionAdd(formData);
|
||||
// result = { id: 1, name: "测试岗位", ... }
|
||||
|
||||
// 分页接口:data 包含 list 和 count
|
||||
const result = await getPositionList(params);
|
||||
// result = { data: [...], count: 100, msg: "操作成功", ok: true, code: 0 }
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | number | 业务码:0=成功,1=失败,401=登录超时 |
|
||||
| `data` | any | 业务数据 |
|
||||
| `count` | number | 分页总数(仅分页接口) |
|
||||
| `msg` | string | 提示消息 |
|
||||
| `ok` | boolean | 业务是否成功 |
|
||||
|
||||
## 错误码规范
|
||||
|
||||
| code | 说明 | 前端行为 |
|
||||
|------|------|----------|
|
||||
| 0 | 成功 | 返回 `data` |
|
||||
| 1 | 业务失败 | `ElMessage.error(msg)` + `throw` |
|
||||
| 401 | 未登录或 Token 过期 | 弹出确认框 → 跳转登录页 |
|
||||
|
||||
HTTP 状态码始终为 200,业务成功/失败通过 `code` 字段区分。
|
||||
|
||||
## 前端错误展示
|
||||
|
||||
业务错误通过 `ElMessage.error` 弹出提示,无需组件手动处理:
|
||||
|
||||
```typescript
|
||||
// 组件中直接调用,错误由拦截器处理
|
||||
try {
|
||||
await positionAdd(formData);
|
||||
message('操作成功');
|
||||
} catch (e) {
|
||||
// 拦截器已弹出 ElMessage.error,此处可选处理
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
HTTP 状态码始终为 200,业务成功/失败通过 `code` 字段区分。这避免了 HTTP 状态码与业务状态码的混淆。前端拦截器统一处理错误提示,业务代码只需处理成功逻辑。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
接口响应规范统一使用 `R.ok()`/`R.failed()` 格式,`code=0` 表示成功返回 `data`,`code=1` 表示失败弹出 `msg`,`code=401` 表示登录超时跳转登录页。前端 `VAxios` 的 `transformRequestData` 拦截器统一解包,调用方直接使用业务数据。
|
||||
@@ -0,0 +1,365 @@
|
||||
# 字典数据联调
|
||||
|
||||
:::tip 说明
|
||||
字典数据通过后端字典 API 获取,前端在下拉选择器中渲染选项。字典项的 `value` 字段统一为字符串类型,前端使用时需要注意类型转换。
|
||||
:::
|
||||
|
||||
## 后端字典 API
|
||||
|
||||
### 获取字典数据
|
||||
|
||||
```
|
||||
GET /api/v1/dict/data/{code}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{ "id": 1, "label": "系统", "value": "0" },
|
||||
{ "id": 2, "label": "业务", "value": "1" }
|
||||
],
|
||||
"msg": "操作成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 字典数据结构
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | number | 字典项 ID |
|
||||
| `label` | string | 显示文本 |
|
||||
| `value` | string | 值(注意:统一为字符串) |
|
||||
|
||||
:::warning 值类型注意
|
||||
字典项的 `value` 字段统一为字符串类型,即使存储的是数字。前端使用时需要注意类型转换。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 序列化映射
|
||||
|
||||
BaseService 的 `serialize_maps` 自动将字典值映射为显示名,列表和详情接口均生效。
|
||||
|
||||
### 基本用法(以 param 模块为例)
|
||||
|
||||
```python
|
||||
# src/modules/param/service.py
|
||||
class ParamService(BaseService[Param]):
|
||||
repo = param_repo
|
||||
model = Param
|
||||
# 字段名 → 数据字典编码
|
||||
serialize_maps = {'type': 'param_type', 'status': 'param_status'}
|
||||
```
|
||||
|
||||
`serialize_maps` 的 key 是模型字段名,value 是数据字典编码。BaseService 序列化时自动:
|
||||
|
||||
1. 取出模型字段的原始值(如 `type=0`)
|
||||
2. 从数据字典查询对应的 label(如 `param_type` 字典中 `value='0'` 的 `label='系统'`)
|
||||
3. 以 `字段名驼峰 + Text` 为 key 写入响应(如 `type` → `typeText`)
|
||||
|
||||
### 响应示例
|
||||
|
||||
模型原始数据:
|
||||
|
||||
```
|
||||
Param(id=1, name="默认密码", code="DEFAULT_PASSWORD", value="123456", type=0, status=1, sort=1)
|
||||
```
|
||||
|
||||
列表/详情接口返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "默认密码",
|
||||
"code": "DEFAULT_PASSWORD",
|
||||
"value": "123456",
|
||||
"type": 0,
|
||||
"typeText": "系统",
|
||||
"status": 1,
|
||||
"statusText": "正常",
|
||||
"sort": 1,
|
||||
"note": null,
|
||||
"createUser": "管理员",
|
||||
"createTime": "2025-03-06 14:30:25"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段命名规则
|
||||
|
||||
| 模型字段 | serialize_maps key | 响应字段 | 说明 |
|
||||
|----------|-------------------|----------|------|
|
||||
| `type` | `'type'` | `typeText` | snake_case → camelCase + `Text` |
|
||||
| `status` | `'status'` | `statusText` | 同上 |
|
||||
| `log_type` | `'log_type'` | `logTypeText` | 下划线也转驼峰 |
|
||||
| `source` | `'source'` | `sourceText` | 同上 |
|
||||
|
||||
转换逻辑(`src/utils/string.py`):
|
||||
|
||||
```python
|
||||
def convert_camel_case(name: str) -> str:
|
||||
"""snake_case → camelCase(log_type → logType)"""
|
||||
parts = name.split('_')
|
||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||
```
|
||||
|
||||
### 数据字典编码
|
||||
|
||||
`serialize_maps` 的 value 必须是已配置的数据字典编码。常用编码:
|
||||
|
||||
| 字典编码 | 说明 | 字典项示例 |
|
||||
|----------|------|-----------|
|
||||
| `param_type` | 参数类型 | 0-系统, 1-业务 |
|
||||
| `param_status` | 参数状态 | 1-正常, 2-禁用 |
|
||||
| `operation_type` | 操作类型 | 0-其他, 1-新增, 2-修改, 3-删除 |
|
||||
| `operation_source` | 操作来源 | 0-后台, 1-前台 |
|
||||
| `operation_status` | 操作状态 | 0-正常, 1-异常 |
|
||||
| `log_type` | 日志类型 | 1-登录, 2-退出 |
|
||||
| `login_source` | 登录来源 | 0-后台, 1-前台 |
|
||||
| `login_status` | 登录状态 | 0-成功, 1-失败 |
|
||||
|
||||
字典编码在后台「字典管理」中配置,或通过 `scripts/seed_dict.py` 初始化。
|
||||
|
||||
### 完整示例:param 模块
|
||||
|
||||
**后端 Service**:
|
||||
|
||||
```python
|
||||
# src/modules/param/service.py
|
||||
class ParamService(BaseService[Param]):
|
||||
repo = param_repo
|
||||
model = Param
|
||||
page_like_fields = ('name', 'code')
|
||||
page_eq_fields = ('type', 'status')
|
||||
page_order_by = (('sort', 'asc'), ('id', 'desc'))
|
||||
unique_fields = {'name': '参数名称不能重复', 'code': '参数编码不能重复'}
|
||||
# 枚举显示名:字段名 → 数据字典编码
|
||||
serialize_maps = {'type': 'param_type', 'status': 'param_status'}
|
||||
```
|
||||
|
||||
**前端 columns.ts**:
|
||||
|
||||
```typescript
|
||||
// src/views/system/param/columns.ts
|
||||
{
|
||||
label: '参数类型',
|
||||
prop: 'type',
|
||||
width: 100,
|
||||
// 方式一:直接使用后端返回的 typeText 字段
|
||||
render(record) {
|
||||
return h(ElTag,
|
||||
{ type: record.row.type == 0 ? 'primary' : 'warning' },
|
||||
{ default: () => record.row.typeText });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'status',
|
||||
width: 100,
|
||||
// 方式二:直接使用后端返回的 statusText 字段
|
||||
render(record) {
|
||||
return h(ElTag,
|
||||
{ type: record.row.status == 1 ? 'success' : 'danger' },
|
||||
{ default: () => record.row.statusText });
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### 其他模块示例
|
||||
|
||||
```python
|
||||
# src/modules/operation_log/service.py
|
||||
serialize_maps = {
|
||||
'type': 'operation_type', # typeText: "新增"/"修改"/"删除"
|
||||
'source': 'operation_source', # sourceText: "后台"/"前台"
|
||||
'status': 'operation_status', # statusText: "正常"/"异常"
|
||||
}
|
||||
|
||||
# src/modules/login_log/service.py
|
||||
serialize_maps = {
|
||||
'log_type': 'log_type', # logTypeText: "登录"/"退出"
|
||||
'source': 'login_source', # sourceText: "后台"/"前台"
|
||||
'status': 'login_status', # statusText: "成功"/"失败"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端字典管理
|
||||
|
||||
### 搜索表单中的字典选项
|
||||
|
||||
搜索表单中直接硬编码选项(简单场景)或通过 API 获取(动态场景):
|
||||
|
||||
**硬编码选项(简单场景)**
|
||||
|
||||
```typescript
|
||||
// src/views/system/param/querySchemas.ts
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'type',
|
||||
component: 'Select',
|
||||
label: '参数类型',
|
||||
componentProps: {
|
||||
placeholder: '请选择参数类型',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '系统', value: 0 },
|
||||
{ label: '业务', value: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '正常', value: 1 },
|
||||
{ label: '禁用', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**API 获取选项(动态场景)**
|
||||
|
||||
```typescript
|
||||
import { getDictItemByCode } from '@/api/common';
|
||||
|
||||
const typeOptions = ref([]);
|
||||
const statusOptions = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const res1 = await getDictItemByCode('param_type');
|
||||
typeOptions.value = res1.data; // [{ label: '系统', value: '0' }, ...]
|
||||
|
||||
const res2 = await getDictItemByCode('param_status');
|
||||
statusOptions.value = res2.data;
|
||||
});
|
||||
|
||||
const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'type',
|
||||
component: 'Select',
|
||||
label: '参数类型',
|
||||
componentProps: { options: typeOptions },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: { options: statusOptions },
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 前端字典 API
|
||||
|
||||
字典相关 API 位于 `src/api/data/dictionary.ts`:
|
||||
|
||||
```typescript
|
||||
// src/api/data/dictionary.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
// 字典列表(分页)
|
||||
export function getDictList(params?) {
|
||||
return http.request({ url: '/dict/page', method: 'GET', params });
|
||||
}
|
||||
|
||||
// 字典项列表(分页)
|
||||
export function getDictItemList(params?) {
|
||||
return http.request({ url: '/dict/item/page', method: 'GET', params });
|
||||
}
|
||||
```
|
||||
|
||||
按字典编码获取字典项(下拉框用)位于 `src/api/common/index.ts`:
|
||||
|
||||
```typescript
|
||||
// src/api/common/index.ts
|
||||
export function getDictItemByCode(code) {
|
||||
return http.request({
|
||||
url: '/dict/item/getDictItemList/' + code,
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
后端对应接口:`GET /api/v1/dict/data/{code}`(`src/api/v1/endpoints/dict.py`)。
|
||||
|
||||
---
|
||||
|
||||
## 值类型处理
|
||||
|
||||
字典 value 统一为字符串,但业务字段可能为数字,需要注意转换:
|
||||
|
||||
```typescript
|
||||
// 字典选项值为字符串
|
||||
const options = [
|
||||
{ label: '系统', value: '0' },
|
||||
{ label: '业务', value: '1' },
|
||||
];
|
||||
|
||||
// 业务字段为数字
|
||||
formData.type = 0;
|
||||
|
||||
// el-option 绑定时需要转换
|
||||
<el-option :value="Number(item.value)" />
|
||||
```
|
||||
|
||||
搜索表单中的 `options` 的 `value` 为字符串 `'0'`/`'1'`,后端 `page_eq_fields` 中的 `type` 字段期望数字。前端 API 传参时自动转换。
|
||||
|
||||
---
|
||||
|
||||
## 字典管理
|
||||
|
||||
### 配置字典
|
||||
|
||||
在后台「字典管理」中配置字典类型和字典项:
|
||||
|
||||
```
|
||||
字典类型: param_type(参数类型)
|
||||
字典项:
|
||||
- label: 系统, value: 0
|
||||
- label: 业务, value: 1
|
||||
|
||||
字典类型: param_status(参数状态)
|
||||
字典项:
|
||||
- label: 正常, value: 1
|
||||
- label: 禁用, value: 2
|
||||
```
|
||||
|
||||
### 字典类型命名规范
|
||||
|
||||
```
|
||||
sys_ —— 系统类字典(sys_status, sys_gender 等)
|
||||
param_ —— 参数类字典(param_type, param_status 等)
|
||||
operation_ —— 操作日志类字典(operation_type, operation_status 等)
|
||||
login_ —— 登录日志类字典(log_type, login_source, login_status 等)
|
||||
cms_ —— 内容类字典(cms_article_status 等)
|
||||
biz_ —— 业务类字典
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 联调流程
|
||||
|
||||
```
|
||||
1. 后台「字典管理」中新增字典类型和字典项
|
||||
2. 后端 service 设置 serialize_maps(字段名 → 字典编码)
|
||||
3. 前端 columns.ts 使用 render 函数渲染状态标签(直接用 xxxText 字段)
|
||||
4. 前端 querySchemas.ts 中配置搜索选项(硬编码或 API 获取)
|
||||
5. 表单提交时 value 为字符串,后端存储时注意类型处理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
字典数据联调通过后端字典 API + `serialize_maps` 映射显示名 + 前端 `render` 函数渲染标签实现。`serialize_maps` 的 value 为数据字典编码,BaseService 自动查询字典并以 `xxxText` 格式输出。字典值统一为字符串,前端使用时注意类型转换。简单场景硬编码选项,动态场景通过 API 获取。
|
||||
@@ -0,0 +1,189 @@
|
||||
# 权限节点联动
|
||||
|
||||
:::tip 说明
|
||||
前后端权限通过统一的权限节点字符串联动,后端 `@permission_required` 校验权限,前端 `v-perm` 指令和 `TableAction` 的 `auth` 属性控制按钮显示。
|
||||
:::
|
||||
|
||||
## 权限节点格式
|
||||
|
||||
统一格式:`sys:{module}:{action}`
|
||||
|
||||
```
|
||||
sys:position:page —— 分页查询
|
||||
sys:position:detail —— 查询详情
|
||||
sys:position:list —— 获取列表
|
||||
sys:position:add —— 新增
|
||||
sys:position:update —— 编辑
|
||||
sys:position:delete —— 删除
|
||||
sys:position:batchDelete —— 批量删除
|
||||
sys:position:status —— 设置状态
|
||||
sys:position:export —— 导出
|
||||
sys:position:import —— 导入
|
||||
```
|
||||
|
||||
## 后端权限校验
|
||||
|
||||
Endpoint 使用 `@permission_required` 装饰器:
|
||||
|
||||
```python
|
||||
# src/api/v1/endpoints/position.py
|
||||
@router.post('/add')
|
||||
@permission_required("sys:position:add")
|
||||
@check_demo
|
||||
def add(request: Request, data: PositionForm):
|
||||
return position_service.add(request, data)
|
||||
```
|
||||
|
||||
校验流程:
|
||||
|
||||
```
|
||||
1. login_required 从 JWT Token 解析用户 ID
|
||||
2. permission_required 查询用户角色 → 角色菜单 → 权限节点列表
|
||||
3. 检查 endpoint 要求的权限节点是否在列表中
|
||||
4. 用户 ID=1(管理员)跳过检查
|
||||
```
|
||||
|
||||
## 前端权限控制
|
||||
|
||||
### v-perm 指令
|
||||
|
||||
按钮使用 `v-perm` 指令(满足任一权限即可显示):
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/index.vue -->
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
|
||||
添加岗位
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
|
||||
v-perm="['sys:position:batchDelete']">
|
||||
删除
|
||||
</el-button>
|
||||
```
|
||||
|
||||
### TableAction auth 属性
|
||||
|
||||
操作列通过 `TableAction` 组件的 `auth` 属性控制:
|
||||
|
||||
```typescript
|
||||
const actionColumn = {
|
||||
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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### usePermission Hook
|
||||
|
||||
在逻辑中检查权限:
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
const { hasPermission, hasSomePermission } = usePermission();
|
||||
|
||||
// v-if 条件渲染
|
||||
const showExportBtn = computed(() => hasPermission(['sys:position:export']));
|
||||
```
|
||||
|
||||
## 菜单配置
|
||||
|
||||
权限节点在菜单管理中配置,菜单类型为「按钮」:
|
||||
|
||||
```
|
||||
菜单管理 → 系统管理 → 岗位管理
|
||||
├── 岗位查询(sys:position:page)—— 类型:菜单
|
||||
├── 岗位新增(sys:position:add)—— 类型:按钮
|
||||
├── 岗位编辑(sys:position:update)—— 类型:按钮
|
||||
├── 岗位删除(sys:position:delete)—— 类型:按钮
|
||||
├── 岗位批量删除(sys:position:batchDelete)—— 类型:按钮
|
||||
└── 岗位状态(sys:position:status)—— 类型:按钮
|
||||
```
|
||||
|
||||
## 权限数据流
|
||||
|
||||
```
|
||||
后端菜单表 → adminMenus API → routerGenerator 生成路由表(含 permission 字段)
|
||||
↓
|
||||
userStore.GetInfo() → 获取 permissions 列表
|
||||
↓
|
||||
v-perm 指令 mounted → hasSomePermission(permissions)
|
||||
↓
|
||||
有权限 → 保留元素 / 无权限 → 移除元素
|
||||
```
|
||||
|
||||
前端权限列表在登录后通过 `getUserInfo` 接口获取,存储在 user store 的 `permissions` 数组中:
|
||||
|
||||
```typescript
|
||||
// src/store/modules/user.ts
|
||||
GetInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getUserInfo().then((res) => {
|
||||
if (res.permissions && res.permissions.length) {
|
||||
this.setPermissions(res.permissions);
|
||||
// permissions 示例:["sys:position:page", "sys:position:add", ...]
|
||||
}
|
||||
this.setUserInfo(res);
|
||||
resolve(res);
|
||||
}).catch(reject);
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
## 联调要点
|
||||
|
||||
```
|
||||
1. 后端 endpoint 的 permission_required 权限字符串必须与菜单配置的节点一致
|
||||
2. 前端 v-perm 指令和 TableAction auth 的权限字符串必须与菜单配置的节点一致
|
||||
3. 权限字符串区分大小写
|
||||
4. 用户 ID=1(管理员)拥有所有权限(后端跳过检查)
|
||||
5. 前端权限列表包含 "*:*:*" 通配符时跳过所有检查
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 按钮不显示
|
||||
|
||||
```
|
||||
检查步骤:
|
||||
1. 确认菜单管理中已配置对应的按钮权限节点
|
||||
2. 确认角色已分配该菜单权限
|
||||
3. 确认用户已关联该角色
|
||||
4. 确认前端 v-perm 字符串与菜单节点完全一致(区分大小写)
|
||||
5. 确认 userStore.permissions 已正确加载
|
||||
```
|
||||
|
||||
### 接口 403
|
||||
|
||||
```
|
||||
检查步骤:
|
||||
1. 确认后端 permission_required 字符串与菜单节点一致
|
||||
2. 确认菜单类型为「按钮」而非「菜单」
|
||||
3. 确认角色已分配该权限
|
||||
4. 确认 Token 未过期
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
权限节点的命名建议遵循 `sys:{module}:{action}` 规范,保持前后端一致。新增模块时,先在菜单管理中配置权限节点,再在后端和前端使用。前端 `v-perm` 仅做 UI 层隐藏,真正的权限校验在后端。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
前后端权限通过统一的权限节点字符串联动。后端 `@permission_required` 做真正的权限校验(用户 ID=1 跳过),前端 `v-perm` 指令和 `TableAction` 的 `auth` 属性做 UI 层隐藏。权限列表在登录时从后端获取,存储在 user store 中。`*:*:*` 通配符表示超级管理员。
|
||||
@@ -0,0 +1,194 @@
|
||||
# 代理配置
|
||||
|
||||
:::tip 说明
|
||||
开发环境通过 Vite 代理转发 API 请求到后端,生产环境通过 Nginx 代理。代理配置由 `VITE_PROXY` 环境变量控制。
|
||||
:::
|
||||
|
||||
## 开发环境代理
|
||||
|
||||
### Vite 配置
|
||||
|
||||
`vite.config.ts` 中通过 `createProxy` 工具函数解析 `VITE_PROXY` 环境变量生成代理配置:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { createProxy } from './build/vite/proxy';
|
||||
|
||||
export default ({ command, mode }: ConfigEnv): UserConfig => {
|
||||
let VITE_PROXY: any = viteEnv.VITE_PROXY;
|
||||
if (!VITE_PROXY) VITE_PROXY = '[[]]';
|
||||
try {
|
||||
if (typeof VITE_PROXY === 'string') VITE_PROXY = JSON.parse(VITE_PROXY);
|
||||
} catch (e) {
|
||||
VITE_PROXY = [[]];
|
||||
}
|
||||
|
||||
return {
|
||||
server: {
|
||||
host: true,
|
||||
port: VITE_PORT,
|
||||
proxy: createProxy(VITE_PROXY),
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
`.env` 文件中配置代理规则(JSON 数组格式):
|
||||
|
||||
```bash
|
||||
# 格式:[["前缀","目标地址"]]
|
||||
VITE_PROXY=[["/api","http://127.0.0.1:8031/api/v1"]]
|
||||
```
|
||||
|
||||
`createProxy` 函数将此配置转换为 Vite 的 `server.proxy` 格式,自动添加 `changeOrigin: true` 等选项。
|
||||
|
||||
### 代理规则
|
||||
|
||||
```
|
||||
前端请求: http://localhost:8001/api/v1/position/page
|
||||
代理转发: http://127.0.0.1:8031/api/v1/position/page
|
||||
```
|
||||
|
||||
`/api` 前缀的请求被代理到后端 `http://127.0.0.1:8031`,`changeOrigin: true` 修改请求头的 Host 为目标地址。
|
||||
|
||||
### 开发流程
|
||||
|
||||
```
|
||||
1. 启动后端: python src/main.py (127.0.0.1:8031)
|
||||
2. 启动前端: pnpm dev (localhost:8001)
|
||||
3. 浏览器访问 localhost:8001
|
||||
4. API 请求自动代理到 127.0.0.1:8031
|
||||
```
|
||||
|
||||
### 前端请求路径
|
||||
|
||||
前端 API 函数中的 URL 不含 `/api/v1` 前缀:
|
||||
|
||||
```typescript
|
||||
// src/api/system/position.ts
|
||||
export function getPositionList(params?) {
|
||||
return http.request({
|
||||
url: '/position/page', // 实际请求:/api/v1/position/page
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`VAxios` 创建时配置了 `prefixUrl` 和 `urlPrefix`,`beforeRequestHook` 自动拼接:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
const urlPrefix = globSetting.urlPrefix || '/v1';
|
||||
|
||||
createAxios({
|
||||
prefixUrl: urlPrefix, // '/v1'
|
||||
requestOptions: {
|
||||
joinPrefix: true,
|
||||
apiUrl: globSetting.apiUrl, // '/api'
|
||||
urlPrefix: urlPrefix, // '/v1'
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
最终请求路径:`/api` + `/v1` + `/position/page` = `/api/v1/position/page`
|
||||
|
||||
## 生产环境代理
|
||||
|
||||
### Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /path/to/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html; # SPA 路由回退
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8031/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 超时配置
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx 代理说明
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `proxy_pass` | 转发目标地址 |
|
||||
| `proxy_set_header Host` | 传递原始 Host |
|
||||
| `proxy_set_header X-Real-IP` | 传递客户端真实 IP |
|
||||
| `proxy_set_header X-Forwarded-For` | 传递代理链 IP |
|
||||
| `proxy_set_header X-Forwarded-Proto` | 传递原始协议 |
|
||||
| `proxy_connect_timeout` | 连接超时 |
|
||||
| `proxy_read_timeout` | 读取超时 |
|
||||
|
||||
## 文件访问代理
|
||||
|
||||
上传文件的访问也需要代理:
|
||||
|
||||
```nginx
|
||||
# 文件访问
|
||||
location /api/file/ {
|
||||
proxy_pass http://127.0.0.1:8031/api/file/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# 静态文件缓存
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
后端的文件访问地址需要与前端一致。开发环境为 `http://127.0.0.1:8031/api/file`,生产环境需要在 `.env.production` 中配置 `VITE_GLOB_FILE_URL` 为实际域名。
|
||||
:::
|
||||
|
||||
## Docker 代理
|
||||
|
||||
Docker 环境中通过 docker-compose 网络通信:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./dist:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
expose:
|
||||
- "8031"
|
||||
```
|
||||
|
||||
Nginx 配置中 `proxy_pass` 指向 Docker 服务名:
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8031/api/;
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
开发环境通过 `VITE_PROXY` 环境变量配置 Vite 代理,`createProxy` 函数解析 JSON 格式的代理规则。前端 API 路径不含 `/api/v1` 前缀,由 `VAxios` 的 `beforeRequestHook` 自动拼接。生产环境通过 Nginx 代理并配置超时、缓存等参数,Docker 环境通过 docker-compose 网络实现服务间通信。
|
||||
@@ -0,0 +1,193 @@
|
||||
# 文件上传联调
|
||||
|
||||
:::tip 说明
|
||||
文件上传涉及前端 Upload 组件、后端上传接口和 URL 拼接三个环节,需要前后端协同配置。
|
||||
:::
|
||||
|
||||
## 上传流程
|
||||
|
||||
```
|
||||
1. 前端 Upload 组件选择文件
|
||||
2. VAxios.uploadFile() 发送 multipart/form-data POST 请求到 /api/v1/upload/uploadFile
|
||||
3. 后端校验文件(扩展名、大小、魔术字节)
|
||||
4. 后端保存文件到临时目录,返回文件信息(fileUrl 等)
|
||||
5. 前端将 fileUrl 存入业务字段
|
||||
6. 业务数据提交时携带 fileUrl
|
||||
7. 后端 save_file() 将临时文件迁移到正式目录
|
||||
```
|
||||
|
||||
## 前端上传实现
|
||||
|
||||
### VAxios.uploadFile
|
||||
|
||||
`VAxios` 类内置 `uploadFile` 方法:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/Axios.ts
|
||||
uploadFile<T = any>(config: AxiosRequestConfig, params: UploadFileParams) {
|
||||
const formData = new window.FormData();
|
||||
const customFilename = params.name || 'file';
|
||||
|
||||
if (params.filename) {
|
||||
formData.append(customFilename, params.file, params.filename);
|
||||
} else {
|
||||
formData.append(customFilename, params.file);
|
||||
}
|
||||
|
||||
if (params.data) {
|
||||
Object.keys(params.data).forEach((key) => {
|
||||
const value = params.data![key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => formData.append(`${key}[]`, item));
|
||||
return;
|
||||
}
|
||||
formData.append(key, params.data![key]);
|
||||
});
|
||||
}
|
||||
|
||||
return this.axiosInstance.request<T>({
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-type': ContentTypeEnum.FORM_DATA,
|
||||
ignoreCancelToken: true,
|
||||
},
|
||||
...config,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Upload 组件使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Upload v-model="formData.cover" :limit="1" accept="image/*" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const formData = ref({
|
||||
name: '',
|
||||
cover: '', // 存储文件 URL
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Upload 组件内部调用上传接口:
|
||||
|
||||
```typescript
|
||||
const handleUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await uploadFile(formData);
|
||||
// res.data.fileUrl = "http://127.0.0.1:8031/api/file/20250306/xxx.jpg"
|
||||
emit('update:modelValue', res.data.fileUrl);
|
||||
};
|
||||
```
|
||||
|
||||
## 后端上传接口
|
||||
|
||||
```
|
||||
POST /api/v1/upload/uploadFile
|
||||
Content-Type: multipart/form-data
|
||||
参数: file(文件字段)
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"originalName": "photo.jpg",
|
||||
"fileExtension": "jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 306995,
|
||||
"fileName": "2025030614302512345.jpg",
|
||||
"filePath": "/20250306/2025030614302512345.jpg",
|
||||
"fileUrl": "http://127.0.0.1:8031/api/file/20250306/2025030614302512345.jpg"
|
||||
},
|
||||
"msg": "上传成功",
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
## URL 拼接
|
||||
|
||||
### 后端拼接
|
||||
|
||||
后端 `save_file()` 函数处理文件迁移后返回相对路径,`get_file_url()` 拼接完整 URL:
|
||||
|
||||
```python
|
||||
def get_file_url(path):
|
||||
if not path:
|
||||
return ""
|
||||
if path.find(FASTAPI_FILE_URL) != -1:
|
||||
return path
|
||||
return FASTAPI_FILE_URL + path
|
||||
```
|
||||
|
||||
### 前端显示
|
||||
|
||||
前端显示图片时,需要拼接完整的 URL:
|
||||
|
||||
```typescript
|
||||
const getFileUrl = (path) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return import.meta.env.VITE_GLOB_FILE_URL + path;
|
||||
};
|
||||
```
|
||||
|
||||
## 临时文件迁移
|
||||
|
||||
业务数据提交时,后端 service 调用 `save_file()` 将临时文件迁移到正式目录:
|
||||
|
||||
```python
|
||||
# 临时路径: {temp}/{日期}/{文件名}
|
||||
# 正式路径: {upload_dir}/{分类}/{日期}/{文件名}
|
||||
|
||||
def save_file(url, directory):
|
||||
disk_path = _url_to_disk_path(url)
|
||||
if disk_path.startswith(FASTAPI_TEMP_PATH):
|
||||
target_dir = os.path.join(FASTAPI_UPLOAD_DIR, directory, time.strftime('%Y%m%d'))
|
||||
new_path = os.path.join(target_dir, os.path.basename(disk_path))
|
||||
shutil.move(disk_path, new_path)
|
||||
return _disk_to_url_path(new_path)
|
||||
return _disk_to_url_path(disk_path)
|
||||
```
|
||||
|
||||
## 配置一致性
|
||||
|
||||
确保以下配置一致:
|
||||
|
||||
| 配置项 | 位置 | 说明 |
|
||||
|--------|------|------|
|
||||
| `FASTAPI_FILE_URL` | 后端 `.env` | 文件访问域名 |
|
||||
| `VITE_GLOB_FILE_URL` | 前端 `.env` | 文件访问域名 |
|
||||
| Nginx `location` | Nginx 配置 | 文件代理路径 |
|
||||
|
||||
:::tip 温馨提示
|
||||
开发环境和生产环境的文件访问域名不同,需要分别配置。`VITE_GLOB_FILE_URL` 通过 `useGlobSetting()` 获取,生产环境通常使用独立的文件服务器或 CDN。
|
||||
:::
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 图片不显示
|
||||
|
||||
```
|
||||
1. 检查 VITE_GLOB_FILE_URL 配置是否正确
|
||||
2. 检查 Nginx 是否配置了 /api/file/ 代理
|
||||
3. 检查文件是否已从临时目录迁移到正式目录
|
||||
```
|
||||
|
||||
### 上传失败
|
||||
|
||||
```
|
||||
1. 检查文件扩展名是否在白名单中
|
||||
2. 检查文件大小是否超过 UPLOAD_MAX_SIZE_MB 限制
|
||||
3. 检查后端服务是否正常运行
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
文件上传通过 `VAxios.uploadFile()` 发送 `multipart/form-data` 请求,后端保存到临时目录并返回 `fileUrl`。业务提交时后端 `save_file()` 迁移到正式目录。前端通过 `VITE_GLOB_FILE_URL` 拼接完整 URL 显示文件。
|
||||
Reference in New Issue
Block a user