Compare commits
32 Commits
feature/co
...
7019bc511b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7019bc511b | |||
| 77aaeebf1d | |||
| 75cc91a4fb | |||
| e388875dc3 | |||
| f20108a283 | |||
| 34bc30a2c5 | |||
| f2266317e2 | |||
| 72af38ea00 | |||
| 87b25dee42 | |||
| b0e62fb966 | |||
| fe24948ce9 | |||
| 88c9d08e95 | |||
| 41a40cc6ee | |||
| 4e407675a2 | |||
| 03de9595d1 | |||
| c7152f5d92 | |||
| 29838b030f | |||
| 0a42e700e2 | |||
| 76420713fa | |||
| cae76234b7 | |||
| d1ef004100 | |||
| fa590f1e27 | |||
| 05ba57282f | |||
| 2e6af6e06c | |||
| a285c9d982 | |||
| 8cc5f4be64 | |||
| 0c6cfe5c17 | |||
| 77a03cab11 | |||
| 882d7bd2fb | |||
| 63aa678ac0 | |||
| 4fe8e15450 | |||
| 415ba67d01 |
716
docs/API-ERROR-HANDLING.md
Normal file
716
docs/API-ERROR-HANDLING.md
Normal 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
|
||||
**维护者:** 开发团队
|
||||
@@ -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
32
src/api/common/upload.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
236
src/api/digitalHuman/modelConfig/modelModule/index.ts
Normal file
236
src/api/digitalHuman/modelConfig/modelModule/index.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
79
src/api/digitalHuman/modelConfig/modelType/index.ts
Normal file
79
src/api/digitalHuman/modelConfig/modelType/index.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
128
src/api/digitalHuman/skill/index.ts
Normal file
128
src/api/digitalHuman/skill/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
459
src/components/model/ModelSelector.vue
Normal file
459
src/components/model/ModelSelector.vue
Normal 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>
|
||||
270
src/components/skill/NodeSkillSelector.vue
Normal file
270
src/components/skill/NodeSkillSelector.vue
Normal 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>
|
||||
215
src/components/skill/SkillSelector.vue
Normal file
215
src/components/skill/SkillSelector.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ const openDialog = async (row?: DialogFormData) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账号详情失败:', error);
|
||||
ElMessage.error('获取账号详情失败');
|
||||
// 错误已由全局拦截器处理
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ const loadDatasets = async () => {
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载数据集列表失败');
|
||||
// 错误已由全局拦截器处理
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
379
src/views/digitalHuman/modelConfig/modelModule/index.vue
Normal file
379
src/views/digitalHuman/modelConfig/modelModule/index.vue
Normal 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>
|
||||
127
src/views/digitalHuman/modelConfig/modelType/index.vue
Normal file
127
src/views/digitalHuman/modelConfig/modelType/index.vue
Normal 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>
|
||||
468
src/views/digitalHuman/skill/index.vue
Normal file
468
src/views/digitalHuman/skill/index.vue
Normal 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>
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('删除失败');
|
||||
// 错误已由全局拦截器处理
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user