5 Commits

Author SHA1 Message Date
2ae927a851 功能:通过分页和任务控制功能增强任务管理
-为创建任务引入分页功能,以提
升用户体验。
-新增暂停、继续和删除任务的功能。
-更新了任务状态处理,包括'已暂停'状态及其相应的UI更新。
2026-04-29 09:52:56 +08:00
f137ae591e feat: update content creation task workflow 2026-04-27 17:01:53 +08:00
d516886fc9 feat: update content creation management 2026-04-27 10:23:27 +08:00
d628dfdd72 feat: sync admin UI updates 2026-04-23 18:44:38 +08:00
24e517dfec 端口修改为8080 2026-04-22 17:48:37 +08:00
48 changed files with 1360 additions and 12588 deletions

View File

@@ -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/
# ========== 修正2kubectl指向临时文件+补充命名空间 ==========
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

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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之间

View File

@@ -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
**维护者:** 开发团队

View File

@@ -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
View File

@@ -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",

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View 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,
});
}

View File

@@ -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,
});
}

View File

@@ -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({

View File

@@ -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,
});
}

View File

@@ -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',
});
}

View File

@@ -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',
});
}

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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',
});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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>

View File

@@ -19,7 +19,7 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8">
<el-form-item label="资产分类" prop="categoryId">
<el-cascader
v-model="ruleForm.categoryId"
@@ -33,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;
}

View File

@@ -1,5 +1,5 @@
<template>
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1200px" :close-on-click-modal="false" @close="onClose">
<el-dialog v-model="dialogVisible" :title="`SKU管理 - ${assetName}`" width="1200px" :close-on-click-modal="false" @close="onClose">
<!-- 搜索区域 -->
<div class="sku-search mb15">
<el-button type="primary" @click="onOpenAddSku">
@@ -28,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 {
// 新增模式:传递所有字段

View File

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

View File

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

View 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>

View File

@@ -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']);

View File

@@ -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();

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -101,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;
}

View File

@@ -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('删除失败');
}
}
};

View File

@@ -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;
}

View File

@@ -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('删除失败');
}
}
};

View File

@@ -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-----

View File

@@ -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-----