32 Commits

Author SHA1 Message Date
7019bc511b dockerfile 2026-05-12 17:03:59 +08:00
77aaeebf1d 更新模型类型字段名称,统一为 modelType,以提高代码一致性和可读性。涉及多个组件和接口的相应调整,确保功能正常。 2026-05-12 16:43:46 +08:00
75cc91a4fb 更新模型配置页面,基于用户角色动态显示对话模型选项,提升权限管理的灵活性和用户体验。 2026-05-12 15:48:12 +08:00
e388875dc3 优化超级管理员角色检查逻辑
- 更新 `checkIsSuperAdmin` 函数的返回值处理,确保正确获取超级管理员状态,提升权限管理的准确性。
2026-05-12 15:22:36 +08:00
f20108a283 更新工作流权限逻辑
- 根据用户角色(超级管理员与普通管理员)调整工作流的使用和编辑权限,确保超级管理员只能编辑工作流,无法进入创作模式。
- 新增对用户角色的检查,优化工作流列表的点击事件处理,提升用户体验和权限管理的清晰度。
2026-05-12 15:17:38 +08:00
34bc30a2c5 更改表单模型类型逻辑 2026-05-12 15:03:47 +08:00
f2266317e2 更新数字人创作页面功能
- 修改 `ExecutionFlowItem` 接口,将 `flowId` 字段重命名为 `Id`,以提高一致性。
- 新增 `getExecutionDetail` 函数,用于获取执行详情,增强工作流管理能力。
- 更新树节点处理逻辑,使用 `workflowId` 替代 `flowId`,确保数据一致性。
- 优化模型配置页面,动态显示访问类型,提升用户体验。
2026-05-12 14:31:37 +08:00
72af38ea00 新增管理员权限检查和模型选择逻辑优化
- 在用户 API 中新增 `checkIsSuperAdmin` 函数,用于检查用户是否为超级管理员。
- 更新模型选择器,非管理员用户只能选择内置模型并需配置 API Key,提升安全性和用户体验。
- 优化模型配置页面,动态显示操作按钮,确保管理员与普通用户的操作权限区分明确。
2026-05-12 13:52:24 +08:00
87b25dee42 更新创作模型配置功能
- 在创作页面中新增会话ID和流ID字段,增强执行流的管理能力。
- 在模型配置页面中添加模型类型字段,提升模型管理的灵活性。
- 优化模型选择器,更新API Key配置弹窗,改善用户交互体验。
2026-05-12 11:24:42 +08:00
b0e62fb966 添加工作空间标识功能
- 在创作页面中新增 `isFromWorkspace` 状态,用于标识用户是否从工作空间进入创作模式。
- 更新表单项,基于 `isFromWorkspace` 状态禁用输入控件,提升用户交互体验和数据一致性。
2026-05-12 01:01:37 +08:00
fe24948ce9 新增推理模型判断逻辑和会话模型开关显示
- 在模型配置页面中添加 `isInferenceModel` 函数,用于判断模型类型是否为推理模型。
- 根据模型类型动态显示会话模型开关,提升用户交互体验。
2026-05-12 00:42:49 +08:00
88c9d08e95 优化预览弹窗样式
- 调整预览弹窗的宽度和顶部位置,提升视觉效果。
- 增加预览容器的高度和隐藏溢出内容,改善用户体验。
2026-05-12 00:38:24 +08:00
41a40cc6ee 更新数字人创作页面功能
- 在树节点中添加点击事件处理,支持工作流节点的详细信息获取和会话ID管理。
- 修改节点操作逻辑,优化预览和下载功能,支持标题节点的操作。
- 引入会话ID管理,确保在工作流切换时正确处理会话状态。
- 更新相关状态和逻辑,提升用户交互体验和数据一致性。
2026-05-12 00:19:15 +08:00
4e407675a2 新增模型类型选择器功能
- 在模型配置页面中添加模型类型选择器,允许用户选择模型类型,提升用户交互体验。
- 更新状态管理,新增 `modelType` 字段以存储所选模型类型,确保数据的完整性和一致性。
2026-05-11 23:18:17 +08:00
03de9595d1 更新对话模型管理功能
- 修改 `updateChatModel` 函数的参数名称,从 `chatSessionEnabled` 更改为 `isChatModel`,以提高一致性。
- 在创作页面中新增对话模型选择器,支持用户搜索和选择对话模型,提升用户体验。
- 实现对话模型的分页和搜索功能,优化模型列表的展示。
- 更新相关样式和逻辑,确保对话模型设置的顺畅交互。
2026-05-11 22:40:58 +08:00
c7152f5d92 新增模型会话开关功能和Token映射字段
- 在模型模块中添加更新会话模型状态的API,支持会话模型的管理。
- 更新模型选择器,保存模型的表单数据和响应体,提升用户体验。
- 在编辑模块中新增Token映射字段,增强模型配置的灵活性。
- 优化会话模型开关的逻辑,确保状态切换后能正确更新列表数据。
2026-05-11 21:06:35 +08:00
29838b030f 添加会话模型和API Key配置功能
- 在模型模块中新增会话开关状态字段,支持会话模型的管理。
- 更新模型选择器,增加系统模型的API Key配置弹窗,提升用户体验。
- 优化错误处理逻辑,确保接口错误由全局拦截器处理,减少冗余提示。
- 更新相关样式以增强界面可读性和美观性。
2026-05-11 20:01:03 +08:00
0a42e700e2 更新模型配置和订阅页面
- 修改模型模块的字段名称,从 `keyword` 更改为 `modelName`,以提高一致性。
- 添加模型类型和访问类型的选择功能,增强用户交互体验。
- 移除不必要的调试日志,优化代码整洁性。
- 更新订阅页面的错误处理逻辑,确保用户在加载失败时获得清晰反馈。
2026-05-11 13:48:20 +08:00
76420713fa 处理模型回显 2026-05-09 22:01:28 +08:00
cae76234b7 更新模型配置字段结构 2026-05-09 19:31:09 +08:00
d1ef004100 更改全局监听 code配置,改变tab切换逻辑 2026-05-09 18:23:25 +08:00
fa590f1e27 更改发送规格 2026-05-09 16:07:28 +08:00
05ba57282f 更新skill用户接口配置 2026-05-09 13:59:14 +08:00
2e6af6e06c feat: 添加执行列表功能以支持工作流执行管理
- 新增执行列表相关接口和数据结构,支持获取执行流和执行项
- 更新创作页面以展示执行流和预览功能,提升用户交互体验
- 优化树形结构展示,确保根据执行流动态生成节点
- 引入文件上传功能,支持用户上传文件并获取文件URL
2026-05-09 11:01:32 +08:00
a285c9d982 feat: 更新数字人创作页面以支持工作流和输入功能
- 修改工作流项接口,新增流模板名称和用户流列表支持
- 引入新的输入区域,允许用户上传文件并选择创作技能
- 实现工作流列表的Tab切换,分为“我的工作流”和“模板工作流”
- 优化界面布局和交互,提升用户体验
2026-05-08 22:00:00 +08:00
8cc5f4be64 feat: 更新数字人创作页面以支持技能选择功能
- 在节点库项中新增技能选择选项,允许用户为节点指定技能
- 更新API请求路径,统一为'/ai-agent'前缀
- 优化动态表单逻辑,确保根据节点类型正确显示技能选择器
- 移除冗余的文件上传函数,改为导入公共上传函数以简化代码结构
2026-05-08 19:06:36 +08:00
0c6cfe5c17 feat: 重构创作页面以支持左侧面板的Tab切换功能
- 新增左侧面板的Tab切换,允许用户在“工作空间”和“当前选中元素”之间切换
- 优化工作空间的树形结构展示,提升用户交互体验
- 添加当前选中元素的动态表单,支持根据选中节点显示相应的属性配置
- 更新相关样式以增强界面可读性和美观性
2026-05-07 20:04:11 +08:00
77a03cab11 refactor: 优化错误处理逻辑以提升用户体验
- 移除多个API请求中的错误提示,改为由后端自动处理错误显示
- 更新相关注释以反映新的错误处理策略
- 保持页面可读性,确保用户在操作失败时获得更清晰的反馈
2026-05-06 19:55:18 +08:00
882d7bd2fb feat: 增强工作流管理功能和动态表单支持
- 新增工作流节点的动态生成和管理功能,允许用户根据节点类型创建和更新表单项
- 优化界面展示,提升用户交互体验
- 更新相关接口和类型定义,确保功能完整性
2026-05-06 19:46:12 +08:00
63aa678ac0 feat: 添加工作流管理功能和动态表单支持
- 新增工作流相关接口和类型定义,包括创建、更新、删除和获取工作流列表的功能
- 更新界面,支持工作流的展示和操作,允许用户保存和管理工作流
- 优化动态表单,支持根据工作流节点动态生成表单项
- 修改按钮事件名称以提高代码可读性
2026-05-06 18:40:51 +08:00
4fe8e15450 feat: 添加节点库功能和动态表单支持
- 新增节点库相关接口和类型定义,支持获取节点库列表
- 更新界面,添加节点库展示和动态表单功能,允许用户根据节点类型动态生成表单项
- 修改按钮事件名称以提高代码可读性
2026-05-06 09:59:09 +08:00
415ba67d01 chore: save work before switching to dev 2026-04-24 09:36:56 +08:00
30 changed files with 7843 additions and 1059 deletions

716
docs/API-ERROR-HANDLING.md Normal file
View File

