签入版本

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,173 @@
# 接口响应规范
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::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": {
"records": [
{ "id": 1, "name": "案例1", "status": 1 },
{ "id": 2, "name": "案例2", "status": 2 }
],
"total": 100,
"size": 10,
"current": 1,
"pages": 10
},
"msg": "操作成功",
"ok": true
}
```
## 后端实现
位于 `utils/R.py`:
```python
from django.http import JsonResponse
def ok(data=None, msg="操作成功", code=0, **kwargs):
response_body = {"code": code, "data": data, "msg": msg, "ok": True}
if kwargs:
response_body.update(kwargs)
return JsonResponse(data=response_body)
def failed(msg="操作失败", code=1, data=None, **kwargs):
response_body = {"code": code, "data": data, "msg": msg, "ok": False}
if kwargs:
response_body.update(kwargs)
return JsonResponse(data=response_body)
```
### 使用示例
```python
# 成功
return R.ok(data=user_info)
return R.ok(data=page_data) # 分页
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 exampleAdd(formData);
// result = undefined (add 接口无返回数据)
// 分页接口:data 包含 records、total 等
const result = await getExamplePage(params);
// result = { records: [...], total: 100, size: 10, current: 1, pages: 10 }
```
## 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | number | 业务码:0=成功,1=失败,401=登录超时 |
| `data` | any | 业务数据 |
| `total` | 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,274 @@
# 字典数据联调
:::tip 说明
字典数据通过后端字典 API 获取,前端在下拉选择器中渲染选项。字典项的 `value` 字段统一为字符串类型,前端使用时需要注意类型转换。
:::
## 后端字典 API
### 获取字典数据
```
GET /dict/item/getDictItemList/{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` 字段统一为字符串类型,即使存储的是数字。前端使用时需要注意类型转换。
:::
---
## 后端字典查询
在 Service 层中,可以通过字典 API 查询字典项的显示文本。通常在构建响应数据时,将枚举值转换为对应的中文描述:
```python
from application.dict_item.services import get_dict_item_label
def get_example_page(request):
# ... 分页查询逻辑 ...
records = []
for item in page_list:
record = {
'id': item.id,
'name': item.name,
'type': item.type,
# 查询字典项的显示文本
'typeText': get_dict_item_label('example_type', item.type),
'status': item.status,
'statusText': get_dict_item_label('example_status', item.status),
# ... 其他字段
}
records.append(record)
```
### 常用字典编码
| 字典编码 | 说明 | 字典项示例 |
|----------|------|-----------|
| `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-失败 |
字典编码在后台「字典管理」中配置。
---
## 前端字典管理
### 搜索表单中的字典选项
搜索表单中直接硬编码选项(简单场景)或通过 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
按字典编码获取字典项(下拉框用)位于 `src/api/common/index.ts`:
```typescript
// src/api/common/index.ts
import { http } from '@/utils/http/axios';
export function getDictItemByCode(code) {
return http.request({
url: '/dict/item/getDictItemList/' + code,
method: 'GET',
});
}
```
后端对应接口:`GET /dict/item/getDictItemList/{code}`(`application/dict_item/views.py`)。
---
## 前端表格渲染
在 `columns.ts` 中使用 `render` 函数渲染状态标签:
```typescript
// src/views/system/param/columns.ts
{
label: '参数类型',
prop: 'type',
width: 100,
render(record) {
return h(ElTag,
{ type: record.row.type == 0 ? 'primary' : 'warning' },
{ default: () => record.row.type == 0 ? '系统' : '业务' });
},
},
{
label: '状态',
prop: 'status',
width: 100,
render(record) {
return h(ElTag,
{ type: record.row.status == 1 ? 'success' : 'danger' },
{ default: () => record.row.status == 1 ? '正常' : '禁用' });
},
},
```
---
## 值类型处理
字典 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'`,后端筛选字段期望数字。前端 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 构建响应数据时查询字典项显示文本
3. 前端 columns.ts 使用 render 函数渲染状态标签
4. 前端 querySchemas.ts 中配置搜索选项(硬编码或 API 获取)
5. 表单提交时 value 为字符串,后端存储时注意类型处理
```
---
## 总结
字典数据联调通过后端字典 API + 前端 `render` 函数渲染标签实现。后端 Service 层在构建响应时查询字典项的显示文本,前端 columns.ts 使用 render 函数渲染状态标签。字典值统一为字符串,前端使用时注意类型转换。简单场景硬编码选项,动态场景通过 API 获取。
@@ -0,0 +1,193 @@
# 权限节点联动
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
前后端权限通过统一的权限节点字符串联动,后端 `PermissionRequired` 中间件校验权限,前端 `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 —— 导入
```
## 后端权限校验
View 类继承 `PermissionRequired` 中间件,声明所需权限:
```python
# application/example/views.py
class ExampleAddView(PermissionRequired, View):
permission_required = ("sys:example:add",)
@operation_log(title="案例管理-添加记录", log_type=LogType.ADD)
def post(self, request):
result = services.add_example(request)
return result
```
校验流程:
```
1. check_login 中间件从 JWT Token 解析用户 ID
2. PermissionRequired 中间件查询用户角色 → 角色菜单 → 权限节点列表
3. 检查 View 要求的权限节点是否在列表中
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 层隐藏,真正的权限校验在后端。
:::
## 总结
前后端权限通过统一的权限节点字符串联动。后端 `PermissionRequired` 中间件做真正的权限校验(用户 ID=1 跳过),前端 `v-perm` 指令和 `TableAction` 的 `auth` 属性做 UI 层隐藏。权限列表在登录时从后端获取,存储在 user store 中。`*:*:*` 通配符表示超级管理员。
@@ -0,0 +1,165 @@
# 代理配置
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
开发环境通过 Vite 代理转发 API 请求到后端 Django 服务,生产环境通过 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.development` 文件中配置代理规则(JSON 数组格式):
```bash
# 格式:[["前缀","目标地址"]]
VITE_PROXY=[["/api","http://127.0.0.1:8000/"]]
```
`createProxy` 函数将此配置转换为 Vite 的 `server.proxy` 格式,自动添加 `changeOrigin: true` 等选项。
### 代理规则
```
前端请求: http://localhost:8001/api/example/page
代理转发: http://127.0.0.1:8000/api/example/page
```
`/api` 前缀的请求被代理到后端 `http://127.0.0.1:8000`,`changeOrigin: true` 修改请求头的 Host 为目标地址。
### 开发流程
```
1. 启动后端: python manage.py runserver (127.0.0.1:8000)
2. 启动前端: cd ui && pnpm dev (localhost:8001)
3. 浏览器访问 localhost:8001
4. API 请求自动代理到 127.0.0.1:8000
```
### 前端请求路径
前端 API 函数中的 URL 不含 `/api` 前缀:
```typescript
// src/api/tool/example.ts
export function getExamplePage(params?) {
return http.request({
url: '/example/page', // 实际请求:/api/example/page
method: 'GET',
params,
});
}
```
`VAxios` 创建时配置了 `prefixUrl`,`beforeRequestHook` 自动拼接:
```typescript
// src/utils/http/axios/index.ts
const urlPrefix = globSetting.urlPrefix || '';
createAxios({
prefixUrl: urlPrefix, // '/api'
requestOptions: {
joinPrefix: true,
urlPrefix: urlPrefix, // '/api'
},
});
```
最终请求路径:`/api` + `/example/page` = `/api/example/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:8000/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 /uploads/ {
proxy_pass http://127.0.0.1:8000/uploads/;
proxy_set_header Host $host;
# 静态文件缓存
expires 7d;
add_header Cache-Control "public, immutable";
}
```
:::tip 温馨提示
后端的文件访问地址需要与前端 `DJANGO_FILE_URL` 配置一致。开发环境为 `http://file.django.elevue`,生产环境需要配置为实际的文件服务域名。
:::
## 总结
开发环境通过 `VITE_PROXY` 环境变量配置 Vite 代理,`createProxy` 函数解析 JSON 格式的代理规则。前端 API 路径不含 `/api` 前缀,由 `VAxios` 的 `beforeRequestHook` 自动拼接。生产环境通过 Nginx 代理并配置超时、缓存等参数。
@@ -0,0 +1,197 @@
# 文件上传联调
<!-- Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 -->
:::tip 说明
文件上传涉及前端 Upload 组件、后端上传接口和 URL 拼接三个环节,需要前后端协同配置。
:::
## 上传流程
```
1. 前端 Upload 组件选择文件
2. VAxios.uploadFile() 发送 multipart/form-data POST 请求到 /api/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 /upload/uploadFile
Content-Type: multipart/form-data
Authorization: Bearer <token>
参数: 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
# utils/common.py
def get_file_url(path):
if not path:
return None
return DJANGO_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
# utils/common.py
# 临时路径: {temp}/{日期}/{文件名}
# 正式路径: {upload_dir}/{分类}/{日期}/{文件名}
def save_file(url, directory):
if url.find(DJANGO_FILE_URL) != -1:
if url.find("/temp") != -1:
path = DJANGO_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d')
mkdir(path)
old_path = url.replace(DJANGO_FILE_URL, DJANGO_UPLOAD_DIR)
new_path = DJANGO_UPLOAD_DIR + "/" + directory + old_path.replace(DJANGO_TEMP_PATH, "")
os.rename(old_path, new_path)
return new_path.replace(DJANGO_UPLOAD_DIR, "")
```
## 配置一致性
确保以下配置一致:
| 配置项 | 位置 | 说明 |
|--------|------|------|
| `DJANGO_FILE_URL` | 后端 `config/env.py` | 文件访问域名 |
| `VITE_GLOB_IMG_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()` 迁移到正式目录。前端通过 `DJANGO_FILE_URL` 拼接完整 URL 显示文件。