签入版本

This commit is contained in:
2026-09-12 23:12:15 +08:00
commit 91998056ca
1841 changed files with 341325 additions and 0 deletions
@@ -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 显示文件。