重命名 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:
王佑琳 2026-06-05 17:27:01 +08:00
parent 16d1496283
commit b81c1f858e
9 changed files with 59 additions and 216 deletions

View File

@ -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 用于 Paintingvideo.vue 用于 Video
│ │ ├── imageUploader/ # 图片上传Painting
│ │ ├── videoImageUploader/ # 视频图片上传Video
│ │ ├── quantity/ # 生成数量选择器(支持 1-6
│ │ ├── quantity/ # 生成数量选择器(支持 1-6,上限由模型配置派生
│ │ ├── Time/ # 视频时长选择器
│ │ └── pattern/ # 视频模式选择器
│ ├── Popover/ # 自定义弹出层Teleport to bodyposition: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 返回 modelParamsVideo 走 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` 组件(共用 Popoveroptions 可 `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` 时预请求,避免首次点击"发送"时才触发。
### 接口速查

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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