@@ -0,0 +1,716 @@
# API 错误处理规范
> 本文档定义了项目中 API 请求的错误处理标准,确保错误提示的一致性和用户体验。
## 📋 目录
- [核心原则](#核心原则)
- [全局拦截器机制](#全局拦截器机制)
- [API 层规范](#api-层规范)
- [页面层规范](#页面层规范)
- [完整示例](#完整示例)
- [常见问题](#常见问题)
---
## 核心原则
### ✅ 避免重复提示
- **全局拦截器**已经处理了大部分错误提示
- **页面层**不应再显示固定的错误提示
- 只在需要自定义错误处理时使用 `errorMode: 'page'`
### ✅ 优先使用后端 message
- 全局拦截器会自动提取后端返回的 `message` 字段
- 页面层使用 `getApiErrorMessage` 工具函数提取错误信息
- 避免写死前端错误文案
### ✅ 业务逻辑与错误提示分离
- `catch` 块只处理必要的业务逻辑(数据清空、状态重置等)
- 错误提示交给全局拦截器或使用 `getApiErrorMessage`
---
## 全局拦截器机制
### 位置
`src/utils/request.ts`
### 错误处理流程
```typescript
// 响应拦截器
service.interceptors.response.use(
(response) => {
const res = response.data;
const code = res.code;
// 业务成功
if (code === 200 || code === 0) {
return res;
}
// 业务失败
const errorMode = response.config.requestOptions?.errorMode || 'global';
if (errorMode === 'global') {
// 全局模式:自动显示后端 message
ElMessage.error(res.message || res.msg || '操作失败');
}
// 抛出错误供页面 catch
return Promise.reject(new Error(res.message || res.msg));
},
(error) => {
// 网络错误、超时等
ElMessage.error('网络请求失败,请稍后重试');
return Promise.reject(error);
}
);
```
### 错误模式
| 模式 | 说明 | 使用场景 |
|------|------|----------|
| `global`(默认) | 全局拦截器自动显示错误 | 大部分接口(推荐) |
| `page` | 页面自己处理错误 | 需要自定义错误处理时 |
---
## API 层规范
### 1. 默认配置(推荐 95% 的场景)
```typescript
// src/api/xxx/index.ts
/**
* 获取列表
* 使用默认配置,全局拦截器自动处理错误
*/
export function getList(params: ListParams) {
return request({
url: '/api/xxx/list',
method: 'get',
params
});
}
/**
* 创建数据
* 使用默认配置
*/
export function createItem(data: CreateParams) {
return request({
url: '/api/xxx/create',
method: 'post',
data
});
}
/**
* 更新数据
* 使用默认配置
*/
export function updateItem(data: UpdateParams) {
return request({
url: '/api/xxx/update',
method: 'put',
data
});
}
/**
* 删除数据
* 使用默认配置
*/
export function deleteItem(id: string) {
return request({
url: `/api/xxx/delete/${id}`,
method: 'delete'
});
}
```
### 2. 页面自定义错误处理(特殊场景)
```typescript
// src/api/xxx/index.ts
/**
* 批量导入
* 需要页面自定义错误处理(显示详细的导入结果)
*/
export function batchImport(data: ImportParams) {
return request({
url: '/api/xxx/import',
method: 'post',
data,
requestOptions: {
errorMode: 'page' // 页面自己处理错误
}
});
}
/**
* 复杂表单提交
* 需要根据不同错误类型做不同处理
*/
export function submitComplexForm(data: FormData) {
return request({
url: '/api/xxx/submit',
method: 'post',
data,
requestOptions: {
errorMode: 'page'
}
});
}
```
### 3. 何时使用 `errorMode: 'page'`
仅在以下情况使用:
- ✅ 需要根据不同错误码做不同处理
- ✅ 需要自定义错误提示格式
- ✅ 需要在错误后执行特殊业务逻辑
- ✅ 需要显示详细的错误信息(如批量操作结果)
---
## 页面层规范
### 1. 默认场景 - 全局错误处理
```typescript
// ✅ 推荐写法
const getList = async () => {
loading.value = true;
try {
const res = await listApi(params);
tableData.value = res.data?.list || [];
total.value = res.data?.total || 0;
} catch (error) {
// 错误已由全局拦截器处理
// 这里只处理必要的业务逻辑
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
// ✅ 简单场景可以不写 catch
const deleteItem = async (id: string) => {
try {
await deleteApi(id);
ElMessage.success('删除成功');
getList(); // 刷新列表
} catch (error) {
// 错误已由全局拦截器处理
}
};
// ✅ 更简洁的写法(如果不需要处理错误)
const deleteItem = async (id: string) => {
await deleteApi(id);
ElMessage.success('删除成功');
getList();
};
```
### 2. 页面自定义错误处理
```typescript
import { getApiErrorMessage } from '/@/utils/request';
// ⚠️ 仅在 API 设置了 errorMode: 'page' 时使用
const saveData = async () => {
loading.value = true;
try {
await saveApi(formData);
ElMessage.success('保存成功');
closeDialog();
} catch (error) {
// 使用 getApiErrorMessage 提取后端错误信息
ElMessage.error(getApiErrorMessage(error, '保存失败'));
} finally {
loading.value = false;
}
};
// 根据不同错误做不同处理
const deleteItem = async (id: string) => {
try {
await deleteApi(id);
ElMessage.success('删除成功');
getList();
} catch (error) {
const msg = getApiErrorMessage(error, '删除失败');
// 根据错误信息做不同处理
if (msg.includes('被引用')) {
ElMessage.warning('该数据已被其他数据引用,无法删除');
showRelatedData(id);
} else if (msg.includes('权限')) {
ElMessage.error('您没有删除权限');
} else {
ElMessage.error(msg);
}
}
};
// 批量操作显示详细结果
const batchImport = async (file: File) => {
try {
const res = await importApi(file);
ElMessage.success(`导入成功 ${res.data.successCount} 条,失败 ${res.data.failCount}`);
if (res.data.failCount > 0) {
showFailDetails(res.data.failList);
}
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '导入失败'));
}
};
```
### 3. getApiErrorMessage 工具函数
```typescript
/**
* 从错误对象中提取错误信息
* @param error - 错误对象
* @param fallback - 默认错误信息
* @returns 错误信息字符串
*/
export function getApiErrorMessage(error: any, fallback: string = '操作失败'): string {
// 优先从 response.data 中获取
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (error?.response?.data?.msg) {
return error.response.data.msg;
}
// 从 Error.message 中获取
if (error?.message && error.message !== 'Network Error') {
return error.message;
}
// 返回默认值
return fallback;
}
```
**使用示例:**
```typescript
import { getApiErrorMessage } from '/@/utils/request';
try {
await someApi();
} catch (error) {
// 使用后端返回的 message如果没有则显示 '操作失败'
ElMessage.error(getApiErrorMessage(error, '操作失败'));
}
```
---
## 完整示例
### 示例 1标准 CRUD 操作
```typescript
// ==================== API 层 ====================
// src/api/user/index.ts
export function getUserList(params: ListParams) {
return request({
url: '/api/user/list',
method: 'get',
params
});
}
export function createUser(data: UserForm) {
return request({
url: '/api/user/create',
method: 'post',
data
});
}
export function updateUser(data: UserForm) {
return request({
url: '/api/user/update',
method: 'put',
data
});
}
export function deleteUser(id: string) {
return request({
url: `/api/user/delete/${id}`,
method: 'delete'
});
}
// ==================== 页面层 ====================
// src/views/user/index.vue
import { getUserList, createUser, updateUser, deleteUser } from '/@/api/user';
// 获取列表
const getList = async () => {
loading.value = true;
try {
const res = await getUserList(queryParams);
tableData.value = res.data?.list || [];
total.value = res.data?.total || 0;
} catch (error) {
// 错误已由全局拦截器处理
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
// 新增/编辑
const onSubmit = async () => {
try {
await formRef.value?.validate();
if (isEdit.value) {
await updateUser(formData);
ElMessage.success('修改成功');
} else {
await createUser(formData);
ElMessage.success('添加成功');
}
dialogVisible.value = false;
getList();
} catch (error) {
// 错误已由全局拦截器处理
}
};
// 删除
const onDelete = async (row: User) => {
try {
await ElMessageBox.confirm(`确定要删除用户"${row.name}"吗?`, '提示', {
type: 'warning'
});
await deleteUser(row.id);
ElMessage.success('删除成功');
getList();
} catch (error) {
if (error === 'cancel') {
// 用户取消操作
return;
}
// 错误已由全局拦截器处理
}
};
```
### 示例 2需要自定义错误处理
```typescript
// ==================== API 层 ====================
// src/api/model/index.ts
export function listModel(params: ListParams) {
return request({
url: '/api/model/list',
method: 'get',
params,
requestOptions: { errorMode: 'page' } // 页面自己处理错误
});
}
export function createModel(data: ModelForm) {
return request({
url: '/api/model/create',
method: 'post',
data,
requestOptions: { errorMode: 'page' }
});
}
// ==================== 页面层 ====================
// src/views/model/index.vue
import { getApiErrorMessage } from '/@/utils/request';
import { listModel, createModel } from '/@/api/model';
// 获取列表
const getList = async () => {
loading.value = true;
try {
const res = await listModel(queryParams);
tableData.value = res.data?.list || [];
total.value = res.data?.total || 0;
} catch (error) {
// 使用 getApiErrorMessage 提取后端错误
ElMessage.error(getApiErrorMessage(error, '获取列表失败'));
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
// 创建
const onCreate = async () => {
try {
await createModel(formData);
ElMessage.success('创建成功');
dialogVisible.value = false;
getList();
} catch (error) {
// 使用 getApiErrorMessage 提取后端错误
ElMessage.error(getApiErrorMessage(error, '创建失败'));
}
};
```
---
## 常见问题
### Q1: 什么时候使用 `errorMode: 'page'`
**A:** 仅在以下情况使用:
- 需要根据不同错误码做不同处理
- 需要自定义错误提示格式
- 需要在错误后执行特殊业务逻辑
- 需要显示详细的错误信息
**大部分情况95%)使用默认的全局错误处理即可。**
---
### Q2: 为什么不能在页面写固定的错误提示?
**A:** 因为全局拦截器已经显示了错误,页面再显示会导致**重复提示**
```typescript
// ❌ 错误写法 - 会重复提示
try {
await getList();
} catch (error) {
ElMessage.error('获取列表失败'); // 全局拦截器已经显示过了
}
// ✅ 正确写法
try {
await getList();
} catch (error) {
// 错误已由全局拦截器处理
tableData.value = [];
}
```
---
### Q3: 如何显示后端返回的错误信息?
**A:** 有两种方式:
1. **使用默认全局处理(推荐)**
```typescript
// API 层不设置 errorMode
export function getList() {
return request({ url: '/api/list', method: 'get' });
}
// 页面层不写错误提示
try {
await getList();
} catch (error) {
// 全局拦截器会自动显示后端的 message
}
```
2. **使用 getApiErrorMessage**
```typescript
// API 层设置 errorMode: 'page'
export function getList() {
return request({
url: '/api/list',
method: 'get',
requestOptions: { errorMode: 'page' }
});
}
// 页面层使用 getApiErrorMessage
import { getApiErrorMessage } from '/@/utils/request';
try {
await getList();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '获取列表失败'));
}
```
---
### Q4: catch 块应该写什么?
**A:** 根据场景决定:
```typescript
// 场景 1只需要清空数据
try {
const res = await getList();
tableData.value = res.data?.list || [];
} catch (error) {
// 错误已由全局拦截器处理
tableData.value = [];
}
// 场景 2需要重置状态
try {
await uploadFile(file);
} catch (error) {
// 错误已由全局拦截器处理
resetUploadState();
fileList.value = [];
}
// 场景 3不需要任何处理
try {
await deleteItem(id);
ElMessage.success('删除成功');
getList();
} catch (error) {
// 错误已由全局拦截器处理
}
// 场景 4需要自定义错误处理API 设置了 errorMode: 'page'
try {
await saveData();
ElMessage.success('保存成功');
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '保存失败'));
}
```
---
### Q5: 如何处理用户取消操作?
**A:** 使用 `if (error === 'cancel')` 判断:
```typescript
const onDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除吗?', '提示', {
type: 'warning'
});
await deleteApi(row.id);
ElMessage.success('删除成功');
getList();
} catch (error) {
if (error === 'cancel') {
// 用户取消操作,不显示错误
return;
}
// 其他错误已由全局拦截器处理
}
};
```
---
### Q6: 如何处理表单验证失败?
**A:** 表单验证失败不会进入 catch无需特殊处理
```typescript
const onSubmit = async () => {
try {
// 表单验证失败会直接 return不会进入 catch
await formRef.value?.validate();
await saveApi(formData);
ElMessage.success('保存成功');
} catch (error) {
// 这里只会捕获 API 请求错误
// 错误已由全局拦截器处理
}
};
```
---
## 快速检查清单
写新接口时,检查以下几点:
- [ ] **API 层**:是否需要设置 `errorMode: 'page'`
- 大部分情况不需要
- 只在需要自定义错误处理时设置
- [ ] **页面层**catch 块是否正确?
- ✅ 只处理业务逻辑(数据清空、状态重置)
- ❌ 不写固定的 `ElMessage.error('xxx失败')`
- ✅ 如果 API 设置了 `errorMode: 'page'`,使用 `getApiErrorMessage`
- [ ] **是否避免了重复提示?**
- ✅ 全局拦截器 OR 页面 getApiErrorMessage
- ❌ 全局拦截器 + 页面固定提示
---
## 工具函数导入
```typescript
// 导入错误提取工具
import { getApiErrorMessage } from '/@/utils/request';
// 使用示例
try {
await someApi();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '操作失败'));
}
```
---
## 总结
### 核心规则
1. **默认使用全局错误处理**95% 的场景)
- API 层不设置 `errorMode`
- 页面层 catch 不写固定错误提示
2. **特殊场景使用页面自定义处理**5% 的场景)
- API 层设置 `errorMode: 'page'`
- 页面层使用 `getApiErrorMessage`
3. **避免重复提示**
- 全局拦截器已经处理了错误
- 页面不要再显示固定错误
### 记住这个公式
```
全局错误处理(默认) = 不设置 errorMode + catch 不写错误提示
页面自定义处理(特殊) = errorMode: 'page' + getApiErrorMessage
```
---
**文档版本:** v1.0
**最后更新:** 2026-05-11
**维护者:** 开发团队

View File

@@ -468,7 +468,6 @@
try {
const token = getToken();
console.log('[subscribe] token:', token);
const headers = {
'Content-Type': 'application/json',
@@ -483,7 +482,6 @@
// 先获取文本检查是否为有效JSON
const text = await response.text();
console.log('[subscribe] response text:', text);
let result;
try {
@@ -502,7 +500,6 @@
renderTypeList(tenantModuleTypes);
renderSkuList(assetData.skus || []);
} catch (error) {
console.error('加载失败:', error);
showError(error.message || '加载套餐信息失败,请稍后重试');
} finally {
showLoading(false);
@@ -692,8 +689,6 @@
// 延迟跳转回原页面
const targetUrl = decodeURIComponent(returnUrl);
// console.log('[subscribe] 开通成功,即将跳转到:', targetUrl);
// console.log('[subscribe] 原始 returnUrl:', returnUrl);
setTimeout(() => {
let finalUrl;

32
src/api/common/upload.ts Normal file
View File

@@ -0,0 +1,32 @@
import request from '/@/utils/request';
// OSS 上传响应
export interface OssUploadResponse {
fileName: string;
fileURL: string;
fileSize: number;
fileFormat: string;
fileAddressPrefix: string;
files: any;
}
/**
* 公共文件上传到 OSS
* @param file 文件对象
* @param config 请求配置
* @returns 返回文件名、文件地址、文件大小等信息
*/
export function uploadFile(file: File, config?: any) {
const formData = new FormData();
formData.append('file', file);
return request<OssUploadResponse>({
url: '/oss/file/uploadFile',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
...config,
});
}

View File

@@ -6,6 +6,42 @@ export interface CreationListParams {
keyword?: string;
}
export interface NodeLibraryFormItem {
field: string;
label: string;
type: 'input' | 'number' | 'textarea' | 'switch' | string;
required: boolean;
default?: string | number | boolean;
}
export interface NodeLibraryModelConfig {
modelName: string;
modelForm: NodeLibraryFormItem[];
}
export interface NodeLibraryItem {
nodeCode: string;
nodeName: string;
modelType: number;
skillOption: boolean;
formConfig: NodeLibraryFormItem[];
modelConfig: NodeLibraryModelConfig[];
}
export interface NodeLibraryGroup {
group: string;
label: string;
items: NodeLibraryItem[];
}
export interface NodeLibraryListResponse {
code: number;
message: string;
data: {
groups: NodeLibraryGroup[];
};
}
export interface CreationImageItem {
name: string;
url: string;
@@ -32,6 +68,25 @@ export interface CreationTreeItem {
contentTypes: CreationContentTypeItem[];
}
// 新的执行列表数据结构
export interface ExecutionItem {
timestamp: string;
content: string;
label: string;
}
export interface ExecutionFlowItem {
flowName: string;
Id?: number | string;
sessionId?: string;
items: ExecutionItem[];
}
export interface ExecutionTreeItem {
createDate: string;
flows: ExecutionFlowItem[];
}
export interface CreationListData {
list: unknown[] | null;
total: number;
@@ -39,12 +94,23 @@ export interface CreationListData {
imgAddressPrefix: string;
}
export interface ExecutionListData {
tree: ExecutionTreeItem[];
imgAddressPrefix: string;
}
export interface CreationListResponse {
code: number;
message: string;
data: CreationListData;
}
export interface ExecutionListResponse {
code: number;
message: string;
data: ExecutionListData;
}
export interface CreationSubmitParams {
mode: string;
content_type: string;
@@ -61,7 +127,6 @@ export interface DownloadToFileParams {
fileURL: string;
}
// requestOptions 用来声明“这个接口的错误提示由谁负责”。
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
return request({
url: '/black-deacon/creation/info/list',
@@ -71,6 +136,22 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
}) as Promise<CreationListResponse>;
}
export function getExecutionList(requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/list',
method: 'get',
requestOptions,
}) as Promise<ExecutionListResponse>;
}
export function getNodeLibraryList(requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/node/library/list',
method: 'get',
requestOptions,
}) as Promise<NodeLibraryListResponse>;
}
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
return request({
url: '/black-deacon/creation/info/creation',
@@ -90,3 +171,157 @@ export function downloadToFile(data: DownloadToFileParams, requestOptions?: Requ
requestOptions,
});
}
export function saveWorkflow(data: { flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/create',
method: 'post',
data,
requestOptions,
});
}
export interface WorkflowItem {
id: string;
flowName?: string;
flowTemplateName?: string;
description: string;
flowContent: any;
nodeInputParams?: any[];
}
export interface WorkflowListResponse {
code: number;
message: string;
data: {
listFlowUserRes: {
list: WorkflowItem[];
total: number;
};
listFlowTemplateRes: {
list: WorkflowItem[];
total: number;
};
isAdmin?: boolean;
};
}
export function getWorkflowList(requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/list',
method: 'get',
requestOptions,
}) as Promise<WorkflowListResponse>;
}
export interface WorkflowDetailResponse {
code: number;
message: string;
data: WorkflowItem;
}
export function getWorkflowDetail(id: string, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/get',
method: 'get',
params: { id },
requestOptions,
}) as Promise<WorkflowDetailResponse>;
}
export function getExecutionDetail(id: string, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/get',
method: 'get',
params: { id },
requestOptions,
}) as Promise<WorkflowDetailResponse>;
}
export function updateWorkflow(data: { id: string; flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/update',
method: 'put',
data,
requestOptions,
});
}
export function deleteWorkflow(id: string, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/delete',
method: 'delete',
params: { id },
requestOptions,
});
}
// 执行工作流相关类型定义
export interface FlowNodeFormField {
default?: any;
field?: string;
label?: string;
options?: { label?: string; value?: string }[];
required?: boolean;
type?: string;
value?: string;
}
export interface FlowNodeInputSource {
field?: string[];
nodeId?: string;
quoteOutput?: boolean;
}
export interface FlowNodeModelItem {
modelApiKey?: string;
modelForm?: FlowNodeFormField[];
modelName?: string;
}
export interface FlowNode {
config?: { [key: string]: any };
formConfig?: FlowNodeFormField[];
id?: string;
inputSource?: FlowNodeInputSource[];
modelConfig?: FlowNodeModelItem;
name?: string;
nodeCode?: string;
outputResult?: FlowNodeFormField[];
skillName?: string;
}
export interface FlowEdge {
from?: string;
id?: string;
to?: string;
}
export interface FlowInfo {
edges?: FlowEdge[];
nodes?: FlowNode[];
startNodeId?: string;
version?: string;
}
export interface ExecuteFlowParams {
desc?: string;
fileUrl?: string[];
flowContent?: FlowInfo;
flowId?: string;
flowName?: string;
nodeInputParams?: FlowNode[];
sessionId?: string;
skillName?: string;
}
export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/execute',
method: 'post',
data,
headers: data instanceof FormData ? { 'Content-Type': 'multipart/form-data' } : undefined,
timeout: 0,
requestOptions,
});
}

View File

