新增 DimensionInput 共享组件,修复多个模型参数 UI 渲染缺陷,补充后端化方案文档
- 新增 DimensionInput 组件(Popover + W/H 数字输入 + 比例锁),支持 combined(单字段 W*H)和 split(独立 width/height)两种模式 - 修复 jimeng/qwen/qwen-edit 尺寸参数不显示:改用 dimension/dimensionWidth/dimensionHeight 替代 number/select - 修复 GPT-Image-2/GPT-Image-2 I2I quality 选择器不显示:通过 Select 组件承载 ui: 'select' - 修复 jimeng/GPT-Image-2 误显示 quantity:showQuantity 移除 fallback,仅匹配 ui: 'quantity' - 新增 docs/模型参数后端化方案.md:API 设计、数据库设计、前后端迁移步骤 - 更新 CLAUDE.md:补充新 UI 类型映射、dimension 模式说明、displayNameMap bug 标注 - 删除废弃文件 Vidu Q3-T2V.json、modelConfig 空目录
This commit is contained in:
parent
b81c1f858e
commit
a1134d85ad
34
CLAUDE.md
34
CLAUDE.md
@ -22,7 +22,7 @@ AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度
|
||||
**Painting 和 Video 走两套不同的任务构造路径:**
|
||||
|
||||
- **Painting(新架构)**:本地模型参数 schema → 专用控件 + 动态表单 → `X-Session-Id` header + 扁平 API body
|
||||
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body
|
||||
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body。Video 模型列表从远程静态 JSON(`VITE_API_MODEL_RESOURCE/.../video.json`)获取,workflow 配置按日缓存于 localStorage
|
||||
|
||||
### 关键目录
|
||||
|
||||
@ -42,6 +42,7 @@ src/
|
||||
│ │ ├── index.vue # 编排中心:组装所有控件,处理 handleStart()、模型配置加载、参数回填
|
||||
│ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组,value 编码为 tag::display_name)
|
||||
│ │ ├── proportion/ # 比例/分辨率选择器(painting.vue 用于 Painting,video.vue 用于 Video)
|
||||
│ │ ├── dimension/ # 尺寸输入 Popover(W/H 数字输入 + 比例锁,支持 split/combined 两种模式)
|
||||
│ │ ├── imageUploader/ # 图片上传(Painting)
|
||||
│ │ ├── videoImageUploader/ # 视频图片上传(Video)
|
||||
│ │ ├── quantity/ # 生成数量选择器(支持 1-6,上限由模型配置派生)
|
||||
@ -67,19 +68,28 @@ src/
|
||||
│ ├── plugins.js # Vite 插件配置(unplugin-auto-import + unplugin-vue-components)
|
||||
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
||||
│ ├── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析(Video 专用)
|
||||
│ ├── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型
|
||||
│ └── modelConfig/ # 静态模型列表:painting.json(按 tag 分组)、video.json(按 pattern 分组)
|
||||
│ └── models/ # Painting 模型参数 schema:每模型一个 JS 文件,定义 params 和各字段的 ui 类型
|
||||
```
|
||||
|
||||
### 模型参数配置(Painting 新架构)
|
||||
|
||||
`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载:
|
||||
`src/config/models/` 下每个模型一个 JS 文件,参数通过 `ui` 字段映射到不同 UI 组件:
|
||||
|
||||
- **`ui: 'textarea'`** → Sender 组件主输入框(prompt)
|
||||
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,options 可含 `custom` 开启自定义 W/H)
|
||||
- **`ui: 'quantity'`** → `Quantity` 组件(动态上限由 model config 的 `options` 数组最大值派生)
|
||||
- **`ui: 'imageUpload'`** → `ImageUploader` 组件
|
||||
- **`ui: 'hidden'`** → 无 UI,仅写入默认值(如 outputFormat: 'png')
|
||||
| `ui` 值 | 组件 | 说明 |
|
||||
|---------|------|------|
|
||||
| `textarea` | Sender 内置 textarea | prompt 输入框 |
|
||||
| `proportion` | `paintingProportion` | 比例选择 Popover(options 含 `custom` 时可自定义 W/H) |
|
||||
| `resolution` | `paintingProportion` 内部 | 分辨率子选项,与 proportion 共用一个 Popover |
|
||||
| `dimension` | `DimensionInput` | **组合模式**:单个 `"W*H"` 字符串参数,通过 `dimension.parse/format` 序列化 |
|
||||
| `dimensionWidth` + `dimensionHeight` | `DimensionInput` | **拆分模式**:两个独立 number 参数,共享同一个 Popover 和比例锁 |
|
||||
| `select` | `Select` | 通用下拉选择(如 quality) |
|
||||
| `quantity` | `Quantity` | 生成张数(上限由 `options` 最大值派生) |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传 |
|
||||
| `hidden` | 无 | 静默写入默认值 |
|
||||
|
||||
**dimension 模式区分**:dialogBox 通过 `dimConfig` computed 自动检测 —— 找 `ui: 'dimension'`(组合)或 `ui: 'dimensionWidth'`(拆分),两种模式共用同一个 `DimensionInput` 组件。
|
||||
|
||||
**条件显示**:`showWhen: { aspectRatio: 'custom' }` 使参数仅在 proportion 选 `custom` 时显示(如 `customWidth`/`customHight`)。
|
||||
|
||||
模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 API 返回的 `tags` 数组字段分组(`text`→生成模型,`edit`→编辑模型,`vision`→视觉理解模型)。
|
||||
|
||||
@ -87,13 +97,15 @@ src/
|
||||
|
||||
`src/config/models/index.js` 中 `displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2` 和 `GPT-image-2` 分别对应编辑/生成),config key 采用内部中文名区分。
|
||||
|
||||
**已知 bug**:`displayNameMap` 存在重复 key `'GPT-Image-2'`,第二条会覆盖第一条,导致文字生图版 GPT-Image-2 查找走 `displayNameMap` 时映射到 I2I 版的 config。当前因 `model.value` 已经是中文 config key,直达 `configs[]` 而非走 map,暂时不触发。若后续改为按 `display_name` 查找,需修复此重复 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` 双向绑定
|
||||
- **派生 UI 配置**:从 model config 计算 `showProportion`、`showDimension`、`showQuality`、`showQuantity`(各自检查对应 `ui` 字段是否存在);`hasCustomSize`(proportion options 含 `custom`);`dimConfig`(自动识别 combined/split 模式)
|
||||
- **状态管理**:`dimWidth`/`dimHeight` 通过 `v-model:width`/`v-model:height` 与 `DimensionInput` 双向绑定;`qualityValue` 通过 `v-model` 与 Select 组件双向绑定
|
||||
- **参数回填**:`fillParamsFromResult()` 供历史记录重编辑使用
|
||||
- **任务发起**:`handleStart()` 收集所有参数 → 构造 `data` → 调用 `taskPolling.js:generate()`
|
||||
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
"nodeInfoList": {
|
||||
"prompt":{ "nodeId":"2", "fieldName":"prompt", "fieldValue":"" },
|
||||
"resolution":{ "nodeId":"2", "fieldName":"resolution", "fieldValue":"" },
|
||||
"proportion":{ "nodeId":"2", "fieldName":"aspect_ratio", "fieldValue":"" },
|
||||
"duration":{ "nodeId":"2", "fieldName":"duration", "fieldValue": 5},
|
||||
"audio":{ "nodeId":"2", "fieldName":"audio", "fieldValue": false}
|
||||
},
|
||||
"workflowId": "2036349280088231938",
|
||||
"display": {
|
||||
"promptPlaceholder": {"default": "描述你想生成的画面和动作。"},
|
||||
"prompt": {"default": ""},
|
||||
"resolution": {"default": "1k","options":[
|
||||
{ "value": "360", "label": "流畅 360P" },
|
||||
{ "value": "540", "label": "标清 540P" },
|
||||
{ "value": "720", "label": "高清 720P" },
|
||||
{ "value": "1k", "label": "超清 1K" }
|
||||
]},
|
||||
"proportion": {"default": "16:9","options":[
|
||||
{ "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" }
|
||||
]},
|
||||
"duration": {"default": 5,"options":[
|
||||
{ "value": 5, "label": "5秒" },
|
||||
{ "value": 10, "label": "10秒" },
|
||||
{ "value": 15, "label": "15秒" }
|
||||
]},
|
||||
"audio": {"default": false}
|
||||
}
|
||||
}
|
||||
1
components.d.ts
vendored
1
components.d.ts
vendored
@ -16,6 +16,7 @@ declare module 'vue' {
|
||||
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']
|
||||
Dimension: typeof import('./src/components/dialogBox/dimension/index.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
|
||||
648
docs/模型参数后端化方案.md
Normal file
648
docs/模型参数后端化方案.md
Normal file
@ -0,0 +1,648 @@
|
||||
# 模型参数配置后端化方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前模型参数配置硬编码在前端 `src/config/models/*.js` 中,新增模型或修改参数需要前端发版,业务层无法感知参数定义。
|
||||
|
||||
目标:将参数配置迁移到业务层,管理员后台配置模型参数,前端通过 API 按模型 ID 动态获取,实现**零发版上线新模型配置**。
|
||||
|
||||
```
|
||||
页面加载:
|
||||
GET /suanli/v1/platforms/{code}/models → 模型列表(现有接口,不变)
|
||||
GET /suanli/v1/platforms/{code}/models/params → 批量拉全部模型参数(新接口)
|
||||
前端存入 Map<modelId, config>
|
||||
|
||||
切换模型:
|
||||
config = paramsMap.get(modelId) → 内存读取,0 网络请求
|
||||
|
||||
兜底(缓存 miss 时):
|
||||
GET /suanli/v1/models/{model_id}/params → 单个模型参数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 第一部分:后端方案
|
||||
|
||||
## 2. API 设计
|
||||
|
||||
### 2.1 批量获取平台下所有模型参数(主接口)
|
||||
|
||||
```
|
||||
GET /suanli/v1/platforms/{platform_code}/models/params
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"models": [
|
||||
{
|
||||
"id": "uuid-of-flux-2",
|
||||
"input_type": "text",
|
||||
"max_images": 4,
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"label": "提示词",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"ui": "textarea"
|
||||
},
|
||||
{
|
||||
"name": "aspectRatio",
|
||||
"label": "比例",
|
||||
"type": "select",
|
||||
"default": "1:1",
|
||||
"ui": "proportion",
|
||||
"options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"],
|
||||
"show_when": null
|
||||
},
|
||||
{
|
||||
"name": "customWidth",
|
||||
"label": "自定义宽度",
|
||||
"type": "number",
|
||||
"default": 1024,
|
||||
"min": 512,
|
||||
"max": 2048,
|
||||
"ui": "hidden",
|
||||
"show_when": { "aspectRatio": "custom" }
|
||||
},
|
||||
{
|
||||
"name": "resolution",
|
||||
"label": "分辨率",
|
||||
"type": "select",
|
||||
"default": "1k",
|
||||
"ui": "resolution",
|
||||
"options": ["1k", "2k", "4k"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "uuid-of-qwen-2.0",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"label": "提示词",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"ui": "textarea"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"label": "尺寸",
|
||||
"type": "string",
|
||||
"default": "1024*1024",
|
||||
"ui": "dimension",
|
||||
"dimension": {
|
||||
"delimiter": "*",
|
||||
"width": { "min": 512, "max": 2048 },
|
||||
"height": { "min": 512, "max": 2048 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "imageNum",
|
||||
"label": "生成张数",
|
||||
"type": "select",
|
||||
"default": 1,
|
||||
"ui": "quantity",
|
||||
"options": [1, 2, 3, 4, 5, 6]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 单个模型参数(兜底接口)
|
||||
|
||||
```
|
||||
GET /suanli/v1/models/{model_id}/params
|
||||
```
|
||||
|
||||
**Response:** `data` 为单个模型对象(不含 `models` 数组包装),其余结构同 2.1。
|
||||
|
||||
### 2.3 参数字段规范
|
||||
|
||||
每个 param 对象的字段定义:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 参数 key,提交任务时作为 body 字段名 |
|
||||
| `label` | string | 是 | 中文显示名 |
|
||||
| `type` | string | 是 | `string` / `number` / `boolean` / `select` / `image` |
|
||||
| `default` | any | 是 | 默认值,类型必须与 `type` 一致 |
|
||||
| `required` | bool | 否 | 是否必填,默认 false |
|
||||
| `ui` | string | 是 | UI 组件标识(见 2.4 映射表) |
|
||||
| `options` | array | 否 | `type=select` 时的可选项列表 |
|
||||
| `min` | number | 否 | `type=number` 时的最小值 |
|
||||
| `max` | number | 否 | `type=number` 时的最大值 |
|
||||
| `max_count` | number | 否 | `ui=imageUpload` 时的最大上传张数 |
|
||||
| `dimension` | object | 否 | `ui=dimension` 时的尺寸配置 |
|
||||
| `show_when` | object | 否 | 条件显示,如 `{"aspectRatio": "custom"}` |
|
||||
|
||||
**`dimension` 对象(仅 `ui=dimension` 时出现):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `delimiter` | string | W/H 分隔符,固定 `*` |
|
||||
| `width.min` | number | 宽度最小值 |
|
||||
| `width.max` | number | 宽度最大值 |
|
||||
| `height.min` | number | 高度最小值 |
|
||||
| `height.max` | number | 高度最大值 |
|
||||
|
||||
### 2.4 `ui` 字段枚举
|
||||
|
||||
| `ui` 值 | 前端渲染组件 | 说明 |
|
||||
|---------|-------------|------|
|
||||
| `textarea` | Sender 内置 textarea | 提示词输入框 |
|
||||
| `proportion` | `paintingProportion` | 比例选择 Popover(含 resolution + custom 尺寸) |
|
||||
| `resolution` | `paintingProportion` 内部 | 分辨率子选项 |
|
||||
| `dimension` | `DimensionInput` | 组合模式 W×H(如 `1024*1024`) |
|
||||
| `dimensionWidth` | `DimensionInput` | 拆分模式:独立宽度(须与 `dimensionHeight` 配对) |
|
||||
| `dimensionHeight` | `DimensionInput` | 拆分模式:独立高度(须与 `dimensionWidth` 配对) |
|
||||
| `select` | `Select` | 通用下拉选择(如 quality) |
|
||||
| `quantity` | `Quantity` | 生成张数选择器 |
|
||||
| `imageUpload` | `ImageUploader` | 参考图上传 |
|
||||
| `hidden` | 无 | 不渲染,静默写入默认值 |
|
||||
|
||||
> `dimensionWidth` + `dimensionHeight` 必须成对出现,前端自动关联到同一个 DimensionInput 组件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据库设计
|
||||
|
||||
### 3.1 表结构
|
||||
|
||||
`model_params` 是独立的配置表,与 `models` 表分离,通过 `model_id` 关联:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ models(平台模型表) │ │ model_params(参数配置表) │
|
||||
│ │ 1:N │ │
|
||||
│ id (UUID) │◄────────│ model_id (FK) │
|
||||
│ display_name │ │ name, label, type │
|
||||
│ platform_code │ │ ui, default_val │
|
||||
│ tags │ │ options, min, max... │
|
||||
│ input_type │ │ sort_order │
|
||||
│ max_images │ │ created_at / updated_at │
|
||||
└─────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_params (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
model_id VARCHAR(64) NOT NULL, -- 关联 models.id(UUID)
|
||||
name VARCHAR(64) NOT NULL, -- 参数 key
|
||||
label VARCHAR(64) NOT NULL, -- 中文显示名
|
||||
type VARCHAR(16) NOT NULL, -- string | number | boolean | select | image
|
||||
default_val JSON NOT NULL, -- 默认值
|
||||
required TINYINT DEFAULT 0,
|
||||
ui VARCHAR(32) NOT NULL, -- UI 组件标识
|
||||
options JSON DEFAULT NULL,
|
||||
min_val INT DEFAULT NULL,
|
||||
max_val INT DEFAULT NULL,
|
||||
max_count INT DEFAULT NULL,
|
||||
show_when JSON DEFAULT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
dimension JSON DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_model_id (model_id)
|
||||
);
|
||||
```
|
||||
|
||||
`dimension` JSON 格式:
|
||||
```json
|
||||
{
|
||||
"delimiter": "*",
|
||||
"width": { "min": 512, "max": 2048 },
|
||||
"height": { "min": 512, "max": 2048 }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 关键约束
|
||||
|
||||
- `model_params` 与 `models` 完全解耦,各自独立维护
|
||||
- 一个 `model_id` 可有多条参数行,`sort_order` 控制前端渲染顺序
|
||||
- 模型可以没有参数配置(`model_params` 中无记录),前端兼容处理(不渲染额外 UI)
|
||||
- `model_id` 由管理员手动关联,不是自动生成
|
||||
|
||||
### 3.3 初始数据迁移
|
||||
|
||||
将当前 `src/config/models/*.js` 中 8 个模型的参数转为 INSERT 语句。`model_id` 须对应 `models` 表中该模型的 UUID。完整示例见第 7 节。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理员配置后台
|
||||
|
||||
```
|
||||
操作流程:
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 1. 进入「模型参数配置」页面 │
|
||||
│ 2. 选择平台 + 模型(从 models 表读取,按 platform_code 过滤) │
|
||||
│ 3. 为该模型添加/编辑参数行,每行配置: │
|
||||
│ - 参数名 (name) - 中文标签 (label) │
|
||||
│ - 数据类型 (type) - UI 组件 (ui) │
|
||||
│ - 默认值 (default) - 可选项 (options) │
|
||||
│ - 数值范围 (min/max) - 尺寸配置 (dimension) │
|
||||
│ - 条件显示 (show_when) │
|
||||
│ 4. 拖拽调整参数排序 (sort_order) │
|
||||
│ 5. 保存后前端缓存 TTL 过期自动生效,也可通知用户手动刷新 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口实现伪代码
|
||||
|
||||
```python
|
||||
# GET /suanli/v1/platforms/{platform_code}/models/params
|
||||
def get_platform_model_params(platform_code):
|
||||
models = db.query(
|
||||
"SELECT id, input_type, max_images FROM models WHERE platform_code = ?",
|
||||
platform_code
|
||||
)
|
||||
result = []
|
||||
for m in models:
|
||||
params = db.query(
|
||||
"SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order",
|
||||
m.id
|
||||
)
|
||||
result.append({
|
||||
"id": m.id,
|
||||
"input_type": m.input_type,
|
||||
"max_images": m.max_images,
|
||||
"params": [format_param(p) for p in params],
|
||||
})
|
||||
return {"code": 0, "data": {"models": result}}
|
||||
|
||||
|
||||
# GET /suanli/v1/models/{model_id}/params
|
||||
def get_model_params(model_id):
|
||||
m = db.query("SELECT id, input_type, max_images FROM models WHERE id = ?", model_id)
|
||||
if not m:
|
||||
return {"code": 404, "msg": "模型不存在"}
|
||||
params = db.query(
|
||||
"SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order",
|
||||
model_id
|
||||
)
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": m.id,
|
||||
"input_type": m.input_type,
|
||||
"max_images": m.max_images,
|
||||
"params": [format_param(p) for p in params],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def format_param(p):
|
||||
return {
|
||||
"name": p.name,
|
||||
"label": p.label,
|
||||
"type": p.type,
|
||||
"default": json.loads(p.default_val),
|
||||
"required": bool(p.required),
|
||||
"ui": p.ui,
|
||||
"options": json.loads(p.options) if p.options else None,
|
||||
"min": p.min_val,
|
||||
"max": p.max_val,
|
||||
"max_count": p.max_count,
|
||||
"show_when": json.loads(p.show_when) if p.show_when else None,
|
||||
"dimension": json.loads(p.dimension) if p.dimension else None,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 响应示例汇总
|
||||
|
||||
### 6.1 即梦 4.6(拆分维度 `dimensionWidth` + `dimensionHeight`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "jimeng-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{ "name": "width", "label": "宽度", "type": "number", "default": 1024, "min": 900, "max": 6197, "ui": "dimensionWidth" },
|
||||
{ "name": "height", "label": "高度", "type": "number", "default": 1024, "min": 768, "max": 4096, "ui": "dimensionHeight" },
|
||||
{ "name": "forceSingle", "label": "强制单张", "type": "boolean", "default": false, "ui": "hidden" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 通义万相 2.0(组合维度 `dimension` + `quantity`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "qwen-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{
|
||||
"name": "size", "label": "尺寸", "type": "string", "default": "1024*1024", "ui": "dimension",
|
||||
"dimension": { "delimiter": "*", "width": { "min": 512, "max": 2048 }, "height": { "min": 512, "max": 2048 } }
|
||||
},
|
||||
{ "name": "imageNum", "label": "生成张数", "type": "select", "default": 1, "ui": "quantity", "options": [1, 2, 3, 4, 5, 6] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 GPT-Image-2(`proportion` + `resolution` + `select`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gpt-image-uuid",
|
||||
"input_type": "text",
|
||||
"params": [
|
||||
{ "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" },
|
||||
{
|
||||
"name": "aspectRatio", "label": "比例", "type": "select", "default": "1:1", "ui": "proportion",
|
||||
"options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"]
|
||||
},
|
||||
{ "name": "customWidth", "label": "自定义宽度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } },
|
||||
{ "name": "customHight", "label": "自定义高度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } },
|
||||
{ "name": "resolution", "label": "分辨率", "type": "select", "default": "1k", "ui": "resolution", "options": ["1k", "2k", "4k"] },
|
||||
{ "name": "quality", "label": "质量", "type": "select", "default": "medium", "ui": "select", "options": ["low", "medium", "high"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 第二部分:前端改动
|
||||
|
||||
## 7. 缓存策略
|
||||
|
||||
三级防护,全部位于客户端浏览器:
|
||||
|
||||
```
|
||||
请求流程:
|
||||
用户操作
|
||||
→ L1 内存缓存(Map,会话级,10min TTL)
|
||||
→ L2 localStorage 缓存(持久化,10min TTL)
|
||||
→ 请求冷却检查(localStorage,30s 冷却期)
|
||||
→ API 请求
|
||||
```
|
||||
|
||||
| 层级 | 存储位置 | 生命周期 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| L1 | JS 内存 Map | 标签页关闭即销毁 | 同会话内切换模型 0 延迟 |
|
||||
| L2 | localStorage | 持久化,跨会话/刷新 | 刷新页面后直接命中,无需请求 |
|
||||
| 冷却期 | localStorage | 独立于缓存 | 限制 API 调用频率最低 30s/次 |
|
||||
|
||||
**正常场景**:首次访问 → API 请求 → 写入 L1 + L2。刷新页面 → L2 命中,0 请求。
|
||||
|
||||
**极端场景**:清掉 localStorage 连刷 10 次 → 请求冷却生效,实际只有 1 次请求到达后端。
|
||||
|
||||
---
|
||||
|
||||
## 8. 新增文件
|
||||
|
||||
### 8.1 API 层
|
||||
|
||||
```js
|
||||
// src/apis/display/index.js(追加以下两个函数)
|
||||
|
||||
// 批量获取平台所有模型参数
|
||||
export const fetchPlatformModelParams = (platformCode) =>
|
||||
service.get(`/suanli/v1/platforms/${platformCode}/models/params`)
|
||||
|
||||
// 获取单个模型参数(兜底)
|
||||
export const fetchModelParams = (modelId) =>
|
||||
service.get(`/suanli/v1/models/${modelId}/params`)
|
||||
```
|
||||
|
||||
### 8.2 缓存层
|
||||
|
||||
```js
|
||||
// src/utils/modelParams.js(新文件)
|
||||
|
||||
import { fetchPlatformModelParams } from '@/apis/display'
|
||||
|
||||
const CACHE_TTL = 10 * 60 * 1000 // 缓存有效期:10 分钟
|
||||
const COOLDOWN = 30 * 1000 // 请求冷却期:30 秒
|
||||
const STORAGE_PREFIX = 'model_params_'
|
||||
|
||||
const memoryCache = new Map() // L1: { platformCode → { data, timestamp } }
|
||||
const pendingRequests = new Map() // 并发去重
|
||||
|
||||
function getStorageCache(platformCode) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + platformCode)
|
||||
if (!raw) return null
|
||||
const { data, timestamp } = JSON.parse(raw)
|
||||
if (Date.now() - timestamp < CACHE_TTL) {
|
||||
return new Map(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function setStorageCache(platformCode, map) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + platformCode, JSON.stringify({
|
||||
data: [...map],
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getCooldownRemaining(platformCode) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + platformCode + '_lastFetch')
|
||||
if (!raw) return 0
|
||||
const elapsed = Date.now() - parseInt(raw)
|
||||
return elapsed < COOLDOWN ? COOLDOWN - elapsed : 0
|
||||
} catch { return 0 }
|
||||
}
|
||||
|
||||
function setFetchTimestamp(platformCode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + platformCode + '_lastFetch', Date.now().toString())
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function clearModelParamsCache(platformCode) {
|
||||
memoryCache.delete(platformCode)
|
||||
localStorage.removeItem(STORAGE_PREFIX + platformCode)
|
||||
}
|
||||
|
||||
export async function getModelParamsMap(platformCode) {
|
||||
// 1. L1 内存缓存
|
||||
const mem = memoryCache.get(platformCode)
|
||||
if (mem && Date.now() - mem.timestamp < CACHE_TTL) {
|
||||
return mem.data
|
||||
}
|
||||
|
||||
// 2. L2 localStorage 缓存
|
||||
const storage = getStorageCache(platformCode)
|
||||
if (storage) {
|
||||
memoryCache.set(platformCode, { data: storage, timestamp: Date.now() })
|
||||
return storage
|
||||
}
|
||||
|
||||
// 3. 并发去重
|
||||
if (pendingRequests.has(platformCode)) {
|
||||
return pendingRequests.get(platformCode)
|
||||
}
|
||||
|
||||
// 4. 请求冷却
|
||||
const cooldown = getCooldownRemaining(platformCode)
|
||||
if (cooldown > 0) {
|
||||
const promise = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
pendingRequests.delete(platformCode)
|
||||
resolve(getModelParamsMap(platformCode))
|
||||
}, cooldown)
|
||||
})
|
||||
pendingRequests.set(platformCode, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
// 5. 发起请求
|
||||
setFetchTimestamp(platformCode)
|
||||
const promise = fetchPlatformModelParams(platformCode)
|
||||
.then(res => {
|
||||
const map = new Map()
|
||||
for (const m of res.data.models) {
|
||||
map.set(m.id, m)
|
||||
}
|
||||
memoryCache.set(platformCode, { data: map, timestamp: Date.now() })
|
||||
setStorageCache(platformCode, map)
|
||||
pendingRequests.delete(platformCode)
|
||||
return map
|
||||
})
|
||||
.catch(err => {
|
||||
pendingRequests.delete(platformCode)
|
||||
throw err
|
||||
})
|
||||
|
||||
pendingRequests.set(platformCode, promise)
|
||||
return promise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 改造文件
|
||||
|
||||
### 9.1 dialogBox/index.vue
|
||||
|
||||
**数据流变化:**
|
||||
|
||||
```
|
||||
当前:
|
||||
model.value (display_name)
|
||||
→ getModelConfig(modelName) // 静态 JS 文件查找
|
||||
→ modelConfig (computed)
|
||||
→ UI 渲染
|
||||
|
||||
新方案:
|
||||
modelId (选中模型的 UUID)
|
||||
→ modelConfig = paramsMap.get(modelId) // 从预取 Map 查找
|
||||
→ UI 渲染
|
||||
```
|
||||
|
||||
**核心代码改造:**
|
||||
|
||||
```js
|
||||
// 当前
|
||||
const modelConfig = computed(() => {
|
||||
return props.type === 'Painting' ? getModelConfig(model.value) : null
|
||||
})
|
||||
|
||||
// 新方案
|
||||
const paramsMap = ref(new Map())
|
||||
const modelConfig = computed(() => {
|
||||
if (props.type !== 'Painting') return null
|
||||
return paramsMap.value.get(currentModelId.value) || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const map = await getModelParamsMap('ai_painting_talk')
|
||||
paramsMap.value = map
|
||||
})
|
||||
```
|
||||
|
||||
**其他改动点:**
|
||||
|
||||
- `modelConfig` watcher 中维度初始化改用统一 `parseDimension()` 替代 `dimension.parse()`
|
||||
- `fillParamsFromResult` 中维度恢复同理
|
||||
- `displayNameMap` 逻辑移除
|
||||
|
||||
### 9.2 model/painting.vue(模型选择器)
|
||||
|
||||
- 选中值从 `display_name` 改为 `model_id`
|
||||
- emit 从 `update:modelValue(displayName)` 改为 `update:modelValue(modelId)`
|
||||
- 同时 emit `update:typeValue(inputType)` 保持不变
|
||||
|
||||
### 9.3 dimension 处理统一化
|
||||
|
||||
移除每个模型配置中自定义的 `parse`/`format` 函数,前端统一处理:
|
||||
|
||||
```js
|
||||
// src/components/dialogBox/index.vue 中集中定义
|
||||
function parseDimension(raw, delimiter = '*') {
|
||||
const parts = (raw || '1024*1024').split(delimiter)
|
||||
return {
|
||||
width: parseInt(parts[0]) || 1024,
|
||||
height: parseInt(parts[1]) || 1024,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDimension(w, h, delimiter = '*') {
|
||||
return `${w}${delimiter}${h}`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移步骤
|
||||
|
||||
| 阶段 | 负责方 | 内容 |
|
||||
|------|--------|------|
|
||||
| **1. 后端准备** | 后端 | 创建 `model_params` 表 → 录入 8 个模型配置 → 实现批量+单个接口 → 联调验证 |
|
||||
| **2. 前端适配** | 前端 | 新增 API 函数 + 缓存层 → 改造 dialogBox + model 选择器 → 保留静态配置为 fallback |
|
||||
| **3. 清理** | 前端 | 确认全量走新接口 → 删除 `src/config/models/*.js` → 删除 `displayNameMap`、`getModelConfig` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 边界情况
|
||||
|
||||
### 11.1 接口失败降级
|
||||
|
||||
迁移期间接口失败时回退静态配置:
|
||||
|
||||
```js
|
||||
const modelConfig = computed(() => {
|
||||
if (paramsMap.value.size > 0) {
|
||||
return paramsMap.value.get(currentModelId.value) || null
|
||||
}
|
||||
return getModelConfig(model.value) // fallback
|
||||
})
|
||||
```
|
||||
|
||||
### 11.2 缓存主动刷新
|
||||
|
||||
管理员修改配置后,前端提供「刷新配置」按钮调用 `clearModelParamsCache()` 立即生效。
|
||||
|
||||
### 11.3 模型无参数配置
|
||||
|
||||
若 `model_id` 在 `model_params` 表中无记录,前端仅渲染 prompt 输入框(textarea),不显示其他 UI 组件。
|
||||
|
||||
### 11.4 `show_when` 条件显示
|
||||
|
||||
当前仅支持 `{ "aspectRatio": "custom" }` 条件。后续如需扩展,前后端同步约定新的条件字段和取值。
|
||||
|
||||
### 11.5 向后兼容
|
||||
|
||||
- 新增接口路径 `models/params`,不影响现有模型列表接口
|
||||
- Video 路径不受影响(继续使用 `runninghub.Playload()` 适配器)
|
||||
- Painting 的 `modelParams` 扁平提交格式不变
|
||||
247
src/components/dialogBox/dimension/index.vue
Normal file
247
src/components/dialogBox/dimension/index.vue
Normal file
@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<Popover placement="top">
|
||||
<div class="dimension-container">
|
||||
<div class="section">
|
||||
<h3>尺寸 (px)</h3>
|
||||
<div class="size-inputs">
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localWidth"
|
||||
:min="minW"
|
||||
:max="maxW"
|
||||
@input="onWidthChange"
|
||||
>
|
||||
</div>
|
||||
<div class="lock-icon" :class="{ locked: isLocked }" @click="toggleLock">
|
||||
<img :src="isLocked ? lockIcon : lockNoIcon" alt="约束比例">
|
||||
<span class="tooltip">{{ isLocked ? '解绑比例' : '约束比例' }}</span>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localHeight"
|
||||
:min="minH"
|
||||
:max="maxH"
|
||||
@input="onHeightChange"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="choice-btn">
|
||||
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||
<span>{{ displayText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Popover from '@/components/Popover/index.vue'
|
||||
import lockIcon from '@/assets/dialog/lock.svg'
|
||||
import lockNoIcon from '@/assets/dialog/lockNo.svg'
|
||||
|
||||
const props = defineProps({
|
||||
width: { type: Number, default: 1024 },
|
||||
height: { type: Number, default: 1024 },
|
||||
minW: { type: Number, default: 256 },
|
||||
maxW: { type: Number, default: 6197 },
|
||||
minH: { type: Number, default: 256 },
|
||||
maxH: { type: Number, default: 4096 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:width', 'update:height'])
|
||||
|
||||
const localWidth = ref(props.width)
|
||||
const localHeight = ref(props.height)
|
||||
const isLocked = ref(true)
|
||||
const lastRatio = ref(props.width / props.height)
|
||||
|
||||
const displayText = computed(() => `${localWidth.value} × ${localHeight.value}`)
|
||||
|
||||
watch(() => props.width, (val) => { localWidth.value = val })
|
||||
watch(() => props.height, (val) => { localHeight.value = val })
|
||||
|
||||
const toggleLock = () => {
|
||||
isLocked.value = !isLocked.value
|
||||
if (isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (val, min, max) => Math.max(min, Math.min(max, Math.round(val)))
|
||||
|
||||
const onWidthChange = () => {
|
||||
localWidth.value = clamp(localWidth.value, props.minW, props.maxW)
|
||||
if (isLocked.value) {
|
||||
localHeight.value = clamp(Math.round(localWidth.value / lastRatio.value), props.minH, props.maxH)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
const onHeightChange = () => {
|
||||
localHeight.value = clamp(localHeight.value, props.minH, props.maxH)
|
||||
if (isLocked.value) {
|
||||
localWidth.value = clamp(Math.round(localHeight.value * lastRatio.value), props.minW, props.maxW)
|
||||
}
|
||||
emit('update:width', localWidth.value)
|
||||
emit('update:height', localHeight.value)
|
||||
}
|
||||
|
||||
watch([localWidth, localHeight], () => {
|
||||
if (!isLocked.value) {
|
||||
lastRatio.value = localWidth.value / localHeight.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.choice-btn {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.choice-btn:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
|
||||
.dimension-container {
|
||||
padding: 20px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-radius: 20px;
|
||||
|
||||
h3 {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.size-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 12px;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 12px 12px 12px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f5f6f7;
|
||||
text-align: right;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.locked {
|
||||
background: #f5f6f7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -41,6 +41,26 @@
|
||||
:resolution-options="paintingResolutionOpts"
|
||||
:allow-custom="hasCustomSize"
|
||||
/>
|
||||
<DimensionInput
|
||||
v-if="showDimension"
|
||||
v-model:width="dimWidth"
|
||||
v-model:height="dimHeight"
|
||||
:min-w="dimMinW"
|
||||
:max-w="dimMaxW"
|
||||
:min-h="dimMinH"
|
||||
:max-h="dimMaxH"
|
||||
/>
|
||||
<Select
|
||||
v-if="showQuality"
|
||||
v-model="qualityValue"
|
||||
:options="qualityOpts"
|
||||
width="auto"
|
||||
class="quality-select"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="quality-label">画质</span>
|
||||
</template>
|
||||
</Select>
|
||||
<Quantity v-if="showQuantity" v-model="quantity" :max="quantityMax" />
|
||||
</div>
|
||||
|
||||
@ -86,6 +106,8 @@ import VideoImageUploader from './videoImageUploader/index.vue'
|
||||
import Time from './Time/index.vue'
|
||||
import paintingProportion from './proportion/painting.vue'
|
||||
import Quantity from './quantity/index.vue'
|
||||
import DimensionInput from './dimension/index.vue'
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { Sender } from 'vue-element-plus-x'
|
||||
import { useDisplayStore } from '@/stores'
|
||||
import { generate } from '@/utils/taskPolling'
|
||||
@ -132,9 +154,7 @@ const showImageUploader = computed(() => {
|
||||
// 模型是否有数量参数(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')
|
||||
return !!modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||||
})
|
||||
|
||||
// 模型是否使用 proportion 组件(aspectRatio 参数)
|
||||
@ -148,6 +168,40 @@ const hasCustomSize = computed(() => {
|
||||
return ratioParam?.options?.includes('custom') || false
|
||||
})
|
||||
|
||||
// 尺寸输入(DimensionInput)相关
|
||||
const dimWidth = ref(1024)
|
||||
const dimHeight = ref(1024)
|
||||
const qualityValue = ref('medium')
|
||||
|
||||
const showDimension = computed(() => {
|
||||
return !!modelConfig.value?.params?.find(p => p.ui === 'dimension' || p.ui === 'dimensionWidth')
|
||||
})
|
||||
|
||||
const dimConfig = computed(() => {
|
||||
if (!modelConfig.value) return null
|
||||
const dimParam = modelConfig.value.params.find(p => p.ui === 'dimension')
|
||||
if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name }
|
||||
const wParam = modelConfig.value.params.find(p => p.ui === 'dimensionWidth')
|
||||
const hParam = modelConfig.value.params.find(p => p.ui === 'dimensionHeight')
|
||||
if (wParam && hParam) return { type: 'split', wParam, hParam }
|
||||
return null
|
||||
})
|
||||
|
||||
const dimMinW = computed(() => dimConfig.value?.config?.width?.min || dimConfig.value?.wParam?.min || 256)
|
||||
const dimMaxW = computed(() => dimConfig.value?.config?.width?.max || dimConfig.value?.wParam?.max || 6197)
|
||||
const dimMinH = computed(() => dimConfig.value?.config?.height?.min || dimConfig.value?.hParam?.min || 256)
|
||||
const dimMaxH = computed(() => dimConfig.value?.config?.height?.max || dimConfig.value?.hParam?.max || 4096)
|
||||
|
||||
const showQuality = computed(() => {
|
||||
return !!modelConfig.value?.params?.find(p => p.name === 'quality')
|
||||
})
|
||||
|
||||
const qualityOpts = computed(() => {
|
||||
const q = modelConfig.value?.params?.find(p => p.name === 'quality')
|
||||
if (q?.options) return q.options.map(o => ({ value: o, label: o }))
|
||||
return []
|
||||
})
|
||||
|
||||
// 从模型配置派生比例选项,回退到默认值
|
||||
const paintingProportionOpts = computed(() => {
|
||||
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||
@ -215,6 +269,19 @@ watch(modelConfig, (config) => {
|
||||
if (cwParam) customWidth.value = cwParam.default || 1024
|
||||
const chParam = config.params.find(p => p.name === 'customHight')
|
||||
if (chParam) customHight.value = chParam.default || 1024
|
||||
// 同步 dimension 和 quality 默认值
|
||||
const qualityParam = config.params.find(p => p.name === 'quality')
|
||||
if (qualityParam) qualityValue.value = qualityParam.default || 'medium'
|
||||
const dc = dimConfig.value
|
||||
if (dc?.type === 'split') {
|
||||
dimWidth.value = dc.wParam.default || 1024
|
||||
dimHeight.value = dc.hParam.default || 1024
|
||||
} else if (dc?.type === 'combined') {
|
||||
const raw = paramValues[dc.paramName] || config.params.find(p => p.name === dc.paramName)?.default || ''
|
||||
const parsed = dc.config.parse(raw)
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 反向同步:UI refs → paramValues
|
||||
@ -240,6 +307,21 @@ watch(customHight, (val) => {
|
||||
paramValues.customHight = val
|
||||
}
|
||||
})
|
||||
watch([dimWidth, dimHeight], ([w, h]) => {
|
||||
const dc = dimConfig.value
|
||||
if (!dc) return
|
||||
if (dc.type === 'split') {
|
||||
paramValues[dc.wParam.name] = w
|
||||
paramValues[dc.hParam.name] = h
|
||||
} else if (dc.type === 'combined') {
|
||||
paramValues[dc.paramName] = dc.config.format(w, h)
|
||||
}
|
||||
})
|
||||
watch(qualityValue, (val) => {
|
||||
if (modelConfig.value?.params?.find(p => p.name === 'quality')) {
|
||||
paramValues.quality = val
|
||||
}
|
||||
})
|
||||
|
||||
// 同步参考图片到 paramValues
|
||||
watch(referenceImages, (imgs) => {
|
||||
@ -403,6 +485,21 @@ const fillParamsFromResult = (resultData) => {
|
||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||
// 从恢复的 modelParams 同步 dimension/quality UI refs
|
||||
nextTick(() => {
|
||||
const dc = dimConfig.value
|
||||
if (dc?.type === 'split') {
|
||||
if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name]
|
||||
if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name]
|
||||
} else if (dc?.type === 'combined') {
|
||||
if (paramValues[dc.paramName]) {
|
||||
const parsed = dc.config.parse(paramValues[dc.paramName])
|
||||
dimWidth.value = parsed.width
|
||||
dimHeight.value = parsed.height
|
||||
}
|
||||
}
|
||||
if (paramValues.quality !== undefined) qualityValue.value = paramValues.quality
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@ -667,4 +764,28 @@ watch(() => props.type, (newType) => {
|
||||
// .gerenate:hover{
|
||||
// background: rgba(0, 15, 51, 0.20);
|
||||
// }
|
||||
// 画质选择器
|
||||
.quality-select {
|
||||
:deep(.select-header) {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
font-family: "Microsoft YaHei";
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
"generate": [
|
||||
{ "value": "flux", "label": "flux" },
|
||||
{ "value": "zImage", "label": "Z-image" },
|
||||
{ "value": "jimeng", "label": "jimeng" },
|
||||
{ "value": "QwenImage", "label": "QwenImage" }
|
||||
],
|
||||
"edit": [
|
||||
{ "value": "BananaPro", "label": "Banana-Pro" },
|
||||
{ "value": "Qwen-image", "label": "Qwen-image" },
|
||||
{ "value": "Kontext", "label": "Kontext" },
|
||||
{ "value": "Jimeng_4.0", "label": "Jimeng.4.0" }
|
||||
],
|
||||
"vision": [
|
||||
{ "value": "Qwen3.5plus", "label": "Qwen3.5plus", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"文生视频": [
|
||||
{ "value": "LTX2.0", "label": "LTX2.0 T2V" },
|
||||
{ "value": "viduQ3-T2V", "label": "viduQ3 T2V" }
|
||||
],
|
||||
"首尾帧": [
|
||||
{ "value": "Hailuo-02-fast", "label": "海螺 fast" },
|
||||
{ "value": "LTX2.0-I2V", "label": "LTX2.0 I2V" },
|
||||
{ "value": "LTX2.3-T2V", "label": "LTX2.3 T2V", "disabled": true },
|
||||
{ "value": "ViduQ3-turbo", "label": "ViduQ3-turbo" }
|
||||
],
|
||||
"数字人": [
|
||||
{ "value": "FlashHead", "label": "FlashHead" }
|
||||
],
|
||||
"全能参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"智能多帧": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
],
|
||||
"主体参考": [
|
||||
{ "value": "Seedance 2.0", "label": "Seedance 2.0", "disabled": true }
|
||||
]
|
||||
}
|
||||
@ -18,7 +18,7 @@ export default {
|
||||
default: 1024,
|
||||
min: 900,
|
||||
max: 6197,
|
||||
ui: 'number',
|
||||
ui: 'dimensionWidth',
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
@ -27,7 +27,14 @@ export default {
|
||||
default: 1024,
|
||||
min: 768,
|
||||
max: 4096,
|
||||
ui: 'number',
|
||||
ui: 'dimensionHeight',
|
||||
},
|
||||
{
|
||||
name: 'forceSingle',
|
||||
label: '强制单张',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
ui: 'hidden',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@ -22,17 +22,19 @@ export default {
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
label: '尺寸',
|
||||
type: 'string',
|
||||
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',
|
||||
ui: 'dimension',
|
||||
dimension: {
|
||||
parse: (val) => {
|
||||
const parts = (val || '1024*1024').split('*')
|
||||
return { width: parseInt(parts[0]) || 1024, height: parseInt(parts[1]) || 1024 }
|
||||
},
|
||||
format: (w, h) => `${w}*${h}`,
|
||||
width: { min: 512, max: 2048 },
|
||||
height: { min: 512, max: 2048 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
|
||||
@ -14,17 +14,19 @@ export default {
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
label: '尺寸',
|
||||
type: 'string',
|
||||
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',
|
||||
ui: 'dimension',
|
||||
dimension: {
|
||||
parse: (val) => {
|
||||
const parts = (val || '1024*1024').split('*')
|
||||
return { width: parseInt(parts[0]) || 1024, height: parseInt(parts[1]) || 1024 }
|
||||
},
|
||||
format: (w, h) => `${w}*${h}`,
|
||||
width: { min: 512, max: 2048 },
|
||||
height: { min: 512, max: 2048 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
|
||||
@ -105,7 +105,6 @@ const isInitializing = ref(true)
|
||||
const { canvasVisible, canvasImage, canvasReferenceImages, canvasSource } = storeToRefs(useDisplay)
|
||||
|
||||
const chargeType = computed(() => getChargeType(props.type))
|
||||
// console.log(chargeType.value)
|
||||
|
||||
const timeOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@ -138,41 +137,61 @@ const toggleDisplay = (newValue, oldValue) => {
|
||||
|
||||
const conversion = (newlist) => {
|
||||
const temp = newlist.map((item) => {
|
||||
const files = item.fileUrl ? item.fileUrl.split(',').filter(url => url.trim()) : []
|
||||
const generateData = JSON.parse(item.result || '{}')
|
||||
// 从 outputs 扁平数组提取 URL
|
||||
const files = item.outputs?.map(o => o.url) || []
|
||||
const request = item.request || {}
|
||||
const generateData = {
|
||||
model: item.model_name || '',
|
||||
modelType: request.modelType || '',
|
||||
prompt: request.prompt || '',
|
||||
proportion: request.aspectRatio || '',
|
||||
referenceImages: request.referenceImages || [],
|
||||
quantity: request.imageNum || files.length || 1,
|
||||
resolution: request.resolution || '',
|
||||
customWidth: request.customWidth,
|
||||
customHight: request.customHight,
|
||||
duration: request.duration || '',
|
||||
videoPattern: request.videoPattern || '',
|
||||
modelParams: { ...request },
|
||||
}
|
||||
// 将 API status 映射为 UI 展示状态
|
||||
let uiStatus = 'success'
|
||||
if (item.status === 'failed' || item.status === 'cancelled') uiStatus = 'error'
|
||||
else if (item.status === 'queued' || item.status === 'processing') uiStatus = 'generate'
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
taskId: item.taskId,
|
||||
taskId: item.id,
|
||||
type: props.type,
|
||||
collection: item.collection,
|
||||
status: 'success',
|
||||
generateData: generateData,
|
||||
time: item.createTime,
|
||||
files: files,
|
||||
collectStatus: item.collectStatus || {}
|
||||
status: uiStatus,
|
||||
generateData,
|
||||
time: item.created_at || '',
|
||||
files,
|
||||
collectStatus: item.collectStatus || {},
|
||||
}
|
||||
})
|
||||
console.log(temp)
|
||||
return temp
|
||||
}
|
||||
|
||||
const fetchHistory = async (isLoadMore = false) => {
|
||||
if (isLoading.value || (!isLoadMore && !hasMoreData.value)) return
|
||||
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
|
||||
try {
|
||||
const pageToFetch = isLoadMore ? currentPage.value + 1 : 1
|
||||
|
||||
|
||||
const result = await requestTaskHistory({
|
||||
user_id: userStore.userInfo.id,
|
||||
platform_code: getPlatformCode(props.type),
|
||||
status: 'completed',
|
||||
page: pageToFetch,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const dataList = result.data?.list || result.data || []
|
||||
|
||||
|
||||
if (dataList.length === 0) {
|
||||
hasMoreData.value = false
|
||||
if (!isLoadMore) {
|
||||
@ -192,9 +211,9 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
} else {
|
||||
useDisplay.initHistoryList(adaptedList)
|
||||
currentPage.value = 1
|
||||
|
||||
|
||||
await nextTick()
|
||||
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setTimeout(() => {
|
||||
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
|
||||
@ -202,7 +221,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
}
|
||||
}, 100 * i)
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollerRef.value && typeof scrollerRef.value.scrollToBottom === 'function') {
|
||||
scrollerRef.value.scrollToBottom()
|
||||
@ -214,9 +233,9 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
}, 300)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
|
||||
hasMoreData.value = dataList.length === 10
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取历史失败:', error)
|
||||
ElMessage({
|
||||
@ -230,7 +249,7 @@ const fetchHistory = async (isLoadMore = false) => {
|
||||
|
||||
const handleScroll = (scrollInfo) => {
|
||||
if (isInitializing.value) return
|
||||
|
||||
|
||||
if (!scrollInfo) return
|
||||
const { isAtPageTop, isAtPageBottom, distanceToPageTop, distanceToPageBottom } = scrollInfo
|
||||
|
||||
@ -241,7 +260,7 @@ const handleScroll = (scrollInfo) => {
|
||||
isLoadingMoreLocked.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
|
||||
if (isAtPageBottom) {
|
||||
useDisplay.Sender_variant = 'updown'
|
||||
} else if (distanceToPageTop >= 350) {
|
||||
@ -282,7 +301,7 @@ const handleDeleteSuccess = (id) => {
|
||||
onMounted(() => {
|
||||
if (!props.loading) return
|
||||
refreshing.value = true
|
||||
|
||||
|
||||
nextTick(() => {
|
||||
useDisplay.scrollerRef = scrollerRef.value
|
||||
useDisplay.resetPagination()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user