重命名 websocket.js 为 taskPolling.js,消除误导性命名;修复比例组件 Popover 宽度问题
- websocket.js → taskPolling.js:文件名不再暗示 WebSocket,准确反映 HTTP 轮询机制 - 删除过期备份文件 websocket copy.js - painting.vue 声明 width/height props,拦截 $attrs 穿透,修复 Popover 宽度 = 尺寸值的 bug - Popover contentStyle:width:auto → fit-content + max-width:600px,彻底解决 fixed 定位宽度异常 - 比例子项 flex:1 + gap:5px 替代 space-between,间距恒定不受选项数量影响 - CLAUDE.md 补充 Select/Img 组件、dialogBox 编排中心、$attrs 穿透陷阱等文档
This commit is contained in:
parent
16d1496283
commit
b81c1f858e
49
CLAUDE.md
49
CLAUDE.md
@ -31,36 +31,44 @@ src/
|
||||
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
|
||||
├── router/index.js # 路由定义 + token 验证守卫
|
||||
├── stores/ # Pinia 状态管理
|
||||
│ ├── user.js # 用户认证、信息(含 sessionId)
|
||||
│ ├── user.js # 用户认证、信息(含 sessionId),使用 pinia persist 持久化 token
|
||||
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||
│ └── param.js # 参数 store(当前为空)
|
||||
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息)
|
||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息、验证码)
|
||||
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
||||
├── components/
|
||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口)
|
||||
│ │ ├── index.vue # 编排中心:组装所有控件,处理 handleStart()、模型配置加载、参数回填
|
||||
│ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组,value 编码为 tag::display_name)
|
||||
│ │ ├── proportion/ # 比例/分辨率选择器(painting.vue 用于 Painting,video.vue 用于 Video)
|
||||
│ │ ├── imageUploader/ # 图片上传(Painting)
|
||||
│ │ ├── videoImageUploader/ # 视频图片上传(Video)
|
||||
│ │ ├── quantity/ # 生成数量选择器(支持 1-6)
|
||||
│ │ ├── quantity/ # 生成数量选择器(支持 1-6,上限由模型配置派生)
|
||||
│ │ ├── Time/ # 视频时长选择器
|
||||
│ │ └── pattern/ # 视频模式选择器
|
||||
│ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度)
|
||||
│ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
|
||||
│ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现)
|
||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo)
|
||||
├── views/ # 页面(home、login)
|
||||
├── utils/
|
||||
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||
│ ├── websocket.js # 任务生成入口:组装参数 → POST 创建任务 → 20s 轮询直至完成/失败
|
||||
│ ├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 20s HTTP 轮询直至完成/失败
|
||||
│ ├── modelApi.js # 模型业务层:localStorage 30s 缓存 + pendingRequests 并发去重 + 平台编码映射
|
||||
│ ├── createTask.js # 任务 body 构造:Painting 返回 modelParams,Video 走 Playload 适配器
|
||||
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置
|
||||
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置(含 localStorage 每日缓存)
|
||||
│ ├── downloadImage.js # 图片/视频下载:fetch → Blob → 自动文件名下载
|
||||
│ ├── tokenError.js # 认证失败处理:提示后 5 秒刷新页面
|
||||
│ ├── encrypt.ts # 加密工具(Base64/MD5/RSA/AES,依赖 crypto-js、jsencrypt)
|
||||
│ └── auth.ts # token 存取工具(localStorage)
|
||||
├── config/
|
||||
│ ├── plugins.js # Vite 插件配置(unplugin-auto-import + unplugin-vue-components)
|
||||
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
||||
│ ├── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析(Video 专用)
|
||||
│ └── models/ # Painting 模型参数配置:每模型一个 JS 文件,定义 params schema
|
||||
│ ├── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型
|
||||
│ └── modelConfig/ # 静态模型列表:painting.json(按 tag 分组)、video.json(按 pattern 分组)
|
||||
```
|
||||
|
||||
### 模型参数配置(Painting 新架构)
|
||||
@ -68,8 +76,8 @@ src/
|
||||
`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载:
|
||||
|
||||
- **`ui: 'textarea'`** → Sender 组件主输入框(prompt)
|
||||
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,含自定义 W/H)
|
||||
- **`ui: 'quantity'`** → `Quantity` 组件(动态 1-6 张)
|
||||
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,options 可含 `custom` 开启自定义 W/H)
|
||||
- **`ui: 'quantity'`** → `Quantity` 组件(动态上限由 model config 的 `options` 数组最大值派生)
|
||||
- **`ui: 'imageUpload'`** → `ImageUploader` 组件
|
||||
- **`ui: 'hidden'`** → 无 UI,仅写入默认值(如 outputFormat: 'png')
|
||||
|
||||
@ -79,6 +87,20 @@ src/
|
||||
|
||||
`src/config/models/index.js` 中 `displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2` 和 `GPT-image-2` 分别对应编辑/生成),config key 采用内部中文名区分。
|
||||
|
||||
### dialogBox 编排中心
|
||||
|
||||
`src/components/dialogBox/index.vue` 是核心编排组件,负责:
|
||||
|
||||
- **模型选择切换**:监听 `model` + `modelType` 变化,调用 `loadModelConfig()` 加载模型参数 schema
|
||||
- **派生 UI 配置**:从 model config 计算 `paintingProportionOpts`(滤除 `custom` 选项)、`paintingResolutionOpts`、`hasCustomSize`(是否显示自定义尺寸)、`quantityMax`
|
||||
- **状态管理**:`customWidth`/`customHight` 通过 `v-model:width`/`v-model:height` 与 `paintingProportion` 双向绑定
|
||||
- **参数回填**:`fillParamsFromResult()` 供历史记录重编辑使用
|
||||
- **任务发起**:`handleStart()` 收集所有参数 → 构造 `data` → 调用 `taskPolling.js:generate()`
|
||||
|
||||
### `$attrs` 穿透注意
|
||||
|
||||
向子组件传递的 prop 如果子组件未声明,会通过 `$attrs` 穿透到根元素。例如 `dialogBox` 向 `paintingProportion` 传递 `v-model:width="customWidth"`,若 `paintingProportion` 未声明 `width` prop,值会穿透到 `<Popover>` 的 `width` prop,导致异常宽度。**所有通过 `v-model` 传递的值,子组件必须声明对应的 prop。**
|
||||
|
||||
### API 层设计原则
|
||||
|
||||
- `src/apis/` 只做纯 HTTP 调用(`service.get/post/delete`),不含缓存、localStorage、业务逻辑
|
||||
@ -88,9 +110,9 @@ src/
|
||||
|
||||
**Painting(新架构):**
|
||||
|
||||
1. 用户设置参数 → 模型选择器按 `tags` 分组,控件根据 model config 的 `ui` 字段渲染
|
||||
2. `handleStart()` 收集 `paramValues`(UI refs 通过 watcher 双向同步)→ 组装 `{ modelParams, request }`
|
||||
3. `websocket.js:generate()` → `createTask(data)` → Painting 直接返回 `data.modelParams`
|
||||
1. 用户设置参数 → `dialogBox` 从 model config 派生 proportion/resolution 选项、hasCustomSize、quantityMax
|
||||
2. `handleStart()` 收集参数 → 组装 `{ modelParams, request }`
|
||||
3. `taskPolling.js:generate()` → `createTask(data)` → Painting 直接返回 `data.modelParams`
|
||||
4. `getModelId(type, modelName)` 查找 UUID(内部调用 `fetchPlatformModels` 走缓存)
|
||||
5. `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`,携带 `X-Session-Id` header
|
||||
6. 返回 task_id → 20s 间隔轮询直至完成/失败
|
||||
@ -103,9 +125,10 @@ src/
|
||||
|
||||
### 关键注意事项
|
||||
|
||||
- **`sessionId`** 来自登录接口返回的 `userInfo.sessionId`,存储在 `useUserStore().userInfo` 中。`websocket.js` 必须使用该值,禁止随机生成。
|
||||
- **`sessionId`** 来自登录接口返回的 `userInfo.sessionId`,存储在 `useUserStore().userInfo` 中。`taskPolling.js` 必须使用该值,禁止随机生成。
|
||||
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
|
||||
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重,避免重复请求。
|
||||
- **页面加载预请求**:平台模型列表在 `dialogBox onMounted` 时预请求,避免首次点击"发送"时才触发。
|
||||
|
||||
### 接口速查
|
||||
|
||||
|
||||
@ -55,11 +55,12 @@ if (!window.__currentOpenPopoverId__) {
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
const w = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||
const isAuto = w === 'auto'
|
||||
return {
|
||||
...position.value,
|
||||
width: w,
|
||||
maxWidth: w === 'auto' ? 'none' : w,
|
||||
minWidth: w === 'auto' ? '0' : w
|
||||
width: isAuto ? 'fit-content' : w,
|
||||
maxWidth: isAuto ? '600px' : w,
|
||||
minWidth: isAuto ? '0' : w
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -160,7 +160,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import request from '@/utils/request'
|
||||
import { getModelId } from '@/utils/modelApi'
|
||||
|
||||
@ -88,7 +88,7 @@ import paintingProportion from './proportion/painting.vue'
|
||||
import Quantity from './quantity/index.vue'
|
||||
import { Sender } from 'vue-element-plus-x'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getModelId, fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
|
||||
@ -73,6 +73,14 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '2k'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 2048
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 2048
|
||||
},
|
||||
proportionOptions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
@ -111,8 +119,8 @@ const resolution = computed({
|
||||
set: (value) => emit('update:resolution', value)
|
||||
})
|
||||
|
||||
const width = ref(2048)
|
||||
const height = ref(2048)
|
||||
const width = ref(props.width)
|
||||
const height = ref(props.height)
|
||||
const isLocked = ref(true)
|
||||
|
||||
const toggleLock = () => {
|
||||
@ -270,7 +278,7 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
.proportion-options{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
margin-bottom: 16px;
|
||||
background-color: #F8F9FA;
|
||||
padding: 5px;
|
||||
@ -279,12 +287,12 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
|
||||
.proportion-item{
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
@ -326,13 +334,13 @@ watch(() => [props.modelValue, props.resolution], () => {
|
||||
}
|
||||
|
||||
.resolution-item{
|
||||
padding: 10px 16px;
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
color: #666;
|
||||
|
||||
&:hover{
|
||||
|
||||
@ -164,7 +164,7 @@ const getProportionStyle = (value) => {
|
||||
.proportion-options{
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
margin-bottom: 16px;
|
||||
background-color: #F8F9FA;
|
||||
padding: 5px;
|
||||
@ -173,12 +173,12 @@ const getProportionStyle = (value) => {
|
||||
|
||||
.proportion-item{
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { h, ref } from 'vue'
|
||||
import { useDisplayStore, useUserStore } from '@/stores'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { createTask, getTask } from '@/utils/createTask'
|
||||
import { userError } from '@/utils/tokenError'
|
||||
|
||||
export function getChargeType(chargeType) {
|
||||
switch (chargeType) {
|
||||
case 'Painting':
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
export function websocketError(code, msg) {
|
||||
let message
|
||||
switch (code) {
|
||||
case 1006:
|
||||
message = '用户身份验证失败'
|
||||
userError()
|
||||
break
|
||||
case 4401: // 后端返回常规错误
|
||||
message = msg
|
||||
break
|
||||
case 4402: // 后端返回外部平台提交时的错误
|
||||
message = JSON.parse(msg)
|
||||
break
|
||||
case 4403: // 外部平台的任务结果的错误
|
||||
message = msg
|
||||
break
|
||||
default:
|
||||
message = '连接异常,请稍后重试'
|
||||
}
|
||||
|
||||
ElNotification({
|
||||
title: '生成失败',
|
||||
|
||||
message: h('i', { style: 'color: teal' }, message),
|
||||
type: 'error',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export function websocketSuccess() {
|
||||
// 合并两个通知为一个
|
||||
ElNotification({
|
||||
title: '生成成功',
|
||||
message: h('div', [
|
||||
h('div', { style: 'font-weight: bold; color: teal;' }, '生成成功!'),
|
||||
h('br'),
|
||||
h('div', { style: 'color: orange; margin-top: 5px;' }, '内测状态,请及时下载生成的文件,云端储存与历史记录保留24小时!')
|
||||
]),
|
||||
type: 'success',
|
||||
duration: 6000 // 增加持续时间以适应更多信息
|
||||
})
|
||||
}
|
||||
|
||||
export async function generate(data, generateData) {
|
||||
const progress_text = ref('')
|
||||
const message = ref('')
|
||||
const useDisplay = useDisplayStore()
|
||||
const token = getToken()
|
||||
const taskId = crypto.randomUUID()
|
||||
let currentTaskId = null
|
||||
|
||||
useDisplay.isSubGerenate = true
|
||||
|
||||
const result = await createTask(data, taskId, token)
|
||||
console.log(result)
|
||||
// const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjY0NDEwODAyMjk1OTgzNzIzMCwicm5TdHIiOiJiWkVwS2JLWFJyZmRIaFFHWXZKTkdzOGdGM0JSRmxQOCJ9.5eQ2GtVdrDntQDe2tnF8vl_DhTfd2uW-KNqzvl1imc0'
|
||||
const wsURL = `${import.meta.env.VITE_API_WORKFLOW_WS}/?token=Bearer ${token}`
|
||||
const socket = new WebSocket(wsURL)
|
||||
console.log('WebSocket连接已建立')
|
||||
|
||||
// 心跳机制相关变量
|
||||
let heartbeatInterval = null
|
||||
const heartbeatIntervalTime = 20000 // 30秒发送一次心跳
|
||||
|
||||
try {
|
||||
// 接收服务器消息
|
||||
socket.onmessage = async (event) => {
|
||||
// 处理pong响应
|
||||
if (event.data === 'pong') {
|
||||
console.log('收到心跳响应')
|
||||
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
|
||||
|
||||
useDisplay.addGeneratingItem({
|
||||
taskId: taskId,
|
||||
type: data.type,
|
||||
generateData: generateData
|
||||
})
|
||||
setTimeout(() => {
|
||||
useDisplay.scrollToBottom()
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
message.value = event.data
|
||||
}
|
||||
|
||||
// 处理链接错误
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket链接出错:', error)
|
||||
|
||||
// 清理心跳定时器
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
ElNotification({
|
||||
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' }, '生成失败,请检查参数后重新提交任务'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -75,7 +75,7 @@ import { VirtualScroller } from '@/components/virtual-scroller'
|
||||
import Canvas from '@/components/canvas/index.vue'
|
||||
import { requestTaskHistory } from '@/apis/display'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getChargeType } from '@/utils/websocket'
|
||||
import { getChargeType } from '@/utils/taskPolling'
|
||||
import { getPlatformCode } from '@/utils/modelApi'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user