@@ -0,0 +1,236 @@
import request from '/@/utils/request';
export interface ModelModuleListParams {
pageNum?: number;
pageSize?: number;
modelName?: string;
modelType?: number | string;
}
export interface ModelFormItem {
field: string;
label: string;
required: boolean;
type: 'input' | 'number' | 'textarea' | 'switch' | string;
}
export interface ModelFormEntry {
key: string;
value: string;
}
/** 模型类型listType 接口项,字段名以后端为准,前端做兼容解析) */
export interface ModelTypeListItem {
id?: number | string;
typeId?: number | string;
modelType?: number | string;
name?: string;
typeName?: string;
label?: string;
}
/** listType 标准返回data.type 为 Record<id, 名称>,如 { "1": "推理模型", "2": "图片模型" } */
export function normalizeModelTypeOptions(res: { data?: unknown }): Array<{ id: number | string; label: string }> {
const data = res?.data as { type?: Record<string, string> } | undefined;
const typeMap = data?.type;
if (typeMap && typeof typeMap === 'object' && !Array.isArray(typeMap)) {
return Object.entries(typeMap)
.map(([key, label]) => {
const n = Number(key);
const id = Number.isNaN(n) ? key : n;
return { id, label: String(label ?? '') };
})
.sort((a, b) => {
const na = Number(a.id);
const nb = Number(b.id);
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
return na - nb;
}
return String(a.id).localeCompare(String(b.id));
});
}
/** 兼容旧结构data 为数组或 data.list */
const raw = res?.data;
const arr: ModelTypeListItem[] = Array.isArray(raw) ? raw : ((raw as { list?: ModelTypeListItem[] })?.list ?? []);
return arr
.map((item) => {
const id = item.id ?? item.typeId ?? item.modelType;
const label = item.name ?? item.typeName ?? item.label ?? (id != null && id !== '' ? String(id) : '');
return { id: id as number | string, label: label || String(id) };
})
.filter((x) => x.id !== undefined && x.id !== null && x.id !== '');
}
export interface ModelModuleItem {
id: number | string;
tenantId?: number;
creator?: string;
createdAt?: string;
updater?: string;
updatedAt?: string;
deletedAt?: string | null;
isDeleted?: boolean;
modelName: string;
/** 模型类型 ID与 listType 返回项对应 */
modelType?: number | string;
baseUrl: string;
route?: string;
httpMethod: string;
apiKey?: string;
isPrivate?: number;
isChatModel?: number;
/** 会话开关状态列表接口返回0 关 1 开;会话开关接口就绪后生效) */
chatSessionEnabled?: number;
enabled: number;
maxConcurrency: number;
queueLimit: number;
timeoutMs?: number;
timeoutSeconds?: number;
expectedSeconds?: number;
retryTimes: number;
retryQueueMaxSeconds: number;
autoCleanSeconds: number;
remark?: string;
headMsg?: string;
form?: ModelFormEntry[] | Record<string, { value: string }>;
requestMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
}
export interface ModelModuleListResponse {
code: number;
message: string;
data: {
list: ModelModuleItem[];
total: number;
};
}
export interface CreateModelParams {
modelName: string;
/** 与 listType 返回的类型 id 一致,可能为数字或字符串 */
modelType: number | string;
baseUrl: string;
httpMethod?: string;
headMsg?: string;
isPrivate: number;
enabled: number;
isChatModel: number;
apiKey?: string;
form: ModelFormEntry[];
requestMapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds: number;
expectedSeconds: number;
retryTimes?: number;
retryQueueMaxSeconds: number;
autoCleanSeconds: number;
remark?: string;
}
export interface ModelConfigTypeItem {
id: number | string;
name: string;
form: ModelFormItem[];
}
export interface ModelConfigGroup {
typeId: number;
type: string;
items: ModelConfigTypeItem[];
}
export interface ModelConfigResponse {
code: number;
message: string;
data: ModelConfigGroup[];
}
/**
* 获取模型列表
*/
export function getModelModuleList(params?: ModelModuleListParams) {
return request({
url: '/model-gateway/model/listModel',
method: 'get',
params,
});
}
/**
* 获取模型类型列表(用于下拉与列表回显)
*/
export function getModelTypeList() {
return request({
url: '/model-gateway/model/listType',
method: 'get',
});
}
/**
* 新增模型配置
*/
export function addModelModule(data: CreateModelParams) {
return request({
url: '/model-gateway/model/createModel',
method: 'post',
data,
});
}
/**
* 修改模型配置
*/
export function updateModelModule(data: Partial<CreateModelParams> & { id: number | string }) {
return request({
url: '/model-gateway/model/updateModel',
method: 'put',
data,
});
}
/**
* 删除模型配置
*/
export function deleteModelModule(id: number | string) {
return request({
url: '/model-gateway/model/deleteModel',
method: 'delete',
data: { id },
});
}
/**
* 获取单条模型配置详情(编辑用,需传 id
*/
export function getModelModuleDetail(id: number | string) {
return request({
url: '/model-gateway/model/getModel',
method: 'get',
params: { id },
});
}
/**
* 更新模型会话开关状态
*/
export function updateChatModel(data: { id: number | string; isChatModel: 0 | 1 }) {
return request({
url: '/model-gateway/model/updateChatModel',
method: 'post',
data,
});
}
/**
* 获取当前会话模型
*/
export function getIsChatModel() {
return request({
url: '/model-gateway/model/getIsChatModel',
method: 'get',
});
}

View File

@@ -0,0 +1,79 @@
import request from '/@/utils/request';
export interface ModelTypeListParams {
pageNum: number;
pageSize: number;
keyword?: string;
}
export interface ModelTypeItem {
id: string;
typeName: string;
typeCode: string;
description?: string;
status: number;
createTime?: string;
updateTime?: string;
}
export interface ModelTypeListResponse {
code: number;
message: string;
data: {
list: ModelTypeItem[];
total: number;
};
}
/**
* 获取模型类型列表
*/
export function getModelTypeList(params: ModelTypeListParams) {
return request({
url: '/api/ai-agent/model-config/model-type/list',
method: 'get',
params,
});
}
/**
* 新增模型类型
*/
export function addModelType(data: Partial<ModelTypeItem>) {
return request({
url: '/api/ai-agent/model-config/model-type/add',
method: 'post',
data,
});
}
/**
* 修改模型类型
*/
export function updateModelType(data: Partial<ModelTypeItem>) {
return request({
url: '/api/ai-agent/model-config/model-type/update',
method: 'put',
data,
});
}
/**
* 删除模型类型
*/
export function deleteModelType(id: string) {
return request({
url: `/api/ai-agent/model-config/model-type/delete/${id}`,
method: 'delete',
});
}
/**
* 获取模型类型详情
*/
export function getModelTypeDetail(id: string) {
return request({
url: `/api/ai-agent/model-config/model-type/detail/${id}`,
method: 'get',
});
}

View File

@@ -0,0 +1,128 @@
import request from '/@/utils/request';
// Skill 技能项
export interface SkillItem {
id: number;
name: string;
description: string;
category: string;
fileName: string;
fileUrl: string;
createdAt: string;
updatedAt: string;
}
// Skill 列表响应
export interface SkillListResponse {
list: SkillItem[];
total: number;
}
// 创建 Skill 参数
export interface CreateSkillParams {
name?: string;
description?: string;
category?: string;
fileName?: string;
fileUrl?: string;
[property: string]: any;
}
// 系统技能列表查询参数
export interface SkillListParams {
pageNum?: number;
pageSize?: number;
keyword?: string;
Total?: number;
}
// 用户技能列表查询参数
export interface UserSkillListParams {
pageNum?: number;
pageSize?: number;
keyword?: string;
Total?: number;
}
/**
* 获取 Skill 系统技能列表
*/
export function getSkillList(params?: SkillListParams, config?: any) {
return request<SkillListResponse>({
url: '/ai-agent/skill/template/list',
method: 'get',
params,
...config,
});
}
/**
* 创建 Skill 系统技能
*/
export function createSkill(data: CreateSkillParams, config?: any) {
return request({
url: '/ai-agent/skill/template/create',
method: 'post',
data,
...config,
});
}
/**
* 删除 Skill 系统技能
*/
export function deleteSkill(id: number, config?: any) {
return request({
url: '/ai-agent/skill/template/delete',
method: 'delete',
data: { id },
...config,
});
}
/**
* 获取 Skill 用户技能列表
*/
export function getUserSkillList(params?: UserSkillListParams, config?: any) {
return request<SkillListResponse>({
url: '/ai-agent/skill/user/list',
method: 'get',
params,
...config,
});
}
/**
* 获取 Skill 用户技能列表
*/
export function getUserSkilllistUser(params?: UserSkillListParams, config?: any) {
return request<SkillListResponse>({
url: '/ai-agent/skill/user/listUser',
method: 'get',
params,
...config,
});
}
/**
* 创建 Skill 用户技能
*/
export function createUserSkill(data: CreateSkillParams, config?: any) {
return request({
url: '/ai-agent/skill/user/create',
method: 'post',
data,
...config,
});
}
/**
* 删除 Skill 用户技能
*/
export function deleteUserSkill(id: number, config?: any) {
return request({
url: '/ai-agent/skill/user/delete',
method: 'delete',
data: { id },
...config,
});
}

View File

