Compare commits

..

No commits in common. "16d14962833da8033501d18a624375af1e55c187" and "6e67acca66c6c7da2a568f6578030497a231d8dd" have entirely different histories.

31 changed files with 430 additions and 1199 deletions

View File

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
]
}
}

View File

@ -3,7 +3,8 @@ VITE_BASE = '/'
# 主服务 # 主服务
VITE_API_PREFIX = '/api' VITE_API_PREFIX = '/api'
VITE_API_BASE_URL = 'http://test.xueai.art/newapi/api' # http://huanda.xueai.art http://106.54.11.219/api 43.248.131.153:8003 VITE_API_BASE_URL = 'http://test.xueai.art/api' # http://huanda.xueai.art http://106.54.11.219/api 43.248.131.153:8003
VITE_API_WS_URL = 'ws://test.xueai.art/api'
# 支付服务 # 支付服务
VITE_API_PAY_PREFIX = '/pay' VITE_API_PAY_PREFIX = '/pay'
@ -11,8 +12,7 @@ VITE_API_PAY_TARGET = 'http://test.xueai.art' # http://43.248.133.202 test.xue
# 任务处理模块 # 任务处理模块
VITE_API_WORKFLOW_UPLOAD = 'http://43.248.97.19:4000/aigc/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow VITE_API_WORKFLOW_UPLOAD = 'http://43.248.97.19:4000/aigc/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
VITE_API_TASK_PREFIX = '/suanli' VITE_API_WORKFLOW_WS = 'ws://43.248.97.19:4000/testworkflow'
VITE_API_TASK_TARGET = 'http://test.xueai.art'
# 是否开启开发者工具 # 是否开启开发者工具
VITE_OPEN_DEVTOOLS = false VITE_OPEN_DEVTOOLS = false

View File

@ -7,6 +7,7 @@ VITE_BUILD_MOCK = false
# 主服务 # 主服务
VITE_API_PREFIX = '/api' VITE_API_PREFIX = '/api'
VITE_API_BASE_URL = 'https://sxwz.xueai.art/api' VITE_API_BASE_URL = 'https://sxwz.xueai.art/api'
VITE_API_WS_URL = 'wss://sxwz.xueai.art/api'
# 支付服务 # 支付服务
VITE_API_PAY_PREFIX = '/pay' VITE_API_PAY_PREFIX = '/pay'
@ -14,8 +15,7 @@ VITE_API_PAY_TARGET = 'https://sxwz.xueai.art' # http://43.248.133.202
# 任务处理模块 # 任务处理模块
VITE_API_WORKFLOW_UPLOAD = 'https://designtools.xueai.art/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow VITE_API_WORKFLOW_UPLOAD = 'https://designtools.xueai.art/workflow/file/upload' # https://sxwz.xueai.art/workflow https://designtools.xueai.art/workflow
VITE_API_TASK_PREFIX = '/suanli' VITE_API_WORKFLOW_WS = 'wss://talkingdraw.xueai.art/testworkflow'
VITE_API_TASK_TARGET = 'http://test.xueai.art'
# 模型资源 # 模型资源
VITE_API_MODEL_RESOURCE = 'https://resources.xueai.art/AIGC' VITE_API_MODEL_RESOURCE = 'https://resources.xueai.art/AIGC'

1
.gitignore vendored
View File

@ -22,4 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
TEST/

168
CLAUDE.md
View File

