diff --git a/CLAUDE.md b/CLAUDE.md index cf68295..b7888a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,10 +58,13 @@ src/ │ │ └── index.vue # 动态渲染平台控件,不含平台分支 │ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度) │ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关) +│ ├── ParamGroup/ # 动态参数容器:遍历 config.params 中未被专用控件处理的 select/switch 参数 +│ ├── SwitchControl/ # 纯 CSS 布尔开关(不用外部组件库) │ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现) │ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转 │ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo) ├── views/ # 页面(home、login) +├── model-configs/ # 运维参考:模型参数配置 JSON 文件(与 API 响应格式一致) └── utils/ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL ├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 20s HTTP 轮询直至完成/失败 @@ -87,6 +90,7 @@ const platform = { controls: [{ // 控件描述符数组 name: 'proportion', component: markRaw(Component), + beforeModel: false, // 设为 true 则渲染在 ModelSelector 之前 show: (config) => boolean, // 根据 model config 决定是否显示 props: (config) => ({ ... }) // 根据 model config 生成 v-model props }], @@ -97,13 +101,14 @@ const platform = { promptPlaceholder, // 提示词占位文本(ref) async loadModels() { ... }, // 获取模型列表 - async loadConfig(modelName, modelType) { ... }, // 加载模型参数配置 - getDefaultModel() { ... }, // 返回默认模型名 + async loadConfig(modelName, modelType) { ... }, // 加载模型参数配置(modelName 可以是 UUID/name/display_name) + getDefaultModel() { ... }, // 返回默认模型标识(可返回 '' 交由 modelSelector 自动纠错) + imageUploadLimit() { ... }, // 返回图片上传槽位数(应累加所有 imageUpload 参数的 maxCount) validateBeforeSubmit() { ... }, // 提交前校验,返回 null 表示通过 - getUploaderBindings() { ... }, // 图片上传组件的绑定参数 + getUploaderBindings() { ... }, // 图片上传组件的绑定参数(modelType + imagesCount) showImageUploader() { ... }, // 是否显示图片上传区域 isImageRequired() { ... }, // 图片是否必填 - buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams + buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams,需将 referenceImages 映射到 imageUpload 参数 fillFromResult(resultData) { ... }, // 从历史结果回填参数 } ``` @@ -124,7 +129,7 @@ props: (config) => ({ `src/components/dialogBox/index.vue` 是纯编排组件,不含任何平台分支: - **平台切换**:`const platform = computed(() => createPlatform(props.type))`,切换时重置默认模型并加载模型列表 -- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,用 `` + `v-bind="ctrl.props(...)"` 渲染 +- **控件渲染**:`visibleControls = platform.controls.filter(c => c.show(getCurrentConfig()))`,按 `beforeModel` 拆分为两组,`beforeModel` 控件 → ModelSelector → 其余控件,用 `` + `v-bind="ctrl.props(...)"` 渲染 - **配置获取**:`getCurrentConfig()` 返回 `platform.modelConfig?.value` - **模型切换**:`watch([model, modelType])` → `platform.loadConfig()` → `syncDefaults()` 将 schema 写入响应式 state → controls 的 `show()`/`props()` 读取 state → `visibleControls` 自动更新 → UI 重渲染 - **任务发起**:`handleStart()` 调用 `platform.validateBeforeSubmit()` → `platform.buildTaskBody()` → `generate()` @@ -140,6 +145,22 @@ props: (config) => ({ 4. `taskPolling.js` 直接读取 `data.body` → `getModelId(type, modelName)` 查找 UUID → POST `/suanli/v1/tasks`(`X-Session-Id` header) 5. 20s 间隔轮询直至完成/失败 +### 模型标识与查找 + +API 返回的模型对象包含三个标识字段: + +| 字段 | 示例 | 用途 | +|------|------|------| +| `id` | `e7e1e743-7621-403d-b8fb-2b7f4fa1b4fc` | UUID 主键,**唯一且稳定**,用于 API 调用和内部追踪 | +| `name` | `vidu-text-to-video-q3-turbo` | 内部标识名,通常也唯一 | +| `display_name` | `Vidu q3-turbo` | 用户可见的显示名,**可能重复**(不同 pattern 下的同名模型) | + +**Video 平台使用 `id`(UUID)作为 `model.value`**,避免 `display_name` 冲突导致的模型查找错误。`modelSelector.vue` 中 `modelGroups` 的 `value` 设为 `m.id`,`label` 仍用 `display_name` 显示。 + +`getModelId(type, modelName)` 查找优先级:`m.id === modelName` → `m.name === modelName` → `m.display_name === modelName`,向下兼容旧的 name/display_name 调用。 + +Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加载后由 `modelSelector` 的 watcher 自动选择第一个可用模型。 + ### 模型参数配置 模型参数配置通过后端 API `GET /suanli/v1/models/:id/config` 获取(60s localStorage 缓存),替代了旧的本地硬编码。参数通过 `ui` 字段映射到前端控件: @@ -152,7 +173,8 @@ props: (config) => ({ | `dimension` | `DimensionInput` | **组合模式**:单字段 `"W*H"` 格式,后端传 `dimension.separator`,前端生成 parse/format | | `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立字段(含 `min`/`max`),共享比例锁 | | `number` | proportion 控件内部 | 自定义宽高(如 Flux 的 customWidth/customHight),配合 `showWhen` 条件显示 | -| `select` | `Select` | 通用下拉(如 quality) | +| `select` | `Select`(通过 ParamGroup 或专用控件) | 通用下拉。专用控件直接使用 Select,其余由 ParamGroup 动态渲染 | +| `switch` | `SwitchControl`(通过 ParamGroup) | 布尔开关,纯 CSS 实现,不用外部组件库 | | `quantity` | `Quantity` | 生成数量,`options` 必须为数字数组,上限由 `Math.max()` 派生 | | `imageUpload` | `ImageUploader` | 参考图上传,`maxCount` 控制上限 | | `hidden` | 无 | 静默写入默认值 | @@ -167,12 +189,47 @@ props: (config) => ({ | 函数 | 说明 | |------|------| -| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。增强项:`dimension.separator` → parse/format 生成、`promptPlaceholder` 同步 | -| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref 回写到 `paramValues` | +| `syncDefaults(config, state)` | 将 API 返回的 config 同步到响应式 state。处理 `paramValues` 初始化、专用 ref 同步(proportion/resolution/quantity/quality/duration/dimension)、`promptPlaceholder` 同步。`resolution` 同时匹配 `ui:"resolution"` 和 `ui:"select"` | +| `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref(proportion/resolution/quantity/quality/duration/dimension)回写到 `paramValues` | | `getDimConfig(config)` | 检测 combined/split 模式 | | `checkShowWhen(param, paramValues)` | 检查 `showWhen` 条件 | -`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。各平台通过包装函数(如 `syncDefaults(config) { _syncDefaults(config, paintingState) }`)适配。 +`state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。Video 平台额外包含 `duration`(用于 syncDefaults 同步时长默认值)。各平台通过包装函数适配。 + +### ParamGroup 动态参数渲染 + +当 API 返回的 params 中包含无专用控件的类型(如 `select`、`switch`),由 `ParamGroup` 统一处理: + +- **容器定位**:作为平台 controls 数组的最后一项,`show()` 检查是否存在未被 `handledUis` 覆盖的参数 +- **`handledUis`**:`['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number']` — 这些类型的参数由专用控件处理,ParamGroup 跳过 +- **`excludeNames` prop**:额外按参数名排除(如 `resolution`、`duration`),因为 VideoProportion/Time 已处理这些参数(即使其 `ui` 类型不在 handledUis 中) +- **双层过滤**:平台 `show()` 做第一层(判断是否渲染 ParamGroup),ParamGroup 内部 `dynamicParams` 做第二层(判断具体渲染哪些参数)。**两层必须保持一致**,否则会出现重复控件 + +```js +// Video 平台的 ParamGroup 配置 +{ + name: 'paramGroup', + component: markRaw(ParamGroup), + show: (config) => { + // 第一层:有未被处理的参数才显示 + return config.params.some((p) => { + if (handledUis.includes(p.ui)) return false + if (p.name === 'resolution') return false + if (p.name === 'duration') return false + return true + }) + }, + props: (config) => ({ + config, + paramValues, + excludeNames: ['resolution', 'duration'] // 第二层过滤 + }) +} +``` + +### 组件规范 + +**禁止在项目组件中使用外部 UI 库**(Element Plus 等),图标除外。自定义组件使用项目自研的 `Select`、`Popover` 或纯 CSS 实现。`SwitchControl` 即为一例——纯 CSS 滑动开关,不依赖 `el-switch`。 ### `$attrs` 穿透注意 @@ -193,6 +250,9 @@ props: (config) => ({ - **VirtualScroller 反旋转**:组件用外层 `transform: rotate(180deg)` 实现 reverse 底部锚定。所有作为 slot 插入的内容必须在根元素加 `transform: rotate(180deg)` 反旋转,否则文字/图片会颠倒显示。参考 `src/views/home/display/components/set.vue:2`。 - **VirtualScroller 存在独立测试页**:`src/components/virtual-scroller/test.html` + `test-data.js`,可用于验证虚拟滚动行为。 - **Element Plus `` 不响应 `autosize` 动态变化**:`ElInput` 只在 mount 时读取 `autosize`,prop 更新时不会重新计算高度。因此 `dialogBox` 中 `Sender` 必须绑定 `:key="useDisplay.Sender_variant"`,通过强制重挂载来使新的 `autosize`(`minRows`/`maxRows`)生效。 +- **Video modelSelector pattern→modelType 映射**:`getModelType()` 在 `modelSelector.vue` 中将 pattern tag 映射为 modelType——"文生视频"→`text`,"首尾帧"→`image`,"数字人"→`digitalHuman`。该值用于 imageUploader 的 `showEndFrame` 判断和上传槽位数量。 +- **Video `imageUploadLimit()` 累加逻辑**:对于有多个 `imageUpload` 参数的模型(如首尾帧模型的 `firstImageUrl` + `lastImageUrl`),应累加所有 `imageUpload` 参数的 `maxCount`,而非只取第一个。否则首尾帧模型只显示一个上传槽位,尾帧上传无法触发。 +- **`buildTaskBody` 参考图映射**:Video 平台在 `buildTaskBody` 中需将 `referenceImages` 按索引顺序写入 `imageUpload` 参数(`referenceImages[0]` → `firstImageUrl`,`referenceImages[1]` → `lastImageUrl`),否则图片数据不会包含在任务请求中。 ### VirtualScroller 坐标系统 @@ -229,7 +289,7 @@ props: (config) => ({ | `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?}`) | +| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id(UUID), name, display_name, tags, disabled?}`)。`id`=UUID 主键,`name`=内部标识,`display_name`=用户可见名 | | `requestModelConfigsBatch` | POST `/suanli/v1/models/configs` | 批量获取模型配置(body: `{ modelIds: [...] }`) | | `requestModelConfig` | GET `/suanli/v1/models/:id/config` | 单条模型配置(60s 缓存优先) | | `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 | @@ -275,5 +335,5 @@ props: (config) => ({ ### 自动导入 - `unplugin-auto-import`:自动导入 Vue/Router/Pinia API -- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件 +- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件,**生成 `components.d.ts`(勿手动编辑)** - Element Plus 图标通过 `unplugin-icons` 按需加载 diff --git a/model-configs/hailuo-02-fast.json b/model-configs/hailuo-02-fast.json new file mode 100644 index 0000000..34e0058 --- /dev/null +++ b/model-configs/hailuo-02-fast.json @@ -0,0 +1,39 @@ +{ + "modelName": "海螺 02-fast 图生视频", + "modelDescription": "RunningHub MiniMax 海螺 02-fast 图生视频模型,基于参考图生成快节奏电影感动画", + "endpoint": "/minimax/hailuo-02/fast", + "params": [ + { + "name": "prompt", + "ui": "textarea", + "label": "提示词", + "required": false + }, + { + "name": "enablePromptExpansion", + "ui": "switch", + "label": "提示词扩展", + "default": true, + "required": true + }, + { + "name": "imageUrl", + "ui": "imageUpload", + "label": "参考图片", + "maxCount": 1, + "required": true, + "maxSizeMB": 10 + }, + { + "name": "duration", + "ui": "select", + "label": "时长(秒)", + "default": "6", + "options": ["6", "10"], + "required": true + } + ], + "promptPlaceholder": "描述基于原图的动画效果(可选)。", + "inputType": "image", + "maxImages": 1 +} diff --git a/model-configs/ltx-2.3image.json b/model-configs/ltx-2.3image.json new file mode 100644 index 0000000..bbd7926 --- /dev/null +++ b/model-configs/ltx-2.3image.json @@ -0,0 +1,47 @@ +{ + "modelName": "LTX-2.3 图生视频", + "modelDescription": "RunningHub LTX-2.3 图生视频模型,基于图片生成视频", + "endpoint": "/rhart-video/ltx-2.3/image-to-video", + "params": [ + { + "name": "imageUrl", + "ui": "imageUpload", + "label": "参考图片", + "maxCount": 1, + "required": true + }, + { + "name": "prompt", + "ui": "textarea", + "label": "提示词", + "required": true + }, + { + "name": "aspectRatio", + "ui": "proportion", + "label": "画面比例", + "default": "16:9", + "options": ["9:16", "16:9"], + "required": true + }, + { + "name": "resolution", + "ui": "resolution", + "label": "分辨率", + "default": "720p", + "options": ["480p", "720p", "1080p"], + "required": true + }, + { + "name": "duration", + "ui": "select", + "label": "时长(秒)", + "default": 5, + "options": [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + "required": true + } + ], + "promptPlaceholder": "描述图片和期望的视频运动效果。", + "inputType": "image", + "maxImages": 1 +} diff --git a/model-configs/ltx-2.3text.json b/model-configs/ltx-2.3text.json new file mode 100644 index 0000000..c2c2cde --- /dev/null +++ b/model-configs/ltx-2.3text.json @@ -0,0 +1,40 @@ +{ + "modelName": "LTX-2.3 文生视频", + "modelDescription": "RunningHub LTX-2.3 文生视频模型(Text-to-Video),基于文本生成视频", + "endpoint": "/rhart-video/ltx-2.3/text-to-video", + "params": [ + { + "name": "prompt", + "ui": "textarea", + "label": "提示词", + "required": true + }, + { + "name": "resolution", + "ui": "select", + "label": "分辨率", + "default": "720p", + "options": ["1080p", "720p", "480p"], + "required": true + }, + { + "name": "aspectRatio", + "ui": "proportion", + "label": "画面比例", + "default": "16:9", + "options": ["16:9", "9:16"], + "required": true + }, + { + "name": "duration", + "ui": "number", + "label": "时长(秒)", + "default": 5, + "min": 5, + "max": 15, + "required": true + } + ], + "promptPlaceholder": "描述你想生成的画面和动作。", + "inputType": "text" +} diff --git a/model-configs/vidu-start-end-to-video-q3-turbo.json b/model-configs/vidu-start-end-to-video-q3-turbo.json new file mode 100644 index 0000000..4ebee2d --- /dev/null +++ b/model-configs/vidu-start-end-to-video-q3-turbo.json @@ -0,0 +1,64 @@ +{ + "modelName": "Vidu 首尾帧生视频 q3-turbo", + "modelDescription": "RunningHub Vidu 首尾帧生视频 q3-turbo 模型,通过首尾帧图片驱动视频生成,支持音视频直出", + "endpoint": "/vidu/start-end-to-video-q3-turbo", + "params": [ + { + "name": "prompt", + "ui": "textarea", + "label": "提示词", + "required": true, + "maxLength": 4000 + }, + { + "name": "firstImageUrl", + "ui": "imageUpload", + "label": "首帧图片", + "maxCount": 1, + "required": true, + "maxSizeMB": 50 + }, + { + "name": "lastImageUrl", + "ui": "imageUpload", + "label": "尾帧图片", + "maxCount": 1, + "required": true, + "maxSizeMB": 50 + }, + { + "name": "duration", + "ui": "select", + "label": "时长(秒)", + "default": "5", + "options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"], + "required": true + }, + { + "name": "resolution", + "ui": "resolution", + "label": "分辨率", + "default": "720p", + "options": ["540p", "720p", "1080p"], + "required": true + }, + { + "name": "movementAmplitude", + "ui": "select", + "label": "运动幅度", + "default": "auto", + "options": ["auto", "small", "medium", "large"], + "required": true + }, + { + "name": "audio", + "ui": "switch", + "label": "音频直出", + "default": true, + "required": true + } + ], + "promptPlaceholder": "描述首尾帧之间的动作补全。文本长度限制 1-4000。", + "inputType": "image", + "maxImages": 2 +} diff --git a/model-configs/vidu-text-to-video-q3-turbo.json b/model-configs/vidu-text-to-video-q3-turbo.json new file mode 100644 index 0000000..c7cdd1f --- /dev/null +++ b/model-configs/vidu-text-to-video-q3-turbo.json @@ -0,0 +1,55 @@ +{ + "modelName": "Vidu 文生视频 q3-turbo", + "modelDescription": "RunningHub Vidu 文生视频 q3-turbo 模型,支持音视频直出", + "endpoint": "/vidu/text-to-video-q3-turbo", + "params": [ + { + "name": "prompt", + "ui": "textarea", + "label": "提示词", + "required": true, + "maxLength": 4000 + }, + { + "name": "style", + "ui": "select", + "label": "风格", + "default": "general", + "options": ["general", "anime"], + "required": true + }, + { + "name": "aspectRatio", + "ui": "proportion", + "label": "画面比例", + "default": "16:9", + "options": ["4:3", "3:4", "16:9", "9:16", "1:1"], + "required": true + }, + { + "name": "resolution", + "ui": "resolution", + "label": "分辨率", + "default": "720p", + "options": ["540p", "720p", "1080p"], + "required": true + }, + { + "name": "duration", + "ui": "select", + "label": "时长(秒)", + "default": "5", + "options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"], + "required": true + }, + { + "name": "audio", + "ui": "switch", + "label": "音频直出", + "default": true, + "required": true + } + ], + "promptPlaceholder": "描述你想生成的视频画面和音频。文本长度限制 1-4000。", + "inputType": "text" +} diff --git a/src/components/ParamGroup/index.vue b/src/components/ParamGroup/index.vue new file mode 100644 index 0000000..9e81ecf --- /dev/null +++ b/src/components/ParamGroup/index.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/Select/index.vue b/src/components/Select/index.vue index e870d59..08fb2a7 100644 --- a/src/components/Select/index.vue +++ b/src/components/Select/index.vue @@ -313,6 +313,7 @@ onBeforeUnmount(() => { box-sizing: border-box; border-radius: 5px; height: 36px; + flex-shrink: 0; display: flex; align-items: center; color: #666; diff --git a/src/components/SwitchControl/index.vue b/src/components/SwitchControl/index.vue new file mode 100644 index 0000000..8adcef2 --- /dev/null +++ b/src/components/SwitchControl/index.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/components/dialogBox/index.vue b/src/components/dialogBox/index.vue index 3d5ceba..10e93b0 100644 --- a/src/components/dialogBox/index.vue +++ b/src/components/dialogBox/index.vue @@ -32,6 +32,12 @@ >