@@ -1,4 +1,8 @@
import request from '/@/utils/request';
import { uploadFile } from '/@/api/common/upload';
// 导出公共上传函数供其他模块使用
export { uploadFile };
// 文档查询参数
export interface DocumentQueryParams {
@@ -114,18 +118,6 @@ export function updateDocument(data: UpdateDocumentParams) {
});
}
// 公共文件上传OSS返回文件路径
export function uploadFile(file: File) {
const formData = new FormData();
formData.append('file', file);
return request({
url: '/oss/file/uploadFile',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// 上传文档
export function uploadDocument(data: FormData) {
return request({

View File

@@ -69,3 +69,10 @@ export function deleteUser(ids: number[]) {
data: { ids },
});
}
export function checkIsSuperAdmin() {
return request({
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
method: 'get',
});
}

View File

@@ -0,0 +1,459 @@
<template>
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
<div class="model-selector-header">
<div class="search-bar">
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<el-button type="success" @click="handleAddModel">+ 新建模型</el-button>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id, 'builtin-model': !model.apiKey }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
<div class="model-badges">
<el-tag v-if="!model.apiKey" type="warning" size="small">内置模型</el-tag>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
</div>
<div class="model-card-body">
<h3 class="model-name">{{ model.modelName }}</h3>
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="model.enabled === 1 ? 'success' : 'info'" size="small">
{{ model.enabled === 1 ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
</template>
<!-- 新建模型弹窗 -->
<EditModule ref="editModuleRef" @refresh="handleRefresh" />
<!-- 内置模型 API Key 输入弹窗 -->
<el-dialog v-model="apiKeyDialogVisible" title="配置内置模型" width="500px" :close-on-click-modal="false" append-to-body>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
<template #title>
<div style="line-height: 1.6">
您选择的是内置模型需要配置您自己的 API Key<br />
系统将为您创建一个模型副本
</div>
</template>
</el-alert>
<el-form :model="apiKeyForm" :rules="apiKeyRules" ref="apiKeyFormRef" label-width="100px">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="apiKeyForm.modelName" placeholder="请输入模型名称" />
</el-form-item>
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="apiKeyForm.apiKey" type="password" show-password placeholder="请输入您的 API Key" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="apiKeyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreatePrivateModel" :loading="creatingModel">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getModelModuleList, addModelModule } from '/@/api/digitalHuman/modelConfig/modelModule';
import { checkIsSuperAdmin } from '/@/api/system/user/index';
import { getApiErrorMessage } from '/@/utils/request';
import EditModule from '/@/views/digitalHuman/modelConfig/modelModule/component/editModule.vue';
interface ModelItem {
id: string;
tenantId?: number;
modelName: string;
modelType: number;
baseUrl: string;
route: string;
httpMethod: string;
enabled: number;
apiKey?: string;
isPrivate?: number;
isChatModel?: number;
headMsg?: string;
form?: any;
requestMapping?: any;
responseMapping?: any;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds?: number;
expectedSeconds?: number;
retryTimes?: number;
retryQueueMaxSeconds?: number;
autoCleanSeconds?: number;
remark?: string;
[key: string]: any;
}
interface Props {
modelValue: boolean;
defaultModel?: ModelItem | null;
modelType?: number;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', model: ModelItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
modelType: 1,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ modelName: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const modelList = ref<ModelItem[]>([]);
const loading = ref(false);
const selectedModel = ref<ModelItem | null>(null);
const editModuleRef = ref();
const isSuperAdmin = ref(false); // 是否为管理员
// 内置模型 API Key 配置
const apiKeyDialogVisible = ref(false);
const apiKeyFormRef = ref<FormInstance>();
const apiKeyForm = reactive({
modelName: '',
apiKey: '',
});
const apiKeyRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
};
const creatingModel = ref(false);
const systemModelToClone = ref<ModelItem | null>(null);
// 检查是否为管理员
const checkAdminStatus = async () => {
try {
const res: any = await checkIsSuperAdmin();
isSuperAdmin.value = res.data?.isSuperAdmin || false;
} catch {
isSuperAdmin.value = false;
}
};
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = props.defaultModel || null;
fetchModelList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const getModelTypeName = (type: number) => {
const typeMap: Record<number, string> = {
1: '推理模型',
2: '图片模型',
3: '音频模型',
};
return typeMap[type] || '未知类型';
};
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
modelType: props.modelType, // 使用传入的 modelType
};
const res: any = await getModelModuleList(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
// 接口错误由 request 全局提示后端 message
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = () => {
fetchModelList();
};
const handleSelectModel = (model: ModelItem) => {
// 如果是管理员,直接选中任何模型,不需要配置 API Key
if (isSuperAdmin.value) {
selectedModel.value = model;
return;
}
// 非管理员判断是否是内置模型apiKey 为空)
if (!model.apiKey) {
// 内置模型,需要用户配置 API Key
systemModelToClone.value = model;
apiKeyForm.modelName = model.modelName;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
} else {
// 用户模型,直接选中
selectedModel.value = model;
}
};
const handleCreatePrivateModel = async () => {
if (!apiKeyFormRef.value || !systemModelToClone.value) return;
try {
await apiKeyFormRef.value.validate();
creatingModel.value = true;
// 基于内置模型创建新模型(继承原模型的所有配置,只替换 apiKey
const systemModel = systemModelToClone.value;
const createParams = {
modelName: apiKeyForm.modelName,
modelType: systemModel.modelType,
baseUrl: systemModel.baseUrl,
httpMethod: systemModel.httpMethod || 'POST',
headMsg: systemModel.headMsg || '',
isPrivate: systemModel.isPrivate ?? 1, // 继承原模型的公有/私有属性
enabled: systemModel.enabled ?? 1,
isChatModel: systemModel.isChatModel || 0,
apiKey: apiKeyForm.apiKey, // 使用用户输入的新 API Key
form: systemModel.form || {},
requestMapping: systemModel.requestMapping || {},
responseMapping: systemModel.responseMapping || {},
maxConcurrency: systemModel.maxConcurrency || 10,
queueLimit: systemModel.queueLimit || 100,
timeoutSeconds: systemModel.timeoutSeconds || 30,
expectedSeconds: systemModel.expectedSeconds || 15,
retryTimes: systemModel.retryTimes || 3,
retryQueueMaxSeconds: systemModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: systemModel.autoCleanSeconds || 300,
remark: systemModel.remark || '',
};
const res: any = await addModelModule(createParams);
ElMessage.success('模型创建成功');
// 关闭对话框
apiKeyDialogVisible.value = false;
// 刷新列表
await fetchModelList();
// 选中新创建的模型
const newModelId = res.data?.id || res.data;
if (newModelId) {
const newModel = modelList.value.find((m) => m.id === String(newModelId));
if (newModel) {
selectedModel.value = newModel;
}
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(getApiErrorMessage(error, '创建模型失败'));
}
} finally {
creatingModel.value = false;
}
};
const handleAddModel = () => {
editModuleRef.value?.openDialog('add');
};
const handleRefresh = () => {
fetchModelList();
};
const handleConfirm = () => {
if (selectedModel.value) {
emit('confirm', selectedModel.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
apiKeyDialogVisible.value = false;
systemModelToClone.value = null;
};
onMounted(() => {
checkAdminStatus();
});
</script>
<style scoped lang="scss">
.model-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.model-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.model-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.model-card.builtin-model {
border-color: #fbbf24;
background: #fffbeb;
}
.model-card.builtin-model:hover {
border-color: #f59e0b;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-type {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.model-badges {
display: flex;
align-items: center;
gap: 8px;
}
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="系统技能" name="system">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
<div v-else class="skill-grid">
<div
v-for="skill in skillList"
:key="skill.id"
class="skill-card"
:class="{ selected: selectedSkill?.id === skill.id }"
@click="handleSelectSkill(skill)"
>
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</el-tab-pane>
<el-tab-pane label="用户技能" name="user">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
<div v-else class="skill-grid">
<div
v-for="skill in skillList"
:key="skill.id"
class="skill-card"
:class="{ selected: selectedSkill?.id === skill.id }"
@click="handleSelectSkill(skill)"
>
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getSkillList, getUserSkilllistUser, type SkillItem } from '/@/api/digitalHuman/skill';
interface Props {
modelValue: boolean;
defaultSkill?: SkillItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', skill: SkillItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultSkill: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const activeTab = ref('system');
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const skillList = ref<SkillItem[]>([]);
const loading = ref(false);
const selectedSkill = ref<SkillItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedSkill.value = props.defaultSkill || null;
fetchSkillList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const handleTabChange = () => {
pagination.pageNum = 1;
searchParams.keyword = '';
selectedSkill.value = null;
fetchSkillList();
};
const fetchSkillList = async () => {
loading.value = true;
try {
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
const res =
activeTab.value === 'system' ? await getSkillList(params) : await getUserSkilllistUser(params);
skillList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch (error) {
skillList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchSkillList();
};
const handlePageChange = () => {
fetchSkillList();
};
const handleSelectSkill = (skill: SkillItem) => {
selectedSkill.value = skill;
};
const handleConfirm = () => {
if (selectedSkill.value) {
emit('confirm', selectedSkill.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedSkill.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.skill-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.skill-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.skill-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.skill-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.skill-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.skill-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.skill-category {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.skill-card-body {
flex: 1;
}
.skill-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
<div v-else class="skill-grid">
<div
v-for="skill in skillList"
:key="skill.id"
class="skill-card"
:class="{ selected: selectedSkill?.id === skill.id }"
@click="handleSelectSkill(skill)"
>
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getUserSkilllistUser, type SkillItem } from '/@/api/digitalHuman/skill';
interface Props {
modelValue: boolean;
defaultSkill?: SkillItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', skill: SkillItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultSkill: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const skillList = ref<SkillItem[]>([]);
const loading = ref(false);
const selectedSkill = ref<SkillItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedSkill.value = props.defaultSkill || null;
fetchSkillList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const fetchSkillList = async () => {
loading.value = true;
try {
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
const res = await getUserSkilllistUser(params);
skillList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch (error) {
skillList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchSkillList();
};
const handlePageChange = () => {
fetchSkillList();
};
const handleSelectSkill = (skill: SkillItem) => {
selectedSkill.value = skill;
};
const handleConfirm = () => {
if (selectedSkill.value) {
emit('confirm', selectedSkill.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedSkill.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.skill-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.skill-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.skill-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.skill-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.skill-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.skill-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.skill-category {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.skill-card-body {
flex: 1;
}
.skill-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -41,7 +41,6 @@ export function redirectToSubscribePage(assetId: string) {
const returnUrl = encodeURIComponent(window.location.href);
// 构建跳转URL
const url = `${SUBSCRIBE_PAGE_URL}?assetId=${assetId}&returnUrl=${returnUrl}`;
console.log('[redirectToSubscribePage] 跳转到开通页面:', url);
window.location.href = url;
}
@@ -49,17 +48,13 @@ export function redirectToSubscribePage(assetId: string) {
* 处理 402 错误码(模块未开通)
*/
export function handleModuleNotEnabled(routePath: string): boolean {
console.log('[模块未开通] 当前路由路径:', routePath);
const assetInfo = getAssetInfoByRoute(routePath);
console.log('[模块未开通] 匹配到的资产信息:', assetInfo);
if (assetInfo) {
redirectToSubscribePage(assetInfo.assetId);
return true;
}
// 如果没有匹配到路由,尝试使用默认的资产管理
console.warn('[模块未开通] 未匹配到路由,使用默认资产管理');
redirectToSubscribePage('696b4acd1be1c8b76c4b4c15');
return true;
}

View File

@@ -7,10 +7,10 @@ import { getChangedFields } from '/@/utils/diffUtils';
import { handleModuleNotEnabled } from '/@/utils/assetSubscribe';
/**
* 控制一次请求的错误提示归属:
* - global: 交给 request.ts 统一弹错,适合绝大多数接口
* - page: 页面自己在 catch 中决定提示文案,避免与全局重复
* - silent: 完全静默,适合轮询、后台刷新等不希望打扰用户的请求
* 控制一次请求的错误提示归属(默认 global
* - global: 由拦截器统一弹出后端返回的 message含 HTTP 与业务 JSON
* - page: 不自动弹窗,仅 reject请在页面 catch 内自行处理(应与全局择一,避免重复
* - silent: 完全静默(轮询等)
*/
export interface RequestOptions {
errorMode?: 'global' | 'page' | 'silent';
@@ -39,6 +39,26 @@ const ERROR_MESSAGE_INTERVAL = 2000;
const getErrorMode = (config?: InternalAxiosRequestConfig) => config?.requestOptions?.errorMode ?? 'global';
const shouldShowGlobalError = (config?: InternalAxiosRequestConfig) => getErrorMode(config) === 'global';
/**
* 从接口响应体解析可读错误文案JSON API、Spring 风格等)
*/
export function extractBackendMessage(data: unknown): string | undefined {
if (data == null) return undefined;
if (typeof data === 'string') {
const t = data.trim();
return t.length > 0 && t.length < 2000 ? t : undefined;
}
if (typeof data !== 'object') return undefined;
const o = data as Record<string, unknown>;
const pick = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
return (
pick(o.message) ||
pick(o.msg) ||
pick(o.error) ||
(typeof o.detail === 'string' ? pick(o.detail) : undefined)
);
}
const closeActiveErrorMessage = () => {
activeErrorMessage?.close();
activeErrorMessage = null;
@@ -174,7 +194,7 @@ const responseInterceptor = (response: AxiosResponse) => {
const res = response.data;
const httpStatus = response.status;
const code = res?.code;
const message = res?.message;
const message = extractBackendMessage(res);
const config = response.config;
if (isTokenExpiredError(httpStatus, code, message)) {
@@ -189,9 +209,28 @@ const responseInterceptor = (response: AxiosResponse) => {
return Promise.reject(new Error('模块未开通'));
}
// 业务失败默认走全局提示;如果页面声明自己处理,这里只抛错不弹窗。
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
const errorMsg = message || `请求失败(${code})`;
// 定义已知的正常 code
const knownSuccessCodes = [0, 200];
// 定义已知的业务错误 code这些 code 会显示后端返回的 message
const knownErrorCodes = [401, 402, 403, 404, 429, 500, 502, 503];
// 业务成功,直接返回
if (code !== undefined && knownSuccessCodes.includes(code)) {
return res;
}
// 业务失败处理
if (code !== undefined && !knownSuccessCodes.includes(code)) {
let errorMsg: string;
// 已知的业务错误 code使用后端返回的 message
if (knownErrorCodes.includes(code)) {
errorMsg = message || `请求失败(${code})`;
} else {
// 未知的 code优先使用后端 message便于排查业务含义
errorMsg = message || '后端异常,请联系管理员';
}
showErrorMessage(errorMsg, config);
return Promise.reject(new Error(errorMsg));
}
@@ -202,7 +241,8 @@ const responseInterceptor = (response: AxiosResponse) => {
const responseErrorHandler = (error: any) => {
const config = error.config as InternalAxiosRequestConfig | undefined;
const httpStatus = error.response?.status;
const responseMessage = error.response?.data?.message;
const responseData = error.response?.data;
const responseMessage = extractBackendMessage(responseData);
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
showErrorMessage('请求超时,请检查网络连接', config);
@@ -213,7 +253,7 @@ const responseErrorHandler = (error: any) => {
return Promise.reject(error);
}
if (isTokenExpiredError(httpStatus, error.response?.data?.code, responseMessage)) {
if (isTokenExpiredError(httpStatus, error.response?.data?.code as number | undefined, responseMessage)) {
handleTokenExpired();
return Promise.reject(new Error('登录状态已过期'));
}
@@ -226,7 +266,7 @@ const responseErrorHandler = (error: any) => {
const lastSubscribeTime = sessionStorage.getItem('lastSubscribeTime');
const now = Date.now();
if (lastSubscribeTime && now - parseInt(lastSubscribeTime) < 5000) {
showErrorMessage(responseMessage || '服务开通中,请稍后刷新页面', config);
showErrorMessage(responseMessage ?? '服务开通中,请稍后刷新页面', config);
return Promise.reject(new Error('模块开通中'));
}
@@ -234,30 +274,30 @@ const responseErrorHandler = (error: any) => {
handleModuleNotEnabled(currentPath);
return Promise.reject(new Error('模块未开通'));
}
showErrorMessage(responseMessage || '服务未开通', config);
showErrorMessage(responseMessage ?? '服务未开通', config);
break;
case 403:
showErrorMessage(responseMessage || '没有权限访问该资源', config);
showErrorMessage(responseMessage ?? '没有权限访问该资源', config);
break;
case 404:
showErrorMessage(responseMessage || '请求的资源不存在', config);
showErrorMessage(responseMessage ?? '请求的资源不存在', config);
break;
case 429:
// 429 是限流,不等于登录过期,这里只保留频率提示。
showErrorMessage(responseMessage || '请求过于频繁,请稍后再试', config);
showErrorMessage(responseMessage ?? '请求过于频繁,请稍后再试', config);
break;
case 500:
showErrorMessage(responseMessage || '服务器内部错误', config);
showErrorMessage(responseMessage ?? '服务器内部错误', config);
break;
case 502:
showErrorMessage(responseMessage || '网关错误', config);
showErrorMessage(responseMessage ?? '网关错误', config);
break;
case 503:
showErrorMessage(responseMessage || '服务不可用', config);
showErrorMessage(responseMessage ?? '服务不可用', config);
break;
default:
if (httpStatus >= 400) {
showErrorMessage(responseMessage || `请求失败(${httpStatus})`, config);
showErrorMessage(responseMessage ?? `请求失败(${httpStatus})`, config);
}
}
@@ -267,5 +307,27 @@ const responseErrorHandler = (error: any) => {
service.interceptors.request.use(requestInterceptor, requestErrorHandler);
service.interceptors.response.use(responseInterceptor, responseErrorHandler);
/**
* 从 axios / 业务 reject 中取出后端返回的提示文案与全局拦截器同源逻辑silent / 特殊场景下可在页面使用)。
*/
export function getApiErrorMessage(error: unknown, fallback = '操作失败'): string {
const e = error as any;
const fromBody = extractBackendMessage(e?.response?.data);
if (fromBody != null && fromBody !== '') {
return fromBody;
}
const msg = e?.message;
if (typeof msg === 'string' && msg.trim() !== '') {
if (/^Request failed with status code \d+$/i.test(msg)) {
return extractBackendMessage(e?.response?.data) ?? fallback;
}
if (msg === 'Network Error') {
return '网络异常,请检查网络连接';
}
return msg;
}
return fallback;
}
export default service;
export { closeActiveErrorMessage, showErrorMessage };

View File

@@ -19,7 +19,7 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8">
<el-form-item label="资产分类" prop="categoryId">
<el-cascader
v-model="ruleForm.categoryId"
@@ -33,7 +33,6 @@
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
@@ -98,12 +97,7 @@
class="w100"
clearable
>
<el-option
v-for="opt in attr.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 多选类型 -->
<el-select
@@ -114,12 +108,7 @@
multiple
clearable
>
<el-option
v-for="opt in attr.options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 文本类型 -->
<el-input
@@ -128,11 +117,7 @@
:placeholder="'请输入' + getAttrLabel(attr)"
/>
<!-- 数字类型 -->
<el-input-number
v-else-if="attr.type === 'number'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
class="w100"
/>
<el-input-number v-else-if="attr.type === 'number'" v-model="ruleForm.metadata[getAttrKey(attr)]" class="w100" />
<!-- 日期类型 -->
<el-date-picker
v-else-if="attr.type === 'date'"
@@ -142,10 +127,7 @@
class="w100"
/>
<!-- 布尔类型 -->
<el-switch
v-else-if="attr.type === 'boolean'"
v-model="ruleForm.metadata[getAttrKey(attr)]"
/>
<el-switch v-else-if="attr.type === 'boolean'" v-model="ruleForm.metadata[getAttrKey(attr)]" />
<!-- 图片类型 -->
<div v-else-if="attr.type === 'image'" class="w100">
<el-upload
@@ -185,13 +167,7 @@
<el-col :span="8">
<el-form-item label="主图" prop="mainImage">
<div class="w100">
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="true"
:http-request="handleMainImageUpload"
accept="image/*"
>
<el-upload class="avatar-uploader" :show-file-list="false" :auto-upload="true" :http-request="handleMainImageUpload" accept="image/*">
<img v-if="mainImagePreview" :src="mainImagePreview" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
@@ -220,7 +196,6 @@
</el-col>
</el-row>
<!-- 资产描述 -->
<el-divider content-position="left">资产描述</el-divider>
<el-form-item label="描述内容" label-width="100px">
@@ -229,7 +204,6 @@
</div>
</el-form-item>
<!-- 实物资产配置 -->
<template v-if="ruleForm.type === 'physical'">
<el-divider content-position="left">实物资产配置</el-divider>
@@ -302,9 +276,17 @@
<el-input v-model="item.key" placeholder="Key" style="width: 200px" />
<span class="separator">:</span>
<el-input v-model="item.value" placeholder="Value" style="width: 200px" />
<el-button type="danger" :icon="Delete" circle size="small" @click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers, index)" />
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers, index)"
/>
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers)">添加请求头</el-button>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.headers)"
>添加请求头</el-button
>
</div>
</el-form-item>
</el-col>
@@ -317,9 +299,17 @@
<el-input v-model="item.key" placeholder="Key" style="width: 200px" />
<span class="separator">:</span>
<el-input v-model="item.value" placeholder="Value" style="width: 200px" />
<el-button type="danger" :icon="Delete" circle size="small" @click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params, index)" />
<el-button
type="danger"
:icon="Delete"
circle
size="small"
@click="removeKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params, index)"
/>
</div>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params)">添加请求参数</el-button>
<el-button type="primary" :icon="Plus" size="small" @click="addKeyValuePair(ruleForm.virtualAssetConfig.apiConfig.params)"
>添加请求参数</el-button
>
</div>
</el-form-item>
</el-col>
@@ -337,7 +327,13 @@
</el-col>
<el-col :span="16" v-if="ruleForm.virtualAssetConfig.apiConfig.authType !== 'none'">
<el-form-item label="认证配置" prop="virtualAssetConfig.apiConfig.authConfig">
<el-input v-model="ruleForm.virtualAssetConfig.apiConfig.authConfig" type="textarea" :rows="2" placeholder="请输入认证配置" class="w100" />
<el-input
v-model="ruleForm.virtualAssetConfig.apiConfig.authConfig"
type="textarea"
:rows="2"
placeholder="请输入认证配置"
class="w100"
/>
</el-form-item>
</el-col>
</el-row>
@@ -386,9 +382,13 @@
<!-- 时间段配置 -->
<el-form-item label="服务时间" prop="serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots">
<div class="config-list-container">
<div v-for="(slot, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots" :key="index" class="config-list-item">
<div
v-for="(slot, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.timeSlots"
:key="index"
class="config-list-item"
>
<el-select v-model="slot.dayOfWeek" placeholder="星期" style="width: 100px">
<el-option v-for="d in 7" :key="d" :label="'周' + ['一','二','三','四','五','六','日'][d-1]" :value="String(d)" />
<el-option v-for="d in 7" :key="d" :label="'周' + ['一', '二', '三', '四', '五', '六', '日'][d - 1]" :value="String(d)" />
</el-select>
<el-time-picker v-model="slot.startTime" format="HH:mm" value-format="HH:mm" placeholder="开始" style="width: 100px" />
<span class="separator">-</span>
@@ -396,29 +396,33 @@
<el-input-number v-model="slot.capacity" :min="1" placeholder="容量" style="width: 100px" controls-position="right" />
<el-button type="danger" :icon="Delete" circle size="small" @click="removeTimeSlot(index)" />
</div>
<el-button
type="primary"
:icon="Plus"
size="small"
@click="addTimeSlot"
:disabled="isTimeSlotLimitReached"
>
添加时间段
</el-button>
<el-button type="primary" :icon="Plus" size="small" @click="addTimeSlot" :disabled="isTimeSlotLimitReached"> 添加时间段 </el-button>
</div>
</el-form-item>
<!-- 例外日期配置 -->
<el-form-item label="休息时间">
<div class="config-list-container">
<div v-for="(exc, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions" :key="index" class="config-list-item">
<div
v-for="(exc, index) in ruleForm.serviceAssetConfig.serviceAssetArrivalConfig.schedule.exceptions"
:key="index"
class="config-list-item"
>
<el-select v-model="exc.exceptionType" placeholder="类型" style="width: 100px" @change="onExceptionTypeChange(exc)">
<el-option label="指定日期" value="date" />
<el-option label="指定星期" value="dayOfWeek" />
</el-select>
<el-date-picker v-if="exc.exceptionType === 'date'" v-model="exc.date" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD" placeholder="日期" style="width: 130px" />
<el-date-picker
v-if="exc.exceptionType === 'date'"
v-model="exc.date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="日期"
style="width: 130px"
/>
<el-select v-if="exc.exceptionType === 'dayOfWeek'" v-model="exc.dayOfWeek" placeholder="星期" style="width: 100px">
<el-option v-for="d in 7" :key="d" :label="'周' + ['一','二','三','四','五','六','日'][d-1]" :value="String(d)" />
<el-option v-for="d in 7" :key="d" :label="'周' + ['一', '二', '三', '四', '五', '六', '日'][d - 1]" :value="String(d)" />
</el-select>
<el-select v-model="exc.status" placeholder="状态" style="width: 90px">
<el-option label="可用" :value="1" />
@@ -591,8 +595,6 @@ const assetFormDiff = createFormDiff<Record<string, any>>();
// 获取租户ID
const tenantId = ref(Session.get('userInfo')?.tenantId || '');
console.log(tenantId.value,'租户id');
const formatImageUrl = (url?: string) => {
if (!url) return '';
@@ -1252,9 +1254,7 @@ const buildRequestBody = async (): Promise<any> => {
// 只有单选和多选类型才传递 options且只传递选中的值对应的选项
if ((attr.type === 'select' || attr.type === 'multi_select') && attr.options && value) {
const selectedValues = Array.isArray(value) ? value : [value];
metaItem.options = attr.options.filter((opt: { label: string; value: string }) =>
selectedValues.includes(opt.value)
);
metaItem.options = attr.options.filter((opt: { label: string; value: string }) => selectedValues.includes(opt.value));
}
metadataArray.push(metaItem);
@@ -1296,7 +1296,7 @@ const onSubmit = async () => {
closeDialog();
emit('getAssetList');
} catch (error) {
console.error('提交失败:', error);
ElMessage.error('提交失败');
} finally {
submitLoading.value = false;
}

View File

@@ -1,5 +1,5 @@
<template>
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1200px" :close-on-click-modal="false" @close="onClose">
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1200px" :close-on-click-modal="false" @close="onClose">
<!-- 搜索区域 -->
<div class="sku-search mb15">
<el-button type="primary" @click="onOpenAddSku">
@@ -28,10 +28,15 @@
{{ item.name }}: {{ item.options?.[0]?.label || (Array.isArray(item.value) ? item.value[0] : item.value) }}
</el-tag>
</span>
<span v-else-if="scope.row.specValues && typeof scope.row.specValues === 'object' && !Array.isArray(scope.row.specValues) && Object.keys(scope.row.specValues).length > 0">
<el-tag v-for="(value, key) in scope.row.specValues" :key="key" size="small" style="margin-right: 4px">
{{ key }}: {{ value }}
</el-tag>
<span
v-else-if="
scope.row.specValues &&
typeof scope.row.specValues === 'object' &&
!Array.isArray(scope.row.specValues) &&
Object.keys(scope.row.specValues).length > 0
"
>
<el-tag v-for="(value, key) in scope.row.specValues" :key="key" size="small" style="margin-right: 4px"> {{ key }}: {{ value }} </el-tag>
</span>
<span v-else>-</span>
</template>
@@ -47,9 +52,7 @@
</template>
</el-table-column>
<el-table-column prop="price" label="价格" width="90" align="center">
<template #default="scope">
¥{{ (scope.row.price / 100).toFixed(2) }}
</template>
<template #default="scope"> ¥{{ (scope.row.price / 100).toFixed(2) }} </template>
</el-table-column>
<el-table-column prop="stock" label="库存数量" width="100" align="center">
<template #default="scope">
@@ -122,7 +125,15 @@
<div class="spec-values-container">
<div v-for="attr in assetSpecAttrs" :key="attr.name" class="spec-item">
<span class="spec-label">{{ attr.name }}</span>
<el-select v-if="attr.options && attr.options.length > 0" v-model="specValuesMap[attr.name]" placeholder="请选择" style="width: 120px" filterable allow-create clearable>
<el-select
v-if="attr.options && attr.options.length > 0"
v-model="specValuesMap[attr.name]"
placeholder="请选择"
style="width: 120px"
filterable
allow-create
clearable
>
<el-option v-for="opt in attr.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input v-else v-model="specValuesMap[attr.name]" placeholder="请输入" style="width: 120px" />
@@ -144,12 +155,7 @@
<span style="margin-left: 8px; color: #909399; font-size: 12px">数值越小越靠前</span>
</el-form-item>
<el-form-item label="图片" prop="imageUrl" required>
<el-upload
class="sku-image-uploader"
:show-file-list="false"
:http-request="handleSkuImageUpload"
accept="image/*"
>
<el-upload class="sku-image-uploader" :show-file-list="false" :http-request="handleSkuImageUpload" accept="image/*">
<img v-if="skuImagePreview" :src="skuImagePreview" class="sku-image" />
<el-icon v-else class="sku-image-uploader-icon"><ele-Plus /></el-icon>
</el-upload>
@@ -194,13 +200,7 @@
style="width: 200px"
/>
<!-- 文本类型 -->
<el-input
v-else
v-model="stockForm[field.name]"
:maxlength="field.maxLength"
placeholder="请输入"
style="width: 200px"
/>
<el-input v-else v-model="stockForm[field.name]" :maxlength="field.maxLength" placeholder="请输入" style="width: 200px" />
</el-form-item>
</template>
</el-form>
@@ -217,7 +217,18 @@
import { ref, reactive } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { ElMessageBox } from 'element-plus';
import { listAssetSkus, createAssetSku, updateAssetSku, deleteAssetSku, getAssetSku, getAsset, uploadAssetImage, getSpecsUnitOptions, getStockFormFields, stockOperation } from '/@/api/assets/asset';
import {
listAssetSkus,
createAssetSku,
updateAssetSku,
deleteAssetSku,
getAssetSku,
getAsset,
uploadAssetImage,
getSpecsUnitOptions,
getStockFormFields,
stockOperation,
} from '/@/api/assets/asset';
import { createFormDiff } from '/@/utils/diffUtils';
import OperationLogDialog from '../../component/operationLogDialog.vue';
import type { UploadRequestOptions, UploadUserFile } from 'element-plus';
@@ -307,11 +318,11 @@ const skuRules: FormRules = {
specsUnit: [{ required: true, message: '请选择规格单位', trigger: 'change' }],
specsCount: [
{ required: true, message: '请输入规格数量', trigger: 'blur' },
{ type: 'number', min: 1, message: '规格数量必须大于0', trigger: 'blur' }
{ type: 'number', min: 1, message: '规格数量必须大于0', trigger: 'blur' },
],
price: [
{ required: true, message: '请输入价格', trigger: 'blur' },
{ type: 'number', min: 0.01, message: '价格必须大于0', trigger: 'blur' }
{ type: 'number', min: 0.01, message: '价格必须大于0', trigger: 'blur' },
],
stock: [
{ required: true, message: '请输入库存数量', trigger: 'blur' },
@@ -325,11 +336,10 @@ const skuRules: FormRules = {
callback();
}
},
trigger: 'blur'
}
trigger: 'blur',
},
],
imageUrl: [{ required: true, message: '请上传SKU图片', trigger: 'change' }],
};
// 打开弹窗
@@ -584,7 +594,6 @@ const onGenerateStock = async (row: any) => {
}
});
} catch (error) {
console.error('获取库存表单字段失败:', error);
ElMessage.error('获取库存表单字段失败');
stockFormVisible.value = false;
} finally {
@@ -619,7 +628,7 @@ const onSubmitStock = async () => {
stockFormVisible.value = false;
getSkuList();
} catch (error) {
console.error('库存操作失败:', error);
ElMessage.error('库存操作失败');
} finally {
stockSubmitLoading.value = false;
}

View File

@@ -138,7 +138,7 @@ const openDialog = async (row?: DialogFormData) => {
}
} catch (error) {
console.error('获取账号详情失败:', error);
ElMessage.error('获取账号详情失败');
// 错误已由全局拦截器处理
} finally {
state.loading = false;
}

View File

@@ -98,7 +98,7 @@ const loadDatasets = async () => {
}));
}
} catch (error) {
ElMessage.error('加载数据集列表失败');
// 错误已由全局拦截器处理
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,753 @@
<template>
<div class="system-edit-module-container">
<el-dialog :title="state.dialog.title" v-model="state.dialog.isShowDialog" width="900px">
<el-form
ref="editModuleFormRef"
v-loading="state.dialog.detailLoading"
:model="state.ruleForm"
:rules="state.rules"
size="default"
label-width="140px"
>
<!-- 基础配置 -->
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="state.ruleForm.modelName" placeholder="请输入模型名称" clearable></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型类型" prop="modelType">
<el-select v-model="state.ruleForm.modelType" placeholder="请选择模型类型" clearable style="width: 100%">
<el-option v-for="t in modelTypeOptions" :key="String(t.id)" :label="t.label" :value="typeOptionValue(t.id)"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型服务地址" prop="baseUrl">
<el-input v-model="state.ruleForm.baseUrl" placeholder="请输入模型服务地址" clearable></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求方式" prop="httpMethod">
<el-select v-model="state.ruleForm.httpMethod" placeholder="请选择请求方式" clearable style="width: 100%">
<el-option label="GET" value="GET"></el-option>
<el-option label="POST" value="POST"></el-option>
<el-option label="PUT" value="PUT"></el-option>
<el-option label="DELETE" value="DELETE"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="访问类型" prop="isPrivate">
<el-radio-group v-model="state.ruleForm.isPrivate" @change="onIsPrivateChange">
<el-radio :label="0">本地模型</el-radio>
<el-radio :label="1">服务商模型</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin && state.ruleForm.isPrivate === 1" :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="API 密钥" prop="apiKey">
<el-input v-model="state.ruleForm.apiKey" type="password" show-password placeholder="请输入 API 密钥字符串" clearable></el-input>
</el-form-item>
</el-col>
<el-col v-if="!props.isSuperAdmin" :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="是否对话模型" prop="isChatModel">
<el-radio-group v-model="state.ruleForm.isChatModel">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求头绑定" prop="headMsg">
<el-button @click="showHeaderDialog = true" style="width: 100%"> 配置请求头 ({{ state.headers.length }}) </el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="自定义字段" prop="form">
<el-button @click="showFormDialog = true" style="width: 100%"> 配置表单字段 ({{ state.formFields.length }}) </el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="是否启用" prop="enabled">
<el-radio-group v-model="state.ruleForm.enabled">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="备注说明" prop="remark">
<el-input v-model="state.ruleForm.remark" type="textarea" placeholder="请输入备注说明" :rows="3" clearable></el-input>
</el-form-item>
</el-col>
</el-row>
<!-- 高级配置折叠区域 -->
<el-divider content-position="left">
<el-button link type="primary" @click="state.showAdvanced = !state.showAdvanced">
{{ state.showAdvanced ? '收起高级配置' : '展开高级配置' }}
<el-icon class="ml5">
<component :is="state.showAdvanced ? 'ArrowUp' : 'ArrowDown'" />
</el-icon>
</el-button>
</el-divider>
<el-collapse-transition>
<el-row v-show="state.showAdvanced" :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大并发数" prop="maxConcurrency">
<el-input-number v-model="state.ruleForm.maxConcurrency" :min="1" :max="1000" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="排队队列上限" prop="queueLimit">
<el-input-number v-model="state.ruleForm.queueLimit" :min="1" :max="10000" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求超时时间(秒)" prop="timeoutSeconds">
<el-input-number v-model="state.ruleForm.timeoutSeconds" :min="1" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="预计执行时间(秒)" prop="expectedSeconds">
<el-input-number v-model="state.ruleForm.expectedSeconds" :min="1" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="失败重试次数" prop="retryTimes">
<el-input-number v-model="state.ruleForm.retryTimes" :min="0" :max="10" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="重试最大排队时间" prop="retryQueueMaxSeconds">
<el-input-number v-model="state.ruleForm.retryQueueMaxSeconds" :min="0" :max="3600" style="width: 100%"></el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="自动清理间隔(秒)" prop="autoCleanSeconds">
<el-input-number v-model="state.ruleForm.autoCleanSeconds" :min="0" :max="86400" style="width: 100%"> </el-input-number>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求映射" prop="requestMapping">
<el-button @click="showRequestMappingDialog = true" style="width: 100%">
配置请求映射 ({{ state.requestMappingFields.length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="响应映射" prop="responseMapping">
<el-button @click="showResponseMappingDialog = true" style="width: 100%">
配置响应映射 ({{ state.responseMappingFields.length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="Token映射" prop="tokenMapping">
<el-input v-model="state.ruleForm.tokenMapping" placeholder="请输入Token映射" clearable></el-input>
</el-form-item>
</el-col>
</el-row>
</el-collapse-transition>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel" size="default"> </el-button>
<el-button type="primary" @click="onSubmit" size="default" :loading="state.dialog.loading" :disabled="state.dialog.detailLoading">
{{ state.dialog.submitTxt }}
</el-button>
</span>
</template>
</el-dialog>
<!-- 请求头配置弹窗 -->
<el-dialog v-model="showHeaderDialog" title="配置请求头" width="600px" :close-on-click-modal="false">
<div class="header-config-container">
<div v-for="(header, index) in state.headers" :key="index" class="header-item">
<el-input v-model="header.key" placeholder="请输入 Key" style="width: 40%" clearable></el-input>
<span class="separator">:</span>
<el-input v-model="header.value" placeholder="请输入 Value" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeHeader(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addHeader" style="margin-top: 10px">+ 添加请求头</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showHeaderDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmHeaders" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 自定义字段配置弹窗 -->
<el-dialog v-model="showFormDialog" title="配置表单字段" width="600px" :close-on-click-modal="false">
<div class="form-config-container">
<div v-for="(field, index) in state.formFields" :key="index" class="form-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeFormField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addFormField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showFormDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmFormFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 请求映射配置弹窗 -->
<el-dialog v-model="showRequestMappingDialog" title="配置请求映射" width="600px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.requestMappingFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 40%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 40%" clearable></el-input>
<el-button type="danger" link @click="removeRequestMappingField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addRequestMappingField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showRequestMappingDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmRequestMappingFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
<!-- 响应映射配置弹窗 -->
<el-dialog v-model="showResponseMappingDialog" title="配置响应映射" width="700px" :close-on-click-modal="false">
<div class="mapping-config-container">
<div v-for="(field, index) in state.responseMappingFields" :key="index" class="mapping-field-item">
<el-input v-model="field.key" placeholder="请输入字段名 (Key)" style="width: 30%" clearable></el-input>
<span class="separator">=</span>
<el-input v-model="field.value" placeholder="请输入字段值 (Value)" style="width: 30%" clearable></el-input>
<el-button :type="field.isMainBody ? 'success' : 'primary'" :plain="!field.isMainBody" @click="setMainBody(index)" size="small">
{{ field.isMainBody ? '✓ 返回主体' : '设置返回主体' }}
</el-button>
<el-button type="danger" link @click="removeResponseMappingField(index)">删除</el-button>
</div>
<el-button type="primary" link @click="addResponseMappingField" style="margin-top: 10px">+ 添加字段</el-button>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showResponseMappingDialog = false" size="default"> </el-button>
<el-button type="primary" @click="confirmResponseMappingFields" size="default"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="systemEditModule">
import { reactive, ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { ArrowUp, ArrowDown } from '@element-plus/icons-vue';
import { addModelModule, updateModelModule, getModelModuleDetail, type ModelFormEntry } from '/@/api/digitalHuman/modelConfig/modelModule/index';
export type ModelTypeOption = { id: number | string; label: string };
const props = withDefaults(
defineProps<{
modelTypes?: ModelTypeOption[];
isSuperAdmin?: boolean;
}>(),
{
modelTypes: () => [] as ModelTypeOption[],
isSuperAdmin: false,
}
);
const modelTypeOptions = computed(() => props.modelTypes);
const typeOptionValue = (id: number | string): number | string => {
const n = Number(id);
return Number.isNaN(n) ? id : n;
};
const editModuleFormRef = ref();
const emit = defineEmits(['refresh']);
const showHeaderDialog = ref(false);
const showFormDialog = ref(false);
const showRequestMappingDialog = ref(false);
const showResponseMappingDialog = ref(false);
const state = reactive({
ruleForm: {
id: '',
modelName: '',
modelType: null as number | string | null,
baseUrl: '',
httpMethod: 'POST',
headMsg: '',
isPrivate: 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
maxConcurrency: 10,
queueLimit: 100,
timeoutSeconds: 30,
expectedSeconds: 15,
retryTimes: 3,
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
tokenMapping: '',
},
rules: {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
modelType: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (value === null || value === undefined || value === '') {
callback(new Error('请选择模型类型'));
} else {
callback();
}
},
trigger: 'change',
},
],
baseUrl: [{ required: true, message: '请输入模型服务地址', trigger: 'blur' }],
httpMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
maxConcurrency: [{ required: true, message: '请输入最大并发数', trigger: 'blur' }],
queueLimit: [{ required: true, message: '请输入排队队列上限', trigger: 'blur' }],
timeoutSeconds: [{ required: true, message: '请输入请求超时时间', trigger: 'blur' }],
expectedSeconds: [{ required: true, message: '请输入预计执行时间', trigger: 'blur' }],
},
dialog: {
isShowDialog: false,
type: '',
title: '',
submitTxt: '',
loading: false,
detailLoading: false,
},
showAdvanced: false,
headers: [] as Array<{ key: string; value: string }>,
formFields: [] as Array<{ key: string; value: string }>,
requestMappingFields: [] as Array<{ key: string; value: string }>,
responseMappingFields: [] as Array<{ key: string; value: string; isMainBody?: boolean }>,
mainBodyIndex: -1, // 记录哪一行被设置为返回主体
});
// 将数组转换为对象
const fieldsToObject = (fields: Array<{ key: string; value: string }>) => {
const obj: Record<string, string> = {};
fields.forEach((f) => {
if (f.key && f.key.trim()) {
obj[f.key.trim()] = f.value || '';
}
});
return obj;
};
const parseHeaders = (headMsg: string) => parseKeyValueString(headMsg);
// 解析 form支持数组 [{ key, value }] 或历史对象 { k: { value } }
const parseFormFields = (form: unknown) => {
if (!form) return [];
if (Array.isArray(form)) {
return (form as ModelFormEntry[])
.filter((item) => item && (item.key !== undefined || item.value !== undefined))
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? '').trim(),
}));
}
if (typeof form === 'object') {
const fields: Array<{ key: string; value: string }> = [];
Object.keys(form as Record<string, unknown>).forEach((key) => {
const v = (form as Record<string, unknown>)[key];
const value = String(v || '');
fields.push({ key, value });
});
return fields;
}
return [];
};
// 解析 requestMapping 对象为数组
const parseRequestMappingFields = (mapping: unknown) => {
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) return [];
return Object.entries(mapping).map(([key, value]) => ({ key, value: String(value) }));
};
// 解析 responseMapping 对象为数组
const parseResponseMappingFields = (mapping: unknown) => {
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) return [];
return Object.entries(mapping).map(([key, value]) => ({ key, value: String(value) }));
};
const buildFormArray = (): ModelFormEntry[] => {
return state.formFields
.filter((field) => field.key?.trim() && field.value?.trim())
.map((field) => ({ key: field.key.trim(), value: field.value.trim() }));
};
const parseKeyValueString = (raw: string) => {
if (!raw) return [];
const headers: Array<{ key: string; value: string }> = [];
const pairs = raw.split(',');
pairs.forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) {
headers.push({ key, value });
}
});
return headers;
};
const stringifyHeaders = () => {
return state.headers
.filter((h) => h.key?.trim() && h.value?.trim())
.map((h) => `${h.key.trim()}:${h.value.trim()}`)
.join(',');
};
const onIsPrivateChange = (val: string | number | boolean | undefined) => {
if (val === 0 || val === '0') {
state.ruleForm.apiKey = '';
}
};
const parseJsonObjectField = (raw: string, fallback: Record<string, unknown>) => {
try {
const o = JSON.parse(raw || '{}');
if (o && typeof o === 'object' && !Array.isArray(o)) {
return o as Record<string, unknown>;
}
} catch {
/* ignore */
}
return fallback;
};
// 添加请求头
const addHeader = () => {
state.headers.push({ key: '', value: '' });
};
// 删除请求头
const removeHeader = (index: number) => {
state.headers.splice(index, 1);
};
// 确认请求头配置
const confirmHeaders = () => {
state.ruleForm.headMsg = stringifyHeaders();
showHeaderDialog.value = false;
};
// 添加表单字段
const addFormField = () => {
state.formFields.push({ key: '', value: '' });
};
// 删除表单字段
const removeFormField = (index: number) => {
state.formFields.splice(index, 1);
};
// 确认表单字段配置
const confirmFormFields = () => {
showFormDialog.value = false;
};
// 请求映射字段操作
const addRequestMappingField = () => {
state.requestMappingFields.push({ key: '', value: '' });
};
const removeRequestMappingField = (index: number) => {
state.requestMappingFields.splice(index, 1);
};
const confirmRequestMappingFields = () => {
showRequestMappingDialog.value = false;
};
// 响应映射字段操作
const addResponseMappingField = () => {
state.responseMappingFields.push({ key: '', value: '', isMainBody: false });
};
const removeResponseMappingField = (index: number) => {
state.responseMappingFields.splice(index, 1);
};
// 设置返回主体(单选)
const setMainBody = (index: number) => {
// 清除所有字段的返回主体标记
state.responseMappingFields.forEach((field, i) => {
field.isMainBody = i === index;
});
state.mainBodyIndex = index;
};
const confirmResponseMappingFields = () => {
showResponseMappingDialog.value = false;
};
const ensureKeyValueRows = (rows: Array<{ key: string; value: string }>) => (rows.length ? rows : [{ key: '', value: '' }]);
const ensureResponseMappingRows = (rows: Array<{ key: string; value: string; isMainBody?: boolean }>) => {
if (!rows.length) return [{ key: '', value: '', isMainBody: false }];
return rows.map((row) => ({ ...row, isMainBody: row.isMainBody || false }));
};
/** 从 getModel 返回的 data 中取出单条模型对象 */
const unwrapModelDetailPayload = (data: unknown): Record<string, unknown> | null => {
if (data == null) return null;
if (Array.isArray(data)) return null;
if (typeof data !== 'object') return null;
const o = data as Record<string, unknown>;
if (typeof o.modelName === 'string') return o;
if (o.model && typeof o.model === 'object' && !Array.isArray(o.model)) {
return o.model as Record<string, unknown>;
}
if (o.detail && typeof o.detail === 'object' && !Array.isArray(o.detail)) {
return o.detail as Record<string, unknown>;
}
if (o.info && typeof o.info === 'object' && !Array.isArray(o.info)) {
return o.info as Record<string, unknown>;
}
return o;
};
const fillFormFromDetailRow = (row: Record<string, unknown>) => {
const timeoutSeconds =
row.timeoutSeconds != null && row.timeoutSeconds !== ''
? Number(row.timeoutSeconds)
: row.timeoutMs != null
? Math.floor(Number(row.timeoutMs) / 1000)
: 30;
const isPrivate = row.isPrivate !== undefined && row.isPrivate !== null ? Number(row.isPrivate) : 0;
state.ruleForm = {
id: row.id as string,
modelName: String(row.modelName ?? ''),
modelType: row.modelType !== undefined && row.modelType !== null ? typeOptionValue(row.modelType as number | string) : null,
baseUrl: String(row.baseUrl ?? ''),
httpMethod: String(row.httpMethod || 'POST'),
headMsg: String(row.headMsg || ''),
isPrivate,
apiKey: isPrivate === 1 ? String(row.apiKey ?? '') : '',
enabled: Number(row.enabled ?? 1),
isChatModel: row.isChatModel !== undefined && row.isChatModel !== null ? Number(row.isChatModel) : 0,
maxConcurrency: Number(row.maxConcurrency ?? 10),
queueLimit: Number(row.queueLimit ?? 100),
timeoutSeconds,
expectedSeconds: Number(row.expectedSeconds ?? 15),
retryTimes: Number(row.retryTimes ?? 3),
retryQueueMaxSeconds: Number(row.retryQueueMaxSeconds ?? 60),
autoCleanSeconds: Number(row.autoCleanSeconds ?? 300),
remark: String(row.remark || ''),
tokenMapping: String(row.tokenMapping || ''),
};
state.headers = ensureKeyValueRows(parseHeaders(String(row.headMsg || '')));
state.formFields = ensureKeyValueRows(parseFormFields(row.form));
// 解析请求映射和响应映射
state.requestMappingFields = ensureKeyValueRows(parseRequestMappingFields(row.requestMapping));
state.responseMappingFields = ensureResponseMappingRows(parseResponseMappingFields(row.responseMapping));
// 根据 responseBody 字段设置返回主体标记 (responseBody 是对象 {key: value})
if (row.responseBody && typeof row.responseBody === 'object') {
const responseBodyKey = Object.keys(row.responseBody)[0];
if (responseBodyKey) {
const mainBodyIndex = state.responseMappingFields.findIndex((f) => f.key === responseBodyKey);
if (mainBodyIndex !== -1) {
state.responseMappingFields[mainBodyIndex].isMainBody = true;
state.mainBodyIndex = mainBodyIndex;
}
}
}
};
// 打开弹窗(编辑时会请求 /model/getModel 详情)
const openDialog = async (type: string, row?: Record<string, unknown>) => {
state.dialog.type = type;
state.dialog.isShowDialog = true;
state.showAdvanced = false;
state.dialog.detailLoading = false;
if (type === 'edit') {
const listRowId = row?.id;
if (listRowId === undefined || listRowId === null || listRowId === '') {
ElMessage.error('缺少模型 ID');
state.dialog.isShowDialog = false;
return;
}
state.dialog.title = '修改模型配置';
state.dialog.submitTxt = '修 改';
state.dialog.detailLoading = true;
try {
const res: any = await getModelModuleDetail(listRowId as string | number);
if (res.code !== 0) {
ElMessage.error(res.message || '获取模型详情失败');
state.dialog.isShowDialog = false;
return;
}
const detail = unwrapModelDetailPayload(res.data) ?? row ?? null;
if (!detail || typeof detail !== 'object') {
ElMessage.error('获取模型详情失败');
state.dialog.isShowDialog = false;
return;
}
fillFormFromDetailRow(detail as Record<string, unknown>);
} catch {
// 接口错误由 request 全局提示后端 message
state.dialog.isShowDialog = false;
} finally {
state.dialog.detailLoading = false;
}
} else {
state.ruleForm = {
id: '',
modelName: '',
modelType: null,
baseUrl: '',
httpMethod: 'POST',
headMsg: '',
isPrivate: props.isSuperAdmin ? 1 : 0,
apiKey: '',
enabled: 1,
isChatModel: 0,
maxConcurrency: 10,
queueLimit: 100,
timeoutSeconds: 30,
expectedSeconds: 15,
retryTimes: 3,
retryQueueMaxSeconds: 60,
autoCleanSeconds: 300,
remark: '',
tokenMapping: '',
};
state.headers = [{ key: '', value: '' }];
state.formFields = [{ key: '', value: '' }];
state.requestMappingFields = [{ key: '', value: '' }];
state.responseMappingFields = [{ key: '', value: '', isMainBody: false }];
state.dialog.title = '新增模型配置';
state.dialog.submitTxt = '新 增';
}
};
// 关闭弹窗
const closeDialog = () => {
state.dialog.isShowDialog = false;
state.dialog.detailLoading = false;
editModuleFormRef.value?.resetFields();
};
// 取消
const onCancel = () => {
closeDialog();
};
// 提交
const onSubmit = () => {
editModuleFormRef.value.validate(async (valid: boolean) => {
if (!valid) return;
state.dialog.loading = true;
try {
state.ruleForm.headMsg = stringifyHeaders();
const requestMapping = fieldsToObject(state.requestMappingFields);
const responseMapping = fieldsToObject(state.responseMappingFields);
// 获取被设置为返回主体的字段 {key: value}
const responseBodyField = state.responseMappingFields.find((f) => f.isMainBody);
const responseBody = responseBodyField ? { [responseBodyField.key.trim()]: responseBodyField.value } : {};
const submitData = {
modelName: state.ruleForm.modelName,
modelType: state.ruleForm.modelType as number | string,
baseUrl: state.ruleForm.baseUrl,
httpMethod: state.ruleForm.httpMethod || 'POST',
headMsg: state.ruleForm.headMsg,
isPrivate: state.ruleForm.isPrivate,
enabled: state.ruleForm.enabled,
isChatModel: state.ruleForm.isChatModel,
apiKey: state.ruleForm.isPrivate === 1 ? String(state.ruleForm.apiKey ?? '').trim() : '',
form: fieldsToObject(state.formFields),
requestMapping,
responseMapping,
responseBody,
maxConcurrency: state.ruleForm.maxConcurrency,
queueLimit: state.ruleForm.queueLimit,
timeoutSeconds: state.ruleForm.timeoutSeconds,
expectedSeconds: state.ruleForm.expectedSeconds,
retryTimes: state.ruleForm.retryTimes,
retryQueueMaxSeconds: state.ruleForm.retryQueueMaxSeconds,
autoCleanSeconds: state.ruleForm.autoCleanSeconds,
remark: state.ruleForm.remark || '',
tokenMapping: state.ruleForm.tokenMapping || '',
};
if (state.dialog.type === 'edit') {
await updateModelModule({ ...submitData, id: state.ruleForm.id });
ElMessage.success('修改成功');
} else {
await addModelModule(submitData);
ElMessage.success('新增成功');
}
closeDialog();
emit('refresh');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
state.dialog.loading = false;
}
});
};
// 暴露变量
defineExpose({
openDialog,
});
</script>
<style scoped lang="scss">
.mapping-config-container {
.mapping-field-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.separator {
font-weight: bold;
color: #606266;
}
}
}
.form-config-container {
max-height: 400px;
overflow-y: auto;
}
.form-field-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.ml5 {
margin-left: 5px;
}
.header-config-container {
max-height: 400px;
overflow-y: auto;
}
.header-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.separator {
font-weight: bold;
color: #606266;
}
</style>

View File

@@ -0,0 +1,379 @@
<template>
<div class="system-user-container layout-padding">
<el-card shadow="hover" class="layout-padding-auto">
<div class="system-user-search mb15">
<el-input v-model="state.tableData.param.modelName" size="default" placeholder="请输入模型名称" style="max-width: 180px" clearable>
</el-input>
<el-select
v-model="state.tableData.param.modelType"
size="default"
placeholder="请选择模型类型"
style="max-width: 180px"
clearable
class="ml10"
>
<el-option v-for="type in state.modelTypes" :key="type.id" :label="type.label" :value="type.id" />
</el-select>
<el-button size="default" type="primary" class="ml10" @click="getTableData">
<el-icon>
<ele-Search />
</el-icon>
查询
</el-button>
<el-button size="default" type="success" class="ml10" @click="onOpenAddModule('add')">
<el-icon>
<ele-FolderAdd />
</el-icon>
新增模型配置
</el-button>
</div>
<el-table :data="state.tableData.data" v-loading="state.tableData.loading" style="width: 100%">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="modelName" label="模型名称" show-overflow-tooltip></el-table-column>
<el-table-column label="模型类型" width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ resolveModelTypeLabel(row.modelType) }}
</template>
</el-table-column>
<!-- <el-table-column prop="baseUrl" label="模型服务地址" show-overflow-tooltip width="200"></el-table-column> -->
<el-table-column prop="isPrivate" label="访问类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.isPrivate === 1 ? 'primary' : 'info'">{{ scope.row.isPrivate === 1 ? '本地模型' : '服务商模型' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="httpMethod" label="请求方式" width="100"></el-table-column>
<el-table-column prop="enabled" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.enabled === 1 ? 'success' : 'danger'">{{ scope.row.enabled === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="maxConcurrency" label="最大并发" width="100"></el-table-column>
<el-table-column prop="queueLimit" label="队列上限" width="100"></el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip></el-table-column>
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip width="160"></el-table-column>
<el-table-column prop="updatedAt" label="修改时间" show-overflow-tooltip width="160"></el-table-column>
<el-table-column label="操作" :width="isSuperAdmin ? 150 : 300" fixed="right">
<template #default="scope">
<div class="action-buttons">
<el-button size="small" text type="primary" @click="onOpenEditModule('edit', scope.row)">修改</el-button>
<!-- 非管理员才显示会话模型按钮 -->
<template v-if="!isSuperAdmin">
<el-button
v-if="isInferenceModel(scope.row.modelType) && Number(scope.row.isChatModel) !== 1"
size="small"
text
type="warning"
@click="onSetChatModel(scope.row)"
>
设为会话模型
</el-button>
<el-tag
v-if="isInferenceModel(scope.row.modelType) && Number(scope.row.isChatModel) === 1"
type="success"
effect="dark"
size="default"
>
当前会话模型
</el-tag>
</template>
<el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="onHandleSizeChange"
@current-change="onHandleCurrentChange"
class="mt15"
:pager-count="5"
:page-sizes="[10, 20, 30, 50]"
v-model:current-page="state.tableData.param.pageNum"
background
v-model:page-size="state.tableData.param.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="state.tableData.total"
>
</el-pagination>
</el-card>
<EditModule ref="editModuleRef" :model-types="state.modelTypes" :is-super-admin="isSuperAdmin" @refresh="getTableData()" />
<!-- 系统模型 API Key 输入弹窗 -->
<el-dialog v-model="apiKeyDialogVisible" title="配置系统模型" width="500px" :close-on-click-modal="false">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
<template #title>
<div style="line-height: 1.6">
您选择的是系统模型需要配置您自己的 API Key<br />
系统将为您创建一个模型副本并设置为会话模型
</div>
</template>
</el-alert>
<el-form :model="apiKeyForm" :rules="apiKeyRules" ref="apiKeyFormRef" label-width="100px">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="apiKeyForm.modelName" placeholder="请输入模型名称" />
</el-form-item>
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="apiKeyForm.apiKey" type="password" show-password placeholder="请输入您的 API Key" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="apiKeyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreatePrivateModelAndSetChat" :loading="creatingModel">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="digitalHumanModelModule">
import { defineAsyncComponent, reactive, onMounted, ref } from 'vue';
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus';
import {
getModelModuleList,
getModelTypeList,
deleteModelModule,
normalizeModelTypeOptions,
updateChatModel,
addModelModule,
} from '/@/api/digitalHuman/modelConfig/modelModule/index';
import { checkIsSuperAdmin } from '/@/api/system/user/index';
import { getApiErrorMessage } from '/@/utils/request';
const EditModule = defineAsyncComponent(() => import('/@/views/digitalHuman/modelConfig/modelModule/component/editModule.vue'));
const editModuleRef = ref();
const isSuperAdmin = ref(false); // 是否为管理员
const state = reactive({
modelTypes: [] as Array<{ id: number | string; label: string }>,
tableData: {
data: [],
total: 0,
loading: false,
param: {
pageNum: 1,
pageSize: 10,
modelName: '',
modelType: undefined as number | string | undefined,
},
},
});
// 系统模型 API Key 配置
const apiKeyDialogVisible = ref(false);
const apiKeyFormRef = ref<FormInstance>();
const apiKeyForm = reactive({
modelName: '',
apiKey: '',
});
const apiKeyRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }],
};
const creatingModel = ref(false);
const systemModelToClone = ref<any>(null);
// 检查是否为管理员
const checkAdminStatus = async () => {
try {
const res: any = await checkIsSuperAdmin();
isSuperAdmin.value = res.data?.isSuperAdmin || false;
} catch {
isSuperAdmin.value = false;
}
};
// 判断是否为推理模型(只有推理模型才能设置为会话模型)
const isInferenceModel = (modelType: number | string | undefined | null) => {
if (modelType === undefined || modelType === null || modelType === '') {
return false;
}
// 查找模型类型标签,判断是否为"推理模型"
const typeInfo = state.modelTypes.find((t) => String(t.id) === String(modelType));
return typeInfo?.label === '推理模型' || String(modelType) === '1';
};
// 设置为会话模型
const onSetChatModel = async (row: any) => {
// 判断是否是系统模型tenantId === 1
if (row.tenantId === 1) {
// 系统模型,需要用户配置 API Key
systemModelToClone.value = row;
apiKeyForm.modelName = row.modelName;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
} else {
// 非系统模型,直接设置为会话模型
try {
await updateChatModel({
id: row.id!,
isChatModel: 1,
});
ElMessage.success('已设置为会话模型');
await getTableData();
} catch {
// 错误已由全局拦截器处理
}
}
};
// 创建私有模型并设置为会话模型
const handleCreatePrivateModelAndSetChat = async () => {
if (!apiKeyFormRef.value || !systemModelToClone.value) return;
try {
await apiKeyFormRef.value.validate();
creatingModel.value = true;
// 基于系统模型创建新模型(继承原模型的所有配置,只替换 apiKey
const systemModel = systemModelToClone.value;
const createParams = {
modelName: apiKeyForm.modelName,
modelType: systemModel.modelType,
baseUrl: systemModel.baseUrl,
httpMethod: systemModel.httpMethod || 'POST',
headMsg: systemModel.headMsg || '',
isPrivate: systemModel.isPrivate ?? 1,
enabled: systemModel.enabled ?? 1,
isChatModel: 1, // 设置为会话模型
apiKey: apiKeyForm.apiKey,
form: systemModel.form || [],
requestMapping: systemModel.requestMapping || {},
responseMapping: systemModel.responseMapping || {},
maxConcurrency: systemModel.maxConcurrency || 10,
queueLimit: systemModel.queueLimit || 100,
timeoutSeconds: systemModel.timeoutSeconds || 30,
expectedSeconds: systemModel.expectedSeconds || 15,
retryTimes: systemModel.retryTimes || 3,
retryQueueMaxSeconds: systemModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: systemModel.autoCleanSeconds || 300,
remark: systemModel.remark || '',
};
await addModelModule(createParams);
ElMessage.success('模型创建成功并已设置为会话模型');
// 关闭对话框
apiKeyDialogVisible.value = false;
// 刷新列表
await getTableData();
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(getApiErrorMessage(error, '创建模型失败'));
}
} finally {
creatingModel.value = false;
}
};
const resolveModelTypeLabel = (modelType: number | string | undefined | null) => {
if (modelType === undefined || modelType === null || modelType === '') {
return '—';
}
const hit = state.modelTypes.find((t) => String(t.id) === String(modelType));
return hit?.label ?? String(modelType);
};
const loadModelTypes = async () => {
try {
const res: any = await getModelTypeList();
if (res.code === 0) {
state.modelTypes = normalizeModelTypeOptions(res);
}
} catch {
// 接口错误由 request 全局提示后端 message
}
};
// 初始化表格数据
const getTableData = async () => {
state.tableData.loading = true;
try {
const res: any = await getModelModuleList(state.tableData.param);
if (res.code === 0) {
state.tableData.data = res.data.list || [];
state.tableData.total = res.data.total || 0;
}
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
state.tableData.loading = false;
}
};
// 打开新增模型模块弹窗
const onOpenAddModule = (type: string) => {
editModuleRef.value.openDialog(type);
};
// 打开修改模型模块弹窗
const onOpenEditModule = (type: string, row: any) => {
editModuleRef.value.openDialog(type, row);
};
// 删除模型模块
const onRowDel = (row: any) => {
ElMessageBox.confirm(`确定要删除模型配置:${row.modelName}`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteModelModule(row.id);
ElMessage.success('删除成功');
getTableData();
} catch {
// 接口错误由 request 全局提示后端 message
}
})
.catch(() => {});
};
// 分页改变
const onHandleSizeChange = (val: number) => {
state.tableData.param.pageSize = val;
getTableData();
};
// 分页改变
const onHandleCurrentChange = (val: number) => {
state.tableData.param.pageNum = val;
getTableData();
};
// 页面加载时
onMounted(async () => {
await checkAdminStatus(); // 检查管理员状态
await loadModelTypes();
getTableData();
});
</script>
<style scoped lang="scss">
.text-muted {
color: var(--el-text-color-placeholder);
}
.system-user-container {
:deep(.el-card__body) {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
.el-table {
flex: 1;
}
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
.el-button {
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="system-model-type-container layout-padding">
<el-card shadow="hover" class="layout-padding-auto">
<div class="system-model-type-search mb15">
<el-input v-model="state.tableData.param.keyword" size="default" placeholder="请输入模型类型名称" style="max-width: 180px" clearable> </el-input>
<el-button size="default" type="primary" class="ml10" @click="getTableData">
<el-icon>
<ele-Search />
</el-icon>
查询
</el-button>
<el-button size="default" type="success" class="ml10" @click="onOpenAddType('add')">
<el-icon>
<ele-FolderAdd />
</el-icon>
新增模型类型
</el-button>
</div>
<el-table :data="state.tableData.data" v-loading="state.tableData.loading" style="width: 100%">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="typeName" label="类型名称" show-overflow-tooltip></el-table-column>
<el-table-column prop="typeCode" label="类型编码" show-overflow-tooltip></el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{ scope.row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" text type="primary" @click="onOpenEditType('edit', scope.row)">修改</el-button>
<el-button size="small" text type="primary" @click="onRowDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="onHandleSizeChange"
@current-change="onHandleCurrentChange"
class="mt15"
:pager-count="5"
:page-sizes="[10, 20, 30]"
v-model:current-page="state.tableData.param.pageNum"
background
v-model:page-size="state.tableData.param.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="state.tableData.total"
>
</el-pagination>
</el-card>
</div>
</template>
<script setup lang="ts" name="digitalHumanModelType">
import { reactive, onMounted } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
const state = reactive({
tableData: {
data: [],
total: 0,
loading: false,
param: {
pageNum: 1,
pageSize: 10,
keyword: '',
},
},
});
// 初始化表格数据
const getTableData = () => {
state.tableData.loading = true;
// TODO: 调用API获取数据
setTimeout(() => {
state.tableData.data = [];
state.tableData.total = 0;
state.tableData.loading = false;
}, 500);
};
// 打开新增模型类型弹窗
const onOpenAddType = (type: string) => {
ElMessage.info('功能开发中...');
};
// 打开修改模型类型弹窗
const onOpenEditType = (type: string, row: any) => {
ElMessage.info('功能开发中...');
};
// 删除模型类型
const onRowDel = (row: any) => {
ElMessage.info('功能开发中...');
};
// 分页改变
const onHandleSizeChange = (val: number) => {
state.tableData.param.pageSize = val;
getTableData();
};
// 分页改变
const onHandleCurrentChange = (val: number) => {
state.tableData.param.pageNum = val;
getTableData();
};
// 页面加载时
onMounted(() => {
getTableData();
});
</script>
<style scoped lang="scss">
.system-model-type-container {
:deep(.el-card__body) {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
.el-table {
flex: 1;
}
}
}
</style>

View File

@@ -0,0 +1,468 @@
<template>
<div class="skill-page">
<div class="page-header">
<div class="header-left">
<h2 class="page-title">Skill 技能管理</h2>
<p class="page-desc">管理和配置 AI 技能模板</p>
</div>
<div class="header-right">
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建技能
</el-button>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable class="search-input" @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 技能列表 -->
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="120" />
<div v-else class="skill-grid">
<div v-for="skill in skillList" :key="skill.id" class="skill-card">
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-dropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, skill)">
<el-icon class="more-icon"><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete">
<el-icon><Delete /></el-icon>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
<div class="skill-file" v-if="skill.fileName">
<el-icon><Document /></el-icon>
<span>{{ skill.fileName }}</span>
</div>
</div>
<div class="skill-card-footer">
<span class="skill-time">{{ formatTime(skill.createdAt) }}</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 创建对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" :close-on-click-modal="false">
<el-form :model="formData" :rules="formRules" ref="formRef" label-position="top">
<el-form-item label="技能名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入技能名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="技能描述" prop="description">
<el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入技能描述" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="分类" prop="category">
<el-input v-model="formData.category" placeholder="请输入分类(如:文本生成、图像生成等)" maxlength="50" />
</el-form-item>
<el-form-item label="上传文件" prop="file">
<el-upload
ref="uploadRef"
class="upload-area"
drag
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-exceed="handleExceed"
:file-list="fileList"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持各种文件格式文件大小不超过 100MB</div>
</template>
</el-upload>
</el-form-item>
<!-- 文件信息预览 -->
<div v-if="formData.fileName" class="file-preview">
<div class="file-preview-header">
<span class="file-preview-title">已上传文件</span>
<el-tag type="success" size="small">上传成功</el-tag>
</div>
<div class="file-preview-info">
<div class="file-info-item">
<span class="file-info-label">文件名</span>
<span class="file-info-value">{{ formData.fileName }}</span>
</div>
<div class="file-info-item">
<span class="file-info-label">文件地址</span>
<span class="file-info-value">{{ formData.fileUrl }}</span>
</div>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadInstance, type UploadProps, type UploadUserFile } from 'element-plus';
import { Plus, Search, MoreFilled, Delete, Document, UploadFilled } from '@element-plus/icons-vue';
import { getUserSkillList, createUserSkill, deleteUserSkill, type SkillItem, type CreateSkillParams } from '/@/api/digitalHuman/skill';
import { uploadFile as uploadFileToOss } from '/@/api/common/upload';
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const skillList = ref<SkillItem[]>([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('创建技能');
const formRef = ref<FormInstance>();
const uploadRef = ref<UploadInstance>();
const submitting = ref(false);
const fileList = ref<UploadUserFile[]>([]);
const formData = reactive<CreateSkillParams>({ name: '', description: '', category: '', fileName: '', fileUrl: '' });
const formRules: FormRules = {
name: [{ required: true, message: '请输入技能名称', trigger: 'blur' }],
category: [{ required: true, message: '请输入分类', trigger: 'blur' }],
};
const formatTime = (time: string) => (time ? time.replace('T', ' ').split('.')[0] : '');
const handleFileChange: UploadProps['onChange'] = async (uploadFile) => {
if (!uploadFile.raw) return;
const maxSize = 100 * 1024 * 1024;
if (uploadFile.raw.size > maxSize) {
ElMessage.warning('文件大小不能超过 100MB');
fileList.value = [];
return;
}
try {
ElMessage.info('正在上传文件到 OSS...');
const uploadRes = await uploadFileToOss(uploadFile.raw);
formData.fileName = uploadRes.data.fileName;
formData.fileUrl = uploadRes.data.fileURL;
fileList.value = [uploadFile];
ElMessage.success('文件上传成功');
} catch (error) {
fileList.value = [];
formData.fileName = '';
formData.fileUrl = '';
}
};
const handleExceed: UploadProps['onExceed'] = () => ElMessage.warning('只能上传一个文件');
const fetchSkillList = async () => {
loading.value = true;
try {
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
const res = await getUserSkillList(params);
skillList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch (error) {
skillList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchSkillList();
};
const handlePageChange = (page: number) => {
pagination.pageNum = page;
fetchSkillList();
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.pageNum = 1;
fetchSkillList();
};
const handleCreate = () => {
dialogTitle.value = '创建技能';
Object.assign(formData, { name: '', description: '', category: '', fileName: '', fileUrl: '' });
fileList.value = [];
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!formData.fileName || !formData.fileUrl) {
ElMessage.warning('请先上传文件');
return;
}
submitting.value = true;
try {
await createUserSkill({
name: formData.name,
description: formData.description,
category: formData.category,
fileName: formData.fileName,
fileUrl: formData.fileUrl,
});
ElMessage.success('创建成功');
dialogVisible.value = false;
fetchSkillList();
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
submitting.value = false;
}
});
};
const handleCommand = async (command: string, skill: SkillItem) => {
if (command === 'delete') {
try {
await ElMessageBox.confirm(`确定要删除技能"${skill.name}"吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
});
await deleteUserSkill(skill.id);
ElMessage.success('删除成功');
fetchSkillList();
} catch (error) {
if (error !== 'cancel') {
// 接口错误由 request 全局提示后端 message
}
}
}
};
onMounted(() => fetchSkillList());
</script>
<style scoped lang="scss">
.skill-page {
padding: 20px;
background: #f6f8fb;
min-height: calc(100vh - 100px);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px 0;
}
.page-desc {
font-size: 14px;
color: #64748b;
margin: 0;
}
.header-right {
display: flex;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 24px;
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input {
flex: 1;
max-width: 400px;
}
.skill-list {
min-height: 400px;
}
.skill-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.skill-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.skill-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.skill-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.skill-category {
display: inline-block;
padding: 4px 12px;
background: #eff6ff;
color: #3b82f6;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.more-icon {
cursor: pointer;
color: #94a3b8;
font-size: 20px;
transition: color 0.2s;
}
.more-icon:hover {
color: #3b82f6;
}
.skill-card-body {
flex: 1;
margin-bottom: 16px;
}
.skill-name {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 14px;
color: #64748b;
line-height: 1.6;
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 44px;
}
.skill-file {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 13px;
color: #475569;
}
.skill-file .el-icon {
color: #3b82f6;
}
.skill-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.skill-time {
font-size: 12px;
color: #94a3b8;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.upload-area {
width: 100%;
}
.upload-area :deep(.el-upload-dragger) {
width: 100%;
}
.file-preview {
margin-top: 16px;
padding: 16px;
background: #f0fdf4;
border-radius: 8px;
border: 1px solid #86efac;
}
.file-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.file-preview-title {
font-size: 14px;
font-weight: 600;
color: #166534;
}
.file-preview-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-info-item {
display: flex;
align-items: flex-start;
font-size: 13px;
}
.file-info-label {
color: #15803d;
margin-right: 8px;
min-width: 80px;
flex-shrink: 0;
}
.file-info-value {
color: #166534;
font-weight: 500;
word-break: break-all;
}
@media (max-width: 768px) {
.skill-grid {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
}
.search-input {
max-width: 100%;
width: 100%;
}
}
</style>

View File

@@ -558,7 +558,7 @@ const getknowledgeList = async () => {
});
knowledgeList.value = response.data.list || [];
} catch (_error) {
ElMessage.error('获取知识库列表失败');
// 错误已由全局拦截器处理
} finally {
knowledgeLoading.value = false;
}
@@ -648,7 +648,7 @@ const onSaveknowledge = async () => {
showknowledgeDialog.value = false;
getknowledgeList();
} catch (_error) {
ElMessage.error('保存失败,请重试');
// 错误已由全局拦截器处理
} finally {
knowledgeSaving.value = false;
}
@@ -673,7 +673,7 @@ const getFileList = async () => {
statusEnabled: item.status === 1,
}));
} catch (_error) {
ElMessage.error('获取文件列表失败');
// 错误已由全局拦截器处理
} finally {
fileLoading.value = false;
}
@@ -745,7 +745,7 @@ const onConfirmUpload = async () => {
showUploadDialog.value = false;
getFileList();
} catch (_error) {
ElMessage.error('创建文档失败,请重试');
// 错误已由全局拦截器处理
} finally {
uploading.value = false;
}
@@ -764,7 +764,7 @@ const onFileStatusChange = async (row: any) => {
} catch (error) {
// 失败时恢复原状态
row.statusEnabled = !row.statusEnabled;
ElMessage.error('状态更新失败');
// 错误已由全局拦截器处理
}
};
@@ -779,7 +779,7 @@ const onGenerateVector = async (row: any) => {
getFileList();
}, 1000);
} catch (error) {
ElMessage.error('生成向量失败');
// 错误已由全局拦截器处理
}
};
@@ -791,7 +791,7 @@ const onViewDocumentDetail = async (row: any) => {
currentDocument.value = response.data;
showDocumentDetailDialog.value = true;
} catch (error) {
ElMessage.error('获取文件详情失败');
// 错误已由全局拦截器处理
}
};
@@ -917,7 +917,7 @@ const onEditModelConfig = async (row: any) => {
// 打开弹窗
showCreateModelDialog.value = true;
} catch (error) {
ElMessage.error('获取模型配置详情失败');
// 错误已由全局拦截器处理
}
};
@@ -928,7 +928,7 @@ const getModelEnums = async () => {
const response = await getAllModelEnums();
modelEnums.value = response.data?.options || [];
} catch (error) {
ElMessage.error('获取模型类型枚举失败');
// 错误已由全局拦截器处理
modelEnums.value = [];
} finally {
modelEnumsLoading.value = false;
@@ -975,7 +975,7 @@ const getModelFormFields = async () => {
}
});
} catch (error) {
ElMessage.error('获取模型表单字段失败');
// 错误已由全局拦截器处理
modelFormFields.value = [];
} finally {
modelFormLoading.value = false;
@@ -1016,7 +1016,7 @@ const onSaveModelConfig = async () => {
showCreateModelDialog.value = false;
getModelConfigList();
} catch (error) {
ElMessage.error(isEditMode.value ? '更新模型配置失败' : '创建模型配置失败');
// 错误已由全局拦截器处理
}
};
@@ -1040,7 +1040,7 @@ const getModelConfigList = async () => {
});
modelConfigList.value = response.data?.list || [];
} catch (error) {
ElMessage.error('获取模型配置列表失败');
// 错误已由全局拦截器处理
modelConfigList.value = [];
} finally {
modelConfigLoading.value = false;
@@ -1128,7 +1128,7 @@ const getTaskList = async () => {
const response = await listTasks();
taskList.value = response.data?.list || [];
} catch (error) {
ElMessage.error('获取任务列表失败');
// 错误已由全局拦截器处理
taskList.value = [];
} finally {
taskListLoading.value = false;
@@ -1149,7 +1149,7 @@ const onReexecuteTask = async (task: any) => {
// 重新获取任务列表
await getTaskList();
} catch (error) {
ElMessage.error('重新执行任务失败,请重试');
// 错误已由全局拦截器处理
}
})
.catch(() => {});

View File

@@ -114,7 +114,7 @@ const openDialog = async (row?: DialogFormData) => {
};
}
} catch (error) {
ElMessage.error('获取主播详情失败');
// 错误已由全局拦截器处理
} finally {
state.loading = false;
}
@@ -160,7 +160,7 @@ const onSubmit = async () => {
closeDialog();
emit('refresh');
} catch (error) {
ElMessage.error('操作失败');
// 错误已由全局拦截器处理
} finally {
state.loading = false;
}

View File

@@ -166,7 +166,7 @@ const getList = async () => {
tableData.total = res.data.total || 0;
}
} catch (error) {
ElMessage.error('获取主播列表失败');
// 错误已由全局拦截器处理
} finally {
tableData.loading = false;
}
@@ -192,7 +192,7 @@ const handleDelete = async (row: TableDataItem) => {
getList();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
// 错误已由全局拦截器处理
}
}
};

View File

@@ -101,14 +101,13 @@ const openDialog = async (row?: { id?: string }) => {
try {
loading.value = true;
// 详情加载失败时由当前弹窗给出更易懂的业务提示。
const res = await getLiveAccountDetail({ id: String(row.id) }, { errorMode: 'page' });
const res = await getLiveAccountDetail({ id: String(row.id) });
if (res?.data) {
fillForm(res.data);
}
dialogVisible.value = true;
} catch (error) {
ElMessage.error('获取直播账号详情失败');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
loading.value = false;
}
@@ -130,19 +129,18 @@ const handleSubmit = async () => {
remark: formData.remark,
};
// 提交失败提示交给当前弹窗自己处理,避免和 request.ts 的统一报错重复。
if (isEdit.value) {
await updateLiveAccount(payload, { errorMode: 'page' });
await updateLiveAccount(payload);
ElMessage.success('修改成功');
} else {
await createLiveAccount(payload, { errorMode: 'page' });
await createLiveAccount(payload);
ElMessage.success('新增成功');
}
dialogVisible.value = false;
emit('refresh');
} catch (error) {
ElMessage.error(isEdit.value ? '修改失败' : '新增失败');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
loading.value = false;
}

View File

@@ -131,17 +131,13 @@ const tableData = reactive({
const getList = async () => {
try {
tableData.loading = true;
// 列表失败文案由当前页面决定,避免和全局请求报错同时出现。
const res = await getLiveAccountList(
{
...tableData.param,
platform: searchForm.platform || undefined,
accountName: searchForm.accountName || undefined,
accountId: searchForm.accountId || undefined,
status: searchForm.status,
},
{ errorMode: 'page' }
);
const res = await getLiveAccountList({
...tableData.param,
platform: searchForm.platform || undefined,
accountName: searchForm.accountName || undefined,
accountId: searchForm.accountId || undefined,
status: searchForm.status,
});
if (res && res.data) {
tableData.data = (res.data.list || []).map((item: any) => ({
...item,
@@ -149,8 +145,8 @@ const getList = async () => {
}));
tableData.total = res.data.total || 0;
}
} catch (error) {
ElMessage.error('获取直播账号列表失败');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
tableData.loading = false;
}
@@ -195,12 +191,12 @@ const handleDelete = async (row: LiveAccountItem) => {
cancelButtonText: '取消',
type: 'warning',
});
await deleteLiveAccount({ id: row.id }, { errorMode: 'page' });
await deleteLiveAccount({ id: row.id });
ElMessage.success('删除成功');
getList();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
// 接口错误由 request 全局提示后端 message
}
}
};

View File

@@ -153,8 +153,7 @@ const openDialog = async (row?: { id?: string }) => {
await loadOptions();
if (row?.id) {
// 详情请求失败时,这个弹窗希望给出更明确的页面语义提示。
const res = await getScheduleDetail({ id: String(row.id) }, { errorMode: 'page' });
const res = await getScheduleDetail({ id: String(row.id) });
const detail = res?.data;
if (detail) {
formData.id = String(detail.id);
@@ -170,8 +169,8 @@ const openDialog = async (row?: { id?: string }) => {
}
dialogVisible.value = true;
} catch (error) {
ElMessage.error(isEdit.value ? '获取排班详情失败' : '加载排班基础数据失败');
} catch {
// 接口错误由 request 全局提示后端 message表单校验错误由表单项展示
} finally {
loading.value = false;
}
@@ -196,19 +195,18 @@ const handleSubmit = async () => {
remark: formData.remark,
};
// 提交失败文案由弹窗自己控制,避免接口层和弹窗层重复报错。
if (isEdit.value) {
await updateSchedule(payload, { errorMode: 'page' });
await updateSchedule(payload);
ElMessage.success('修改排班成功');
} else {
await createSchedule(payload, { errorMode: 'page' });
await createSchedule(payload);
ElMessage.success('新增排班成功');
}
dialogVisible.value = false;
emit('refresh');
} catch (error) {
ElMessage.error(isEdit.value ? '修改排班失败' : '新增排班失败');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
loading.value = false;
}

View File

@@ -144,16 +144,12 @@ const getStatusTagType = (status: number): 'success' | 'info' | 'warning' => {
const getList = async () => {
try {
tableData.loading = true;
// 列表失败文案由当前页面决定,避免和 request.ts 的全局错误提示重复。
const res = await getScheduleList(
{
...tableData.param,
anchorName: searchForm.anchorName || undefined,
accountName: searchForm.accountName || undefined,
status: searchForm.status,
} as any,
{ errorMode: 'page' }
);
const res = await getScheduleList({
...tableData.param,
anchorName: searchForm.anchorName || undefined,
accountName: searchForm.accountName || undefined,
status: searchForm.status,
} as any);
const scheduleData = res?.data;
if (scheduleData) {
tableData.data = (scheduleData.list || []).map((item: any) => ({
@@ -166,8 +162,8 @@ const getList = async () => {
}));
tableData.total = scheduleData.total || 0;
}
} catch (error) {
ElMessage.error('获取排班列表失败');
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
tableData.loading = false;
}
@@ -211,12 +207,12 @@ const handleDelete = async (row: ScheduleItem) => {
cancelButtonText: '取消',
type: 'warning',
});
await deleteSchedule({ id: row.id }, { errorMode: 'page' });
await deleteSchedule({ id: row.id });
ElMessage.success('删除成功');
getList();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
// 接口错误由 request 全局提示后端 message
}
}
};