@ -8,21 +8,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
pnpm dev # 启动 Vite 开发服务器 pnpm dev # 启动 Vite 开发服务器
pnpm build # 生产构建 pnpm build # 生产构建
pnpm preview # 预览生产构建 pnpm preview # 预览生产构建
npx eslint . # 代码检查(@antfu/eslint-configVue 支持,无 TypeScript
``` ```
## 技术栈 ## 技术栈
Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + `vue-element-plus-x`(多媒体编辑) + Less + pnpm Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + Less + pnpm
## 架构概览 ## 架构概览
AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端suanli和第三方 AI 平台RunningHub提交生成任务并轮询结果。 这是一个 AI 绘画/视频生成的前端操作平台,通过 WebSocket 连接后端和第三方 AI 平台RunningHub提交生成任务并接收结果。
**Painting 和 Video 走两套不同的任务构造路径:**
- **Painting新架构**:本地模型参数 schema → 专用控件 + 动态表单 → `X-Session-Id` header + 扁平 API body
- **Video旧架构**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body
### 关键目录 ### 关键目录
@ -31,132 +25,54 @@ src/
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller ├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
├── router/index.js # 路由定义 + token 验证守卫 ├── router/index.js # 路由定义 + token 验证守卫
├── stores/ # Pinia 状态管理 ├── stores/ # Pinia 状态管理
│ ├── user.js # 用户认证、信息(含 sessionId │ ├── user.js # 用户认证、信息、免费次数
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等) │ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
│ └── param.js # 参数 store当前为空 │ └── param.js # 占位 store当前为空
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑 ├── apis/ # HTTP API 模块
│ ├── auth/ # 认证相关登录、token 校验、用户信息) │ ├── auth/ # 登录/登出/用户信息/验证码
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除 │ └── display/ # 获取历史列表/收藏/删除
├── components/ ├── components/ # 通用组件
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口) │ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件
│ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组value 编码为 tag::display_name │ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现)
│ │ ├── proportion/ # 比例/分辨率选择器painting.vue 用于 Paintingvideo.vue 用于 Video │ ├── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
│ │ ├── imageUploader/ # 图片上传Painting │ └── ...
│ │ ├── videoImageUploader/ # 视频图片上传Video ├── views/ # 页面
│ │ ├── quantity/ # 生成数量选择器(支持 1-6 │ ├── home/index.vue # 主页面容器dialogBox + display
│ │ ├── Time/ # 视频时长选择器 │ ├── home/display/ # 历史记录展示区
│ │ └── pattern/ # 视频模式选择器 │ └── login/ # 登录页(跳转外部登录)
│ ├── virtual-scroller/# 虚拟滚动列表组件自定义实现reverse 模式)
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
├── views/ # 页面home、login
├── utils/ ├── utils/
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth不带 Bearer+ 按前缀路由 baseURL │ ├── request.js # Axios 实例,拦截器处理 token 和不同服务的 baseURL 路由
│ ├── websocket.js # 任务生成入口:组装参数 → POST 创建任务 → 20s 轮询直至完成/失败 │ ├── websocket.js # WebSocket 生成任务的核心流程(心跳、提交流程、结果处理)
│ ├── modelApi.js # 模型业务层localStorage 30s 缓存 + pendingRequests 并发去重 + 平台编码映射 │ ├── createTask.js # 根据配置构造任务 payload
│ ├── createTask.js # 任务 body 构造Painting 返回 modelParamsVideo 走 Playload 适配器 │ ├── modelConfig.js # 从远程 JSON 加载模型配置localStorage 每日缓存
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置 │ ├── auth.ts # token 存取工具localStorage
│ └── auth.ts # token 存取工具localStorage │ └── encrypt.ts # 加密工具Base64/MD5/RSA/AES
├── config/ ├── config/
│ ├── plugins.js # Vite 插件配置unplugin-auto-import + unplugin-vue-components │ ├── index.js # 平台配置入口
├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用) └── runninghub/ # RunningHub 平台适配器Payload 构造和 Result 解析
│ ├── runninghub/ # RunningHub 平台适配器Playload() 构造和 result() 解析Video 专用) └── config/ # 项目根目录下
│ └── models/ # Painting 模型参数配置:每模型一个 JS 文件,定义 params schema └── plugins.js # Vite 插件配置(自动导入/组件注册/图标)
``` ```
### 模型参数配置Painting 新架构)
`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载:
- **`ui: 'textarea'`** → Sender 组件主输入框prompt
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover含自定义 W/H
- **`ui: 'quantity'`** → `Quantity` 组件(动态 1-6 张)
- **`ui: 'imageUpload'`** → `ImageUploader` 组件
- **`ui: 'hidden'`** → 无 UI仅写入默认值如 outputFormat: 'png'
模型选择器从 API`fetchPlatformModels`)获取模型列表,按 API 返回的 `tags` 数组字段分组(`text`→生成模型,`edit`→编辑模型,`vision`→视觉理解模型)。
### `displayNameMap` 机制
`src/config/models/index.js``displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2``GPT-image-2` 分别对应编辑/生成config key 采用内部中文名区分。
### API 层设计原则
- `src/apis/` 只做纯 HTTP 调用(`service.get/post/delete`不含缓存、localStorage、业务逻辑
- 缓存、数据转换等业务逻辑放在 `src/utils/`
### 核心数据流 ### 核心数据流
**Painting新架构** 1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等)
2. 点击生成 → `websocket.js:generate()` 被调用
1. 用户设置参数 → 模型选择器按 `tags` 分组,控件根据 model config 的 `ui` 字段渲染 3. 先通过 `createTask.js` 调用 `config/runninghub``Playload()` 构造任务数据(从远程 JSON 加载 workflow 配置)
2. `handleStart()` 收集 `paramValues`UI refs 通过 watcher 双向同步)→ 组装 `{ modelParams, request }` 4. 建立 WebSocket 连接,经过握手协议(`please give me taskId` → `OK! Please continue.`)提交任务
3. `websocket.js:generate()``createTask(data)` → Painting 直接返回 `data.modelParams` 5. 任务排队中 → `displayStore.addGeneratingItem()` 在前端列表中插入 "生成中" 条目
4. `getModelId(type, modelName)` 查找 UUID内部调用 `fetchPlatformModels` 走缓存) 6. 完成后 WebSocket 关闭code=1000 reason=success`getTask()` 解析结果 URL → `updateItemToSuccess()` 更新列表
5. `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`,携带 `X-Session-Id` header
6. 返回 task_id → 20s 间隔轮询直至完成/失败
**Video旧架构保留**
1. 用户设置 Pattern、videoModel、比例、时长
2. `createTask(data)``runninghub.Playload(data)``fetchModelConfig()` 获取 workflow JSON → 返回 `{ workflowId, nodeInfoList }`
3. 后续同 Painting 步骤 4-6
### 关键注意事项
- **`sessionId`** 来自登录接口返回的 `userInfo.sessionId`,存储在 `useUserStore().userInfo` 中。`websocket.js` 必须使用该值,禁止随机生成。
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
- **模型列表缓存**`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重,避免重复请求。
### 接口速查
| 函数 | 端点 | 用途 |
|------|------|------|
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建任务(带 `X-Session-Id` header |
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize` |
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, display_name, tags, disabled?}` |
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
| `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 |
### 任务响应格式
```json
// GET /suanli/v1/tasks/:id 返回结构
{
"code": 0,
"data": {
"task_id": "uuid",
"status": "completed", // queued | processing | completed | failed
"outputs": [ // ⚠️ 扁平数组,不是 { images: [...] }
{ "url": "https://...", "type": "png" }
],
"vendor_error": "..." // 仅 failed 时有值
}
}
```
### 请求拦截器路由
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据 URL 前缀切换后端:
| URL 前缀 | 环境变量 |
|----------|----------|
| `/suanli` | `VITE_API_TASK_TARGET` |
| `/pay` | `VITE_API_PAY_TARGET` |
| `/aigc` | `VITE_API_AIGC_TARGET` |
| 其他 | `VITE_API_BASE_URL`(默认) |
### 平台编码映射
| 类型 | 平台编码 |
|------|----------|
| Painting | `ai_painting_talk` |
| Video | `ai_video_talk` |
映射函数 `getPlatformCode()` 位于 `utils/modelApi.js`
### 自动导入 ### 自动导入
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API - `unplugin-auto-import` 自动导入 Vue/VRouter/Pinia API无需在 `.vue` 文件中手动 `import { ref, computed, watch } from 'vue'`
- `unplugin-vue-components`自动注册 `src/components/` 下的组件和 Element Plus 组件 - `unplugin-vue-components` 自动注册 `src/components/` 下的组件和 Element Plus 组件
- Element Plus 图标通过 `unplugin-icons` 按需加载 - Element Plus 图标通过 `unplugin-icons` 按需加载
### 环境变量
有两套环境文件。`VITE_API_BASE_URL` 定义主 API 地址,请求拦截器根据 URL 前缀自动切换不同的后端服务(主服务/支付服务/AIGC 工作流服务)。`VITE_API_WORKFLOW_WS` 定义 WebSocket 地址。
### 路由守卫
`src/router/index.js``beforeEach` 守卫检查 token 存在性和有效性(调用 `/auth/check/token`),无效则跳转 `/login`。支持通过 URL query `?token=xxx` 传入 token。

9
components.d.ts vendored
View File

@ -11,18 +11,10 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
2: typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default']
3: typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default']
Canvas: typeof import('./src/components/canvas/index.vue')['default'] Canvas: typeof import('./src/components/canvas/index.vue')['default']
copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default']
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default'] DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
IEpCalendar: typeof import('~icons/ep/calendar')['default'] IEpCalendar: typeof import('~icons/ep/calendar')['default']
@ -34,7 +26,6 @@ declare module 'vue' {
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default'] ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
Img: typeof import('./src/components/Img/index.vue')['default'] Img: typeof import('./src/components/Img/index.vue')['default']
Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default'] Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default']
ParamControl: typeof import('./src/components/dialogBox/ParamControl.vue')['default']
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default'] Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
Popover: typeof import('./src/components/Popover/index.vue')['default'] Popover: typeof import('./src/components/Popover/index.vue')['default']
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default'] Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']

View File

@ -38,10 +38,14 @@ export function logout() {
/** @desc 获取用户信息 */ /** @desc 获取用户信息 */
export const getUserInfo = () => { export const getUserInfo = () => {
return service.get(`/sysUser/currentUser return service.get(`${BASE_URL}/user/info`)
`) }
/** @desc 获取路由信息 */
export const getUserRoute = () => {
return service.get(`${BASE_URL}/route`)
} }
export const checkUsertoken = () => { export const checkUsertoken = () => {
return service.post(`/login/validateToken`) return service.get(`${BASE_URL}/check/token`)
} }

View File

@ -1,6 +1,9 @@
import service from '@/utils/request' import service from '@/utils/request'
// ==================== 历史记录 APIaxios ==================== // 获取生成历史列表
export function getGenerateHistoryList(query) {
return service.get('/taskRecordHistory', { params: query })
}
// 取消或收藏 // 取消或收藏
export function cancelOrCollect(query) { export function cancelOrCollect(query) {
@ -12,28 +15,7 @@ export function deleteGenerateHistory(query) {
return service.delete('/taskRecordHistory/delete', { params: query }) return service.delete('/taskRecordHistory/delete', { params: query })
} }
// ==================== 任务 APIaxios经由 /suanli 前缀路由到算力调度后端) ==================== // 获取免费次数
export function getFreeTimes(id) {
// 创建生成任务HTTP POST /suanli/v1/tasks return service.get('/plantformBalance/userBalances', { params: { id } })
export function requestCreateTask(body, sessionId) {
return service.post('/suanli/v1/tasks', body, {
headers: { 'X-Session-Id': sessionId }
})
}
// 查询任务状态 / 获取历史任务结果HTTP GET /suanli/v1/tasks/:id
export function requestTaskStatus(taskId) {
return service.get(`/suanli/v1/tasks/${taskId}`)
}
// 获取历史任务列表HTTP GET /suanli/v1/tasks/history支持平台筛选和分页
export function requestTaskHistory(params) {
return service.get('/suanli/v1/tasks/history', { params })
}
// ==================== 平台模型 API ====================
// 获取平台模型列表(原始 HTTP 调用,不含缓存逻辑)
export function fetchPlatformModels(code) {
return service.get(`/suanli/v1/platforms/${code}/models`)
} }

View File

@ -47,27 +47,20 @@ const contentRef = ref(null)
const visible = ref(props.modelValue) const visible = ref(props.modelValue)
const position = ref({ top: 0, left: 0 }) const position = ref({ top: 0, left: 0 })
const popoverId = ref(Math.random().toString(36).substr(2, 9)) const popoverId = ref(Math.random().toString(36).substr(2, 9))
let resizeObserver = null
if (!window.__currentOpenPopoverId__) { if (!window.__currentOpenPopoverId__) {
window.__currentOpenPopoverId__ = null window.__currentOpenPopoverId__ = null
} }
const contentStyle = computed(() => { const contentStyle = computed(() => ({
const w = typeof props.width === 'number' ? `${props.width}px` : props.width width: typeof props.width === 'number' ? `${props.width}px` : props.width,
return { ...position.value
...position.value, }))
width: w,
maxWidth: w === 'auto' ? 'none' : w,
minWidth: w === 'auto' ? '0' : w
}
})
const togglePopover = async () => { const togglePopover = async () => {
if (visible.value) { if (visible.value) {
visible.value = false visible.value = false
window.__currentOpenPopoverId__ = null window.__currentOpenPopoverId__ = null
stopResizeObserver()
} else { } else {
if (window.__currentOpenPopoverId__ && window.__currentOpenPopoverId__ !== popoverId.value) { if (window.__currentOpenPopoverId__ && window.__currentOpenPopoverId__ !== popoverId.value) {
window.dispatchEvent(new CustomEvent('close-other-popovers', { detail: { excludeId: popoverId.value } })) window.dispatchEvent(new CustomEvent('close-other-popovers', { detail: { excludeId: popoverId.value } }))
@ -79,7 +72,6 @@ const togglePopover = async () => {
window.__currentOpenPopoverId__ = popoverId.value window.__currentOpenPopoverId__ = popoverId.value
await nextTick() await nextTick()
updatePosition() updatePosition()
startResizeObserver()
} }
emit('update:modelValue', visible.value) emit('update:modelValue', visible.value)
} }
@ -119,22 +111,6 @@ const updatePosition = () => {
} }
} }
const startResizeObserver = () => {
if (!contentRef.value) return
stopResizeObserver()
resizeObserver = new ResizeObserver(() => {
updatePosition()
})
resizeObserver.observe(contentRef.value)
}
const stopResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
}
const handleClickOutside = (e) => { const handleClickOutside = (e) => {
if (!visible.value) return if (!visible.value) return
@ -149,7 +125,6 @@ const handleClickOutside = (e) => {
) { ) {
visible.value = false visible.value = false
window.__currentOpenPopoverId__ = null window.__currentOpenPopoverId__ = null
stopResizeObserver()
emit('update:modelValue', false) emit('update:modelValue', false)
} }
} }
@ -157,14 +132,12 @@ const handleClickOutside = (e) => {
const handleCloseOtherPopovers = (e) => { const handleCloseOtherPopovers = (e) => {
if (e.detail.excludeId !== popoverId.value) { if (e.detail.excludeId !== popoverId.value) {
visible.value = false visible.value = false
stopResizeObserver()
} }
} }
const handleCloseOtherSelects = () => { const handleCloseOtherSelects = () => {
visible.value = false visible.value = false
window.__currentOpenPopoverId__ = null window.__currentOpenPopoverId__ = null
stopResizeObserver()
} }
watch(() => props.modelValue, async (val) => { watch(() => props.modelValue, async (val) => {
@ -172,9 +145,6 @@ watch(() => props.modelValue, async (val) => {
if (val) { if (val) {
await nextTick() await nextTick()
updatePosition() updatePosition()
startResizeObserver()
} else {
stopResizeObserver()
} }
}) })
@ -192,7 +162,6 @@ onBeforeUnmount(() => {
window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('close-other-popovers', handleCloseOtherPopovers) window.removeEventListener('close-other-popovers', handleCloseOtherPopovers)
window.removeEventListener('close-other-selects', handleCloseOtherSelects) window.removeEventListener('close-other-selects', handleCloseOtherSelects)
stopResizeObserver()
}) })
</script> </script>

View File

@ -261,8 +261,6 @@ onBeforeUnmount(() => {
border: 1px solid #e8e8e8; border: 1px solid #e8e8e8;
animation: fadeIn 0.2s ease; animation: fadeIn 0.2s ease;
gap: 10px; gap: 10px;
max-height: 360px;
overflow-y: auto;
} }
@keyframes fadeIn { @keyframes fadeIn {

View File

@ -161,9 +161,8 @@
<script setup> <script setup>
import { generate } from '@/utils/websocket' import { generate } from '@/utils/websocket'
import { useDisplayStore } from '@/stores' import { useDisplayStore, useUserStore } from '@/stores'
import request from '@/utils/request' import request from '@/utils/request'
import { getModelId } from '@/utils/modelApi'
const props = defineProps({ const props = defineProps({
visible: { visible: {
@ -726,8 +725,6 @@ const handleSend = async () => {
const proportion = getImageAspectRatio() const proportion = getImageAspectRatio()
const modelId = await getModelId(props.type, 'GPT')
const generateData = { const generateData = {
model: 'GPT-image2.0', model: 'GPT-image2.0',
modelType: 'edit', modelType: 'edit',
@ -740,15 +737,15 @@ const handleSend = async () => {
AIGC: 'Painting', AIGC: 'Painting',
platform: 'runninghub', platform: 'runninghub',
modelName: 'GPT', modelName: 'GPT',
modelId,
quantity: 1, quantity: 1,
free: useUserStore().freeTimes,
params: [ params: [
{ name: 'prompt', data: inputText.value + '并且去除掉图1中的框' }, { name: 'prompt', data: inputText.value + '并且去除掉图1中的框' },
{ name: 'index', data: 1 }, { name: 'index', data: 1 },
{ name: 'proportion', data: proportion?.aspectRatio || '4:3' }, { name: 'proportion', data: proportion?.aspectRatio || '4:3' },
], ],
imgs: uploadedImgs, imgs: uploadedImgs,
request: JSON.stringify(generateData) result: JSON.stringify(generateData)
} }
emit('send', { emit('send', {

View File

@ -7,13 +7,13 @@
<div class="sender-top"> <div class="sender-top">
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div> <div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
<div v-show="showImageUploader" class="upload-img-container"> <div v-show="modelType !== 'text'" class="upload-img-container">
<div class="reference-diagram"> <div class="reference-diagram">
<ImageUploader <ImageUploader
v-if="props.type === 'Painting'" v-if="props.type === 'Painting'"
ref="referenceDiagramRef" ref="referenceDiagramRef"
v-model="referenceImages" v-model="referenceImages"
:limit="imageUploadLimit" :limit="4"
@open-canvas="handleOpenCanvas" @open-canvas="handleOpenCanvas"
/> />
<VideoImageUploader <VideoImageUploader
@ -32,16 +32,12 @@
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap"> <div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
<paintingModel v-model="model" v-model:typeValue="modelType" /> <paintingModel v-model="model" v-model:typeValue="modelType" />
<paintingProportion <paintingProportion
v-if="showProportion"
v-model="proportion" v-model="proportion"
v-model:resolution="resolution" v-model:resolution="resolution"
v-model:width="customWidth" :proportion-options="proportionOptions"
v-model:height="customHight" :resolution-options="resolutionOptions"
:proportion-options="paintingProportionOpts"
:resolution-options="paintingResolutionOpts"
:allow-custom="hasCustomSize"
/> />
<Quantity v-if="showQuantity" v-model="quantity" :max="quantityMax" /> <Quantity v-model="quantity" />
</div> </div>
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap"> <div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
@ -77,22 +73,20 @@
</template> </template>
<script setup> <script setup>
import paintingProportion from './proportion/painting.vue'
import videoProportion from './proportion/video.vue' import videoProportion from './proportion/video.vue'
import paintingModel from './model/painting.vue' import paintingModel from './model/painting.vue'
import videoModel from './model/video.vue' import videoModel from './model/video.vue'
import Quantity from './quantity/index.vue'
import Pattern from './pattern/index.vue' import Pattern from './pattern/index.vue'
import ImageUploader from './imageUploader/index.vue' import ImageUploader from './imageUploader/index.vue'
import VideoImageUploader from './videoImageUploader/index.vue' import VideoImageUploader from './videoImageUploader/index.vue'
import Time from './Time/index.vue' import Time from './Time/index.vue'
import paintingProportion from './proportion/painting.vue'
import Quantity from './quantity/index.vue'
import { Sender } from 'vue-element-plus-x' import { Sender } from 'vue-element-plus-x'
import { useDisplayStore } from '@/stores' import { useDisplayStore, useUserStore } from '@/stores'
import { generate } from '@/utils/websocket' import { generate } from '@/utils/websocket'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getModelId, fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
import { fetchModelConfig } from '@/utils/modelConfig' import { fetchModelConfig } from '@/utils/modelConfig'
import { getModelConfig } from '@/config/models/index.js'
const props = defineProps({ const props = defineProps({
isGenerate: { isGenerate: {
@ -111,162 +105,30 @@ const props = defineProps({
const router = useRouter() const router = useRouter()
const useDisplay = useDisplayStore() const useDisplay = useDisplayStore()
const useUser = useUserStore()
const isgerenate = ref(false) const isgerenate = ref(false)
const model = ref() // const model = ref() //
const modelType = ref('text') const modelType = ref('text')
// const modelDisplayConfig = ref(null)
const modelConfig = computed(() => {
return props.type === 'Painting' ? getModelConfig(model.value) : null
})
//
const paramValues = reactive({})
const showImageUploader = computed(() => {
if (props.type === 'Video') return modelType.value !== 'text'
return modelType.value !== 'text' || modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
})
// imageNum
const showQuantity = computed(() => {
if (props.type !== 'Painting') return false
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
if (qtyParam) return true
return modelType.value === 'text' && !modelConfig.value?.params?.find(p => p.name === 'forceSingle')
})
// 使 proportion aspectRatio
const showProportion = computed(() => {
return !!modelConfig.value?.params?.find(p => p.ui === 'proportion')
})
// aspectRatio 'custom'
const hasCustomSize = computed(() => {
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
return ratioParam?.options?.includes('custom') || false
})
// 退
const paintingProportionOpts = computed(() => {
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
if (ratioParam?.options) {
return ratioParam.options
.filter(o => o !== 'custom')
.map(o => ({ value: o, label: o }))
}
return proportionOptions.value
})
// resolution
const paintingResolutionOpts = computed(() => {
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
if (resParam?.options) {
return resParam.options.map(o => ({ value: o, label: o.toUpperCase() }))
}
return []
})
const imageUploadLimit = computed(() => {
if (!modelConfig.value) return 4
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
return imageParam?.maxCount || modelConfig.value.maxImages || 4
})
const promptPlaceholder = ref('描述你想生成的画面和动作。') // const promptPlaceholder = ref('描述你想生成的画面和动作。') //
const prompt = ref('') // const prompt = ref('') //
const proportion = ref('16:9') // Video const proportion = ref('16:9') //
const resolution = ref('1k') // Video const resolution = ref('1k') //
const referenceImages = ref([]) const referenceImages = ref([])
// //
const quantity = ref(1) // const quantity = ref(1) //
const customWidth = ref(1024) //
const customHight = ref(1024) //
const quantityMax = computed(() => {
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
if (qtyParam?.options?.length) return Math.max(...qtyParam.options)
return 4
})
// paramValues UI refs
watch(modelConfig, (config) => {
if (!config) return
config.params.forEach(p => {
if (!(p.name in paramValues)) {
if (p.name === 'outputFormat') {
paramValues[p.name] = 'png'
} else {
paramValues[p.name] = p.default ?? ''
}
}
})
// UI
const ratioParam = config.params.find(p => p.ui === 'proportion')
if (ratioParam) proportion.value = ratioParam.default || '1:1'
const resParam = config.params.find(p => p.ui === 'resolution')
if (resParam) resolution.value = resParam.default || '2k'
const qtyParam = config.params.find(p => p.ui === 'quantity')
if (qtyParam) quantity.value = qtyParam.default || 1
const cwParam = config.params.find(p => p.name === 'customWidth')
if (cwParam) customWidth.value = cwParam.default || 1024
const chParam = config.params.find(p => p.name === 'customHight')
if (chParam) customHight.value = chParam.default || 1024
}, { immediate: true })
// UI refs paramValues
watch(proportion, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'proportion')
if (p) paramValues[p.name] = val
})
watch(resolution, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'resolution')
if (p) paramValues[p.name] = val
})
watch(quantity, (val) => {
const p = modelConfig.value?.params?.find(param => param.ui === 'quantity')
if (p) paramValues[p.name] = val
})
watch(customWidth, (val) => {
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
paramValues.customWidth = val
}
})
watch(customHight, (val) => {
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
paramValues.customHight = val
}
})
// paramValues
watch(referenceImages, (imgs) => {
const imageParam = modelConfig.value?.params?.find(p => p.ui === 'imageUpload')
if (imageParam) {
paramValues[imageParam.name] = imgs.map(img => img.url)
}
}, { deep: true })
// //
const duration = ref(5) // const duration = ref(5) //
const videoPattern = ref('文生视频') // '' const videoPattern = ref('文生视频') // ''
const resolutionOptions = ref([ const resolutionOptions = ref([])
{ value: '1k', label: '标清 1K' }, const proportionOptions = ref([])
{ value: '2k', label: '高清 2K' },
{ value: '4k', label: '超清 4K' },
])
const proportionOptions = ref([
{ value: '智能', label: '智能' },
{ value: '21:9', label: '21:9' },
{ value: '16:9', label: '16:9' },
{ value: '4:3', label: '4:3' },
{ value: '1:1', label: '1:1' },
{ value: '3:4', label: '3:4' },
{ value: '9:16', label: '9:16' },
])
const durationOptions = ref([]) const durationOptions = ref([])
const isInitialized = ref(false) const isInitialized = ref(false)
@ -279,38 +141,44 @@ const autoSizeConfig = computed(() => {
} }
}) })
const modelDisplayConfig = ref(null) const loadModelConfig = async (modelName, currentModelType) => {
// Video: workflow
const loadVideoModelConfig = async (modelName, currentModelType) => {
try { try {
const config = await fetchModelConfig(props.type, modelName, currentModelType) const config = await fetchModelConfig(props.type, modelName, currentModelType)
modelDisplayConfig.value = config modelDisplayConfig.value = config
if (config.display) { if (config.display) {
const display = config.display const display = config.display
if (display.promptPlaceholder) { if (display.promptPlaceholder) {
promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。' promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。'
} }
if (display.prompt && !isInitialized.value) { if (display.prompt && !isInitialized.value) {
prompt.value = display.prompt.default || '' prompt.value = display.prompt.default || ''
} }
if (display.resolution) { if (display.resolution) {
resolution.value = display.resolution.default || '1k' resolution.value = display.resolution.default || '1k'
resolutionOptions.value = display.resolution.options || [] resolutionOptions.value = display.resolution.options || []
} }
if (display.proportion) { if (display.proportion) {
proportion.value = display.proportion.default || '16:9' proportion.value = display.proportion.default || '16:9'
proportionOptions.value = display.proportion.options || [] proportionOptions.value = display.proportion.options || []
} }
if (display.duration) { if (display.duration) {
duration.value = display.duration.default || 5 duration.value = display.duration.default || 5
durationOptions.value = display.duration.options || [] durationOptions.value = display.duration.options || []
} }
} }
isInitialized.value = true isInitialized.value = true
return config
} catch (error) { } catch (error) {
console.error('加载视频模型配置失败:', error) console.error('加载模型配置失败:', error)
return null
} }
} }
@ -331,7 +199,7 @@ const handleStart = async () => {
ElMessage.error('请输入提示词') ElMessage.error('请输入提示词')
return return
} }
if (showImageUploader.value && !referenceImages.value.length){ if (modelType.value === 'image' && !referenceImages.value.length){
ElMessage.warning('请上传图片') ElMessage.warning('请上传图片')
return return
} }
@ -343,10 +211,6 @@ const handleStart = async () => {
imgs.push({ name: `image_${index + 1}`, url: img.url }) imgs.push({ name: `image_${index + 1}`, url: img.url })
}) })
// Painting
const modelParams = { ...paramValues }
if (prompt.value) modelParams.prompt = prompt.value
const generateData = { const generateData = {
model: model.value, model: model.value,
modelType: currentModelType, modelType: currentModelType,
@ -355,34 +219,27 @@ const handleStart = async () => {
referenceImages: referenceImages.value, referenceImages: referenceImages.value,
quantity: quantity.value, quantity: quantity.value,
resolution: resolution.value, resolution: resolution.value,
customWidth: customWidth.value,
customHight: customHight.value,
duration: duration.value, duration: duration.value,
videoPattern: videoPattern.value, videoPattern: videoPattern.value
modelParams,
} }
const modelId = await getModelId(currentType, model.value)
// Painting Video params
const isPainting = currentType === 'Painting'
const data = { const data = {
type: currentType, type: currentType,
modelType: currentModelType, modelType: currentModelType,
AIGC: currentType, AIGC: currentType,
platform: 'runninghub', platform: 'runninghub',
modelName: model.value, modelName: model.value,
modelId: modelId || '', quantity: quantity.value,
modelParams: isPainting ? modelParams : {}, free: useUser.freeTimes,
params: isPainting ? [] : [ params: [
{ name: 'prompt', data: prompt.value}, { name: 'prompt', data: prompt.value},
{ name: 'quantity', data: quantity.value}, { name: 'quantity', data: quantity.value},
{ name: 'proportion', data: proportion.value}, { name: 'proportion', data: proportion.value},
{ name: 'resolution', data: resolution.value}, { name: 'resolution', data: resolution.value},
{ name: 'duration', data: duration.value }, { name: 'duration', data: duration.value}
], ],
imgs, imgs,
request: JSON.stringify(generateData) result: JSON.stringify(generateData)
} }
await generate(data, generateData) await generate(data, generateData)
console.log('生成中', isgerenate.value) console.log('生成中', isgerenate.value)
@ -398,11 +255,8 @@ const fillParamsFromResult = (resultData) => {
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
if (resultData.quantity !== undefined) quantity.value = resultData.quantity if (resultData.quantity !== undefined) quantity.value = resultData.quantity
if (resultData.resolution !== undefined) resolution.value = resultData.resolution if (resultData.resolution !== undefined) resolution.value = resultData.resolution
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
if (resultData.duration !== undefined) duration.value = resultData.duration if (resultData.duration !== undefined) duration.value = resultData.duration
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
} }
defineExpose({ defineExpose({
@ -432,27 +286,27 @@ watch(() => useDisplay.isSubGerenate, (newValue) => {
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => { watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
console.log('模型或类型改变:', newModel, newModelType) console.log('模型或类型改变:', newModel, newModelType)
if (!newModel) return if (newModel && newModelType) {
if (props.type !== 'Painting') { await loadModelConfig(newModel, newModelType)
await loadVideoModelConfig(newModel, newModelType)
} }
}) })
// ""
const prefetchModels = () => {
const code = getPlatformCode(props.type)
fetchPlatformModels(code)
}
watch(() => props.type, (newType) => { watch(() => props.type, (newType) => {
if (newType === 'Video') { if (newType === 'Video') {
model.value = 'LTX2.0' model.value = 'LTX2.0'
} else { } else {
model.value = 'flux' model.value = 'flux'
} }
prefetchModels() const chargeType = newType === 'Painting' ? 1 : 4
useUser.fetchFreeTimes(chargeType)
}, { immediate: true }) }, { immediate: true })
//
onMounted(async () => {
const chargeType = props.type === 'Painting' ? 1 : 4
await useUser.fetchFreeTimes(chargeType)
console.log('免费次数', useUser.freeTimes)
})
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -1,6 +1,6 @@
<template> <template>
<Select <Select
v-model="selectValue" v-model="model"
:grouped-options="modelGroups" :grouped-options="modelGroups"
class="model-select" class="model-select"
position="top" position="top"
@ -13,123 +13,122 @@
<script setup> <script setup>
import Select from '@/components/Select/index.vue' import Select from '@/components/Select/index.vue'
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
import { getModelConfig } from '@/config/models/index.js'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: 'Flux 2' }, modelValue: {
typeValue: { type: String, default: 'text' }, type: String,
default: 'flux'
},
typeValue: {
type: String,
default: 'text'
}
}) })
const emit = defineEmits(['update:modelValue', 'update:typeValue']) const emit = defineEmits(['update:modelValue', 'update:typeValue'])
const platformModels = ref([]) const paintingConfig = ref({
generate: [],
edit: [],
vision: []
})
const categoryMap = [ const fetchConfig = async () => {
{ tag: 'text', label: '生成模型', inputType: 'text' }, try {
{ tag: 'edit', label: '编辑模型', inputType: 'image' }, const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/painting.json`
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' }, const response = await fetch(url)
const data = await response.json()
paintingConfig.value = data
} catch (error) {
console.error('Failed to fetch painting config:', error)
}
}
fetchConfig()
watch(() => paintingConfig.value, (newConfig) => {
const allModels = [
...(newConfig.generate || []),
...(newConfig.edit || []),
...(newConfig.vision || [])
] ]
if (allModels.length > 0) {
function parseValue(encoded) { const enabledModels = allModels.filter(m => !m.disabled)
if (!encoded) return null if (enabledModels.length > 0) {
const idx = encoded.indexOf('::') const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
if (idx === -1) return null if (!currentModelExists) {
return { tag: encoded.substring(0, idx), modelName: encoded.substring(idx + 2) } const firstEnabled = enabledModels[0].value
emit('update:modelValue', firstEnabled)
const newType = getModelType(firstEnabled)
emit('update:typeValue', newType)
} }
function encodeValue(tag, modelName) {
return `${tag}::${modelName}`
} }
}
}, { deep: true })
function findTagForModel(modelName) {
for (const cat of categoryMap) { const model = computed({
const model = platformModels.value.find(m => (m.display_name || m.name) === modelName && m.tags?.includes(cat.tag)) get: () => props.modelValue,
if (model) return cat.tag set: (value) => {
emit('update:modelValue', value)
const newType = getModelType(value)
emit('update:typeValue', newType)
}
})
const generateModels = computed(() => paintingConfig.value.generate || [])
const editModels = computed(() => paintingConfig.value.edit || [])
const visionModels = computed(() => paintingConfig.value.vision || [])
const modelGroups = computed(() => {
return [
{
label: '生成模型',
options: generateModels.value
},
{
label: '编辑模型',
options: editModels.value
},
{
label: '视觉理解模型',
options: visionModels.value
}
]
})
const getModelType = (value) => {
if (generateModels.value.find(m => m.value === value)) {
return 'text'
}
if (editModels.value.find(m => m.value === value)) {
return 'image'
}
if (visionModels.value.find(m => m.value === value)) {
return 'vision'
} }
return 'text' return 'text'
} }
function tagToInputType(tag) { const getFirstEnabledModel = () => {
const cat = categoryMap.find(c => c.tag === tag) const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
return cat?.inputType || 'text' const firstEnabled = allModels.find(m => !m.disabled)
return firstEnabled ? firstEnabled.value : ''
} }
// Select watch(() => props.modelValue, (newValue) => {
const selectValue = computed({ const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
get: () => { const currentModel = allModels.find(m => m.value === newValue)
if (!props.modelValue) return '' if (currentModel && currentModel.disabled) {
const tag = findTagForModel(props.modelValue) const firstEnabled = getFirstEnabledModel()
return encodeValue(tag, props.modelValue)
},
set: (encoded) => {
const parsed = parseValue(encoded)
if (!parsed) return
emit('update:modelValue', parsed.modelName)
emit('update:typeValue', tagToInputType(parsed.tag))
},
})
// API
const loadModels = async () => {
try {
const code = getPlatformCode('Painting')
const models = await fetchPlatformModels(code)
platformModels.value = models || []
} catch (error) {
console.error('加载平台模型列表失败:', error)
}
}
loadModels()
// value tag::displayName
const modelGroups = computed(() => {
const models = platformModels.value
if (models.length === 0) return []
return categoryMap
.filter(cat => models.some(m => m.tags?.includes(cat.tag)))
.map(cat => ({
label: cat.label,
options: models
.filter(m => m.tags?.includes(cat.tag))
.map(m => ({
value: `${cat.tag}::${m.display_name || m.name}`,
label: m.display_name || m.name,
disabled: m.disabled || false,
}))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
}))
})
//
watch(platformModels, (models) => {
if (models.length === 0) return
const currentModel = models.find(m => (m.display_name || m.name) === props.modelValue || m.id === props.modelValue)
if (!currentModel || currentModel.disabled) {
const firstEnabled = models.find(m => !m.disabled)
if (firstEnabled) { if (firstEnabled) {
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name) emit('update:modelValue', firstEnabled)
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name))) const newType = getModelType(firstEnabled)
emit('update:typeValue', newType)
} }
} }
}, { immediate: true }) }, { immediate: true })
// modelValue
watch(() => props.modelValue, (newValue) => {
if (!newValue) return
const models = platformModels.value
if (models.length === 0) return
const currentModel = models.find(m => (m.display_name || m.name) === newValue)
if (currentModel && currentModel.disabled) {
const firstEnabled = models.find(m => !m.disabled)
if (firstEnabled) {
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
}
}
})
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -1,5 +1,5 @@
<template> <template>
<Popover placement="top"> <Popover placement="top" :width="400">
<div class="proportion-container"> <div class="proportion-container">
<div class="section"> <div class="section">
<h3>选择比例</h3> <h3>选择比例</h3>
@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<div v-if="allowCustom" class="section"> <div class="section">
<h3>尺寸(px)</h3> <h3>尺寸(px)</h3>
<div class="size-inputs"> <div class="size-inputs">
<div class="input-group"> <div class="input-group">
@ -85,10 +85,6 @@ const props = defineProps({
{ value: '9:16', label: '9:16' } { value: '9:16', label: '9:16' }
] ]
}, },
allowCustom: {
type: Boolean,
default: true,
},
resolutionOptions: { resolutionOptions: {
type: Array, type: Array,
default: () => [ default: () => [
@ -248,7 +244,6 @@ watch(() => [props.modelValue, props.resolution], () => {
.proportion-container{ .proportion-container{
padding: 20px; padding: 20px;
min-width: 300px;
} }
.section{ .section{
@ -270,7 +265,7 @@ watch(() => [props.modelValue, props.resolution], () => {
.proportion-options{ .proportion-options{
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 8px; justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
background-color: #F8F9FA; background-color: #F8F9FA;
padding: 5px; padding: 5px;
@ -326,13 +321,14 @@ watch(() => [props.modelValue, props.resolution], () => {
} }
.resolution-item{ .resolution-item{
padding: 10px 16px; flex: 1;
padding: 10px;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap; // background: #f5f5f5;
color: #666; color: #666;
&:hover{ &:hover{
@ -354,7 +350,6 @@ watch(() => [props.modelValue, props.resolution], () => {
.input-group{ .input-group{
flex: 1; flex: 1;
min-width: 0;
position: relative; position: relative;
label{ label{
@ -368,7 +363,6 @@ watch(() => [props.modelValue, props.resolution], () => {
} }
input{ input{
box-sizing: border-box;
width: 100%; width: 100%;
height: 36px; height: 36px;
padding: 12px 12px 12px 30px; padding: 12px 12px 12px 30px;

View File

@ -1,5 +1,5 @@
<template> <template>
<Popover placement="top"> <Popover placement="top" :width="400">
<div class="proportion-container"> <div class="proportion-container">
<div class="section"> <div class="section">
<h3>选择比例</h3> <h3>选择比例</h3>
@ -142,7 +142,6 @@ const getProportionStyle = (value) => {
.proportion-container{ .proportion-container{
padding: 20px; padding: 20px;
min-width: 300px;
} }
.section{ .section{

View File

@ -18,10 +18,6 @@ const props = defineProps({
modelValue: { modelValue: {
type: Number, type: Number,
default: 1 default: 1
},
max: {
type: Number,
default: 4
} }
}) })
@ -32,9 +28,12 @@ const quantity = computed({
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
}) })
const quantityOptions = computed(() => const quantityOptions = [
Array.from({ length: props.max }, (_, i) => ({ value: i + 1, label: `${i + 1}` })) { value: 1, label: '1 张' },
) { value: 2, label: '2 张' },
{ value: 3, label: '3 张' },
{ value: 4, label: '4 张' }
]
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -1,51 +0,0 @@
// Flux 2 Dev — 文生图
export default {
name: 'Flux 2',
tag: '文生图',
inputType: 'text',
params: [
{
name: 'prompt',
label: '提示词',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'aspectRatio',
label: '比例',
type: 'select',
default: '1:1',
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2', 'custom'],
ui: 'proportion',
},
{
name: 'customWidth',
label: '宽度',
type: 'number',
default: 1024,
min: 256,
max: 1536,
ui: 'number',
showWhen: { aspectRatio: 'custom' },
},
{
name: 'customHight',
label: '高度',
type: 'number',
default: 1024,
min: 256,
max: 1536,
ui: 'number',
showWhen: { aspectRatio: 'custom' },
},
{
name: 'outputFormat',
label: '输出格式',
type: 'string',
default: 'png',
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
ui: 'hidden',
},
],
}

View File

@ -1,48 +0,0 @@
// GPT-Image-2 I2I — 图片编辑
export default {
name: 'GPT-Image-2 I2I',
tag: '图片编辑',
inputType: 'image',
maxImages: 10,
params: [
{
name: 'imageUrls',
label: '参考图片',
type: 'image',
required: true,
ui: 'imageUpload',
maxCount: 10,
},
{
name: 'prompt',
label: '编辑指令',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'aspectRatio',
label: '比例',
type: 'select',
default: '1:1',
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
ui: 'proportion',
},
{
name: 'resolution',
label: '分辨率',
type: 'select',
default: '2k',
options: ['1k', '2k', '4k'],
ui: 'resolution',
},
{
name: 'quality',
label: '画质',
type: 'select',
default: 'medium',
options: ['low', 'medium', 'high'],
ui: 'select',
},
],
}

View File

@ -1,39 +0,0 @@
// GPT-Image-2 — 文生图
export default {
name: 'GPT-Image-2',
tag: '文生图',
inputType: 'text',
params: [
{
name: 'prompt',
label: '提示词',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'aspectRatio',
label: '比例',
type: 'select',
default: '1:1',
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
ui: 'proportion',
},
{
name: 'resolution',
label: '分辨率',
type: 'select',
default: '2k',
options: ['1k', '2k', '4k'],
ui: 'resolution',
},
{
name: 'quality',
label: '画质',
type: 'select',
default: 'medium',
options: ['low', 'medium', 'high'],
ui: 'select',
},
],
}

View File

@ -1,45 +0,0 @@
// 模型配置注册表 — 按模型名称查找参数 schema
import flux from './flux.js'
import zImage from './z-image.js'
import jimeng from './jimeng.js'
import qwen from './qwen.js'
import gptImage from './gpt-image.js'
import nanoPro from './nano-pro.js'
import qwenEdit from './qwen-edit.js'
import gptImageI2i from './gpt-image-i2i.js'
const configs = {
'Flux 2': flux,
'Z-Image Turbo': zImage,
'即梦4.6': jimeng,
'通义万相2.0': qwen,
'GPT-Image-2': gptImage,
'Nano Pro': nanoPro,
'通义万相2.0 Pro': qwenEdit,
'GPT-Image-2 I2i': gptImageI2i,
}
// API display_name → config key 映射API 返回的 display_name 可能与 config 的 name 不同)
const displayNameMap = {
'flux': 'Flux 2',
'Z-image': 'Z-Image Turbo',
'Jimeng4.6': '即梦4.6',
'QwenImage2.0': '通义万相2.0',
'GPT-image-2': 'GPT-Image-2',
'Banana-Pro': 'Nano Pro',
'QwenImage2.0-Pro': '通义万相2.0 Pro',
'GPT-Image-2': 'GPT-Image-2 I2I',
}
/** 根据模型名称获取参数配置,支持 API display_name 和 config key 两种方式查找 */
export function getModelConfig(modelName) {
if (configs[modelName]) return configs[modelName]
const mappedKey = displayNameMap[modelName]
if (mappedKey && configs[mappedKey]) return configs[mappedKey]
return null
}
/** 获取所有模型配置 */
export function getAllModelConfigs() {
return configs
}

View File

@ -1,33 +0,0 @@
// 即梦 4.6 — 文生图(直接指定宽高像素)
export default {
name: '即梦4.6',
tag: '文生图',
inputType: 'text',
params: [
{
name: 'prompt',
label: '提示词',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'width',
label: '宽度',
type: 'number',
default: 1024,
min: 900,
max: 6197,
ui: 'number',
},
{
name: 'height',
label: '高度',
type: 'number',
default: 1024,
min: 768,
max: 4096,
ui: 'number',
},
],
}

View File

@ -1,40 +0,0 @@
// Nano Pro — 图片编辑
export default {
name: 'Nano Pro',
tag: '图片编辑',
inputType: 'image',
maxImages: 10,
params: [
{
name: 'imageUrls',
label: '参考图片',
type: 'image',
required: true,
ui: 'imageUpload',
maxCount: 10,
},
{
name: 'prompt',
label: '编辑指令',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'aspectRatio',
label: '比例',
type: 'select',
default: '1:1',
options: ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
ui: 'proportion',
},
{
name: 'resolution',
label: '分辨率',
type: 'select',
default: '2k',
options: ['1k', '2k', '4k'],
ui: 'resolution',
},
],
}

View File

@ -1,46 +0,0 @@
// 通义万相 2.0 Pro — 图片编辑
export default {
name: '通义万相2.0 Pro',
tag: '图片编辑',
inputType: 'image',
maxImages: 3,
params: [
{
name: 'imageUrls',
label: '参考图片',
type: 'image',
required: true,
ui: 'imageUpload',
maxCount: 3,
},
{
name: 'prompt',
label: '提示词',
type: 'string',
default: '',
ui: 'textarea',
},
{
name: 'size',
label: '分辨率',
type: 'select',
default: '1024*1024',
options: [
'1024*1024', '1536*1536',
'768*1152', '1024*1536', '1152*768', '1536*1024',
'960*1280', '1080*1440', '1280*960', '1440*1080',
'720*1280', '1080*1920', '1280*720', '1920*1080',
'1344*576', '2048*872',
],
ui: 'select',
},
{
name: 'imageNum',
label: '生成张数',
type: 'select',
default: 1,
options: [1, 2, 3, 4, 5, 6],
ui: 'quantity',
},
],
}

View File

@ -1,38 +0,0 @@
// 通义万相 2.0 — 文生图
export default {
name: '通义万相2.0',
tag: '文生图',
inputType: 'text',
maxImages: 6,
params: [
{
name: 'prompt',
label: '提示词',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'size',
label: '分辨率',
type: 'select',
default: '1024*1024',
options: [
'1024*1024', '1536*1536',
'768*1152', '1024*1536', '1152*768', '1536*1024',
'960*1280', '1080*1440', '1280*960', '1440*1080',
'720*1280', '1080*1920', '1280*720', '1920*1080',
'1344*576', '2048*872',
],
ui: 'select',
},
{
name: 'imageNum',
label: '生成张数',
type: 'select',
default: 1,
options: [1, 2, 3, 4, 5, 6],
ui: 'quantity',
},
],
}

View File

@ -1,31 +0,0 @@
// Z-Image Turbo — 文生图
export default {
name: 'Z-Image Turbo',
tag: '文生图',
inputType: 'text',
params: [
{
name: 'prompt',
label: '提示词',
type: 'string',
required: true,
ui: 'textarea',
},
{
name: 'aspectRatio',
label: '比例',
type: 'select',
default: '1:1',
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2'],
ui: 'proportion',
},
{
name: 'outputFormat',
label: '输出格式',
type: 'string',
default: 'png',
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
ui: 'hidden',
},
],
}

View File

@ -4,6 +4,7 @@ import {
getUserInfo as getUserInfoApi, getUserInfo as getUserInfoApi,
logout as logoutApi logout as logoutApi
} from '@/apis/auth' } from '@/apis/auth'
import { getFreeTimes } from '@/apis/display'
import { clearToken, getToken, setToken } from '@/utils/auth' import { clearToken, getToken, setToken } from '@/utils/auth'
const storeSetup = () => { const storeSetup = () => {
@ -33,6 +34,7 @@ const storeSetup = () => {
const dept = ref({}) // 当前用户所在部门集合 const dept = ref({}) // 当前用户所在部门集合
const isLogin = ref(false) const isLogin = ref(false)
const freeTimes = ref(0) // 免费次数
// 重置token // 重置token
const resetToken = () => { const resetToken = () => {
@ -43,40 +45,49 @@ const storeSetup = () => {
// 检查token有效性 // 检查token有效性
const checkTokenValid = async () => { const checkTokenValid = async () => {
const res = await checkUsertokenApi() const res = await checkUsertokenApi()
console.log('checkTokenValid:', res) console.log('checkTokenValid:', res) // 打印响应数据以进行调试
if (res.code === '401' || res.status === '401' || res.success === false) { if (res.code === '401' || res.success === false) {
console.error('Token is invalid:', res.message) // 检查响应数据是否存在,以避免空响应导致的错误
console.error('Token is invalid:', res.message)// 打印错误信息以进行调试
return false return false
} }
console.log('Token is valid') console.log('Token is valid') // 打印成功信息以进行调试
return true return true
} }
// 获取用户信息 // 获取用户信息
const getInfo = async () => { const getInfo = async () => {
const res = await getUserInfoApi() const res = await getUserInfoApi()
// 兼容新旧格式:新格式 data.userInfo 嵌套,旧格式 data 扁平 Object.assign(userInfo, res.data)
const u = res.data.userInfo || res.data // userInfo.avatar = getAvatar(res.data.avatar, res.data.gender)
Object.assign(userInfo, u) userInfo.username = res.data.username
userInfo.id = u.userId || u.id if (typeof res.data.routers === 'string' && res.data.routers.trim() !== '') {
userInfo.username = u.userName || u.username userInfo.routers = res.data.routers.split(',').map((item) => item.trim()) // 补充trim处理更完善
if (typeof u.routers === 'string' && u.routers.trim() !== '') {
userInfo.routers = u.routers.split(',').map((item) => item.trim())
} else { } else {
userInfo.routers = [] userInfo.routers = []
} }
// 角色和权限在 data 层级(非 userInfo 内) if (res.data.roles && res.data.roles.length) {
const roleList = res.data.roles || u.roles roles.value = res.data.roles
if (roleList?.length) { permissions.value = res.data.permissions
roles.value = roleList
permissions.value = res.data.permissions || u.permissions || []
} }
} }
// 获取免费次数
const fetchFreeTimes = async (chargeType = 1) => {
if (userInfo.id) {
const res = await getFreeTimes(userInfo.id)
const balanceList = res.data || []
const target = balanceList.find((item) => item.chargeType === chargeType)
freeTimes.value = target?.balance || 0
return freeTimes.value
}
return 0
}
// 登录 // 登录
const accountLogin = async (req) => { const accountLogin = async (req) => {
const res = await accountLoginApi(req) const res = await accountLoginApi(req)
if (res.data == null || res.code === '500' || res.status === 500 || res.success === false) { if (res.data == null || res.code === '500' || res.success === false) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
ElMessage({ ElMessage({
title: '提示', title: '提示',
@ -123,12 +134,14 @@ const storeSetup = () => {
dept, dept,
username, username,
isLogin, isLogin,
freeTimes,
accountLogin, accountLogin,
logout, logout,
logoutCallBack, logoutCallBack,
getInfo, getInfo,
resetToken, resetToken,
checkTokenValid checkTokenValid,
fetchFreeTimes
} }
} }

View File

@ -1,15 +1,22 @@
import outPlatform from '@/config/index' import outPlatform from '@/config/index'
// 构造任务 body // 处理音频生成任务的数据并返回
export async function createTask(data) { export async function createTask(data, taskId, token) {
// Painting 使用新架构:直接使用动态模型参数 console.log(data)
if (data.type === 'Painting') {
return data.modelParams || {}
}
// Video 继续使用旧 workflow 适配器
const payload = await outPlatform[data.platform].Playload(data) const payload = await outPlatform[data.platform].Playload(data)
return payload
return {
AIGC: data.AIGC,
platform: data.platform,
taskType: data.modelType === 'text' ? 1 : 2,
modelName: data.modelName,
payload,
taskId,
token,
quantity: data.quantity,
free: data.free,
result: data.result
}
} }
// 获取结果 // 获取结果

View File

@ -1,110 +0,0 @@
import { fetchPlatformModels as fetchModelsRaw } from '@/apis/display'
const CACHE_PREFIX = 'platform_models_'
const CACHE_TTL = 30 * 1000 // 30秒有效期
function getCacheKey(code) {
return `${CACHE_PREFIX}${code}`
}
function getFromCache(code) {
try {
const key = getCacheKey(code)
const stored = localStorage.getItem(key)
if (!stored) return null
const data = JSON.parse(stored)
// 清理旧格式缓存(无 timestamp 字段)或过期缓存
if (!data.timestamp || Date.now() - data.timestamp > CACHE_TTL) {
localStorage.removeItem(key)
return null
}
return data.models
} catch {
return null
}
}
function saveToCache(code, models) {
try {
const key = getCacheKey(code)
localStorage.setItem(key, JSON.stringify({
models,
timestamp: Date.now()
}))
} catch (error) {
console.error('保存模型列表缓存失败:', error)
}
}
// 类型 → 平台编码映射
export function getPlatformCode(type) {
switch (type) {
case 'Painting':
return 'ai_painting_talk'
case 'Video':
return 'ai_video_talk'
default:
return 'ai_painting_talk'
}
}
const pendingRequests = new Map() // 并发请求去重
// 获取平台模型列表localStorage 缓存30秒有效
export async function fetchPlatformModels(code) {
const cached = getFromCache(code)
if (cached) {
return cached
}
// 已有进行中的请求则复用,避免并发重复请求
if (pendingRequests.has(code)) {
return pendingRequests.get(code)
}
const promise = (async () => {
try {
const result = await fetchModelsRaw(code)
if (result.code === 0 && result.data?.models) {
saveToCache(code, result.data.models)
return result.data.models
}
console.error('获取模型列表失败:', result.message)
return []
} catch (error) {
console.error('获取模型列表失败:', error)
return []
} finally {
pendingRequests.delete(code)
}
})()
pendingRequests.set(code, promise)
return promise
}
// 根据模型名称查找 model_id
export async function getModelId(type, modelName) {
if (!modelName) return ''
const code = getPlatformCode(type)
const models = await fetchPlatformModels(code)
const found = models.find(m => m.name === modelName || m.display_name === modelName)
return found?.id || ''
}
// 清除所有平台模型缓存
export function clearPlatformModelCache() {
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith(CACHE_PREFIX)) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
}

View File

@ -29,19 +29,21 @@ const StatusCodeMessage = {
service.interceptors.request.use( service.interceptors.request.use(
(config) => { (config) => {
const token = getToken() const token = getToken()
if (token) {
if (!config.headers) { if (!config.headers) {
config.headers = {} config.headers = {}
} }
config.headers.Authorization = `Bearer ${token}`
// 统一 Auth 头不带 Bearer 前缀 }
if (token) config.headers.Authorization = token // console.log(config.baseURL)
if (config.url?.startsWith(import.meta.env.VITE_API_PAY_PREFIX)) { // 支付服务路由
if (config.url?.startsWith(import.meta.env.VITE_API_TASK_PREFIX)) { // 算力调度后端
config.baseURL = import.meta.env.VITE_API_TASK_TARGET
} else if (config.url?.startsWith(import.meta.env.VITE_API_PAY_PREFIX)) { // 支付服务路由
config.baseURL = import.meta.env.VITE_API_PAY_TARGET config.baseURL = import.meta.env.VITE_API_PAY_TARGET
} else if (config.url?.startsWith(import.meta.env.VITE_API_AIGC_PREFIX)) { // 资源服务路由 } else if (config.url?.startsWith(import.meta.env.VITE_API_AIGC_PREFIX)) { // 资源服务路由
// config.url = config.url.replace(import.meta.env.VITE_API_AIGC_PREFIX, '')
config.baseURL = import.meta.env.VITE_API_AIGC_TARGET config.baseURL = import.meta.env.VITE_API_AIGC_TARGET
} else if (config.url?.startsWith(import.meta.env.VITE_API_MUSIC_WORKFLOW_PREFIX)) { // 音频生成平台工作流服务路由
config.url = config.url.replace(import.meta.env.VITE_API_MUSIC_WORKFLOW_PREFIX, '')
config.baseURL = import.meta.env.VITE_API_MUSIC_WORKFLOW_TARGET
} }
return config return config
}, },
@ -55,11 +57,11 @@ service.interceptors.request.use(
service.interceptors.response.use( service.interceptors.response.use(
(response) => { (response) => {
const { data } = response const { data } = response
const { success, code, status, msg, message } = data const { success, code, msg } = data
if (success || code === 0 || status === 0) { if (success || code === 0) {
console.log('msg: \n', msg) console.log('msg: \n', msg)
return response.data return response.data
} else if (code === 401 && response.config.url !== '/login/validateToken`') { // 判断code=401时进行页面刷新但是不对检验token这个路由的请求判断防止出现死循环 } else if (code === 401 && response.config.url !== '/auth/check/token`') { // 判断code=401时进行页面刷新但是不对检验token这个路由的请求判断防止出现死循环
userError() userError()
} }
console.log('CodeMessage: \n', StatusCodeMessage[code]) console.log('CodeMessage: \n', StatusCodeMessage[code])

View File

@ -1,9 +1,9 @@
import { ElNotification } from 'element-plus' import { ElNotification } from 'element-plus'
import { h } from 'vue' import { h, ref } from 'vue'
import { useDisplayStore, useUserStore } from '@/stores' import { useDisplayStore, useUserStore } from '@/stores'
import { createTask } from '@/utils/createTask' import { getToken } from '@/utils/auth'
import { createTask, getTask } from '@/utils/createTask'
import { userError } from '@/utils/tokenError' import { userError } from '@/utils/tokenError'
import { requestCreateTask, requestTaskStatus } from '@/apis/display'
export function getChargeType(chargeType) { export function getChargeType(chargeType) {
switch (chargeType) { switch (chargeType) {
@ -23,13 +23,13 @@ export function websocketError(code, msg) {
message = '用户身份验证失败' message = '用户身份验证失败'
userError() userError()
break break
case 4401: case 4401: // 后端返回常规错误
message = msg message = msg
break break
case 4402: case 4402: // 后端返回外部平台提交时的错误
message = JSON.parse(msg) message = JSON.parse(msg)
break break
case 4403: case 4403: // 外部平台的任务结果的错误
message = msg message = msg
break break
default: default:
@ -38,13 +38,15 @@ export function websocketError(code, msg) {
ElNotification({ ElNotification({
title: '生成失败', title: '生成失败',
message: h('i', { style: 'color: teal' }, message), message: h('i', { style: 'color: teal' }, message),
type: 'error', type: 'error',
duration: 6000 duration: 6000 // 增加持续时间以适应更多信息
}) })
} }
export function websocketSuccess() { export function websocketSuccess() {
// 合并两个通知为一个
ElNotification({ ElNotification({
title: '生成成功', title: '生成成功',
message: h('div', [ message: h('div', [
@ -53,140 +55,135 @@ export function websocketSuccess() {
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态请及时下载生成的文件云端储存与历史记录保留24小时') h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态请及时下载生成的文件云端储存与历史记录保留24小时')
]), ]),
type: 'success', type: 'success',
duration: 6000 duration: 6000 // 增加持续时间以适应更多信息
}) })
} }
// 当前活跃的轮询定时器集合,用于页面卸载时清理
const activePollIntervals = new Set()
export async function generate(data, generateData) { export async function generate(data, generateData) {
const progress_text = ref('')
const message = ref('')
const useDisplay = useDisplayStore() const useDisplay = useDisplayStore()
let taskId = null const token = getToken()
let pollInterval = null const taskId = crypto.randomUUID()
let currentTaskId = null
if (!data.modelId) {
ElNotification({
title: '生成失败',
message: h('i', { style: 'color: teal' }, '未找到模型ID请联系管理员配置'),
type: 'error'
})
return
}
useDisplay.isSubGerenate = true useDisplay.isSubGerenate = true
// 从登录态获取 sessionId const result = await createTask(data, taskId, token)
const sessionId = useUserStore().userInfo.sessionId console.log(result)
if (!sessionId) { // const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0'
ElNotification({ const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}`
title: '生成失败', const socket = new WebSocket(wsURL)
message: h('i', { style: 'color: teal' }, '用户身份已过期,请重新登录'), console.log('WebSocket连接已建立')
type: 'error'
}) // 心跳机制相关变量
useDisplay.isSubGerenate = false let heartbeatInterval = null
return const heartbeatIntervalTime = 20000 // 30秒发送一次心跳
}
try { try {
// 通过 createTask 获取 body 内容RunningHub workflow payload // 接收服务器消息
const body = await createTask(data) socket.onmessage = async (event) => {
// 处理pong响应
// 构造请求体 if (event.data === 'pong') {
const requestBody = { console.log('收到心跳响应')
model_id: data.modelId,
body,
request: data.request
}
// POST 创建任务
const createResult = await requestCreateTask(requestBody, sessionId)
if (createResult.code !== 0) {
ElNotification({
title: '生成失败',
message: h('i', { style: 'color: teal' }, createResult.message || '任务创建失败'),
type: 'error'
})
useDisplay.isSubGerenate = false
return return
} } else if (event.data === 'please give me taskId') {
socket.send(`setTaskId:${taskId}`)
progress_text.value = '信息提交中...'
return
} else if (event.data === 'OK! Please continue. ') {
socket.send(JSON.stringify({
type: 'generate',
data: result
}))
return
} else if (event.data === '任务提交成功,正在排队中...') {
progress_text.value = '视频生成中...'
currentTaskId = taskId
taskId = createResult.data.task_id
// 在列表中插入"生成中"条目
useDisplay.addGeneratingItem({ useDisplay.addGeneratingItem({
taskId, taskId: taskId,
type: data.type, type: data.type,
generateData generateData: generateData
}) })
setTimeout(() => { setTimeout(() => {
useDisplay.scrollToBottom() useDisplay.scrollToBottom()
}, 100) }, 100)
return
// 轮询任务状态
const pollTask = async () => {
try {
const pollResult = await requestTaskStatus(taskId)
if (pollResult.code !== 0) return
const taskData = pollResult.data
if (taskData.status === 'completed') {
clearInterval(pollInterval)
activePollIntervals.delete(pollInterval)
useDisplay.isSubGerenate = false
// 提取结果 URL
const urls = taskData.outputs?.map(img => img.url) || []
if (urls.length > 0) {
useDisplay.updateItemToSuccess(taskId, urls)
websocketSuccess()
} else {
websocketError(4403, '未获取到生成结果')
}
} else if (taskData.status === 'failed') {
clearInterval(pollInterval)
activePollIntervals.delete(pollInterval)
useDisplay.isSubGerenate = false
websocketError(4403, taskData.vendor_error || '生成失败')
}
// queued / processing 状态继续轮询
} catch (error) {
console.error('轮询任务状态失败:', error)
} }
message.value = event.data
} }
// 每 20 秒轮询一次 // 处理链接错误
pollInterval = setInterval(pollTask, 20000) socket.onerror = (error) => {
activePollIntervals.add(pollInterval) console.error('WebSocket链接出错:', error)
// 5 秒后先做第一次轮询 // 清理心跳定时器
setTimeout(pollTask, 5000) if (heartbeatInterval) {
clearInterval(heartbeatInterval)
}
} catch (error) { // eslint-disable-next-line no-undef
console.error('创建任务失败:', error)
useDisplay.isSubGerenate = false
ElNotification({ ElNotification({
title: '生成通知', title: '生成通知',
// eslint-disable-next-line no-undef
message: h('i', { style: 'color: teal' }, '生成视频失败'),
type: 'error'
})
}
// 处理链接关闭
socket.onclose = async (event) => {
console.log('WebSocket已关闭:', event)
useDisplay.isSubGerenate = false
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
}
const res = JSON.parse(message.value)
if (event.code === 1006) {
console.error('用户身份验证失败')
userError()
} else if (event.code === 1000 && event.reason === 'success') {
console.log('收到服务器消息:', res)
const result = await getTask(res)
if(useUserStore().freeTimes) await useUserStore().fetchFreeTimes()
if (result.type) {
if (currentTaskId) {
useDisplay.updateItemToSuccess(currentTaskId, result.urls)
}
websocketSuccess()
} else {
websocketError(4403, result.message)
}
} else {
websocketError(event.code, event.reason)
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
}
}
// 等待 WebSocket 连接打开
socket.onopen = () => {
console.log('WebSocket连接已建立')
// 启动心跳机制
heartbeatInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send('ping')
console.log('发送心跳包')
}
}, heartbeatIntervalTime)
}
} catch (error) {
console.log('Error creating AI3D_file:', error)
// eslint-disable-next-line no-undef
ElNotification({
title: '生成通知',
// eslint-disable-next-line no-undef
message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'), message: h('i', { style: 'color: teal' }, '生成失败,请检查参数后重新提交任务'),
type: 'error' type: 'error'
}) })
if (pollInterval) {
clearInterval(pollInterval)
activePollIntervals.delete(pollInterval)
} }
} }
}
// 页面卸载时清理所有轮询
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
for (const interval of activePollIntervals) {
clearInterval(interval)
}
activePollIntervals.clear()
})
}

View File

@ -73,10 +73,9 @@ import RefreshOverlay from './components/RefreshOverlay.vue'
import Select from '@/components/Select/index.vue' import Select from '@/components/Select/index.vue'
import { VirtualScroller } from '@/components/virtual-scroller' import { VirtualScroller } from '@/components/virtual-scroller'
import Canvas from '@/components/canvas/index.vue' import Canvas from '@/components/canvas/index.vue'
import { requestTaskHistory } from '@/apis/display' import { getGenerateHistoryList } from '@/apis/display'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getChargeType } from '@/utils/websocket' import { getChargeType } from '@/utils/websocket'
import { getPlatformCode } from '@/utils/modelApi'
const props = defineProps({ const props = defineProps({
if: { if: {
@ -164,11 +163,12 @@ const fetchHistory = async (isLoadMore = false) => {
try { try {
const pageToFetch = isLoadMore ? currentPage.value + 1 : 1 const pageToFetch = isLoadMore ? currentPage.value + 1 : 1
const result = await requestTaskHistory({ const result = await getGenerateHistoryList({
user_id: userStore.userInfo.id, userId: userStore.userInfo.id,
platform_code: getPlatformCode(props.type), chargeType: chargeType.value,
page: pageToFetch, page: pageToFetch,
pageSize: 10 size: 10,
sort: 'createTime,desc'
}) })
const dataList = result.data?.list || result.data || [] const dataList = result.data?.list || result.data || []