Compare commits
5 Commits
ded04de609
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ae927a851 | |||
| f137ae591e | |||
| d516886fc9 | |||
| d628dfdd72 | |||
| 24e517dfec |
@@ -1,68 +0,0 @@
|
||||
name: 全局K3s部署
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
jobs:
|
||||
deploy:
|
||||
# ========== 核心修复:替换为具体Ubuntu版本,解决运行期匹配问题 ==========
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
# 从组织级Secrets读取,不用在仓库重复配置
|
||||
K3S_HOST: ${{ secrets.K3S_HOST }}
|
||||
APP_NAME: ${{ gitea.repo_name }}
|
||||
# 补充:若后续要推送镜像,需替换为实际镜像仓库地址(比如你的Gitea镜像仓库)
|
||||
REGISTRY: 116.204.74.41:3000/red-future
|
||||
steps:
|
||||
# ========== 核心:新增国内Git代理,彻底解决GitHub拉取慢 ==========
|
||||
- name: 配置国内GitHub代理加速
|
||||
run: |
|
||||
# 全局Git代理:所有GitHub请求走国内镜像站
|
||||
git config --global url."https://ghproxy.com/https://github.com/".insteadOf "https://github.com/"
|
||||
# 可选:替换Ubuntu源为清华源,加速依赖安装
|
||||
sed -i 's/archive.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
|
||||
apt update -y
|
||||
# ========== 核心修改:替换checkout源,避开GitHub ==========
|
||||
- name: 拉取代码(Gitea官方源)
|
||||
uses: gitea/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 可选:拉取完整历史,加速后续操作
|
||||
timeout-minutes: 10 # 增加超时,避免拉取中断
|
||||
|
||||
# 1. 初始化 Docker Buildx(原内容不变)
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 2. 可选:登录镜像仓库(若需推送镜像,取消注释并配置密钥)
|
||||
# - name: Login to Gitea Registry
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# registry: 116.204.74.41:3000
|
||||
# username: ${{ secrets.GITEA_USER }}
|
||||
# password: ${{ secrets.GITEA_PWD }}
|
||||
|
||||
# 3. 构建+推送(原内容不变)
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ env.REGISTRY }}/${{ env.APP_NAME }}:${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.APP_NAME }}:buildcache,mode=max
|
||||
|
||||
# 4. 修复后的SSH部署步骤(解决路径+命名空间问题)
|
||||
- name: SSH部署K3s
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.K3S_PEM_KEY }}" > k3s.pem
|
||||
chmod 600 k3s.pem
|
||||
|
||||
# ========== 修正1:上传仓库根目录的deploy.yaml到K3s临时目录 ==========
|
||||
scp -i k3s.pem -o StrictHostKeyChecking=no ./deploy.yaml root@${K3S_HOST}:/tmp/
|
||||
|
||||
# ========== 修正2:kubectl指向临时文件+补充命名空间 ==========
|
||||
ssh -i k3s.pem -o StrictHostKeyChecking=no root@${K3S_HOST} << CMD
|
||||
kubectl apply -f /tmp/deploy.yaml
|
||||
kubectl rollout restart deployment ${APP_NAME} -n default
|
||||
rm -f /tmp/deploy.yaml # 可选:清理临时文件
|
||||
CMD
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,29 +1,16 @@
|
||||
# ==================== 第一阶段:构建前端 ====================
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
# 配置Alpine国内镜像源
|
||||
FROM node:18-alpine
|
||||
|
||||
# 配置Alpine国内镜像源(加速apk)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
EXPOSE 8080
|
||||
|
||||
# ==================== 第二阶段:部署到Nginx ====================
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||
|
||||
# 复制nginx配置文件
|
||||
COPY ngnix.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 复制SSL证书
|
||||
COPY ssl/* /etc/nginx/ssl/
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# 配置Alpine国内镜像源(加速apk)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
44
deploy.yaml
44
deploy.yaml
@@ -1,44 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${APP_NAME}
|
||||
namespace: default
|
||||
labels:
|
||||
app: ${APP_NAME}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: ${APP_NAME}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: ${APP_NAME}
|
||||
spec:
|
||||
containers:
|
||||
- name: ${APP_NAME}
|
||||
image: ${REGISTRY}/${APP_NAME}:${gitea.sha}
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80 # 你的项目实际端口(比如前端80、后端8080)
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${APP_NAME}-service
|
||||
namespace: default
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: ${APP_NAME}
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30001 # 必须在30000-32767之间
|
||||
@@ -1,716 +0,0 @@
|
||||
# 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
|
||||
**维护者:** 开发团队
|
||||
62
ngnix.conf
62
ngnix.conf
@@ -1,62 +0,0 @@
|
||||
# Nginx 静态文件服务 + 智能代理
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# 静态资源根目录(dist)
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SSL 配置
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 根路径默认进入网页端
|
||||
location = / {
|
||||
return 302 /web/index.html;
|
||||
}
|
||||
|
||||
# 网页端(public/web/index.html -> dist/web/index.html)
|
||||
location /web/ {
|
||||
alias /usr/share/nginx/html/web/;
|
||||
try_files $uri $uri/ /web/index.html;
|
||||
}
|
||||
|
||||
# 后台管理端(dist/index.html,前缀 /sys/)
|
||||
location /sys/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /sys/index.html;
|
||||
}
|
||||
|
||||
# 1. 先尝试作为静态文件查找
|
||||
location / {
|
||||
try_files $uri $uri/ @proxy;
|
||||
}
|
||||
|
||||
# 2. 无法找到的请求(API路径)代理到后端
|
||||
location @proxy {
|
||||
# 判断 URI 最后一段是否有扩展名
|
||||
# 有扩展名返回 404,无扩展名则代理
|
||||
if ($uri ~ \.[^./]+$) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
proxy_pass http://116.204.74.41:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
}
|
||||
59
package-lock.json
generated
59
package-lock.json
generated
@@ -923,6 +923,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@interactjs/core/-/core-1.10.27.tgz",
|
||||
"integrity": "sha512-SliUr/3ZbLAdED8LokzYzWHWMdCB5Cq+UnpXuRy+BIod1j97m4IUFf/D1iIKUBBjBcucgXbz28z96WnenVCB7Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@interactjs/utils": "1.10.27"
|
||||
}
|
||||
@@ -993,6 +994,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@interactjs/modifiers/-/modifiers-1.10.27.tgz",
|
||||
"integrity": "sha512-ei/qfoQ+9/8k6WzNzdNqHI6cWkIV576N4Ap16r5CoqOWwhA6Xzj3OMHf1g0t1O4eSq2HdJsVJn3eLNfw9HsbeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@interactjs/snappers": "1.10.27"
|
||||
},
|
||||
@@ -1059,7 +1061,8 @@
|
||||
"version": "1.10.27",
|
||||
"resolved": "https://registry.npmjs.org/@interactjs/utils/-/utils-1.10.27.tgz",
|
||||
"integrity": "sha512-+qfLOio2OxQqg1cXSnRaCl+N8MQDQLDS9w+aOGxH8YLAhIMyt7Asxx/46//sT8orgsi16pmlBPtngPHT9s8zKw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.1.2",
|
||||
@@ -1151,6 +1154,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@logicflow/core/-/core-2.2.1.tgz",
|
||||
"integrity": "sha512-VzLPrCrT4eXnOLjoGQ5v4GUSay3+6rd3YNZD0qOJw4vME5e4WjQ5fd+hKK2zlIzgdRI4D54dXiEFJrS6xdV6yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -1904,6 +1908,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1914,6 +1919,7 @@
|
||||
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1985,6 +1991,7 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -2160,6 +2167,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
|
||||
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@transloadit/prettier-bytes": "0.0.7",
|
||||
"@uppy/store-default": "^2.1.1",
|
||||
@@ -2191,6 +2199,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
|
||||
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@uppy/companion-client": "^2.2.2",
|
||||
"@uppy/utils": "^4.1.2",
|
||||
@@ -2419,6 +2428,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
|
||||
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-url": "^1.2.4"
|
||||
},
|
||||
@@ -2451,6 +2461,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
|
||||
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/event-emitter": "^0.3.3",
|
||||
"event-emitter": "^0.3.5",
|
||||
@@ -2569,6 +2580,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2824,6 +2836,7 @@
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
@@ -3041,6 +3054,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
|
||||
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ssr-window": "^3.0.0-alpha.1"
|
||||
}
|
||||
@@ -3077,6 +3091,7 @@
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.1"
|
||||
@@ -3305,6 +3320,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4097,7 +4113,8 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
||||
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
@@ -4284,13 +4301,15 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -4307,32 +4326,37 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.foreach": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
|
||||
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
@@ -4345,13 +4369,15 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.toarray": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
|
||||
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
@@ -4464,6 +4490,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mobx/-/mobx-5.15.7.tgz",
|
||||
"integrity": "sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mobx"
|
||||
@@ -4528,6 +4555,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -4814,6 +4842,7 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -5074,6 +5103,7 @@
|
||||
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -5292,6 +5322,7 @@
|
||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
|
||||
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"immer": "^9.0.6",
|
||||
"is-plain-object": "^5.0.0",
|
||||
@@ -5315,6 +5346,7 @@
|
||||
"resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
|
||||
"integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
@@ -5480,6 +5512,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5557,6 +5590,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5626,6 +5660,7 @@
|
||||
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -5794,6 +5829,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -5813,6 +5849,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-sfc": "3.5.25",
|
||||
|
||||
@@ -468,6 +468,7 @@
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
console.log('[subscribe] token:', token);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -482,6 +483,7 @@
|
||||
|
||||
// 先获取文本,检查是否为有效JSON
|
||||
const text = await response.text();
|
||||
console.log('[subscribe] response text:', text);
|
||||
|
||||
let result;
|
||||
try {
|
||||
@@ -500,6 +502,7 @@
|
||||
renderTypeList(tenantModuleTypes);
|
||||
renderSkuList(assetData.skus || []);
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
showError(error.message || '加载套餐信息失败,请稍后重试');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
@@ -689,6 +692,8 @@
|
||||
|
||||
// 延迟跳转回原页面
|
||||
const targetUrl = decodeURIComponent(returnUrl);
|
||||
// console.log('[subscribe] 开通成功,即将跳转到:', targetUrl);
|
||||
// console.log('[subscribe] 原始 returnUrl:', returnUrl);
|
||||
|
||||
setTimeout(() => {
|
||||
let finalUrl;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface StatsParams {
|
||||
pending?: number;
|
||||
verified?: number;
|
||||
rejected?: number;
|
||||
}
|
||||
|
||||
export interface ImageMaterialItem {
|
||||
id: number;
|
||||
imageId: string;
|
||||
accountId: string;
|
||||
imageUsage?: string;
|
||||
previewUrl?: string;
|
||||
verifyStatus: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface VideoMaterialItem {
|
||||
id: number;
|
||||
videoId: string;
|
||||
accountId: string;
|
||||
previewUrl?: string;
|
||||
verifyStatus: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface VerifyLogItem {
|
||||
id: number;
|
||||
materialType: string;
|
||||
materialId: string;
|
||||
accountId: string;
|
||||
taskId?: string;
|
||||
verifyStatus: string;
|
||||
suggestion?: number;
|
||||
requestParams?: string;
|
||||
responseResult?: string;
|
||||
errorMsg?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PageParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
accountId?: string;
|
||||
materialType?: string;
|
||||
verifyStatus?: string;
|
||||
materialId?: string;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: T[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: StatsParams;
|
||||
}
|
||||
|
||||
export function getImageStats(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/stats-image',
|
||||
method: 'get',
|
||||
requestOptions,
|
||||
}) as Promise<StatsResponse>;
|
||||
}
|
||||
|
||||
export function getVideoStats(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/stats-video',
|
||||
method: 'get',
|
||||
requestOptions,
|
||||
}) as Promise<StatsResponse>;
|
||||
}
|
||||
|
||||
export function getImageList(params: PageParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/list-image',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ListResponse<ImageMaterialItem>>;
|
||||
}
|
||||
|
||||
export function getVideoList(params: PageParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/list-video',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ListResponse<VideoMaterialItem>>;
|
||||
}
|
||||
|
||||
export function getVerifyLogList(params: PageParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/list-log',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ListResponse<VerifyLogItem>>;
|
||||
}
|
||||
|
||||
export function manualVerifyImage(data: { materialId: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/manual-verify-image',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function manualVerifyVideo(data: { materialId: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/manual-verify-video',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function pollImageResults(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/yidun/callback/controller/poll-image-results',
|
||||
method: 'post',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function pollVideoResults(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/yidun/callback/controller/poll-video-results',
|
||||
method: 'post',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function batchVerifyImage(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/batch-verify-image',
|
||||
method: 'post',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function batchVerifyVideo(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/batch-verify-video',
|
||||
method: 'post',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ExportParams {
|
||||
materialType?: 'IMAGE' | 'VIDEO';
|
||||
}
|
||||
|
||||
export function exportRejectedMaterials(data: ExportParams = {}, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/export-rejected',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AccountItem {
|
||||
accountId: number;
|
||||
corporationName: string;
|
||||
}
|
||||
|
||||
export function getAccountList(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/cid/material/verify/controller/list-accounts',
|
||||
method: 'post',
|
||||
data: {},
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
92
src/api/digitalHuman/creation/index.ts
Normal file
92
src/api/digitalHuman/creation/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface CreationListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface CreationImageItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CreationTitleItem {
|
||||
title: string;
|
||||
htmlFileUrl: string;
|
||||
imageUrls: CreationImageItem[] | null;
|
||||
}
|
||||
|
||||
export interface CreationThemeItem {
|
||||
theme: string;
|
||||
titles: CreationTitleItem[];
|
||||
}
|
||||
|
||||
export interface CreationContentTypeItem {
|
||||
contentType: string;
|
||||
themes: CreationThemeItem[];
|
||||
}
|
||||
|
||||
export interface CreationTreeItem {
|
||||
createdDate: string;
|
||||
contentTypes: CreationContentTypeItem[];
|
||||
}
|
||||
|
||||
export interface CreationListData {
|
||||
list: unknown[] | null;
|
||||
total: number;
|
||||
Tree: CreationTreeItem[];
|
||||
imgAddressPrefix: string;
|
||||
}
|
||||
|
||||
export interface CreationListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: CreationListData;
|
||||
}
|
||||
|
||||
export interface CreationSubmitParams {
|
||||
mode: string;
|
||||
content_type: string;
|
||||
theme: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
style: string;
|
||||
count: number;
|
||||
image_per_post: number;
|
||||
image_ratio: string;
|
||||
}
|
||||
|
||||
export interface DownloadToFileParams {
|
||||
fileURL: string;
|
||||
}
|
||||
|
||||
// requestOptions 用来声明“这个接口的错误提示由谁负责”。
|
||||
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<CreationListResponse>;
|
||||
}
|
||||
|
||||
export function createCreation(data: CreationSubmitParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/creation',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 0,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadToFile(data: DownloadToFileParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -66,21 +66,3 @@ export function deleteknowledge(id: string) {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取知识库详情
|
||||
export function getknowledge(id: string) {
|
||||
return request({
|
||||
url: '/rag/dataset/detail',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// 更新知识库状态
|
||||
export function updateknowledgeStatus(data: { id: string; status: string }) {
|
||||
return request({
|
||||
url: '/rag/dataset/updateStatus',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import request from '/@/utils/request';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
|
||||
// 导出公共上传函数供其他模块使用
|
||||
export { uploadFile };
|
||||
|
||||
// 文档查询参数
|
||||
export interface DocumentQueryParams {
|
||||
@@ -118,6 +114,18 @@ 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({
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
export interface CreationListParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface NodeLibraryFormItem {
|
||||
field: string;
|
||||
label: string;
|
||||
type: 'input' | 'number' | 'textarea' | 'switch' | string;
|
||||
required: boolean;
|
||||
default?: string | number | boolean;
|
||||
value?: unknown;
|
||||
options?: Array<{ label: string; value: string | number }> | null;
|
||||
expand?: NodeLibraryFormItem[] | Record<string, unknown> | null;
|
||||
fieldConstraint?: {
|
||||
fileTypes?: string;
|
||||
maxFileSize?: number;
|
||||
maxFileCount?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
numberType?: 'integer' | 'decimal' | string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CreationTitleItem {
|
||||
title: string;
|
||||
htmlFileUrl: string;
|
||||
imageUrls: CreationImageItem[] | null;
|
||||
}
|
||||
|
||||
export interface CreationThemeItem {
|
||||
theme: string;
|
||||
titles: CreationTitleItem[];
|
||||
}
|
||||
|
||||
export interface CreationContentTypeItem {
|
||||
contentType: string;
|
||||
themes: CreationThemeItem[];
|
||||
}
|
||||
|
||||
export interface CreationTreeItem {
|
||||
createdDate: string;
|
||||
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;
|
||||
Tree: CreationTreeItem[];
|
||||
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;
|
||||
theme: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
style: string;
|
||||
count: number;
|
||||
image_per_post: number;
|
||||
image_ratio: string;
|
||||
}
|
||||
|
||||
export interface DownloadToFileParams {
|
||||
fileURL: string;
|
||||
}
|
||||
|
||||
export function getCreationList(params: CreationListParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/creation/info/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) 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',
|
||||
method: 'post',
|
||||
data,
|
||||
timeout: 0,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadToFile(data: DownloadToFileParams, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
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[];
|
||||
outputParams?: Array<Record<string, string>>;
|
||||
imgAddressPrefix?: string;
|
||||
fileUrls?: string[];
|
||||
resultUrl?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
resultUrl?: 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,
|
||||
});
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
export interface ModelModuleListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
modelName?: string;
|
||||
modelType?: number | string;
|
||||
enabled?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
operatorName?: 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>;
|
||||
responseBody?: Record<string, unknown>;
|
||||
extendMapping?: Record<string, unknown>;
|
||||
responseTokenField?: string;
|
||||
tokenConfig?: Record<string, unknown>;
|
||||
queryConfig?: 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',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务商列表
|
||||
*/
|
||||
export function getOperatorList() {
|
||||
return request({
|
||||
url: '/model-gateway/model/listOperator',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 用户技能详情
|
||||
*/
|
||||
export function getUserSkillDetail(id: number, config?: any) {
|
||||
return request<SkillItem>({
|
||||
url: '/ai-agent/skill/user/get',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Skill 用户技能
|
||||
*/
|
||||
export function updateUserSkill(data: CreateSkillParams & { id: number }, config?: any) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/user/update',
|
||||
method: 'put',
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
export function getPwConfig() {
|
||||
return request({
|
||||
url: '/admin-go/api/v1/system/pwconfig/get',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function savePwConfig(data: any) {
|
||||
return request({
|
||||
url: '/admin-go/api/v1/system/pwconfig/save',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
@@ -69,10 +69,3 @@ export function deleteUser(ids: number[]) {
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
export function checkIsSuperAdmin() {
|
||||
return request({
|
||||
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
<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 }"
|
||||
@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.isOwner === 0" 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" :model-types="modelTypes" :is-super-admin="isSuperAdmin" @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,
|
||||
getModelTypeList,
|
||||
normalizeModelTypeOptions,
|
||||
type CreateModelParams,
|
||||
type ModelFormEntry,
|
||||
} from '/@/api/settings/modelConfig/modelModule';
|
||||
import { checkIsSuperAdmin } from '/@/api/system/user/index';
|
||||
import { getApiErrorMessage } from '/@/utils/request';
|
||||
import EditModule from '/@/views/settings/modelConfig/modelModule/component/editModule.vue';
|
||||
|
||||
interface ModelItem {
|
||||
id: string;
|
||||
tenantId?: number;
|
||||
modelName: string;
|
||||
modelType: number | string;
|
||||
baseUrl: string;
|
||||
route: string;
|
||||
httpMethod: string;
|
||||
enabled: number;
|
||||
apiKey?: string;
|
||||
isPrivate?: number;
|
||||
isChatModel?: number;
|
||||
headMsg?: string;
|
||||
operatorName?: string;
|
||||
responseBody?: Record<string, unknown>;
|
||||
tokenConfig?: Record<string, unknown> | string;
|
||||
extendMapping?: Record<string, unknown> | string;
|
||||
queryConfig?: Record<string, unknown>;
|
||||
form?: ModelFormEntry[] | Record<string, unknown>;
|
||||
requestMapping?: Record<string, unknown>;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
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);
|
||||
const modelTypes = ref<Array<{ id: number | string; label: string }>>([]);
|
||||
|
||||
// 内置模型 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 builtInModelToClone = 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);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelType,
|
||||
() => {
|
||||
if (!visible.value) return;
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
}
|
||||
);
|
||||
|
||||
const getModelTypeName = (type: number | string) => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '推理模型',
|
||||
2: '图片模型',
|
||||
3: '音频模型',
|
||||
};
|
||||
return typeMap[Number(type)] || '未知类型';
|
||||
};
|
||||
|
||||
const parseJsonObjectField = (raw: unknown): Record<string, unknown> => {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '{}');
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
const fieldsToUnknownObject = (fields: Array<{ key: string; value: string }>) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
fields.forEach((f) => {
|
||||
const k = String(f.key || '').trim();
|
||||
if (!k) return;
|
||||
obj[k] = String(f.value ?? '');
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
|
||||
const flattenNestedObject = (obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> => {
|
||||
const rows: Array<{ key: string; value: string }> = [];
|
||||
Object.entries(obj || {}).forEach(([k, v]) => {
|
||||
const fk = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
rows.push(...flattenNestedObject(v as Record<string, unknown>, fk));
|
||||
return;
|
||||
}
|
||||
rows.push({ key: fk, value: String(v ?? '') });
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
const nestFieldsToObject = (fields: Array<{ key: string; value: string }>) => {
|
||||
const root: Record<string, unknown> = {};
|
||||
fields.forEach((f) => {
|
||||
const path = String(f.key || '').trim();
|
||||
if (!path) return;
|
||||
const parts = path
|
||||
.split('.')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
let cur: Record<string, unknown> = root;
|
||||
parts.forEach((part, idx) => {
|
||||
if (idx === parts.length - 1) {
|
||||
cur[part] = String(f.value ?? '');
|
||||
return;
|
||||
}
|
||||
if (!cur[part] || typeof cur[part] !== 'object' || Array.isArray(cur[part])) {
|
||||
cur[part] = {};
|
||||
}
|
||||
cur = cur[part] as Record<string, unknown>;
|
||||
});
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
const buildQueryConfigFromRaw = (rawQc: Record<string, unknown> | null): Record<string, unknown> => {
|
||||
if (!rawQc) return { responseType: 'sync', callbackUrl: '' };
|
||||
const rt = String(rawQc.responseType || 'sync');
|
||||
if (rt === 'callback') return { responseType: 'callback', callbackUrl: String(rawQc.callbackUrl || '') };
|
||||
if (rt === 'pull') {
|
||||
const hFields = Object.entries((rawQc.headers as Record<string, unknown>) || {}).map(([k, v]) => ({ key: k, value: String(v ?? '') }));
|
||||
const bFields = flattenNestedObject((rawQc.body as Record<string, unknown>) || {});
|
||||
return {
|
||||
responseType: 'pull',
|
||||
callbackUrl: '',
|
||||
method: String(rawQc.method || 'GET'),
|
||||
url: String(rawQc.url || ''),
|
||||
headers: fieldsToUnknownObject(hFields),
|
||||
body: nestFieldsToObject(bFields),
|
||||
response: ((rawQc.response as unknown[]) || [])
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { value: item, isTokenField: false, isMainBody: false };
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
value: String(row.value ?? ''),
|
||||
isTokenField: Boolean(row.isTokenField),
|
||||
isMainBody: Boolean(row.isMainBody),
|
||||
};
|
||||
})
|
||||
.filter((row) => row.value !== ''),
|
||||
};
|
||||
}
|
||||
return { responseType: 'sync', callbackUrl: '' };
|
||||
};
|
||||
|
||||
const fetchModelList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
modelName: searchParams.modelName || undefined,
|
||||
modelType: props.modelType,
|
||||
enabled: 1,
|
||||
};
|
||||
const res: any = await getModelModuleList(params);
|
||||
modelList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
} catch {
|
||||
modelList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: ModelItem) => {
|
||||
if (isSuperAdmin.value) {
|
||||
selectedModel.value = model;
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.isOwner === 0) {
|
||||
builtInModelToClone.value = model;
|
||||
apiKeyForm.modelName = model.modelName;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
} else {
|
||||
selectedModel.value = model;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePrivateModel = async () => {
|
||||
if (!apiKeyFormRef.value || !builtInModelToClone.value) return;
|
||||
|
||||
try {
|
||||
await apiKeyFormRef.value.validate();
|
||||
|
||||
creatingModel.value = true;
|
||||
|
||||
const builtInModel = builtInModelToClone.value;
|
||||
const formList: ModelFormEntry[] = Array.isArray(builtInModel.form)
|
||||
? (builtInModel.form as ModelFormEntry[])
|
||||
: Object.entries((builtInModel.form as Record<string, unknown>) || {}).map(([key, value]) => ({
|
||||
key: String(key),
|
||||
value: String(value ?? ''),
|
||||
}));
|
||||
const createParams: CreateModelParams = {
|
||||
modelName: apiKeyForm.modelName,
|
||||
modelType: builtInModel.modelType,
|
||||
operatorName: builtInModel.operatorName || '',
|
||||
baseUrl: builtInModel.baseUrl,
|
||||
httpMethod: builtInModel.httpMethod || 'POST',
|
||||
headMsg: builtInModel.headMsg || '',
|
||||
isPrivate: builtInModel.isPrivate ?? 1,
|
||||
enabled: builtInModel.enabled ?? 1,
|
||||
isChatModel: builtInModel.isChatModel || 0,
|
||||
apiKey: apiKeyForm.apiKey,
|
||||
form: formList,
|
||||
requestMapping: (builtInModel.requestMapping as Record<string, unknown>) || {},
|
||||
responseMapping: (builtInModel.responseMapping as Record<string, unknown>) || {},
|
||||
responseBody: builtInModel.responseBody || {},
|
||||
maxConcurrency: builtInModel.maxConcurrency || 10,
|
||||
queueLimit: builtInModel.queueLimit || 100,
|
||||
timeoutSeconds: builtInModel.timeoutSeconds || 30,
|
||||
expectedSeconds: builtInModel.expectedSeconds || 15,
|
||||
retryTimes: builtInModel.retryTimes || 3,
|
||||
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
|
||||
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
|
||||
remark: builtInModel.remark || '',
|
||||
|
||||
extendMapping: fieldsToUnknownObject(
|
||||
Object.entries(parseJsonObjectField(builtInModel.extendMapping)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
|
||||
),
|
||||
tokenConfig: fieldsToUnknownObject(
|
||||
Object.entries(parseJsonObjectField(builtInModel.tokenConfig)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
|
||||
),
|
||||
queryConfig: buildQueryConfigFromRaw(
|
||||
builtInModel.queryConfig && typeof builtInModel.queryConfig === 'object' && !Array.isArray(builtInModel.queryConfig)
|
||||
? (builtInModel.queryConfig as Record<string, unknown>)
|
||||
: null
|
||||
),
|
||||
};
|
||||
|
||||
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;
|
||||
builtInModelToClone.value = null;
|
||||
};
|
||||
|
||||
const loadModelTypes = async () => {
|
||||
try {
|
||||
const res: any = await getModelTypeList();
|
||||
if (res.code === 0) {
|
||||
modelTypes.value = normalizeModelTypeOptions(res);
|
||||
}
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
checkAdminStatus();
|
||||
loadModelTypes();
|
||||
});
|
||||
</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>
|
||||
@@ -1,270 +0,0 @@
|
||||
<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/settings/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>
|
||||
@@ -1,215 +0,0 @@
|
||||
<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/settings/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>
|
||||
@@ -5,10 +5,10 @@ const SUBSCRIBE_PAGE_URL = '/web/subscribe.html';
|
||||
const ROUTE_ASSET_MAP: Record<string, { assetId: string; serviceName: string }> = {
|
||||
// CID广告业务(聚合广告)
|
||||
'/cidService': { assetId: '696f423705e496ba4ccbe665', serviceName: '聚合广告' },
|
||||
|
||||
|
||||
// AI客服业务
|
||||
'/customerService': { assetId: '696f421205e496ba4ccbe662', serviceName: 'AI客服' },
|
||||
|
||||
|
||||
// 聚合电商业务(资产管理)
|
||||
'/assets': { assetId: '696b4acd1be1c8b76c4b4c15', serviceName: '资产管理' },
|
||||
};
|
||||
@@ -21,14 +21,14 @@ export function getAssetInfoByRoute(routePath: string): { assetId: string; servi
|
||||
if (ROUTE_ASSET_MAP[routePath]) {
|
||||
return ROUTE_ASSET_MAP[routePath];
|
||||
}
|
||||
|
||||
|
||||
// 前缀匹配
|
||||
for (const [prefix, info] of Object.entries(ROUTE_ASSET_MAP)) {
|
||||
if (routePath.startsWith(prefix)) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -48,13 +49,17 @@ 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):
|
||||
* - global: 由拦截器统一弹出后端返回的 message(含 HTTP 与业务 JSON)
|
||||
* - page: 不自动弹窗,仅 reject;请在页面 catch 内自行处理(应与全局择一,避免重复)
|
||||
* - silent: 完全静默(轮询等)
|
||||
* 控制一次请求的错误提示归属:
|
||||
* - global: 交给 request.ts 统一弹错,适合绝大多数接口
|
||||
* - page: 页面自己在 catch 中决定提示文案,避免与全局重复
|
||||
* - silent: 完全静默,适合轮询、后台刷新等不希望打扰用户的请求
|
||||
*/
|
||||
export interface RequestOptions {
|
||||
errorMode?: 'global' | 'page' | 'silent';
|
||||
@@ -39,26 +39,6 @@ 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;
|
||||
@@ -194,7 +174,7 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
const res = response.data;
|
||||
const httpStatus = response.status;
|
||||
const code = res?.code;
|
||||
const message = extractBackendMessage(res);
|
||||
const message = res?.message;
|
||||
const config = response.config;
|
||||
|
||||
if (isTokenExpiredError(httpStatus, code, message)) {
|
||||
@@ -209,28 +189,9 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
return Promise.reject(new Error('模块未开通'));
|
||||
}
|
||||
|
||||
// 定义已知的正常 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 || '后端异常,请联系管理员';
|
||||
}
|
||||
|
||||
// 业务失败默认走全局提示;如果页面声明自己处理,这里只抛错不弹窗。
|
||||
if (code !== undefined && code !== 0 && code !== 200 && code !== 403) {
|
||||
const errorMsg = message || `请求失败(${code})`;
|
||||
showErrorMessage(errorMsg, config);
|
||||
return Promise.reject(new Error(errorMsg));
|
||||
}
|
||||
@@ -241,8 +202,7 @@ const responseInterceptor = (response: AxiosResponse) => {
|
||||
const responseErrorHandler = (error: any) => {
|
||||
const config = error.config as InternalAxiosRequestConfig | undefined;
|
||||
const httpStatus = error.response?.status;
|
||||
const responseData = error.response?.data;
|
||||
const responseMessage = extractBackendMessage(responseData);
|
||||
const responseMessage = error.response?.data?.message;
|
||||
|
||||
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||
showErrorMessage('请求超时,请检查网络连接', config);
|
||||
@@ -253,7 +213,7 @@ const responseErrorHandler = (error: any) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isTokenExpiredError(httpStatus, error.response?.data?.code as number | undefined, responseMessage)) {
|
||||
if (isTokenExpiredError(httpStatus, error.response?.data?.code, responseMessage)) {
|
||||
handleTokenExpired();
|
||||
return Promise.reject(new Error('登录状态已过期'));
|
||||
}
|
||||
@@ -266,7 +226,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('模块开通中'));
|
||||
}
|
||||
|
||||
@@ -274,30 +234,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,27 +267,5 @@ 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 };
|
||||
|
||||
@@ -1,968 +0,0 @@
|
||||
<template>
|
||||
<div class="ads-compliance-tencent">
|
||||
<el-card shadow="hover" class="main-card">
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="main-tabs">
|
||||
<el-tab-pane label="图片素材" name="image">
|
||||
<!-- 图片素材内容区域 -->
|
||||
<div class="tab-content">
|
||||
<!-- 工具栏区域 -->
|
||||
<div class="toolbar">
|
||||
<!-- 左侧:筛选表单 -->
|
||||
<el-form :model="imageFilters" :inline="true" class="search-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="imageFilters.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="待校验" value="PENDING"></el-option>
|
||||
<el-option label="送检中" value="SUBMITTING"></el-option>
|
||||
<el-option label="校验通过" value="VERIFIED"></el-option>
|
||||
<el-option label="校验不通过" value="REJECTED"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户">
|
||||
<el-select v-model="imageFilters.accountId" placeholder="请选择账户" style="width: 220px" clearable>
|
||||
<el-option
|
||||
v-for="account in accountList"
|
||||
:key="account.accountId"
|
||||
:label="`${account.accountId} - ${account.corporationName}`"
|
||||
:value="String(account.accountId)"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchImage">搜索</el-button>
|
||||
<el-button @click="resetImageFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<!-- <el-button type="success" @click="batchVerifyImage" :loading="batchLoading"> 批量校验图片 </el-button> -->
|
||||
<!-- <el-button type="primary" plain @click="pollImageResults" :loading="pollLoading">
|
||||
<el-icon><Refresh /></el-icon> 刷新检测结果
|
||||
</el-button> -->
|
||||
<el-button type="warning" plain @click="exportImageUrls">
|
||||
<el-icon><Download /></el-icon> 导出失败素材
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<div class="table-wrapper">
|
||||
<el-table :data="imageList" border style="width: 100%" v-loading="imageLoading">
|
||||
<el-table-column prop="accountId" label="账户ID" width="120"></el-table-column>
|
||||
<el-table-column prop="imageUsage" label="用途" width="120"></el-table-column>
|
||||
<el-table-column label="预览" width="180">
|
||||
<template #default="scope">
|
||||
<img
|
||||
v-if="scope.row.previewUrl"
|
||||
:src="scope.row.previewUrl"
|
||||
class="media-preview"
|
||||
@click="previewMedia(scope.row.previewUrl, 'image')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="verifyStatus" label="校验状态" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
|
||||
{{ getStatusText(scope.row.verifyStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页区域 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
@current-change="handleImagePageChange"
|
||||
v-model:current-page="imagePage"
|
||||
v-model:page-size="imagePageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="imageTotal"
|
||||
background
|
||||
>
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="视频素材" name="video">
|
||||
<!-- 视频素材内容区域 -->
|
||||
<div class="tab-content">
|
||||
<!-- 工具栏区域 -->
|
||||
<div class="toolbar">
|
||||
<!-- 左侧:筛选表单 -->
|
||||
<el-form :model="videoFilters" :inline="true" class="search-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="videoFilters.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="全部" value=""></el-option>
|
||||
<el-option label="待校验" value="PENDING"></el-option>
|
||||
<el-option label="送检中" value="SUBMITTING"></el-option>
|
||||
<el-option label="校验通过" value="VERIFIED"></el-option>
|
||||
<el-option label="校验不通过" value="REJECTED"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户">
|
||||
<el-select v-model="videoFilters.accountId" placeholder="请选择账户" style="width: 220px" clearable>
|
||||
<el-option
|
||||
v-for="account in accountList"
|
||||
:key="account.accountId"
|
||||
:label="`${account.accountId} - ${account.corporationName}`"
|
||||
:value="String(account.accountId)"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchVideo">搜索</el-button>
|
||||
<el-button @click="resetVideoFilter">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<!-- <el-button type="success" @click="batchVerifyVideo" :loading="batchLoading"> 批量校验视频 </el-button>
|
||||
<el-button type="primary" plain @click="pollVideoResults" :loading="pollLoading">
|
||||
<el-icon><Refresh /></el-icon> 刷新检测结果
|
||||
</el-button> -->
|
||||
<el-button type="warning" plain @click="exportVideoUrls">
|
||||
<el-icon><Download /></el-icon> 导出失败素材
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<div class="table-wrapper">
|
||||
<el-table :data="videoList" border style="width: 100%" v-loading="videoLoading">
|
||||
<el-table-column prop="accountId" label="账户ID" width="120"></el-table-column>
|
||||
<el-table-column label="预览" width="220">
|
||||
<template #default="scope">
|
||||
<div class="video-preview" @click="previewMedia(scope.row.previewUrl, 'video')">
|
||||
<video
|
||||
v-if="scope.row.previewUrl"
|
||||
:src="scope.row.previewUrl"
|
||||
class="video-thumbnail"
|
||||
poster="data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
preload="metadata"
|
||||
@loadedmetadata="(e) => ((e.target as HTMLVideoElement).currentTime = 0)"
|
||||
></video>
|
||||
<el-icon v-else><VideoPlay style="font-size: 32px" /></el-icon>
|
||||
<div class="play-overlay">
|
||||
<el-icon><VideoPlay style="font-size: 24px" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="verifyStatus" label="校验状态" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
|
||||
{{ getStatusText(scope.row.verifyStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页区域 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
@current-change="handleVideoPageChange"
|
||||
v-model:current-page="videoPage"
|
||||
v-model:page-size="videoPageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="videoTotal"
|
||||
background
|
||||
>
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<el-dialog title="媒体预览" v-model="previewVisible" width="60%" class="preview-dialog">
|
||||
<div class="preview-content">
|
||||
<img v-if="previewType === 'image'" :src="previewUrl" />
|
||||
<video v-if="previewType === 'video'" :src="previewUrl" controls></video>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志对话框 -->
|
||||
<el-dialog title="校验日志" v-model="logVisible" width="70%">
|
||||
<el-table :data="currentLogList" border style="width: 100%">
|
||||
<el-table-column prop="time" label="时间" width="180"></el-table-column>
|
||||
<!-- <el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="'table-status status-' + (scope.row.type || 'info').toLowerCase()">
|
||||
{{ getLogTypeText(scope.row.type) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="'table-status status-' + (scope.row.status || 'info').toLowerCase()">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="suggestion" label="建议" width="120">
|
||||
<template #default="scope">
|
||||
<span :class="'table-status status-' + getSuggestionClass(scope.row.suggestion)">
|
||||
{{ getSuggestionText(scope.row.suggestion) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMsg" label="错误信息" min-width="200"></el-table-column>
|
||||
<el-table-column prop="message" label="日志内容" min-width="200"></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!currentLogList.length" style="text-align: center; color: #909399; padding: 40px">暂无日志记录</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Download, VideoPlay } from '@element-plus/icons-vue';
|
||||
import {
|
||||
getImageStats,
|
||||
getVideoStats,
|
||||
getImageList,
|
||||
getVideoList,
|
||||
getVerifyLogList,
|
||||
exportRejectedMaterials,
|
||||
getAccountList,
|
||||
// manualVerifyImage,
|
||||
// manualVerifyVideo,
|
||||
// pollImageResults as pollImageResultsApi,
|
||||
// pollVideoResults as pollVideoResultsApi,
|
||||
// batchVerifyImage as batchVerifyImageApi,
|
||||
// batchVerifyVideo as batchVerifyVideoApi,
|
||||
} from '/@/api/ads/compliance/tencent/materialVerify';
|
||||
|
||||
// 响应式状态
|
||||
const activeTab = ref('image');
|
||||
// const pollLoading = ref(false);
|
||||
// const batchLoading = ref(false);
|
||||
|
||||
// 账户列表
|
||||
const accountList = ref<any[]>([]);
|
||||
|
||||
// 图片统计
|
||||
const imageStats = reactive({ pending: 0, verified: 0, rejected: 0 });
|
||||
// 视频统计
|
||||
const videoStats = reactive({ pending: 0, verified: 0, rejected: 0 });
|
||||
|
||||
// 图片列表
|
||||
const imageList = ref<any[]>([]);
|
||||
const imageLoading = ref(false);
|
||||
const imagePage = ref(1);
|
||||
const imagePageSize = ref(10);
|
||||
const imageTotal = ref(0);
|
||||
const imageFilters = reactive({ status: '', accountId: '' });
|
||||
|
||||
// 视频列表
|
||||
const videoList = ref<any[]>([]);
|
||||
const videoLoading = ref(false);
|
||||
const videoPage = ref(1);
|
||||
const videoPageSize = ref(10);
|
||||
const videoTotal = ref(0);
|
||||
const videoFilters = reactive({ status: '', accountId: '' });
|
||||
|
||||
// 对话框
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
const previewType = ref('image');
|
||||
|
||||
// 日志对话框
|
||||
const logVisible = ref(false);
|
||||
const currentLogList = ref<any[]>([]);
|
||||
|
||||
// 加载统计
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [imgRes, vidRes] = await Promise.all([getImageStats(), getVideoStats()]);
|
||||
const imgStats = imgRes.data || {};
|
||||
const vidStats = vidRes.data || {};
|
||||
Object.assign(imageStats, {
|
||||
pending: imgStats.pending || 0,
|
||||
verified: imgStats.verified || 0,
|
||||
rejected: imgStats.rejected || 0,
|
||||
});
|
||||
Object.assign(videoStats, {
|
||||
pending: vidStats.pending || 0,
|
||||
verified: vidStats.verified || 0,
|
||||
rejected: vidStats.rejected || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
ElMessage.error('加载统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 加载图片列表
|
||||
const loadImageList = () => {
|
||||
imageLoading.value = true;
|
||||
getImageList({
|
||||
page: imagePage.value,
|
||||
pageSize: imagePageSize.value,
|
||||
status: imageFilters.status,
|
||||
accountId: imageFilters.accountId,
|
||||
})
|
||||
.then((res) => {
|
||||
imageList.value = res.data?.list || [];
|
||||
imageTotal.value = res.data?.total || 0;
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('加载图片列表失败');
|
||||
})
|
||||
.finally(() => {
|
||||
imageLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideoList = () => {
|
||||
videoLoading.value = true;
|
||||
getVideoList({
|
||||
page: videoPage.value,
|
||||
pageSize: videoPageSize.value,
|
||||
status: videoFilters.status,
|
||||
accountId: videoFilters.accountId,
|
||||
})
|
||||
.then((res) => {
|
||||
videoList.value = res.data?.list || [];
|
||||
videoTotal.value = res.data?.total || 0;
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('加载视频列表失败');
|
||||
})
|
||||
.finally(() => {
|
||||
videoLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// Tab 切换监听
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'image') {
|
||||
loadImageList();
|
||||
} else if (newTab === 'video') {
|
||||
loadVideoList();
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索
|
||||
const searchImage = () => {
|
||||
imagePage.value = 1;
|
||||
loadImageList();
|
||||
};
|
||||
|
||||
const searchVideo = () => {
|
||||
videoPage.value = 1;
|
||||
loadVideoList();
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetImageFilter = () => {
|
||||
Object.assign(imageFilters, { status: '', accountId: '' });
|
||||
searchImage();
|
||||
};
|
||||
|
||||
const resetVideoFilter = () => {
|
||||
Object.assign(videoFilters, { status: '', accountId: '' });
|
||||
searchVideo();
|
||||
};
|
||||
|
||||
// 分页
|
||||
const handleImagePageChange = (page: number) => {
|
||||
imagePage.value = page;
|
||||
loadImageList();
|
||||
};
|
||||
|
||||
const handleVideoPageChange = (page: number) => {
|
||||
videoPage.value = page;
|
||||
loadVideoList();
|
||||
};
|
||||
|
||||
// 手动送检
|
||||
// const verifyImage = (imageId: string) => {
|
||||
// ElMessageBox.confirm('确认提交图片 ' + imageId + ' 进行校验?', '提示', {
|
||||
// confirmButtonText: '确定',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// })
|
||||
// .then(() => {
|
||||
// manualVerifyImage({ materialId: imageId })
|
||||
// .then(() => {
|
||||
// ElMessage.success('提交成功');
|
||||
// loadImageList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// };
|
||||
|
||||
// const verifyVideo = (videoId: string) => {
|
||||
// ElMessageBox.confirm('确认提交视频 ' + videoId + ' 进行校验?', '提示', {
|
||||
// confirmButtonText: '确定',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// })
|
||||
// .then(() => {
|
||||
// manualVerifyVideo({ materialId: videoId })
|
||||
// .then(() => {
|
||||
// ElMessage.success('提交成功');
|
||||
// loadVideoList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// };
|
||||
|
||||
// // 刷新检测结果
|
||||
// const pollImageResults = () => {
|
||||
// pollLoading.value = true;
|
||||
// pollImageResultsApi()
|
||||
// .then((res: any) => {
|
||||
// ElMessage.success(res?.msg || '刷新完成');
|
||||
// loadImageList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {})
|
||||
// .finally(() => {
|
||||
// pollLoading.value = false;
|
||||
// });
|
||||
// };
|
||||
|
||||
// const pollVideoResults = () => {
|
||||
// pollLoading.value = true;
|
||||
// pollVideoResultsApi()
|
||||
// .then((res: any) => {
|
||||
// ElMessage.success(res?.msg || '刷新完成');
|
||||
// loadVideoList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {})
|
||||
// .finally(() => {
|
||||
// pollLoading.value = false;
|
||||
// });
|
||||
// };
|
||||
|
||||
// 导出失败素材
|
||||
const exportImageUrls = () => {
|
||||
ElMessage.info('正在导出图片失败素材...');
|
||||
exportRejectedMaterials({ materialType: 'IMAGE' })
|
||||
.then((res: any) => {
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
downloadJsonAsCsv('图片失败素材.csv', res.data.items);
|
||||
ElMessage.success('导出成功');
|
||||
} else {
|
||||
ElMessage.warning('没有失败的图片素材');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('导出失败');
|
||||
});
|
||||
};
|
||||
|
||||
const exportVideoUrls = () => {
|
||||
ElMessage.info('正在导出视频失败素材...');
|
||||
exportRejectedMaterials({ materialType: 'VIDEO' })
|
||||
.then((res: any) => {
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
downloadJsonAsCsv('视频失败素材.csv', res.data.items);
|
||||
ElMessage.success('导出成功');
|
||||
} else {
|
||||
ElMessage.warning('没有失败的视频素材');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('导出失败');
|
||||
});
|
||||
};
|
||||
|
||||
const downloadJsonAsCsv = (filename: string, items: any[]) => {
|
||||
const headers = ['序号', '账户(名称)', '预览URL', '描述', '失败原因', '检测时间'];
|
||||
const rows = [headers];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
// 将日期转换为 Excel 可识别的格式,添加等号前缀强制作为文本处理
|
||||
const formattedDate = item.createdAt ? `="${item.createdAt}"` : '-';
|
||||
rows.push([
|
||||
index + 1,
|
||||
`${item.accountId} - ${item.corporationName || ''}`,
|
||||
(item.previewUrl || '').trim(),
|
||||
item.description || '-',
|
||||
item.errorMsg || '-',
|
||||
formattedDate,
|
||||
]);
|
||||
});
|
||||
|
||||
const csv = rows
|
||||
.map((r) =>
|
||||
r
|
||||
.map((v) => {
|
||||
// 如果值以 "=" 开头,需要特殊处理避免被 Excel 当作公式
|
||||
if (String(v).startsWith('=')) {
|
||||
return v;
|
||||
}
|
||||
return `"${String(v).replace(/"/g, '""')}"`;
|
||||
})
|
||||
.join(',')
|
||||
)
|
||||
.join('\n');
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
// 批量送检
|
||||
// const batchVerifyImage = () => {
|
||||
// ElMessageBox.confirm('确认批量校验待处理的图片?', '提示', {
|
||||
// confirmButtonText: '确定',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// })
|
||||
// .then(() => {
|
||||
// batchLoading.value = true;
|
||||
// batchVerifyImageApi()
|
||||
// .then((res: any) => {
|
||||
// ElMessage.success(res?.msg || '批量校验完成');
|
||||
// loadImageList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {})
|
||||
// .finally(() => {
|
||||
// batchLoading.value = false;
|
||||
// });
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// };
|
||||
|
||||
// const batchVerifyVideo = () => {
|
||||
// ElMessageBox.confirm('确认批量校验待处理的视频?', '提示', {
|
||||
// confirmButtonText: '确定',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// })
|
||||
// .then(() => {
|
||||
// batchLoading.value = true;
|
||||
// batchVerifyVideoApi()
|
||||
// .then((res: any) => {
|
||||
// ElMessage.success(res?.msg || '批量校验完成');
|
||||
// loadVideoList();
|
||||
// loadStats();
|
||||
// })
|
||||
// .catch(() => {})
|
||||
// .finally(() => {
|
||||
// batchLoading.value = false;
|
||||
// });
|
||||
// })
|
||||
// .catch(() => {});
|
||||
// };
|
||||
|
||||
// 预览
|
||||
const previewMedia = (url: string, type: string) => {
|
||||
if (!url) {
|
||||
ElMessage.warning('无预览地址');
|
||||
return;
|
||||
}
|
||||
previewUrl.value = url;
|
||||
previewType.value = type;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
// 工具方法
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: '待校验',
|
||||
SUBMITTING: '送检中',
|
||||
VERIFIED: '校验通过',
|
||||
REJECTED: '校验不通过',
|
||||
};
|
||||
return map[status] || status || '待校验';
|
||||
};
|
||||
|
||||
// 解析响应结果
|
||||
const parseResponseResult = (responseResult: string) => {
|
||||
try {
|
||||
const result = JSON.parse(responseResult);
|
||||
if (result.antispam && result.antispam.riskDescription) {
|
||||
return result.antispam.riskDescription;
|
||||
}
|
||||
if (result.riskDescription) {
|
||||
return result.riskDescription;
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,返回原始字符串
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// 查看日志
|
||||
const viewLog = (row: any) => {
|
||||
const materialId = row.imageId || row.videoId;
|
||||
const materialType = row.imageId ? 'IMAGE' : 'VIDEO';
|
||||
|
||||
getVerifyLogList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
materialId,
|
||||
materialType,
|
||||
})
|
||||
.then((res: any) => {
|
||||
if (res.data && res.data.list && res.data.list.length > 0) {
|
||||
currentLogList.value = res.data.list.map((item: any) => {
|
||||
const riskDesc = parseResponseResult(item.responseResult);
|
||||
return {
|
||||
time: item.createdAt || '-',
|
||||
type: item.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
|
||||
status: item.verifyStatus,
|
||||
suggestion: item.suggestion,
|
||||
errorMsg: item.errorMsg || '-',
|
||||
message: riskDesc || `校验状态:${getStatusText(item.verifyStatus)}`,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
currentLogList.value = [
|
||||
{
|
||||
time: row.createdAt || new Date().toLocaleString('zh-CN'),
|
||||
type: row.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
|
||||
status: row.verifyStatus,
|
||||
suggestion: row.suggestion,
|
||||
errorMsg: row.errorMsg || '-',
|
||||
message: `结果:${getStatusText(row.verifyStatus)}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
currentLogList.value = [
|
||||
{
|
||||
time: row.createdAt || new Date().toLocaleString('zh-CN'),
|
||||
type: row.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
|
||||
status: row.verifyStatus,
|
||||
suggestion: row.suggestion,
|
||||
errorMsg: row.errorMsg || '-',
|
||||
message: `结果:${getStatusText(row.verifyStatus)}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
logVisible.value = true;
|
||||
};
|
||||
|
||||
// 获取建议文本
|
||||
const getSuggestionText = (suggestion: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '通过',
|
||||
1: '建议通过',
|
||||
2: '建议删除',
|
||||
3: '建议删除',
|
||||
};
|
||||
return map[suggestion] ?? '-';
|
||||
};
|
||||
|
||||
// 获取建议样式类
|
||||
const getSuggestionClass = (suggestion: number) => {
|
||||
if (suggestion === 0) return 'success';
|
||||
if (suggestion === 1) return 'info';
|
||||
if (suggestion === 2 || suggestion === 3) return 'rejected';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
// 获取日志类型文本
|
||||
const getLogTypeText = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
INFO: '信息',
|
||||
SUCCESS: '成功',
|
||||
WARN: '警告',
|
||||
ERROR: '错误',
|
||||
};
|
||||
return map[type] || type || '信息';
|
||||
};
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccountList = () => {
|
||||
getAccountList()
|
||||
.then((res: any) => {
|
||||
if (res.data && res.data.list) {
|
||||
accountList.value = res.data.list;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('获取账户列表失败');
|
||||
});
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadAccountList();
|
||||
loadStats();
|
||||
loadImageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ads-compliance-tencent {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 12px 0 20px 0;
|
||||
line-height: 1.6;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeeef;
|
||||
}
|
||||
|
||||
.main-tabs {
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-right: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-form-item__label) {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-button) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-buttons :deep(.el-button) {
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-wrapper :deep(.el-table) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table-wrapper :deep(.el-table__header th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.table-status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.status-pending {
|
||||
background: #fdf6ec;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.status-submitting {
|
||||
background: #e8f4fd;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.status-verified {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.status-rejected {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
&.status-info {
|
||||
background: #e8f4fd;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
&.status-success {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.status-warn {
|
||||
background: #fdf6ec;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
background: #fef0f0;
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.video-preview:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.video-preview:not(:hover) .play-overlay {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-height: calc(80vh - 120px);
|
||||
overflow: hidden;
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: calc(80vh - 120px);
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,6 +33,7 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
@@ -97,7 +98,12 @@
|
||||
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
|
||||
@@ -108,7 +114,12 @@
|
||||
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
|
||||
@@ -117,7 +128,11 @@
|
||||
: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'"
|
||||
@@ -127,7 +142,10 @@
|
||||
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
|
||||
@@ -167,7 +185,13 @@
|
||||
<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>
|
||||
@@ -196,6 +220,7 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
|
||||
<!-- 资产描述 -->
|
||||
<el-divider content-position="left">资产描述</el-divider>
|
||||
<el-form-item label="描述内容" label-width="100px">
|
||||
@@ -204,6 +229,7 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
<!-- 实物资产配置 -->
|
||||
<template v-if="ruleForm.type === 'physical'">
|
||||
<el-divider content-position="left">实物资产配置</el-divider>
|
||||
@@ -223,7 +249,7 @@
|
||||
<!-- 虚拟资产配置 -->
|
||||
<template v-if="ruleForm.type === 'virtual'">
|
||||
<el-divider content-position="left">虚拟资产配置</el-divider>
|
||||
|
||||
|
||||
<!-- 虚拟类型选择 -->
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
@@ -276,17 +302,9 @@
|
||||
<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>
|
||||
@@ -299,17 +317,9 @@
|
||||
<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>
|
||||
@@ -327,13 +337,7 @@
|
||||
</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>
|
||||
@@ -342,7 +346,7 @@
|
||||
<!-- 服务资产配置 -->
|
||||
<template v-if="ruleForm.type === 'service'">
|
||||
<el-divider content-position="left">服务资产配置</el-divider>
|
||||
|
||||
|
||||
<!-- 服务类型选择 -->
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8">
|
||||
@@ -382,13 +386,9 @@
|
||||
<!-- 时间段配置 -->
|
||||
<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,33 +396,29 @@
|
||||
<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" />
|
||||
@@ -595,6 +591,8 @@ 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 '';
|
||||
@@ -982,7 +980,7 @@ const openDialog = (row?: any, edit?: boolean) => {
|
||||
if (data.type === 'virtual' && data.virtualAssetConfig) {
|
||||
// 先处理 headers 和 params 为数组格式,再赋值
|
||||
const config = { ...data.virtualAssetConfig };
|
||||
|
||||
|
||||
// 确保 apiConfig 存在
|
||||
if (!config.apiConfig) {
|
||||
config.apiConfig = {
|
||||
@@ -1038,7 +1036,7 @@ const openDialog = (row?: any, edit?: boolean) => {
|
||||
exc.exceptionType = 'dayOfWeek';
|
||||
} else {
|
||||
// 默认值
|
||||
exc.exceptionType = 'date';
|
||||
exc.exceptionType = 'date';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1123,7 +1121,7 @@ const onCancel = () => {
|
||||
// 上传图片并返回URL
|
||||
const uploadImage = async (file: File): Promise<string> => {
|
||||
const res: any = await uploadAssetImage(file);
|
||||
|
||||
|
||||
// 1. 尝试获取并设置 fileAddressPrefix
|
||||
// 优先检查顶层,再检查 data 内部
|
||||
if (res.fileAddressPrefix) {
|
||||
@@ -1135,7 +1133,7 @@ const uploadImage = async (file: File): Promise<string> => {
|
||||
// 2. 尝试获取 fileURL / url
|
||||
// 优先检查顶层 fileURL
|
||||
if (res.fileURL) return res.fileURL;
|
||||
|
||||
|
||||
// 检查 data 对象中的 fileURL 或 url
|
||||
if (res.data && typeof res.data === 'object') {
|
||||
if (res.data.fileURL) return res.data.fileURL;
|
||||
@@ -1207,7 +1205,7 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
} else if (ruleForm.type === 'virtual') {
|
||||
// 深拷贝 virtualAssetConfig 以避免修改原对象
|
||||
const virtualConfig = JSON.parse(JSON.stringify(ruleForm.virtualAssetConfig));
|
||||
|
||||
|
||||
// 将数组转换为对象
|
||||
if (virtualConfig.apiConfig) {
|
||||
if (Array.isArray(virtualConfig.apiConfig.headers)) {
|
||||
@@ -1217,7 +1215,7 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
});
|
||||
virtualConfig.apiConfig.headers = headersObj;
|
||||
}
|
||||
|
||||
|
||||
if (Array.isArray(virtualConfig.apiConfig.params)) {
|
||||
const paramsObj: Record<string, string> = {};
|
||||
virtualConfig.apiConfig.params.forEach((item: KeyValuePair) => {
|
||||
@@ -1226,7 +1224,7 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
virtualConfig.apiConfig.params = paramsObj;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
body.virtualAssetConfig = virtualConfig;
|
||||
} else if (ruleForm.type === 'service') {
|
||||
body.serviceAssetConfig = ruleForm.serviceAssetConfig;
|
||||
@@ -1254,7 +1252,9 @@ 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);
|
||||
@@ -1269,13 +1269,13 @@ const buildRequestBody = async (): Promise<any> => {
|
||||
const onSubmit = async () => {
|
||||
const form = formRef.value;
|
||||
if (!form) return;
|
||||
|
||||
|
||||
form.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const fullRequestBody = await buildRequestBody();
|
||||
|
||||
|
||||
let requestBody: any;
|
||||
if (isEdit.value) {
|
||||
// 编辑模式:通过 _originalData 让拦截器自动处理最小化传参
|
||||
@@ -1296,7 +1296,7 @@ const onSubmit = async () => {
|
||||
closeDialog();
|
||||
emit('getAssetList');
|
||||
} catch (error) {
|
||||
ElMessage.error('提交失败');
|
||||
console.error('提交失败:', 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,15 +28,10 @@
|
||||
{{ 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>
|
||||
@@ -52,7 +47,9 @@
|
||||
</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">
|
||||
@@ -125,15 +122,7 @@
|
||||
<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" />
|
||||
@@ -155,7 +144,12 @@
|
||||
<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>
|
||||
@@ -200,7 +194,13 @@
|
||||
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,18 +217,7 @@
|
||||
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';
|
||||
@@ -318,15 +307,15 @@ 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' },
|
||||
{
|
||||
{
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (skuForm.unlimitedStock) {
|
||||
callback();
|
||||
@@ -335,11 +324,12 @@ const skuRules: FormRules = {
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
imageUrl: [{ required: true, message: '请上传SKU图片', trigger: 'change' }],
|
||||
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
@@ -570,14 +560,14 @@ const onGenerateStock = async (row: any) => {
|
||||
currentSkuName.value = row.skuName;
|
||||
stockFormVisible.value = true;
|
||||
stockFormLoading.value = true;
|
||||
|
||||
|
||||
// 重置表单
|
||||
Object.keys(stockForm).forEach((key) => delete stockForm[key]);
|
||||
|
||||
|
||||
try {
|
||||
const res = await getStockFormFields(row.id);
|
||||
stockFormFields.value = res.data.fields || [];
|
||||
|
||||
|
||||
// 设置默认值,根据字段类型转换
|
||||
stockFormFields.value.forEach((field) => {
|
||||
if (field.default !== undefined) {
|
||||
@@ -594,6 +584,7 @@ const onGenerateStock = async (row: any) => {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取库存表单字段失败:', error);
|
||||
ElMessage.error('获取库存表单字段失败');
|
||||
stockFormVisible.value = false;
|
||||
} finally {
|
||||
@@ -605,7 +596,7 @@ const onGenerateStock = async (row: any) => {
|
||||
const onSubmitStock = async () => {
|
||||
const form = stockFormRef.value;
|
||||
if (!form) return;
|
||||
|
||||
|
||||
form.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
stockSubmitLoading.value = true;
|
||||
@@ -622,13 +613,13 @@ const onSubmitStock = async () => {
|
||||
submitData[field.name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
await stockOperation(submitData as any);
|
||||
ElMessage.success('库存生成成功');
|
||||
stockFormVisible.value = false;
|
||||
getSkuList();
|
||||
} catch (error) {
|
||||
ElMessage.error('库存操作失败');
|
||||
console.error('库存操作失败:', error);
|
||||
} finally {
|
||||
stockSubmitLoading.value = false;
|
||||
}
|
||||
@@ -711,7 +702,7 @@ const formatImageUrl = (url?: string) => {
|
||||
// 上传图片
|
||||
const uploadImage = async (file: File): Promise<string> => {
|
||||
const res: any = await uploadAssetImage(file);
|
||||
|
||||
|
||||
if (res.fileAddressPrefix) {
|
||||
fileAddressPrefix.value = res.fileAddressPrefix;
|
||||
} else if (res.data && typeof res.data === 'object' && res.data.fileAddressPrefix) {
|
||||
@@ -844,7 +835,7 @@ const onSubmitSku = async () => {
|
||||
specsUnit: skuForm.specsUnit,
|
||||
specsCount: skuForm.specsCount,
|
||||
};
|
||||
|
||||
|
||||
const changedFields = skuFormDiff.getChanges(currentFormData, {
|
||||
alwaysInclude: ['id'],
|
||||
transformers: {
|
||||
@@ -853,15 +844,15 @@ const onSubmitSku = async () => {
|
||||
imageUrl: (val) => val || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// 添加 id
|
||||
const changedData: Record<string, any> = { id: editSkuId.value, ...changedFields };
|
||||
|
||||
|
||||
// 比较规格属性
|
||||
if (specValuesMapDiff.hasChanges(specValuesMap) && specValues.length > 0) {
|
||||
changedData.specValues = specValues;
|
||||
}
|
||||
|
||||
|
||||
await updateAssetSku(changedData as any);
|
||||
} else {
|
||||
// 新增模式:传递所有字段
|
||||
|
||||
@@ -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('加载数据集列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
986
src/views/digitalHuman/creation/index.vue
Normal file
986
src/views/digitalHuman/creation/index.vue
Normal file
@@ -0,0 +1,986 @@
|
||||
<template>
|
||||
<div class="creation-page" :class="{ 'is-submitting': submitLoading }">
|
||||
<div v-if="submitLoading" class="creation-loading-mask">
|
||||
<div class="creation-loading-card">
|
||||
<div class="loading-orbit">
|
||||
<span class="loading-ring ring-outer"></span>
|
||||
<span class="loading-ring ring-inner"></span>
|
||||
<span class="loading-core"></span>
|
||||
</div>
|
||||
<div class="loading-title">正在创作中</div>
|
||||
<div class="loading-desc">内容生成较慢,请稍候,创作完成后会自动刷新结果</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel left" v-loading="treeLoading">
|
||||
<div class="title">工作空间</div>
|
||||
<div class="tree-wrap">
|
||||
<el-empty v-if="treeNodes.length === 0 && !treeLoading" description="暂无作品数据" />
|
||||
<el-tree
|
||||
v-else
|
||||
:data="treeNodes"
|
||||
node-key="id"
|
||||
:props="treeProps"
|
||||
default-expand-all
|
||||
:highlight-current="true"
|
||||
:expand-on-click-node="false"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<div class="tree-node-main">
|
||||
<el-icon v-if="data.nodeType === 'date'"><ele-Calendar /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'contentType'"><ele-Collection /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'theme'"><ele-CollectionTag /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'title'"><ele-FolderOpened /></el-icon>
|
||||
<el-icon v-else-if="data.nodeType === 'html'"><ele-Document /></el-icon>
|
||||
<el-icon v-else><ele-Picture /></el-icon>
|
||||
<span class="ellipsis">{{ data.label }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
|
||||
type="primary"
|
||||
link
|
||||
class="tree-download"
|
||||
@click.stop="downloadNode(data)"
|
||||
><el-icon><ele-Download /></el-icon
|
||||
></el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel center">
|
||||
<div class="form-header">
|
||||
<div class="title">内容创建参数配置</div>
|
||||
<el-badge :value="taskBadgeCount" :hidden="taskBadgeCount === 0" class="task-badge">
|
||||
<el-button circle class="task-trigger" @click="taskDialogVisible = true">
|
||||
<el-icon><ele-Bell /></el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item prop="mode" class="span-1"
|
||||
><el-select v-model="formData.mode" placeholder="创作模式"
|
||||
><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item prop="content_type" class="span-1"
|
||||
><el-select v-model="formData.content_type" placeholder="内容类型" filterable allow-create default-first-option
|
||||
><el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item prop="theme" class="span-1"
|
||||
><el-input v-model="formData.theme" placeholder="主题(系列名),例如:春季通勤穿搭"
|
||||
/></el-form-item>
|
||||
<el-form-item prop="title" class="span-1"
|
||||
><el-input v-model="formData.title" placeholder="标题(具体标题),例如:通勤穿搭技巧"
|
||||
/></el-form-item>
|
||||
<el-form-item prop="style" class="span-1"
|
||||
><el-select v-model="formData.style" placeholder="内容风格" filterable allow-create default-first-option
|
||||
><el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item prop="count" class="span-1">
|
||||
<div class="number-field">
|
||||
<span class="number-label">生成条数</span>
|
||||
<el-input-number v-model="formData.count" :min="1" :max="5" controls-position="right" class="w100" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showImageConfig" prop="image_per_post" class="span-1">
|
||||
<div class="number-field">
|
||||
<span class="number-label">每条配图数量</span>
|
||||
<el-input-number v-model="formData.image_per_post" :min="1" :max="3" controls-position="right" class="w100" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showImageConfig" prop="image_ratio" class="span-1"
|
||||
><el-select v-model="formData.image_ratio" placeholder="图片比例"
|
||||
><el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item prop="description" class="span-2 description-item">
|
||||
<div class="chat-input-box">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
resize="none"
|
||||
placeholder=" 说点什么"
|
||||
class="chat-textarea"
|
||||
/>
|
||||
<div v-if="descriptionFiles.length" class="chat-file-list">
|
||||
<el-tag v-for="file in descriptionFiles" :key="file.uid" closable type="info" effect="plain" @close="removeDescriptionFile(file.uid)">
|
||||
{{ file.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="chat-toolbar">
|
||||
<div class="chat-actions">
|
||||
<el-upload
|
||||
v-model:file-list="descriptionFiles"
|
||||
class="chat-upload"
|
||||
multiple
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
||||
>
|
||||
<el-button text class="toolbar-btn">
|
||||
<el-icon><ele-Paperclip /></el-icon>
|
||||
<span>上传图片/文件</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-button circle type="primary" class="chat-send-btn" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><ele-Top /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel right" v-loading="previewLoading">
|
||||
<div class="title preview-title">预览区域</div>
|
||||
<div class="preview-main">
|
||||
<el-empty v-if="!selectedPreview" description="请选择预览节点" />
|
||||
<iframe v-else-if="selectedPreview.nodeType === 'html'" :src="selectedPreview.url" class="iframe" frameborder="0"></iframe>
|
||||
<div v-else class="img-wrap">
|
||||
<el-image :src="selectedPreview.url" :preview-src-list="[selectedPreview.url]" fit="contain" preview-teleported class="img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="taskDialogVisible" title="创作任务" width="680px" append-to-body class="task-dialog">
|
||||
<div class="task-list">
|
||||
<el-empty v-if="creationTasks.length === 0" description="暂无创作任务" />
|
||||
<div v-for="task in pagedCreationTasks" :key="task.id" class="task-item">
|
||||
<div class="task-item-header">
|
||||
<div class="task-name">{{ task.title }}</div>
|
||||
<el-tag :type="getTaskTagType(task.status)" effect="light">{{ getTaskStatusText(task.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="task-summary">{{ task.summary }}</div>
|
||||
<div class="task-time">
|
||||
创建:{{ task.createdAt }}<span v-if="task.updatedAt"> | 更新:{{ task.updatedAt }}</span>
|
||||
</div>
|
||||
<div v-if="task.error" class="task-error">{{ task.error }}</div>
|
||||
<div class="task-actions-row">
|
||||
<el-button v-if="task.status === 'running'" type="warning" link @click="pauseTask(task)">暂停</el-button>
|
||||
<el-button v-if="task.status === 'paused'" type="primary" link :loading="submitLoading" @click="continueTask(task)">继续</el-button>
|
||||
<el-button v-if="task.status === 'failed'" type="primary" link :loading="submitLoading" @click="retryTask(task)">重新执行</el-button>
|
||||
<el-button type="danger" link @click="deleteTask(task.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="creationTasks.length > taskPageSize" class="task-pagination" :style="{ marginTop: '20px' }">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="taskPage"
|
||||
:page-size="taskPageSize"
|
||||
:total="creationTasks.length"
|
||||
@current-change="handleTaskPageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules, type UploadUserFile } from 'element-plus';
|
||||
import {
|
||||
createCreation,
|
||||
downloadToFile,
|
||||
getCreationList,
|
||||
type CreationListParams,
|
||||
type CreationSubmitParams,
|
||||
type CreationTreeItem,
|
||||
} from '/@/api/digitalHuman/creation';
|
||||
import { uploadFile } from '/@/api/knowledge/document';
|
||||
|
||||
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: NodeType;
|
||||
children?: TreeNode[];
|
||||
createdDate?: string;
|
||||
contentType?: string;
|
||||
theme?: string;
|
||||
creationTitle?: string;
|
||||
fileUrl?: string;
|
||||
}
|
||||
interface PreviewState {
|
||||
url: string;
|
||||
nodeType: 'html' | 'image';
|
||||
}
|
||||
const formRef = ref<FormInstance>();
|
||||
type CreationTaskStatus = 'running' | 'success' | 'failed' | 'paused';
|
||||
interface CreationTask {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: CreationTaskStatus;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
error?: string;
|
||||
params: CreationSubmitParams;
|
||||
}
|
||||
const treeLoading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const imgAddressPrefix = ref('');
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const selectedPreview = ref<PreviewState | null>(null);
|
||||
const descriptionFiles = ref<UploadUserFile[]>([]);
|
||||
const taskDialogVisible = ref(false);
|
||||
const taskPage = ref(1);
|
||||
const taskPageSize = ref(3);
|
||||
const mockCreationParams: CreationSubmitParams = {
|
||||
mode: '混合模式(文案 + 图片)',
|
||||
content_type: '穿搭分享',
|
||||
theme: '春季通勤穿搭',
|
||||
title: '通勤穿搭技巧',
|
||||
description: '模拟任务描述',
|
||||
style: '生活分享 — 亲切自然,像朋友聊天',
|
||||
count: 1,
|
||||
image_per_post: 1,
|
||||
image_ratio: '3:4 — 小红书',
|
||||
};
|
||||
const creationTasks = ref<CreationTask[]>([
|
||||
{
|
||||
id: 3,
|
||||
title: '小个子显高穿搭法则',
|
||||
summary: '穿搭分享 / 小个子显高技巧 / 5个显高穿搭法则',
|
||||
status: 'running',
|
||||
createdAt: '2026-04-27 10:20:18',
|
||||
updatedAt: '2026-04-27 10:21:03',
|
||||
params: { ...mockCreationParams, theme: '小个子显高技巧', title: '5个显高穿搭法则' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '春季通勤穿搭技巧',
|
||||
summary: '穿搭分享 / 春季通勤穿搭 / 通勤穿搭技巧',
|
||||
status: 'success',
|
||||
createdAt: '2026-04-27 09:45:12',
|
||||
updatedAt: '2026-04-27 09:48:36',
|
||||
params: { ...mockCreationParams },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: '周末约会氛围感穿搭',
|
||||
summary: '穿搭分享 / 约会穿搭 / 周末约会氛围感穿搭',
|
||||
status: 'failed',
|
||||
createdAt: '2026-04-27 09:12:05',
|
||||
updatedAt: '2026-04-27 09:13:21',
|
||||
error: '素材上传失败,请重新执行任务',
|
||||
params: { ...mockCreationParams, theme: '约会穿搭', title: '周末约会氛围感穿搭' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '氛围感穿搭',
|
||||
summary: '穿搭分享 / 约会穿搭 / 氛围感穿搭',
|
||||
status: 'failed',
|
||||
createdAt: '2026-04-27 09:12:05',
|
||||
updatedAt: '2026-04-27 09:13:21',
|
||||
error: '素材上传失败,请重新执行任务',
|
||||
params: { ...mockCreationParams, theme: '约会穿搭', title: '周末约会氛围感穿搭' },
|
||||
},
|
||||
]);
|
||||
const taskIdSeed = ref(3);
|
||||
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
|
||||
const treeProps = { children: 'children', label: 'label' };
|
||||
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
|
||||
const formData = reactive<CreationSubmitParams>({
|
||||
mode: '混合模式(文案 + 图片)',
|
||||
content_type: '穿搭分享',
|
||||
theme: '',
|
||||
title: '',
|
||||
description: '',
|
||||
style: '生活分享 — 亲切自然,像朋友聊天',
|
||||
count: 1,
|
||||
image_per_post: 1,
|
||||
image_ratio: '3:4 — 小红书',
|
||||
});
|
||||
const showImageConfig = computed(() => formData.mode === '混合模式(文案 + 图片)' || formData.mode === '纯图片模式');
|
||||
const modeOptions = ['混合模式(文案 + 图片)', '纯文案模式', '纯图片模式'];
|
||||
const contentTypeOptions = ['穿搭分享', '好物推荐', '美妆护肤', '探店分享', '旅行日常', '美食分享'];
|
||||
const styleOptions = [
|
||||
'生活分享 — 亲切自然,像朋友聊天',
|
||||
'专业测评 — 深度分析,数据支撑',
|
||||
'种草推荐 — 强调亮点,感染力强',
|
||||
'干货教学 — 条理清晰,步骤明确',
|
||||
];
|
||||
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
|
||||
const taskBadgeCount = computed(() => creationTasks.value.filter((task) => task.status !== 'success').length);
|
||||
const pagedCreationTasks = computed(() => {
|
||||
const start = (taskPage.value - 1) * taskPageSize.value;
|
||||
return creationTasks.value.slice(start, start + taskPageSize.value);
|
||||
});
|
||||
watch(
|
||||
() => creationTasks.value.length,
|
||||
(total) => {
|
||||
const maxPage = Math.max(1, Math.ceil(total / taskPageSize.value));
|
||||
if (taskPage.value > maxPage) taskPage.value = maxPage;
|
||||
}
|
||||
);
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
taskPage.value = page;
|
||||
};
|
||||
const formatTaskTime = () => {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
|
||||
};
|
||||
const getTaskStatusText = (status: CreationTaskStatus) => ({ running: '执行中', success: '已完成', failed: '失败', paused: '已暂停' })[status];
|
||||
const getTaskTagType = (status: CreationTaskStatus) =>
|
||||
({ running: 'warning', success: 'success', failed: 'danger', paused: 'info' })[status] as 'warning' | 'success' | 'danger' | 'info';
|
||||
const buildTaskSummary = (params: CreationSubmitParams) =>
|
||||
`${params.content_type} / ${params.theme || '未填写主题'} / ${params.title || '未填写标题'}`;
|
||||
watch(
|
||||
() => formData.mode,
|
||||
() => {
|
||||
if (!showImageConfig.value) {
|
||||
formData.image_per_post = 1;
|
||||
formData.image_ratio = '3:4 — 小红书';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const rules: FormRules = {
|
||||
mode: [{ required: true, message: '请选择创作模式', trigger: 'change' }],
|
||||
content_type: [{ required: true, message: '请选择内容类型', trigger: 'change' }],
|
||||
theme: [{ required: true, message: '请输入主题', trigger: 'blur' }],
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
style: [{ required: true, message: '请选择内容风格', trigger: 'change' }],
|
||||
count: [{ required: true, message: '请输入生成条数', trigger: 'change' }],
|
||||
image_per_post: [
|
||||
{
|
||||
required: true,
|
||||
message: '请输入配图数量',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
if (!showImageConfig.value) return callback();
|
||||
if (!value) return callback(new Error('请输入配图数量'));
|
||||
callback();
|
||||
},
|
||||
},
|
||||
],
|
||||
image_ratio: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择图片比例',
|
||||
trigger: 'change',
|
||||
validator: (rule, value, callback) => {
|
||||
void rule;
|
||||
if (!showImageConfig.value) return callback();
|
||||
if (!value) return callback(new Error('请选择图片比例'));
|
||||
callback();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const joinUrl = (base: string, path: string) => `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const buildAssetUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
const prefix = imgAddressPrefix.value || '';
|
||||
if (/^https?:\/\//i.test(prefix)) return joinUrl(prefix, path);
|
||||
if (prefix) return joinUrl(joinUrl(apiBaseUrl, prefix), path);
|
||||
return joinUrl(apiBaseUrl, path);
|
||||
};
|
||||
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
|
||||
tree.map((dateGroup, dIndex) => ({
|
||||
id: `date-${dIndex}`,
|
||||
label: dateGroup.createdDate,
|
||||
nodeType: 'date' as const,
|
||||
children: (dateGroup.contentTypes || []).map((contentTypeGroup, cIndex) => ({
|
||||
id: `content-type-${dIndex}-${cIndex}`,
|
||||
label: contentTypeGroup.contentType,
|
||||
nodeType: 'contentType' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
children: (contentTypeGroup.themes || []).map((themeGroup, tIndex) => ({
|
||||
id: `theme-${dIndex}-${cIndex}-${tIndex}`,
|
||||
label: themeGroup.theme,
|
||||
nodeType: 'theme' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
children: (themeGroup.titles || []).map((titleItem, i) => ({
|
||||
id: `title-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
||||
label: titleItem.title || `作品${i + 1}`,
|
||||
nodeType: 'title' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
children: [
|
||||
...(titleItem.htmlFileUrl
|
||||
? [
|
||||
{
|
||||
id: `html-${dIndex}-${cIndex}-${tIndex}-${i}`,
|
||||
label: 'HTML',
|
||||
nodeType: 'html' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
fileUrl: titleItem.htmlFileUrl,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(titleItem.imageUrls || []).map((img, imgIndex) => ({
|
||||
id: `img-${dIndex}-${cIndex}-${tIndex}-${i}-${imgIndex}`,
|
||||
label: img.name || `图片 ${imgIndex + 1}`,
|
||||
nodeType: 'image' as const,
|
||||
createdDate: dateGroup.createdDate,
|
||||
contentType: contentTypeGroup.contentType,
|
||||
theme: themeGroup.theme,
|
||||
creationTitle: titleItem.title || `作品${i + 1}`,
|
||||
fileUrl: img.url,
|
||||
})),
|
||||
],
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
const handleNodeClick = (data: TreeNode) => {
|
||||
if (data.contentType) formData.content_type = data.contentType;
|
||||
if (data.theme) formData.theme = data.theme;
|
||||
if (data.nodeType === 'title') {
|
||||
formData.title = data.creationTitle || data.label || formData.title;
|
||||
return;
|
||||
}
|
||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
||||
const url = buildAssetUrl(data.fileUrl);
|
||||
if (!url) return ElMessage.warning('当前节点没有可预览地址');
|
||||
selectedPreview.value = { url, nodeType: data.nodeType };
|
||||
formData.title = data.creationTitle || formData.title;
|
||||
};
|
||||
const downloadNode = async (data: TreeNode) => {
|
||||
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
|
||||
if (!data.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
|
||||
try {
|
||||
const response = await downloadToFile({ fileURL: data.fileUrl });
|
||||
const blob = response instanceof Blob ? response : response?.data;
|
||||
if (!(blob instanceof Blob)) throw new Error('无效的下载数据');
|
||||
const fileName = decodeURIComponent(data.fileUrl.split('/').pop() || `${data.label}.${data.nodeType === 'html' ? 'html' : 'png'}`);
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
ElMessage.success('下载成功');
|
||||
} catch {
|
||||
ElMessage.error('下载失败');
|
||||
}
|
||||
};
|
||||
const getList = async () => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined });
|
||||
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
|
||||
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
|
||||
selectedPreview.value = null;
|
||||
} catch {
|
||||
treeNodes.value = [];
|
||||
imgAddressPrefix.value = '';
|
||||
selectedPreview.value = null;
|
||||
ElMessage.error('获取作品创作列表失败');
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
const removeDescriptionFile = (uid?: number) => {
|
||||
descriptionFiles.value = descriptionFiles.value.filter((file) => file.uid !== uid);
|
||||
};
|
||||
const extractUploadUrl = (res: unknown) => {
|
||||
const data = (res as { data?: Record<string, string> })?.data;
|
||||
const root = res as Record<string, string>;
|
||||
return data?.url || data?.fileUrl || data?.filePath || data?.path || root?.url || root?.fileUrl || root?.filePath || root?.path || '';
|
||||
};
|
||||
const buildDescription = async () => {
|
||||
const description = formData.description?.trim() || '';
|
||||
const rawFiles = descriptionFiles.value.map((item) => item.raw).filter(Boolean) as File[];
|
||||
if (rawFiles.length === 0) return description || undefined;
|
||||
const uploadedFiles = await Promise.all(
|
||||
rawFiles.map(async (file) => {
|
||||
const res = await uploadFile(file);
|
||||
return {
|
||||
name: file.name,
|
||||
url: extractUploadUrl(res),
|
||||
};
|
||||
})
|
||||
);
|
||||
const attachmentText = uploadedFiles.map((file, index) => `${index + 1}. ${file.name}${file.url ? `:${file.url}` : ''}`).join('\n');
|
||||
return [description, `参考附件:\n${attachmentText}`].filter(Boolean).join('\n\n');
|
||||
};
|
||||
const runCreationTask = async (task: CreationTask) => {
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
selectedPreview.value = null;
|
||||
task.status = 'running';
|
||||
task.error = undefined;
|
||||
task.updatedAt = formatTaskTime();
|
||||
await createCreation(task.params);
|
||||
task.status = 'success';
|
||||
task.updatedAt = formatTaskTime();
|
||||
ElMessage.success('创作任务已提交');
|
||||
await getList();
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.updatedAt = formatTaskTime();
|
||||
task.error = error instanceof Error ? error.message : '任务执行失败,请稍后重试';
|
||||
ElMessage.error('提交创作任务失败');
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
const retryTask = async (task: CreationTask) => {
|
||||
if (submitLoading.value) return;
|
||||
await runCreationTask(task);
|
||||
};
|
||||
const deleteTask = (taskId: number) => {
|
||||
creationTasks.value = creationTasks.value.filter((task) => task.id !== taskId);
|
||||
};
|
||||
const pauseTask = (task: CreationTask) => {
|
||||
if (task.status !== 'running') return;
|
||||
task.status = 'paused';
|
||||
task.updatedAt = formatTaskTime();
|
||||
};
|
||||
const continueTask = async (task: CreationTask) => {
|
||||
if (task.status !== 'paused' || submitLoading.value) return;
|
||||
await runCreationTask(task);
|
||||
};
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value || submitLoading.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
const description = await buildDescription();
|
||||
const params: CreationSubmitParams = {
|
||||
...formData,
|
||||
count: Number(formData.count),
|
||||
image_per_post: Number(formData.image_per_post),
|
||||
description,
|
||||
};
|
||||
const task: CreationTask = {
|
||||
id: ++taskIdSeed.value,
|
||||
title: params.title || `创作任务 ${taskIdSeed.value}`,
|
||||
summary: buildTaskSummary(params),
|
||||
status: 'running',
|
||||
createdAt: formatTaskTime(),
|
||||
params,
|
||||
};
|
||||
creationTasks.value.unshift(task);
|
||||
taskDialogVisible.value = true;
|
||||
await runCreationTask(task);
|
||||
} catch {
|
||||
ElMessage.error('提交创作任务失败');
|
||||
}
|
||||
};
|
||||
onMounted(getList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.creation-page {
|
||||
height: calc(100vh - 100px);
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: #f6f8fb;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
.creation-page.is-submitting {
|
||||
overflow: hidden;
|
||||
}
|
||||
.creation-loading-mask {
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(246, 248, 251, 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.creation-loading-card {
|
||||
width: min(420px, calc(100% - 40px));
|
||||
padding: 36px 28px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 18px 48px rgba(64, 102, 255, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.loading-orbit {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.loading-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
animation: orbit-rotate 1.8s linear infinite;
|
||||
}
|
||||
.ring-outer {
|
||||
border-width: 4px;
|
||||
border-color: #5b8cff transparent #8fb3ff transparent;
|
||||
}
|
||||
.ring-inner {
|
||||
inset: 15px;
|
||||
border-width: 4px;
|
||||
border-color: transparent #7c9dff transparent #d2deff;
|
||||
animation-direction: reverse;
|
||||
animation-duration: 1.2s;
|
||||
}
|
||||
.loading-core {
|
||||
position: absolute;
|
||||
inset: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #5b8cff 0%, #7a5cff 100%);
|
||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
||||
animation: core-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.loading-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.loading-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #5f6b7a;
|
||||
}
|
||||
@keyframes orbit-rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 10px rgba(91, 140, 255, 0.12);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 18px rgba(91, 140, 255, 0.2);
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-header .title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.task-trigger {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-color: #e6ebf3;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.task-trigger:hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.task-item {
|
||||
padding: 14px;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 12px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.task-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.task-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-summary,
|
||||
.task-time {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #7b8794;
|
||||
}
|
||||
.task-error {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff2f0;
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.task-actions-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.preview-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.tree-wrap,
|
||||
.center,
|
||||
.preview-main {
|
||||
overflow: auto;
|
||||
}
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.tree-node-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.tree-download {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compact-form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px 12px;
|
||||
}
|
||||
.span-1 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.description-item {
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.number-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.number-label {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
pointer-events: none;
|
||||
}
|
||||
.number-field :deep(.el-input-number .el-input__inner) {
|
||||
padding-left: 92px;
|
||||
text-align: left;
|
||||
}
|
||||
.chat-input-box {
|
||||
width: 100%;
|
||||
padding: 14px 14px 10px;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf3;
|
||||
box-shadow: 0 10px 24px rgba(31, 45, 61, 0.08);
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
.chat-input-box:focus-within {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
box-shadow: 0 12px 28px rgba(64, 158, 255, 0.14);
|
||||
}
|
||||
.chat-textarea {
|
||||
font-size: 14px;
|
||||
}
|
||||
.chat-file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 4px 2px;
|
||||
}
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-upload {
|
||||
display: inline-flex;
|
||||
}
|
||||
.toolbar-btn {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 15px;
|
||||
color: #303133;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.toolbar-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.toolbar-btn :deep(.el-icon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.chat-send-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.w100 {
|
||||
width: 100%;
|
||||
}
|
||||
.preview-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf1f7;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.img-wrap {
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
:deep(.chat-input-box .el-textarea__inner) {
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:deep(.chat-input-box .el-textarea__inner::placeholder) {
|
||||
color: #a8abb2;
|
||||
}
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 4px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:deep(.el-form-item__label) {
|
||||
display: none;
|
||||
}
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper),
|
||||
:deep(.el-textarea__inner),
|
||||
:deep(.el-input-number) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 0 1px #e8edf5 inset;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
:deep(.el-input__wrapper:hover),
|
||||
:deep(.el-select__wrapper:hover),
|
||||
:deep(.el-textarea__inner:hover),
|
||||
:deep(.el-input-number:hover) {
|
||||
box-shadow: 0 0 0 1px #d6e2f2 inset;
|
||||
}
|
||||
:deep(.el-input__wrapper.is-focus),
|
||||
:deep(.el-select__wrapper.is-focused),
|
||||
:deep(.el-textarea__inner:focus) {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
|
||||
}
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-select__wrapper) {
|
||||
min-height: 40px;
|
||||
}
|
||||
:deep(.el-select),
|
||||
:deep(.el-input),
|
||||
:deep(.el-input-number),
|
||||
:deep(.el-textarea) {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1800px) {
|
||||
.creation-page {
|
||||
grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -69,7 +69,7 @@ export default {
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { createknowledge, updateknowledge, getknowledge } from '/@/api/knowledge/dataset';
|
||||
import { createknowledge, updateknowledge, getknowledge } from '/@/api/knowledge/knowledge';
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['getknowledgeList']);
|
||||
|
||||
@@ -105,8 +105,8 @@ export default {
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { listknowledges, deleteknowledge, updateknowledgeStatus } from '/@/api/knowledge/dataset';
|
||||
import Editknowledge from './component/editDataset.vue';
|
||||
import { listknowledges, deleteknowledge, updateknowledgeStatus } from '/@/api/knowledge/knowledge';
|
||||
import Editknowledge from './component/editknowledge.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" :close-on-click-modal="false">
|
||||
<el-form :model="saveForm" label-position="top">
|
||||
<el-form-item label="工作流名称" required>
|
||||
<el-input v-model="saveForm.flowName" placeholder="请输入工作流名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作流描述">
|
||||
<el-input v-model="saveForm.description" type="textarea" :rows="4" placeholder="请输入工作流描述(选填)" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="emit('confirm')">{{ confirmText }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
saveForm: { flowName: string; description: string };
|
||||
currentEditingWorkflowId: string | null;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm'): void;
|
||||
}>();
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => (props.currentEditingWorkflowId ? '编辑工作流' : '保存工作流'));
|
||||
const confirmText = computed(() => (props.currentEditingWorkflowId ? '确定更新' : '确定保存'));
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,398 +0,0 @@
|
||||
<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="isOwner" label="模型归属" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isOwner === 0 ? 'warning' : 'success'">{{ scope.row.isOwner === 0 ? '内置模型' : '用户模型' }}</el-tag>
|
||||
</template>
|
||||
</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/settings/modelConfig/modelModule/index';
|
||||
import { checkIsSuperAdmin } from '/@/api/system/user/index';
|
||||
import { getApiErrorMessage } from '/@/utils/request';
|
||||
|
||||
const EditModule = defineAsyncComponent(() => import('/@/views/settings/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 builtInModelToClone = ref<any>(null);
|
||||
|
||||
// 检查是否为管理员
|
||||
const checkAdminStatus = async () => {
|
||||
try {
|
||||
const res: any = await checkIsSuperAdmin();
|
||||
isSuperAdmin.value = res.data?.isSuperAdmin || false;
|
||||
} catch {
|
||||
isSuperAdmin.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否为推理模型(只有推理模型才能设置为会话模型)
|
||||
const normalizeModelTypeKey = (modelType: number | string | undefined | null) => {
|
||||
if (modelType === undefined || modelType === null || modelType === '') {
|
||||
return '';
|
||||
}
|
||||
const raw = String(modelType).trim();
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? raw : String(n);
|
||||
};
|
||||
|
||||
const isInferenceModel = (modelType: number | string | undefined | null) => {
|
||||
if (modelType === undefined || modelType === null || modelType === '') {
|
||||
return false;
|
||||
}
|
||||
// 查找模型类型标签,判断是否为"推理模型"
|
||||
const modelTypeKey = normalizeModelTypeKey(modelType);
|
||||
const typeInfo = state.modelTypes.find((t) => normalizeModelTypeKey(t.id) === modelTypeKey);
|
||||
return typeInfo?.label === '推理模型' || modelTypeKey === '1' || modelTypeKey === '100';
|
||||
};
|
||||
|
||||
// 设置为会话模型
|
||||
const onSetChatModel = async (row: any) => {
|
||||
// 判断是否是内置模型(isOwner === 0 表示管理员创建的内置模型)
|
||||
if (row.isOwner === 0) {
|
||||
// 内置模型,需要用户配置 API Key 创建副本
|
||||
builtInModelToClone.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 || !builtInModelToClone.value) return;
|
||||
|
||||
try {
|
||||
await apiKeyFormRef.value.validate();
|
||||
|
||||
creatingModel.value = true;
|
||||
|
||||
// 基于内置模型创建新模型(继承原模型的所有配置,只替换 apiKey)
|
||||
const builtInModel = builtInModelToClone.value;
|
||||
const createParams = {
|
||||
modelName: apiKeyForm.modelName,
|
||||
modelType: builtInModel.modelType,
|
||||
baseUrl: builtInModel.baseUrl,
|
||||
httpMethod: builtInModel.httpMethod || 'POST',
|
||||
headMsg: builtInModel.headMsg || '',
|
||||
isPrivate: builtInModel.isPrivate ?? 1,
|
||||
enabled: builtInModel.enabled ?? 1,
|
||||
isChatModel: 1, // 设置为会话模型
|
||||
apiKey: apiKeyForm.apiKey,
|
||||
form: builtInModel.form || {},
|
||||
requestMapping: builtInModel.requestMapping || {},
|
||||
responseMapping: builtInModel.responseMapping || {},
|
||||
responseBody: builtInModel.responseBody || {},
|
||||
tokenMapping: builtInModel.tokenMapping || '',
|
||||
prompt: builtInModel.prompt || '',
|
||||
maxConcurrency: builtInModel.maxConcurrency || 10,
|
||||
queueLimit: builtInModel.queueLimit || 100,
|
||||
timeoutSeconds: builtInModel.timeoutSeconds || 30,
|
||||
expectedSeconds: builtInModel.expectedSeconds || 15,
|
||||
retryTimes: builtInModel.retryTimes || 3,
|
||||
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
|
||||
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
|
||||
remark: builtInModel.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 modelTypeKey = normalizeModelTypeKey(modelType);
|
||||
const hit = state.modelTypes.find((t) => normalizeModelTypeKey(t.id) === modelTypeKey);
|
||||
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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,476 +0,0 @@
|
||||
<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>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<el-card shadow="hover" class="search-card">
|
||||
<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-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 技能表格 -->
|
||||
<el-card shadow="hover" class="table-card">
|
||||
<el-table :data="skillList" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="name" label="技能名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="description" label="技能描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="fileName" label="文件名" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="fileUrl" label="文件地址" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.fileUrl" target="_blank" type="primary" :underline="false">{{ row.fileUrl }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatTime(row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<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-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<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="file">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-area"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
:on-exceed="handleExceed"
|
||||
:file-list="fileList"
|
||||
accept=".zip"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">将 zip 文件拖到此处,或<em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">只支持 .zip 格式,且压缩包内必须包含 .md 文件,文件大小不超过 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">{{ isEdit ? '当前文件' : '上传成功' }}</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, UploadFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
getUserSkillList,
|
||||
createUserSkill,
|
||||
updateUserSkill,
|
||||
deleteUserSkill,
|
||||
getUserSkillDetail,
|
||||
type SkillItem,
|
||||
type CreateSkillParams,
|
||||
} from '/@/api/settings/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 isEdit = ref(false);
|
||||
const currentEditId = ref<number | null>(null);
|
||||
const formRef = ref<FormInstance>();
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const submitting = ref(false);
|
||||
const fileList = ref<UploadUserFile[]>([]);
|
||||
const formData = reactive<CreateSkillParams>({ name: '', description: '', fileName: '', fileUrl: '' });
|
||||
const formRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入技能名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const formatTime = (time: string) => (time ? time.replace('T', ' ').split('.')[0] : '');
|
||||
|
||||
// 验证 zip 文件中是否包含 .md 文件
|
||||
const validateZipContainsMd = async (file: File): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result as ArrayBuffer;
|
||||
// 使用 JSZip 库解析 zip 文件
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
// 检查是否有 .md 文件
|
||||
const hasMdFile = Object.keys(zip.files).some(filename => filename.toLowerCase().endsWith('.md'));
|
||||
resolve(hasMdFile);
|
||||
} catch (error) {
|
||||
console.error('解析 zip 文件失败:', error);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => resolve(false);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange: UploadProps['onChange'] = async (uploadFile) => {
|
||||
if (!uploadFile.raw) return;
|
||||
|
||||
// 1. 验证文件格式必须是 zip
|
||||
const fileName = uploadFile.name.toLowerCase();
|
||||
if (!fileName.endsWith('.zip')) {
|
||||
ElMessage.warning('只支持上传 .zip 格式的文件');
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 验证文件大小
|
||||
const maxSize = 100 * 1024 * 1024;
|
||||
if (uploadFile.raw.size > maxSize) {
|
||||
ElMessage.warning('文件大小不能超过 100MB');
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 验证 zip 包内必须包含 .md 文件
|
||||
ElMessage.info('正在验证文件...');
|
||||
const hasMdFile = await validateZipContainsMd(uploadFile.raw);
|
||||
if (!hasMdFile) {
|
||||
ElMessage.warning('zip 压缩包内必须包含 .md 文件');
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 上传文件
|
||||
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 = () => {
|
||||
isEdit.value = false;
|
||||
currentEditId.value = null;
|
||||
dialogTitle.value = '创建技能';
|
||||
Object.assign(formData, { name: '', description: '', fileName: '', fileUrl: '' });
|
||||
fileList.value = [];
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEdit = async (row: SkillItem) => {
|
||||
isEdit.value = true;
|
||||
currentEditId.value = row.id;
|
||||
dialogTitle.value = '编辑技能';
|
||||
|
||||
try {
|
||||
// 获取详情
|
||||
const res = await getUserSkillDetail(row.id);
|
||||
const detail = res.data || row;
|
||||
|
||||
Object.assign(formData, {
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
fileName: detail.fileName,
|
||||
fileUrl: detail.fileUrl,
|
||||
});
|
||||
|
||||
// 如果有文件,设置文件列表用于显示
|
||||
if (detail.fileName) {
|
||||
fileList.value = [
|
||||
{
|
||||
name: detail.fileName,
|
||||
url: detail.fileUrl,
|
||||
} as UploadUserFile,
|
||||
];
|
||||
} else {
|
||||
fileList.value = [];
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取技能详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
fileName: formData.fileName,
|
||||
fileUrl: formData.fileUrl,
|
||||
};
|
||||
|
||||
if (isEdit.value && currentEditId.value) {
|
||||
// 编辑
|
||||
await updateUserSkill({ ...submitData, id: currentEditId.value });
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
// 创建
|
||||
await createUserSkill(submitData);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
fetchSkillList();
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (row: SkillItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除技能"${row.name}"吗?此操作不可恢复。`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteUserSkill(row.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: 20px;
|
||||
}
|
||||
|
||||
.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-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div class="system-pwconfig-container">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>密码策略配置</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="ruleForm" ref="formRef" :rules="rules" label-width="180px" style="max-width: 800px;">
|
||||
<el-form-item label="启用密码策略" prop="enabled">
|
||||
<el-switch v-model="ruleForm.enabled" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最小密码长度" prop="minLength">
|
||||
<el-input-number v-model="ruleForm.minLength" :min="4" :max="32" placeholder="请输入最小密码长度" />
|
||||
<span class="ml10 text-muted">位</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大密码长度" prop="maxLength">
|
||||
<el-input-number v-model="ruleForm.maxLength" :min="4" :max="128" placeholder="请输入最大密码长度" />
|
||||
<span class="ml10 text-muted">位</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="必须包含大写字母" prop="requireUppercase">
|
||||
<el-switch v-model="ruleForm.requireUppercase" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="必须包含小写字母" prop="requireLowercase">
|
||||
<el-switch v-model="ruleForm.requireLowercase" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="必须包含数字" prop="requireDigit">
|
||||
<el-switch v-model="ruleForm.requireDigit" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="必须包含特殊字符" prop="requireSpecialChar">
|
||||
<el-switch v-model="ruleForm.requireSpecialChar" />
|
||||
<div class="text-muted mt5" style="font-size: 12px;">特殊字符包括:!@#$%^&*()_+-=[]{}|;:,.<>?</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码过期天数" prop="expireDays">
|
||||
<el-input-number v-model="ruleForm.expireDays" :min="0" :max="365" placeholder="请输入密码过期天数" />
|
||||
<span class="ml10 text-muted">天(0表示永不过期)</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="禁止重复使用次数" prop="historyLimit">
|
||||
<el-input-number v-model="ruleForm.historyLimit" :min="0" :max="24" placeholder="请输入禁止重复使用次数" />
|
||||
<span class="ml10 text-muted">次(0表示不限制)</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="登录失败锁定次数" prop="maxRetryCount">
|
||||
<el-input-number v-model="ruleForm.maxRetryCount" :min="0" :max="10" placeholder="请输入登录失败锁定次数" />
|
||||
<span class="ml10 text-muted">次(0表示不锁定)</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="锁定时长" prop="lockTimeMinutes">
|
||||
<el-input-number v-model="ruleForm.lockTimeMinutes" :min="1" :max="1440" placeholder="请输入锁定时长" />
|
||||
<span class="ml10 text-muted">分钟</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="ruleForm.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSubmit" :loading="loading">保存配置</el-button>
|
||||
<el-button @click="resetForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive, toRefs, defineComponent, ref, onMounted, unref } from 'vue';
|
||||
import { ElMessage, FormInstance, FormRules } from 'element-plus';
|
||||
import { getPwConfig, savePwConfig } from "/@/api/system/pwconfig";
|
||||
|
||||
interface RuleFormState {
|
||||
enabled: boolean;
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
requireUppercase: boolean;
|
||||
requireLowercase: boolean;
|
||||
requireDigit: boolean;
|
||||
requireSpecialChar: boolean;
|
||||
expireDays: number;
|
||||
historyLimit: number;
|
||||
maxRetryCount: number;
|
||||
lockTimeMinutes: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface PwConfigState {
|
||||
ruleForm: RuleFormState;
|
||||
rules: FormRules;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'systemPwConfig',
|
||||
setup() {
|
||||
const formRef = ref<FormInstance>();
|
||||
const state = reactive<PwConfigState>({
|
||||
loading: false,
|
||||
ruleForm: {
|
||||
enabled: false,
|
||||
minLength: 8,
|
||||
maxLength: 32,
|
||||
requireUppercase: false,
|
||||
requireLowercase: true,
|
||||
requireDigit: true,
|
||||
requireSpecialChar: false,
|
||||
expireDays: 90,
|
||||
historyLimit: 5,
|
||||
maxRetryCount: 5,
|
||||
lockTimeMinutes: 30,
|
||||
remark: '',
|
||||
},
|
||||
rules: {
|
||||
minLength: [
|
||||
{ required: true, message: '请输入最小密码长度', trigger: 'blur' }
|
||||
],
|
||||
maxLength: [
|
||||
{ required: true, message: '请输入最大密码长度', trigger: 'blur' }
|
||||
],
|
||||
expireDays: [
|
||||
{ required: true, message: '请输入密码过期天数', trigger: 'blur' }
|
||||
],
|
||||
historyLimit: [
|
||||
{ required: true, message: '请输入禁止重复使用次数', trigger: 'blur' }
|
||||
],
|
||||
maxRetryCount: [
|
||||
{ required: true, message: '请输入登录失败锁定次数', trigger: 'blur' }
|
||||
],
|
||||
lockTimeMinutes: [
|
||||
{ required: true, message: '请输入锁定时长', trigger: 'blur' }
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
// 获取配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res: any = await getPwConfig();
|
||||
if (res.code === 0 && res.data) {
|
||||
state.ruleForm = {
|
||||
...state.ruleForm,
|
||||
...res.data,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// 错误由全局拦截器处理
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const onSubmit = async () => {
|
||||
const formWrap = unref(formRef);
|
||||
if (!formWrap) return;
|
||||
|
||||
await formWrap.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
// 验证最小长度不大于最大长度
|
||||
if (state.ruleForm.minLength > state.ruleForm.maxLength) {
|
||||
ElMessage.error('最小密码长度不能大于最大密码长度');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
try {
|
||||
await savePwConfig(state.ruleForm);
|
||||
ElMessage.success('保存成功');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 页面加载时
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
|
||||
return {
|
||||
formRef,
|
||||
onSubmit,
|
||||
resetForm,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.text-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-pwconfig-container {
|
||||
:deep(.el-card__body) {
|
||||
padding-top: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,13 +101,14 @@ const openDialog = async (row?: { id?: string }) => {
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await getLiveAccountDetail({ id: String(row.id) });
|
||||
// 详情加载失败时由当前弹窗给出更易懂的业务提示。
|
||||
const res = await getLiveAccountDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||
if (res?.data) {
|
||||
fillForm(res.data);
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} catch (error) {
|
||||
ElMessage.error('获取直播账号详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -129,18 +130,19 @@ const handleSubmit = async () => {
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败提示交给当前弹窗自己处理,避免和 request.ts 的统一报错重复。
|
||||
if (isEdit.value) {
|
||||
await updateLiveAccount(payload);
|
||||
await updateLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await createLiveAccount(payload);
|
||||
await createLiveAccount(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
emit('refresh');
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '修改失败' : '新增失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -131,13 +131,17 @@ 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,
|
||||
});
|
||||
// 列表失败文案由当前页面决定,避免和全局请求报错同时出现。
|
||||
const res = await getLiveAccountList(
|
||||
{
|
||||
...tableData.param,
|
||||
platform: searchForm.platform || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
accountId: searchForm.accountId || undefined,
|
||||
status: searchForm.status,
|
||||
},
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
if (res && res.data) {
|
||||
tableData.data = (res.data.list || []).map((item: any) => ({
|
||||
...item,
|
||||
@@ -145,8 +149,8 @@ const getList = async () => {
|
||||
}));
|
||||
tableData.total = res.data.total || 0;
|
||||
}
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} catch (error) {
|
||||
ElMessage.error('获取直播账号列表失败');
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
@@ -191,12 +195,12 @@ const handleDelete = async (row: LiveAccountItem) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteLiveAccount({ id: row.id });
|
||||
await deleteLiveAccount({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,7 +153,8 @@ const openDialog = async (row?: { id?: string }) => {
|
||||
await loadOptions();
|
||||
|
||||
if (row?.id) {
|
||||
const res = await getScheduleDetail({ id: String(row.id) });
|
||||
// 详情请求失败时,这个弹窗希望给出更明确的页面语义提示。
|
||||
const res = await getScheduleDetail({ id: String(row.id) }, { errorMode: 'page' });
|
||||
const detail = res?.data;
|
||||
if (detail) {
|
||||
formData.id = String(detail.id);
|
||||
@@ -169,8 +170,8 @@ const openDialog = async (row?: { id?: string }) => {
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message;表单校验错误由表单项展示
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '获取排班详情失败' : '加载排班基础数据失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -195,18 +196,19 @@ const handleSubmit = async () => {
|
||||
remark: formData.remark,
|
||||
};
|
||||
|
||||
// 提交失败文案由弹窗自己控制,避免接口层和弹窗层重复报错。
|
||||
if (isEdit.value) {
|
||||
await updateSchedule(payload);
|
||||
await updateSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('修改排班成功');
|
||||
} else {
|
||||
await createSchedule(payload);
|
||||
await createSchedule(payload, { errorMode: 'page' });
|
||||
ElMessage.success('新增排班成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
emit('refresh');
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '修改排班失败' : '新增排班失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -144,12 +144,16 @@ const getStatusTagType = (status: number): 'success' | 'info' | 'warning' => {
|
||||
const getList = async () => {
|
||||
try {
|
||||
tableData.loading = true;
|
||||
const res = await getScheduleList({
|
||||
...tableData.param,
|
||||
anchorName: searchForm.anchorName || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
status: searchForm.status,
|
||||
} as any);
|
||||
// 列表失败文案由当前页面决定,避免和 request.ts 的全局错误提示重复。
|
||||
const res = await getScheduleList(
|
||||
{
|
||||
...tableData.param,
|
||||
anchorName: searchForm.anchorName || undefined,
|
||||
accountName: searchForm.accountName || undefined,
|
||||
status: searchForm.status,
|
||||
} as any,
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
const scheduleData = res?.data;
|
||||
if (scheduleData) {
|
||||
tableData.data = (scheduleData.list || []).map((item: any) => ({
|
||||
@@ -162,8 +166,8 @@ const getList = async () => {
|
||||
}));
|
||||
tableData.total = scheduleData.total || 0;
|
||||
}
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
} catch (error) {
|
||||
ElMessage.error('获取排班列表失败');
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
@@ -207,12 +211,12 @@ const handleDelete = async (row: ScheduleItem) => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteSchedule({ id: row.id });
|
||||
await deleteSchedule({ id: row.id }, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
// 接口错误由 request 全局提示后端 message
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIUYPm47gB5/E1nGqiCSdyGLE6hK/MwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUyMDA1NDg1MVoXDTM2MDUx
|
||||
NzA1NDg1MVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAiXk9CZgDdtkAmIfw/3iFo2G9WA7ctgBSjG+0MKe2rp0P
|
||||
WfnpiBAG3jiFLT+TUvcleXEUdukcN2wlr8e3jaHSv6cEI4OKk5OsBk8FY1QnenD1
|
||||
NeYUovKppMklAG9PfzOtFY0upu3bQtG5+P2j0+9s3+Rci2YHVX/T5L8TnKGqUkDw
|
||||
gEY//KtUSmvmCbqNOw5t13GYTUvEsfrDVicwlWgyvPCbmPtLRKF4gTBGvGh0Q8x6
|
||||
PqWRU80/MU0NXHYcdPYtHljjRU5tLq/YxTqYvGKCd1fLq2BlG54mjUU9wfDvyh/N
|
||||
YGkX+Pw8JWbvka5V9K1sqxM4j3wqPJPiIJbrAf561wIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQUOws9qwD3ykeP1cFoVkAh3w3MzIIwHwYDVR0jBBgwFoAUOws9qwD3ykeP1cFo
|
||||
VkAh3w3MzIIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAJytt
|
||||
ZQj8E+PohYYKzNjWj+NwoldKpNeCTr73zIwTB20C1GdusxU5AiGC6spT3hKk2rMU
|
||||
CRIlhpfFbTQX5GIxOsWAQ2ru1zmN0L0mA25TZ6c/D6RZPVMjTTolLKIghdQkAyFm
|
||||
Twclb7yTILu7HEj4xf2xiVT98ZxZMGfiwCm3PlB7ur9CqMBRF4IDxQ+s60oHKdcj
|
||||
tHmUv/g/t4y9aIaqyqQuPeERU7BWdDLEyE4WrPijw2cif6IhaJeK29iqIHqb3vo8
|
||||
YMj9Ef/83Csks8idDz5r0rVa2MB5hf11FBe+gLVAh8vOiXMaBnoiTBPhiGfIZTBl
|
||||
uJjuVzPObt0drvdZwQ==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCJeT0JmAN22QCY
|
||||
h/D/eIWjYb1YDty2AFKMb7Qwp7aunQ9Z+emIEAbeOIUtP5NS9yV5cRR26Rw3bCWv
|
||||
x7eNodK/pwQjg4qTk6wGTwVjVCd6cPU15hSi8qmkySUAb09/M60VjS6m7dtC0bn4
|
||||
/aPT72zf5FyLZgdVf9PkvxOcoapSQPCARj/8q1RKa+YJuo07Dm3XcZhNS8Sx+sNW
|
||||
JzCVaDK88JuY+0tEoXiBMEa8aHRDzHo+pZFTzT8xTQ1cdhx09i0eWONFTm0ur9jF
|
||||
Opi8YoJ3V8urYGUbniaNRT3B8O/KH81gaRf4/DwlZu+RrlX0rWyrEziPfCo8k+Ig
|
||||
lusB/nrXAgMBAAECggEAAgqvqp0/++vv6OOM1AQVe8kBVbXp8u1hDmv2mGcQqZBN
|
||||
wJCT1Yod0i26/fW6/Y2XnhZwesdVFci62K5rQxkL46i1peufedr+7r+J6cfTFGYU
|
||||
oOlY/msaT4NGDieNbXqPeIujO/9sdRoCCRoC6PYYUoNfEWe7qJs5nBsxPiW7YCwK
|
||||
Kg2h932FOvI+7AxllV0vUsHfPYbirH4jl0b00lKoeUdQh8pB4hTUMgksCEWnJwr3
|
||||
4YQz3iqwIbWTy3/c1EULV0x5QmP0CsadSLSddo8gOB8k4aJStfNGzTuuLsrsETSU
|
||||
NifQGsP1YzHMieKT3BqwoZeoIqXng0O1dGyPsaZMKQKBgQC9i/Ii6HL2UdpgDTq4
|
||||
m+jFyEaSAXkOoIivhjRmoZhpBUNRJsIpm4xQPjYiO68USl5XkxxHqiMqF1SDxLAW
|
||||
9WViuytkDM/uawSY8F/f3PoKKPdJCM1NE34oEWOp7Hs+HWOhhtJqUxDzKJLwfASV
|
||||
blwLGJ5XlEbMuf/D79hW2kez7wKBgQC5q6r0C4c+ayfAn7RftopIu8VCquNNwdGh
|
||||
qQfMuGKDXPZfwBYDlZWHXwbH8CrlNHfPpZCRH8fmPK5T6zCX6XrrccJ+GMcFFwuw
|
||||
vOMkVYQOfL8Y6WfnzRpC2hZPIbNxSJbt8PEzZleCWuyvl2RULj+XCEDBRrSnzkRf
|
||||
UW3bBUYfmQKBgQCmQXYlgFY2EB3HWlNDUh2ePckIkBoq5kx+CO01iFAy7dbZ+3Eb
|
||||
JcCxMaAx7r/mwER39CU+BtHJPaV33rHFsYE4VIv+ue44Zc4mh9DQfvciqkQc34eU
|
||||
L6DcbERK644+MXEOYT92211mqxQXs/AhUieR5AofL4PaVtssddgAdn2mKQKBgQCd
|
||||
08Rc8RPExlejUN6F4ehIjXzP/+16YoAtGD8uEaqEGdjAacsMvvG9gdSEzq3X6jLS
|
||||
St3JzycgmoJhiXHkIC9BxpDamXtL41wnn1jBwQOhQP88UOPnUhy5qSL/nxkm+dp6
|
||||
Zq7Rz3QhteKuHFXHjQy2+Of6o3zbi+Xo1JI6dJA4OQKBgGK/L+ImW1930CwBEvR/
|
||||
pLwCvwEhP5EXd7SJtCuubPiKsnNS3Bb8FqiKYlOX3ANz2z2EoBdJQvTGhca6ORhU
|
||||
Gr5s61dui2np1aH2NduxmeJx2ywhkaNfz/j1mbESh2CjqkO5qvN96z6EST5KlDqP
|
||||
zFDSFLd4cs0yRko2tulKVAAU
|
||||
-----END PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user