签入版本
This commit is contained in:
@@ -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 显示文件。
|
||||
Reference in New Issue
Block a user