回退动态参数控件为独立组件,模型配置对齐 API 文档,修复多处缺陷
- 删除 params/ 动态控件,恢复 paintingProportion/Quantity 独立组件 - 模型参数 UI 双向绑定:proportion/resolution/quantity/customSize 同步到 paramValues - 模型选择器适配 API tags 数组和 display_name,新增 displayNameMap 映射 - 模型配置对齐 RunningHub 文档,精简即梦/通义万相多余参数 - 模型列表缓存改为 30s TTL + pendingRequests 并发去重 - sessionId 改为从登录态获取,禁止随机生成 - Select 下拉菜单增加 max-height 防止溢出 - 更新 CLAUDE.md 架构文档
This commit is contained in:
parent
239b32fb95
commit
4f7357eefc
117
CLAUDE.md
117
CLAUDE.md
@ -20,8 +20,8 @@ AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度
|
|||||||
|
|
||||||
**Painting 和 Video 走两套不同的任务构造路径:**
|
**Painting 和 Video 走两套不同的任务构造路径:**
|
||||||
|
|
||||||
- **Painting(新架构)**:本地模型参数 schema → 动态表单 → 扁平 API body 提交
|
- **Painting(新架构)**:本地模型参数 schema → 专用控件 + 动态表单 → `X-Session-Id` header + 扁平 API body
|
||||||
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body 提交
|
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body
|
||||||
|
|
||||||
### 关键目录
|
### 关键目录
|
||||||
|
|
||||||
@ -30,27 +30,30 @@ src/
|
|||||||
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
|
├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller
|
||||||
├── router/index.js # 路由定义 + token 验证守卫
|
├── router/index.js # 路由定义 + token 验证守卫
|
||||||
├── stores/ # Pinia 状态管理
|
├── stores/ # Pinia 状态管理
|
||||||
│ ├── user.js # 用户认证、信息
|
│ ├── user.js # 用户认证、信息(含 sessionId)
|
||||||
│ └── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
│ ├── display.js # 生成历史列表、UI 状态(滚动、画布等)
|
||||||
|
│ └── param.js # 参数 store(当前为空)
|
||||||
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
||||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息)
|
│ ├── auth/ # 认证相关(登录、token 校验、用户信息)
|
||||||
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口)
|
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口)
|
||||||
│ │ ├── model/ # 模型选择器(painting 按 tag 分组,video 按 pattern 分组)
|
│ │ ├── model/ # 模型选择器(按 API 返回的 tags 分组,value 编码为 tag::display_name)
|
||||||
│ │ ├── params/ # 动态参数控件(Painting 新架构):ProportionSelect、ResolutionSelect、SelectInput 等
|
│ │ ├── proportion/ # 比例/分辨率选择器(painting.vue 用于 Painting,video.vue 用于 Video)
|
||||||
│ │ ├── proportion/ # 比例选择器(仅 video.vue,Video 旧架构)
|
|
||||||
│ │ ├── imageUploader/ # 图片上传(Painting)
|
│ │ ├── imageUploader/ # 图片上传(Painting)
|
||||||
│ │ └── videoImageUploader/ # 视频图片上传(Video)
|
│ │ ├── videoImageUploader/ # 视频图片上传(Video)
|
||||||
|
│ │ ├── quantity/ # 生成数量选择器(支持 1-6)
|
||||||
|
│ │ ├── Time/ # 视频时长选择器
|
||||||
|
│ │ └── pattern/ # 视频模式选择器
|
||||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
||||||
├── views/ # 页面(home、login)
|
├── views/ # 页面(home、login)
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||||
│ ├── websocket.js # 任务生成入口:组装参数 → 调用 API → 20s 轮询直至完成/失败
|
│ ├── websocket.js # 任务生成入口:组装参数 → POST 创建任务 → 20s 轮询直至完成/失败
|
||||||
│ ├── modelApi.js # 模型业务层:localStorage 每日缓存 + 模型名称→UUID 查找 + 平台编码映射
|
│ ├── modelApi.js # 模型业务层:localStorage 30s 缓存 + pendingRequests 并发去重 + 平台编码映射
|
||||||
│ ├── createTask.js # 任务 body 构造:Painting 返回 modelParams,Video 走 Playload 适配器
|
│ ├── createTask.js # 任务 body 构造:Painting 返回 modelParams,Video 走 Playload 适配器
|
||||||
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置(Video 专用)
|
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置
|
||||||
│ └── auth.ts # token 存取工具(localStorage)
|
│ └── auth.ts # token 存取工具(localStorage)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
||||||
@ -60,67 +63,56 @@ src/
|
|||||||
|
|
||||||
### 模型参数配置(Painting 新架构)
|
### 模型参数配置(Painting 新架构)
|
||||||
|
|
||||||
`src/config/models/` 下每个模型一个 JS 文件,定义该模型的 API 参数 schema:
|
`src/config/models/` 下每个模型一个 JS 文件,参数通过不同 UI 组件承载:
|
||||||
|
|
||||||
```js
|
- **`ui: 'textarea'`** → Sender 组件主输入框(prompt)
|
||||||
export default {
|
- **`ui: 'proportion'`** + **`ui: 'resolution'`** → `paintingProportion` 组件(共用 Popover,含自定义 W/H)
|
||||||
name: 'Flux 2',
|
- **`ui: 'quantity'`** → `Quantity` 组件(动态 1-6 张)
|
||||||
tag: '文生图', // 与 API 返回的 tag 对应,用于模型选择器分组
|
- **`ui: 'imageUpload'`** → `ImageUploader` 组件
|
||||||
inputType: 'text', // 'text' | 'image' | 'both' — 控制是否显示图片上传
|
- **`ui: 'hidden'`** → 无 UI,仅写入默认值(如 outputFormat: 'png')
|
||||||
maxImages: 4, // 最大上传图片数(inputType 为 image/both 时有效)
|
|
||||||
params: [
|
|
||||||
{
|
|
||||||
name: 'prompt', // API 字段名
|
|
||||||
label: '提示词',
|
|
||||||
type: 'string', // 'string' | 'number' | 'boolean' | 'select' | 'image'
|
|
||||||
required: true,
|
|
||||||
ui: 'textarea', // 渲染控件:'textarea' | 'proportion' | 'resolution' | 'select' | 'number' | 'switch' | 'imageUpload'
|
|
||||||
default: '',
|
|
||||||
options: [...], // select 类型的枚举值
|
|
||||||
showWhen: { aspectRatio: 'custom' }, // 条件显示(可选)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `src/config/models/index.js` 提供 `getModelConfig(modelName)` 查找函数
|
模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 API 返回的 `tags` 数组字段分组(`text`→生成模型,`edit`→编辑模型,`vision`→视觉理解模型)。
|
||||||
- `ui: 'textarea'` 的参数由 Sender 组件承载(prompt),`ui: 'imageUpload'` 由独立上传组件处理,其余渲染为 params/ 下的动态控件
|
|
||||||
- 模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 `tag` 字段分组
|
### `displayNameMap` 机制
|
||||||
|
|
||||||
|
`src/config/models/index.js` 中 `displayNameMap` 负责将 API 返回的 `display_name` 映射到 config key。因为同一模型在不同 tag 下可能共用一个 `display_name`(如 `GPT-Image-2` 和 `GPT-image-2` 分别对应编辑/生成),config key 采用内部中文名区分。
|
||||||
|
|
||||||
### API 层设计原则
|
### API 层设计原则
|
||||||
|
|
||||||
- `src/apis/` 中的函数只做**纯 HTTP 调用**(`service.get/post/delete` 等),不包含缓存、localStorage、业务判断等逻辑
|
- `src/apis/` 只做纯 HTTP 调用(`service.get/post/delete`),不含缓存、localStorage、业务逻辑
|
||||||
- 缓存、数据转换等业务逻辑放在 `src/utils/` 中,调用 apis 层的原始函数
|
- 缓存、数据转换等业务逻辑放在 `src/utils/` 中
|
||||||
|
|
||||||
示例:`utils/modelApi.js` 导入 `apis/display` 的原始 `fetchPlatformModels`,在其上叠加 localStorage 每日缓存和 `getModelId` 查找逻辑。
|
|
||||||
|
|
||||||
### 核心数据流
|
### 核心数据流
|
||||||
|
|
||||||
**Painting(新架构):**
|
**Painting(新架构):**
|
||||||
|
|
||||||
1. 用户在 `dialogBox` 中设置参数——模型选择器从 API 获取模型列表按 tag 分组,参数控件根据模型 config 动态渲染
|
1. 用户设置参数 → 模型选择器按 `tags` 分组,控件根据 model config 的 `ui` 字段渲染
|
||||||
2. 点击生成 → `dialogBox:handleStart()` 收集 `paramValues`(含 prompt)→ 组装 data(含 `modelParams` 扁平对象)
|
2. `handleStart()` 收集 `paramValues`(UI refs 通过 watcher 双向同步)→ 组装 `{ modelParams, request }`
|
||||||
3. 调用 `websocket.js:generate(data, generateData)`
|
3. `websocket.js:generate()` → `createTask(data)` → Painting 直接返回 `data.modelParams`
|
||||||
4. `generate()` 内部通过 `createTask(data)` → 因 `type === 'Painting'` 直接返回 `data.modelParams` 作为 body
|
4. `getModelId(type, modelName)` 查找 UUID(内部调用 `fetchPlatformModels` 走缓存)
|
||||||
5. 调用 `modelApi.getModelId(type, modelName)` 查找模型 UUID
|
5. `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`,携带 `X-Session-Id` header
|
||||||
6. 调用 `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`(`{ model_id, body: modelParams, request }`)
|
6. 返回 task_id → 20s 间隔轮询直至完成/失败
|
||||||
7. 返回 task_id → 轮询直至完成
|
|
||||||
|
|
||||||
**Video(旧架构,保留):**
|
**Video(旧架构,保留):**
|
||||||
|
|
||||||
1. 用户在 `dialogBox` 中设置参数(Pattern、videoModel、比例、时长)
|
1. 用户设置 Pattern、videoModel、比例、时长
|
||||||
2. 点击生成 → `dialogBox:handleStart()` 组装 data(含旧 params 数组)
|
2. `createTask(data)` → `runninghub.Playload(data)` → `fetchModelConfig()` 获取 workflow JSON → 返回 `{ workflowId, nodeInfoList }`
|
||||||
3. `createTask(data)` → `runninghub.Playload(data)` → `fetchModelConfig()` 获取 workflow JSON → 返回 `{ workflowId, nodeInfoList }`
|
3. 后续同 Painting 步骤 4-6
|
||||||
4. 后续同 Painting 的步骤 5-7
|
|
||||||
|
### 关键注意事项
|
||||||
|
|
||||||
|
- **`sessionId`** 来自登录接口返回的 `userInfo.sessionId`,存储在 `useUserStore().userInfo` 中。`websocket.js` 必须使用该值,禁止随机生成。
|
||||||
|
- **`X-Session-Id`** 自定义 header 需要 nginx 在 `/suanli/` location 的 `Access-Control-Allow-Headers` 中加入,否则 POST 请求会触发 CORS 预检失败。
|
||||||
|
- **模型列表缓存**:`modelApi.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重,避免重复请求。
|
||||||
|
|
||||||
### 接口速查
|
### 接口速查
|
||||||
|
|
||||||
| 函数 | 端点 | 用途 |
|
| 函数 | 端点 | 用途 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建生成任务 |
|
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建任务(带 `X-Session-Id` header) |
|
||||||
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
|
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
|
||||||
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize`) |
|
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize`) |
|
||||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, name, tag, disabled?}`) |
|
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, display_name, tags, disabled?}`) |
|
||||||
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
|
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
|
||||||
| `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 |
|
| `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 |
|
||||||
|
|
||||||
@ -128,15 +120,12 @@ export default {
|
|||||||
|
|
||||||
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据 URL 前缀切换后端:
|
拦截器统一设置 `Authorization: <token>`(不带 Bearer 前缀),根据 URL 前缀切换后端:
|
||||||
|
|
||||||
| URL 前缀 | 环境变量 | 示例 target |
|
| URL 前缀 | 环境变量 |
|
||||||
|----------|----------|-------------|
|
|----------|----------|
|
||||||
| `/suanli` | `VITE_API_TASK_TARGET` | `http://test.xueai.art` |
|
| `/suanli` | `VITE_API_TASK_TARGET` |
|
||||||
| `/pay` | `VITE_API_PAY_TARGET` | `http://test.xueai.art` |
|
| `/pay` | `VITE_API_PAY_TARGET` |
|
||||||
| 其他 | `VITE_API_BASE_URL`(默认) | `http://test.xueai.art/newapi/api` |
|
| `/aigc` | `VITE_API_AIGC_TARGET` |
|
||||||
|
| 其他 | `VITE_API_BASE_URL`(默认) |
|
||||||
### 响应格式兼容
|
|
||||||
|
|
||||||
响应拦截器同时识别 `code === 0` 和 `status === 0` 两种成功状态。用户信息接口兼容新旧两种格式:`data.userInfo` 嵌套(新)和 `data` 扁平(旧),在 store 的 `getInfo()` 中做了 `const u = res.data.userInfo || res.data` 的兼容处理。
|
|
||||||
|
|
||||||
### 平台编码映射
|
### 平台编码映射
|
||||||
|
|
||||||
@ -149,10 +138,6 @@ export default {
|
|||||||
|
|
||||||
### 自动导入
|
### 自动导入
|
||||||
|
|
||||||
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API,`.vue` 中无需手动 import
|
- `unplugin-auto-import`:自动导入 Vue/Router/Pinia API
|
||||||
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件
|
- `unplugin-vue-components`:自动注册 `src/components/` 下的组件和 Element Plus 组件
|
||||||
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
- Element Plus 图标通过 `unplugin-icons` 按需加载
|
||||||
|
|
||||||
### 路由守卫
|
|
||||||
|
|
||||||
`src/router/index.js` 的 `beforeEach` 守卫检查 token 存在性和有效性(调用 `POST /login/validateToken`),无效则跳转 `/login`。支持通过 URL query `?token=xxx` 传入 token。
|
|
||||||
|
|||||||
7
components.d.ts
vendored
7
components.d.ts
vendored
@ -18,6 +18,7 @@ declare module 'vue' {
|
|||||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
@ -32,18 +33,14 @@ declare module 'vue' {
|
|||||||
IEpStar: typeof import('~icons/ep/star')['default']
|
IEpStar: typeof import('~icons/ep/star')['default']
|
||||||
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/index.vue')['default']
|
||||||
Img: typeof import('./src/components/Img/index.vue')['default']
|
Img: typeof import('./src/components/Img/index.vue')['default']
|
||||||
NumberInput: typeof import('./src/components/dialogBox/params/NumberInput.vue')['default']
|
|
||||||
Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default']
|
Painting: typeof import('./src/components/dialogBox/model/painting.vue')['default']
|
||||||
|
ParamControl: typeof import('./src/components/dialogBox/ParamControl.vue')['default']
|
||||||
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
|
Pattern: typeof import('./src/components/dialogBox/pattern/index.vue')['default']
|
||||||
Popover: typeof import('./src/components/Popover/index.vue')['default']
|
Popover: typeof import('./src/components/Popover/index.vue')['default']
|
||||||
ProportionSelect: typeof import('./src/components/dialogBox/params/ProportionSelect.vue')['default']
|
|
||||||
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
Quantity: typeof import('./src/components/dialogBox/quantity/index.vue')['default']
|
||||||
ResolutionSelect: typeof import('./src/components/dialogBox/params/ResolutionSelect.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Select: typeof import('./src/components/Select/index.vue')['default']
|
Select: typeof import('./src/components/Select/index.vue')['default']
|
||||||
SelectInput: typeof import('./src/components/dialogBox/params/SelectInput.vue')['default']
|
|
||||||
SwitchInput: typeof import('./src/components/dialogBox/params/SwitchInput.vue')['default']
|
|
||||||
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
Time: typeof import('./src/components/dialogBox/Time/index.vue')['default']
|
||||||
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
||||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||||
|
|||||||
@ -261,6 +261,8 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid #e8e8e8;
|
border: 1px solid #e8e8e8;
|
||||||
animation: fadeIn 0.2s ease;
|
animation: fadeIn 0.2s ease;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
|
|||||||
@ -31,13 +31,17 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
|
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
|
||||||
<paintingModel v-model="model" v-model:typeValue="modelType" />
|
<paintingModel v-model="model" v-model:typeValue="modelType" />
|
||||||
<template v-for="param in visibleParams" :key="param.name">
|
<paintingProportion
|
||||||
<ProportionSelect v-if="param.ui === 'proportion'" v-model="paramValues[param.name]" :param="param" />
|
v-if="showProportion"
|
||||||
<ResolutionSelect v-if="param.ui === 'resolution'" v-model="paramValues[param.name]" :param="param" />
|
v-model="proportion"
|
||||||
<SelectInput v-if="param.ui === 'select'" v-model="paramValues[param.name]" :param="param" />
|
v-model:resolution="resolution"
|
||||||
<NumberInput v-if="param.ui === 'number'" v-model="paramValues[param.name]" :param="param" />
|
v-model:width="customWidth"
|
||||||
<SwitchInput v-if="param.ui === 'switch'" v-model="paramValues[param.name]" :param="param" />
|
v-model:height="customHight"
|
||||||
</template>
|
:proportion-options="paintingProportionOpts"
|
||||||
|
:resolution-options="paintingResolutionOpts"
|
||||||
|
:allow-custom="hasCustomSize"
|
||||||
|
/>
|
||||||
|
<Quantity v-if="showQuantity" v-model="quantity" :max="quantityMax" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
|
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
|
||||||
@ -80,11 +84,8 @@ import Pattern from './pattern/index.vue'
|
|||||||
import ImageUploader from './imageUploader/index.vue'
|
import ImageUploader from './imageUploader/index.vue'
|
||||||
import VideoImageUploader from './videoImageUploader/index.vue'
|
import VideoImageUploader from './videoImageUploader/index.vue'
|
||||||
import Time from './Time/index.vue'
|
import Time from './Time/index.vue'
|
||||||
import ProportionSelect from './params/ProportionSelect.vue'
|
import paintingProportion from './proportion/painting.vue'
|
||||||
import ResolutionSelect from './params/ResolutionSelect.vue'
|
import Quantity from './quantity/index.vue'
|
||||||
import SelectInput from './params/SelectInput.vue'
|
|
||||||
import NumberInput from './params/NumberInput.vue'
|
|
||||||
import SwitchInput from './params/SwitchInput.vue'
|
|
||||||
import { Sender } from 'vue-element-plus-x'
|
import { Sender } from 'vue-element-plus-x'
|
||||||
import { useDisplayStore } from '@/stores'
|
import { useDisplayStore } from '@/stores'
|
||||||
import { generate } from '@/utils/websocket'
|
import { generate } from '@/utils/websocket'
|
||||||
@ -123,34 +124,48 @@ const modelConfig = computed(() => {
|
|||||||
// 模型参数值
|
// 模型参数值
|
||||||
const paramValues = reactive({})
|
const paramValues = reactive({})
|
||||||
|
|
||||||
// 同步模型默认值到 paramValues
|
|
||||||
watch(modelConfig, (config) => {
|
|
||||||
if (!config) return
|
|
||||||
config.params.forEach(p => {
|
|
||||||
if (!(p.name in paramValues)) {
|
|
||||||
paramValues[p.name] = p.default ?? ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// 需要显示的参数控件(排除 textarea 和 imageUpload 类型,由 Sender 和独立上传组件处理)
|
|
||||||
const visibleParams = computed(() => {
|
|
||||||
if (!modelConfig.value) return []
|
|
||||||
return modelConfig.value.params.filter(p => {
|
|
||||||
// 条件显示
|
|
||||||
if (p.showWhen) {
|
|
||||||
for (const [key, val] of Object.entries(p.showWhen)) {
|
|
||||||
if (paramValues[key] !== val) return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// textarea 由 Sender 承载,imageUpload 由独立上传组件处理
|
|
||||||
return p.ui !== 'textarea' && p.ui !== 'imageUpload'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const showImageUploader = computed(() => {
|
const showImageUploader = computed(() => {
|
||||||
if (props.type === 'Video') return modelType.value !== 'text'
|
if (props.type === 'Video') return modelType.value !== 'text'
|
||||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
return modelType.value !== 'text' || modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模型是否有数量参数(imageNum),或生成类模型显示张数选择
|
||||||
|
const showQuantity = computed(() => {
|
||||||
|
if (props.type !== 'Painting') return false
|
||||||
|
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||||||
|
if (qtyParam) return true
|
||||||
|
return modelType.value === 'text' && !modelConfig.value?.params?.find(p => p.name === 'forceSingle')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模型是否使用 proportion 组件(aspectRatio 参数)
|
||||||
|
const showProportion = computed(() => {
|
||||||
|
return !!modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模型是否支持自定义尺寸(aspectRatio 选项含 'custom')
|
||||||
|
const hasCustomSize = computed(() => {
|
||||||
|
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||||
|
return ratioParam?.options?.includes('custom') || false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从模型配置派生比例选项,回退到默认值
|
||||||
|
const paintingProportionOpts = computed(() => {
|
||||||
|
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||||||
|
if (ratioParam?.options) {
|
||||||
|
return ratioParam.options
|
||||||
|
.filter(o => o !== 'custom')
|
||||||
|
.map(o => ({ value: o, label: o }))
|
||||||
|
}
|
||||||
|
return proportionOptions.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从模型配置派生分辨率选项(仅模型有 resolution 参数时显示)
|
||||||
|
const paintingResolutionOpts = computed(() => {
|
||||||
|
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
|
||||||
|
if (resParam?.options) {
|
||||||
|
return resParam.options.map(o => ({ value: o, label: o.toUpperCase() }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageUploadLimit = computed(() => {
|
const imageUploadLimit = computed(() => {
|
||||||
@ -168,13 +183,90 @@ const referenceImages = ref([])
|
|||||||
|
|
||||||
// 绘画
|
// 绘画
|
||||||
const quantity = ref(1) // 生成数量
|
const quantity = ref(1) // 生成数量
|
||||||
|
const customWidth = ref(1024) // 自定义宽度
|
||||||
|
const customHight = ref(1024) // 自定义高度
|
||||||
|
|
||||||
|
const quantityMax = computed(() => {
|
||||||
|
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||||||
|
if (qtyParam?.options?.length) return Math.max(...qtyParam.options)
|
||||||
|
return 4
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同步模型默认值到 paramValues 和 UI refs
|
||||||
|
watch(modelConfig, (config) => {
|
||||||
|
if (!config) return
|
||||||
|
config.params.forEach(p => {
|
||||||
|
if (!(p.name in paramValues)) {
|
||||||
|
if (p.name === 'outputFormat') {
|
||||||
|
paramValues[p.name] = 'png'
|
||||||
|
} else {
|
||||||
|
paramValues[p.name] = p.default ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 同步默认值到 UI 控件
|
||||||
|
const ratioParam = config.params.find(p => p.ui === 'proportion')
|
||||||
|
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||||||
|
const resParam = config.params.find(p => p.ui === 'resolution')
|
||||||
|
if (resParam) resolution.value = resParam.default || '2k'
|
||||||
|
const qtyParam = config.params.find(p => p.ui === 'quantity')
|
||||||
|
if (qtyParam) quantity.value = qtyParam.default || 1
|
||||||
|
const cwParam = config.params.find(p => p.name === 'customWidth')
|
||||||
|
if (cwParam) customWidth.value = cwParam.default || 1024
|
||||||
|
const chParam = config.params.find(p => p.name === 'customHight')
|
||||||
|
if (chParam) customHight.value = chParam.default || 1024
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 反向同步:UI refs → paramValues
|
||||||
|
watch(proportion, (val) => {
|
||||||
|
const p = modelConfig.value?.params?.find(param => param.ui === 'proportion')
|
||||||
|
if (p) paramValues[p.name] = val
|
||||||
|
})
|
||||||
|
watch(resolution, (val) => {
|
||||||
|
const p = modelConfig.value?.params?.find(param => param.ui === 'resolution')
|
||||||
|
if (p) paramValues[p.name] = val
|
||||||
|
})
|
||||||
|
watch(quantity, (val) => {
|
||||||
|
const p = modelConfig.value?.params?.find(param => param.ui === 'quantity')
|
||||||
|
if (p) paramValues[p.name] = val
|
||||||
|
})
|
||||||
|
watch(customWidth, (val) => {
|
||||||
|
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
|
||||||
|
paramValues.customWidth = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(customHight, (val) => {
|
||||||
|
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
|
||||||
|
paramValues.customHight = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同步参考图片到 paramValues
|
||||||
|
watch(referenceImages, (imgs) => {
|
||||||
|
const imageParam = modelConfig.value?.params?.find(p => p.ui === 'imageUpload')
|
||||||
|
if (imageParam) {
|
||||||
|
paramValues[imageParam.name] = imgs.map(img => img.url)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// 视频
|
// 视频
|
||||||
const duration = ref(5) // 时间
|
const duration = ref(5) // 时间
|
||||||
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
|
const videoPattern = ref('文生视频') // 视频模式下,默认值为'文生视频'
|
||||||
|
|
||||||
const resolutionOptions = ref([])
|
const resolutionOptions = ref([
|
||||||
const proportionOptions = ref([])
|
{ value: '1k', label: '标清 1K' },
|
||||||
|
{ value: '2k', label: '高清 2K' },
|
||||||
|
{ value: '4k', label: '超清 4K' },
|
||||||
|
])
|
||||||
|
const proportionOptions = ref([
|
||||||
|
{ value: '智能', label: '智能' },
|
||||||
|
{ value: '21:9', label: '21:9' },
|
||||||
|
{ value: '16:9', label: '16:9' },
|
||||||
|
{ value: '4:3', label: '4:3' },
|
||||||
|
{ value: '1:1', label: '1:1' },
|
||||||
|
{ value: '3:4', label: '3:4' },
|
||||||
|
{ value: '9:16', label: '9:16' },
|
||||||
|
])
|
||||||
const durationOptions = ref([])
|
const durationOptions = ref([])
|
||||||
|
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
@ -189,18 +281,6 @@ const autoSizeConfig = computed(() => {
|
|||||||
|
|
||||||
const modelDisplayConfig = ref(null)
|
const modelDisplayConfig = ref(null)
|
||||||
|
|
||||||
// Painting: 从本地配置加载模型参数 schema
|
|
||||||
const loadPaintingModelConfig = (modelName) => {
|
|
||||||
const config = getModelConfig(modelName)
|
|
||||||
if (config?.params) {
|
|
||||||
config.params.forEach(p => {
|
|
||||||
if (!(p.name in paramValues)) {
|
|
||||||
paramValues[p.name] = p.default ?? ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Video: 从远程加载 workflow 配置(保留旧逻辑)
|
// Video: 从远程加载 workflow 配置(保留旧逻辑)
|
||||||
const loadVideoModelConfig = async (modelName, currentModelType) => {
|
const loadVideoModelConfig = async (modelName, currentModelType) => {
|
||||||
try {
|
try {
|
||||||
@ -275,6 +355,8 @@ const handleStart = async () => {
|
|||||||
referenceImages: referenceImages.value,
|
referenceImages: referenceImages.value,
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
resolution: resolution.value,
|
resolution: resolution.value,
|
||||||
|
customWidth: customWidth.value,
|
||||||
|
customHight: customHight.value,
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
videoPattern: videoPattern.value,
|
videoPattern: videoPattern.value,
|
||||||
modelParams,
|
modelParams,
|
||||||
@ -316,6 +398,8 @@ const fillParamsFromResult = (resultData) => {
|
|||||||
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
||||||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||||
|
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
|
||||||
|
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
|
||||||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||||||
@ -349,9 +433,7 @@ watch(() => useDisplay.isSubGerenate, (newValue) => {
|
|||||||
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
|
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
|
||||||
console.log('模型或类型改变:', newModel, newModelType)
|
console.log('模型或类型改变:', newModel, newModelType)
|
||||||
if (!newModel) return
|
if (!newModel) return
|
||||||
if (props.type === 'Painting') {
|
if (props.type !== 'Painting') {
|
||||||
loadPaintingModelConfig(newModel)
|
|
||||||
} else {
|
|
||||||
await loadVideoModelConfig(newModel, newModelType)
|
await loadVideoModelConfig(newModel, newModelType)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -371,10 +453,6 @@ watch(() => props.type, (newType) => {
|
|||||||
prefetchModels()
|
prefetchModels()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
prefetchModels()
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Select
|
<Select
|
||||||
v-model="model"
|
v-model="selectValue"
|
||||||
:grouped-options="modelGroups"
|
:grouped-options="modelGroups"
|
||||||
class="model-select"
|
class="model-select"
|
||||||
position="top"
|
position="top"
|
||||||
@ -25,7 +25,52 @@ const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
|||||||
|
|
||||||
const platformModels = ref([])
|
const platformModels = ref([])
|
||||||
|
|
||||||
// 从 API 加载模型列表并按 tag 分组
|
const categoryMap = [
|
||||||
|
{ tag: 'text', label: '生成模型', inputType: 'text' },
|
||||||
|
{ tag: 'edit', label: '编辑模型', inputType: 'image' },
|
||||||
|
{ tag: 'vision', label: '视觉理解模型', inputType: 'vision' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function parseValue(encoded) {
|
||||||
|
if (!encoded) return null
|
||||||
|
const idx = encoded.indexOf('::')
|
||||||
|
if (idx === -1) return null
|
||||||
|
return { tag: encoded.substring(0, idx), modelName: encoded.substring(idx + 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeValue(tag, modelName) {
|
||||||
|
return `${tag}::${modelName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTagForModel(modelName) {
|
||||||
|
for (const cat of categoryMap) {
|
||||||
|
const model = platformModels.value.find(m => (m.display_name || m.name) === modelName && m.tags?.includes(cat.tag))
|
||||||
|
if (model) return cat.tag
|
||||||
|
}
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagToInputType(tag) {
|
||||||
|
const cat = categoryMap.find(c => c.tag === tag)
|
||||||
|
return cat?.inputType || 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select 双向绑定值(内部编码)
|
||||||
|
const selectValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!props.modelValue) return ''
|
||||||
|
const tag = findTagForModel(props.modelValue)
|
||||||
|
return encodeValue(tag, props.modelValue)
|
||||||
|
},
|
||||||
|
set: (encoded) => {
|
||||||
|
const parsed = parseValue(encoded)
|
||||||
|
if (!parsed) return
|
||||||
|
emit('update:modelValue', parsed.modelName)
|
||||||
|
emit('update:typeValue', tagToInputType(parsed.tag))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从 API 加载模型列表
|
||||||
const loadModels = async () => {
|
const loadModels = async () => {
|
||||||
try {
|
try {
|
||||||
const code = getPlatformCode('Painting')
|
const code = getPlatformCode('Painting')
|
||||||
@ -38,59 +83,50 @@ const loadModels = async () => {
|
|||||||
|
|
||||||
loadModels()
|
loadModels()
|
||||||
|
|
||||||
// 模型列表加载后自动纠正不可用模型
|
// 按固定分类分组,value 编码为 tag::displayName
|
||||||
watch(platformModels, (models) => {
|
|
||||||
if (models.length === 0) return
|
|
||||||
const currentModel = models.find(m => m.name === props.modelValue || m.id === props.modelValue)
|
|
||||||
if (!currentModel || currentModel.disabled) {
|
|
||||||
const firstEnabled = models.find(m => !m.disabled)
|
|
||||||
if (firstEnabled) {
|
|
||||||
emit('update:modelValue', firstEnabled.name)
|
|
||||||
const config = getModelConfig(firstEnabled.name)
|
|
||||||
emit('update:typeValue', config?.inputType || 'text')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// 按 tag 分组
|
|
||||||
const modelGroups = computed(() => {
|
const modelGroups = computed(() => {
|
||||||
const models = platformModels.value
|
const models = platformModels.value
|
||||||
if (models.length === 0) return []
|
if (models.length === 0) return []
|
||||||
|
|
||||||
const groups = {}
|
return categoryMap
|
||||||
models.forEach(m => {
|
.filter(cat => models.some(m => m.tags?.includes(cat.tag)))
|
||||||
const tag = m.tag || '其他'
|
.map(cat => ({
|
||||||
if (!groups[tag]) groups[tag] = []
|
label: cat.label,
|
||||||
groups[tag].push({
|
options: models
|
||||||
value: m.name,
|
.filter(m => m.tags?.includes(cat.tag))
|
||||||
label: m.name,
|
.map(m => ({
|
||||||
disabled: m.disabled || false,
|
value: `${cat.tag}::${m.display_name || m.name}`,
|
||||||
})
|
label: m.display_name || m.name,
|
||||||
})
|
disabled: m.disabled || false,
|
||||||
|
}))
|
||||||
return Object.entries(groups).map(([label, options]) => ({ label, options }))
|
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const model = computed({
|
// 模型列表加载后自动纠正不可用模型
|
||||||
get: () => props.modelValue,
|
watch(platformModels, (models) => {
|
||||||
set: (value) => {
|
if (models.length === 0) return
|
||||||
emit('update:modelValue', value)
|
const currentModel = models.find(m => (m.display_name || m.name) === props.modelValue || m.id === props.modelValue)
|
||||||
const config = getModelConfig(value)
|
if (!currentModel || currentModel.disabled) {
|
||||||
emit('update:typeValue', config?.inputType || 'text')
|
const firstEnabled = models.find(m => !m.disabled)
|
||||||
},
|
if (firstEnabled) {
|
||||||
})
|
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
|
||||||
|
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// 外部改变 modelValue 时校验是否可用
|
// 外部改变 modelValue 时校验是否可用
|
||||||
watch(() => props.modelValue, (newValue) => {
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
if (!newValue) return
|
||||||
const models = platformModels.value
|
const models = platformModels.value
|
||||||
if (models.length === 0) return
|
if (models.length === 0) return
|
||||||
const currentModel = models.find(m => m.name === newValue)
|
const currentModel = models.find(m => (m.display_name || m.name) === newValue)
|
||||||
if (currentModel && currentModel.disabled) {
|
if (currentModel && currentModel.disabled) {
|
||||||
const firstEnabled = models.find(m => !m.disabled)
|
const firstEnabled = models.find(m => !m.disabled)
|
||||||
if (firstEnabled) {
|
if (firstEnabled) {
|
||||||
emit('update:modelValue', firstEnabled.name)
|
emit('update:modelValue', firstEnabled.display_name || firstEnabled.name)
|
||||||
const config = getModelConfig(firstEnabled.name)
|
emit('update:typeValue', tagToInputType(findTagForModel(firstEnabled.display_name || firstEnabled.name)))
|
||||||
emit('update:typeValue', config?.inputType || 'text')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="param-number">
|
|
||||||
<span class="param-label">{{ param.label }}</span>
|
|
||||||
<el-input-number
|
|
||||||
:model-value="modelValue"
|
|
||||||
:min="param.min"
|
|
||||||
:max="param.max"
|
|
||||||
size="small"
|
|
||||||
style="width: 140px"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { type: Number, default: 0 },
|
|
||||||
param: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.param-number {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.param-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="param-proportion">
|
|
||||||
<span class="param-label">{{ param.label }}</span>
|
|
||||||
<div class="proportion-grid">
|
|
||||||
<div
|
|
||||||
v-for="opt in param.options"
|
|
||||||
:key="opt"
|
|
||||||
class="proportion-item"
|
|
||||||
:class="{ active: modelValue === opt }"
|
|
||||||
@click="$emit('update:modelValue', opt)"
|
|
||||||
>
|
|
||||||
<div class="proportion-preview" :style="getPreviewStyle(opt)"></div>
|
|
||||||
<span class="proportion-text">{{ opt === 'custom' ? '自定义' : opt }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { type: String, default: '' },
|
|
||||||
param: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const getPreviewStyle = (ratio) => {
|
|
||||||
if (ratio === 'custom') return {}
|
|
||||||
const [w, h] = ratio.split(':').map(Number)
|
|
||||||
const max = 28
|
|
||||||
let pw, ph
|
|
||||||
if (w >= h) { pw = max; ph = (h / w) * max }
|
|
||||||
else { ph = max; pw = (w / h) * max }
|
|
||||||
return { width: `${pw}px`, height: `${ph}px` }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.param-proportion {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.param-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.proportion-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.proportion-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
min-width: 42px;
|
|
||||||
|
|
||||||
&:hover { background: #f0f1f2; }
|
|
||||||
&.active { background: #626aef; color: #fff; border-color: #626aef; }
|
|
||||||
}
|
|
||||||
.proportion-preview {
|
|
||||||
background: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
.proportion-item.active .proportion-preview {
|
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
.proportion-text {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="param-resolution">
|
|
||||||
<span class="param-label">{{ param.label }}</span>
|
|
||||||
<div class="resolution-options">
|
|
||||||
<span
|
|
||||||
v-for="opt in param.options"
|
|
||||||
:key="opt"
|
|
||||||
class="resolution-item"
|
|
||||||
:class="{ active: modelValue === opt }"
|
|
||||||
@click="$emit('update:modelValue', opt)"
|
|
||||||
>{{ opt.toUpperCase() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { type: String, default: '' },
|
|
||||||
param: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.param-resolution {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.param-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.resolution-options {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.resolution-item {
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover { background: #f0f1f2; }
|
|
||||||
&.active { background: #626aef; color: #fff; border-color: #626aef; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="param-select">
|
|
||||||
<span class="param-label">{{ param.label }}</span>
|
|
||||||
<el-select
|
|
||||||
:model-value="modelValue"
|
|
||||||
size="small"
|
|
||||||
style="width: 140px"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
>
|
|
||||||
<el-option v-for="opt in param.options" :key="opt" :label="opt" :value="opt" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { default: '' },
|
|
||||||
param: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.param-select {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.param-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="param-switch">
|
|
||||||
<span class="param-label">{{ param.label }}</span>
|
|
||||||
<el-switch
|
|
||||||
:model-value="modelValue"
|
|
||||||
size="small"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: { type: Boolean, default: false },
|
|
||||||
param: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.param-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.param-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
455
src/components/dialogBox/proportion/painting.vue
Normal file
455
src/components/dialogBox/proportion/painting.vue
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="top" :width="400">
|
||||||
|
<div class="proportion-container">
|
||||||
|
<div class="section">
|
||||||
|
<h3>选择比例</h3>
|
||||||
|
<div class="proportion-options">
|
||||||
|
<div
|
||||||
|
v-for="item in proportionOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="proportion-item"
|
||||||
|
:class="{ active: proportion === item.value }"
|
||||||
|
:style="getProportionStyle(item.value)"
|
||||||
|
@click="selectProportion(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resolutionOptions.length > 0" class="section">
|
||||||
|
<h3>选择分辨率</h3>
|
||||||
|
<div class="resolution-options">
|
||||||
|
<div
|
||||||
|
v-for="item in resolutionOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="resolution-item"
|
||||||
|
:class="{ active: resolution === item.value }"
|
||||||
|
@click="selectResolution(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="allowCustom" class="section">
|
||||||
|
<h3>尺寸(px)</h3>
|
||||||
|
<div class="size-inputs">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>W</label>
|
||||||
|
<input type="number" v-model.number="width" @input="updateWidth" :disabled="isLocked">
|
||||||
|
</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="height" @input="updateHeight" :disabled="isLocked">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #reference>
|
||||||
|
<div class="choice-btn">
|
||||||
|
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||||
|
<span>{{ proportion }}</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({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '1:1'
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
type: String,
|
||||||
|
default: '2k'
|
||||||
|
},
|
||||||
|
proportionOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: '智能', label: '智能' },
|
||||||
|
{ value: '21:9', label: '21:9' },
|
||||||
|
{ value: '16:9', label: '16:9' },
|
||||||
|
{ value: '4:3', label: '4:3' },
|
||||||
|
{ value: '1:1', label: '1:1' },
|
||||||
|
{ value: '3:4', label: '3:4' },
|
||||||
|
{ value: '9:16', label: '9:16' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
allowCustom: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
resolutionOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: '1k', label: '标清 1K' },
|
||||||
|
{ value: '2k', label: '高清 2K' },
|
||||||
|
{ value: '4k', label: '超清 4K' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:resolution', 'update:width', 'update:height'])
|
||||||
|
|
||||||
|
const proportion = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolution = computed({
|
||||||
|
get: () => props.resolution,
|
||||||
|
set: (value) => emit('update:resolution', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const width = ref(2048)
|
||||||
|
const height = ref(2048)
|
||||||
|
const isLocked = ref(true)
|
||||||
|
|
||||||
|
const toggleLock = () => {
|
||||||
|
isLocked.value = !isLocked.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectProportion = (value) => {
|
||||||
|
proportion.value = value
|
||||||
|
updateDimensionsByProportion(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectResolution = (value) => {
|
||||||
|
resolution.value = value
|
||||||
|
updateDimensionsByResolution(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDimensionsByProportion = (proportionValue) => {
|
||||||
|
if (proportionValue === '智能') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [w, h] = proportionValue.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
if (width.value > height.value) {
|
||||||
|
height.value = Math.round(width.value / aspectRatio)
|
||||||
|
} else {
|
||||||
|
width.value = Math.round(height.value * aspectRatio)
|
||||||
|
}
|
||||||
|
emitUpdateDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDimensionsByResolution = (resolutionValue) => {
|
||||||
|
let baseSize
|
||||||
|
switch (resolutionValue) {
|
||||||
|
case '1k':
|
||||||
|
baseSize = 1024
|
||||||
|
break
|
||||||
|
case '2k':
|
||||||
|
baseSize = 2048
|
||||||
|
break
|
||||||
|
case '4k':
|
||||||
|
baseSize = 4096
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
baseSize = 2048
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proportion.value === '智能') {
|
||||||
|
width.value = baseSize
|
||||||
|
height.value = baseSize
|
||||||
|
} else {
|
||||||
|
const [w, h] = proportion.value.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
width.value = baseSize
|
||||||
|
height.value = Math.round(baseSize / aspectRatio)
|
||||||
|
} else {
|
||||||
|
height.value = baseSize
|
||||||
|
width.value = Math.round(baseSize * aspectRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitUpdateDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
if (isLocked.value && proportion.value !== '智能') {
|
||||||
|
const [w, h] = proportion.value.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
height.value = Math.round(width.value / aspectRatio)
|
||||||
|
}
|
||||||
|
emitUpdateDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
if (isLocked.value && proportion.value !== '智能') {
|
||||||
|
const [w, h] = proportion.value.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
width.value = Math.round(height.value * aspectRatio)
|
||||||
|
}
|
||||||
|
emitUpdateDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitUpdateDimensions = () => {
|
||||||
|
emit('update:width', width.value)
|
||||||
|
emit('update:height', height.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProportionStyle = (value) => {
|
||||||
|
if (value === '智能') {
|
||||||
|
return {
|
||||||
|
'--width': '20px',
|
||||||
|
'--height': '20px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [w, h] = value.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
const baseSize = 20
|
||||||
|
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
return {
|
||||||
|
'--width': `${baseSize}px`,
|
||||||
|
'--height': `${Math.round(baseSize / aspectRatio)}px`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
'--width': `${Math.round(baseSize * aspectRatio)}px`,
|
||||||
|
'--height': `${baseSize}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.modelValue, props.resolution], () => {
|
||||||
|
updateDimensionsByResolution(resolution.value)
|
||||||
|
}, { immediate: true })
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.choice-btn:hover{
|
||||||
|
background: #e9eaeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proportion-container{
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
&:last-child{
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3{
|
||||||
|
font-family: "Microsoft YaHei";
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.proportion-options{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: #F8F9FA;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proportion-item{
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: bottom;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
&::before{
|
||||||
|
content: '';
|
||||||
|
width: var(--width, 20px);
|
||||||
|
height: var(--height, 20px);
|
||||||
|
background: #F5F6F7;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 2px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active{
|
||||||
|
color: #000F33;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
&.active::before{
|
||||||
|
border-color: #000F33;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-options{
|
||||||
|
display: flex;
|
||||||
|
padding: 5px;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #F8F9FA;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-item{
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
// background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active{
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-inputs{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group{
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
label{
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled{
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
67
src/components/dialogBox/quantity/index.vue
Normal file
67
src/components/dialogBox/quantity/index.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<Select
|
||||||
|
v-model="quantity"
|
||||||
|
:options="quantityOptions"
|
||||||
|
class="quantity-select"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<img src="@/assets/dialog/quantity.svg" alt="" style="width: 16px;">
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Select from '@/components/Select/index.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: Number,
|
||||||
|
default: 4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const quantity = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantityOptions = computed(() =>
|
||||||
|
Array.from({ length: props.max }, (_, i) => ({ value: i + 1, label: `${i + 1} 张` }))
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.quantity-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-menu) {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-item) {
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -42,10 +42,10 @@ export default {
|
|||||||
{
|
{
|
||||||
name: 'outputFormat',
|
name: 'outputFormat',
|
||||||
label: '输出格式',
|
label: '输出格式',
|
||||||
type: 'select',
|
type: 'string',
|
||||||
default: 'png',
|
default: 'png',
|
||||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||||
ui: 'select',
|
ui: 'hidden',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
// GPT-Image-2 — 图生图/图片编辑
|
// GPT-Image-2 I2I — 图片编辑
|
||||||
export default {
|
export default {
|
||||||
name: 'GPT-Image-2 I2I',
|
name: 'GPT-Image-2 I2I',
|
||||||
tag: '图片编辑',
|
tag: '图片编辑',
|
||||||
inputType: 'image',
|
inputType: 'image',
|
||||||
maxImages: 10,
|
maxImages: 10,
|
||||||
params: [
|
params: [
|
||||||
|
{
|
||||||
|
name: 'imageUrls',
|
||||||
|
label: '参考图片',
|
||||||
|
type: 'image',
|
||||||
|
required: true,
|
||||||
|
ui: 'imageUpload',
|
||||||
|
maxCount: 10,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
label: '编辑指令',
|
label: '编辑指令',
|
||||||
@ -12,14 +20,6 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
ui: 'textarea',
|
ui: 'textarea',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'imageUrls',
|
|
||||||
label: '参考图片',
|
|
||||||
type: 'image',
|
|
||||||
required: true,
|
|
||||||
maxCount: 10,
|
|
||||||
ui: 'imageUpload',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'aspectRatio',
|
name: 'aspectRatio',
|
||||||
label: '比例',
|
label: '比例',
|
||||||
|
|||||||
@ -16,12 +16,27 @@ const configs = {
|
|||||||
'GPT-Image-2': gptImage,
|
'GPT-Image-2': gptImage,
|
||||||
'Nano Pro': nanoPro,
|
'Nano Pro': nanoPro,
|
||||||
'通义万相2.0 Pro': qwenEdit,
|
'通义万相2.0 Pro': qwenEdit,
|
||||||
'GPT-Image-2 I2I': gptImageI2i,
|
'GPT-Image-2 I2i': gptImageI2i,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据模型名称获取参数配置 */
|
// API display_name → config key 映射(API 返回的 display_name 可能与 config 的 name 不同)
|
||||||
|
const displayNameMap = {
|
||||||
|
'flux': 'Flux 2',
|
||||||
|
'Z-image': 'Z-Image Turbo',
|
||||||
|
'Jimeng4.6': '即梦4.6',
|
||||||
|
'QwenImage2.0': '通义万相2.0',
|
||||||
|
'GPT-image-2': 'GPT-Image-2',
|
||||||
|
'Banana-Pro': 'Nano Pro',
|
||||||
|
'QwenImage2.0-Pro': '通义万相2.0 Pro',
|
||||||
|
'GPT-Image-2': 'GPT-Image-2 I2I',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据模型名称获取参数配置,支持 API display_name 和 config key 两种方式查找 */
|
||||||
export function getModelConfig(modelName) {
|
export function getModelConfig(modelName) {
|
||||||
return configs[modelName] || null
|
if (configs[modelName]) return configs[modelName]
|
||||||
|
const mappedKey = displayNameMap[modelName]
|
||||||
|
if (mappedKey && configs[mappedKey]) return configs[mappedKey]
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取所有模型配置 */
|
/** 获取所有模型配置 */
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// 即梦 4.6 — 文生图
|
// 即梦 4.6 — 文生图(直接指定宽高像素)
|
||||||
export default {
|
export default {
|
||||||
name: '即梦4.6',
|
name: '即梦4.6',
|
||||||
tag: '文生图',
|
tag: '文生图',
|
||||||
@ -29,39 +29,5 @@ export default {
|
|||||||
max: 4096,
|
max: 4096,
|
||||||
ui: 'number',
|
ui: 'number',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'scale',
|
|
||||||
label: '文本影响度',
|
|
||||||
type: 'number',
|
|
||||||
default: 50,
|
|
||||||
min: 1,
|
|
||||||
max: 100,
|
|
||||||
ui: 'number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'forceSingle',
|
|
||||||
label: '强制单张',
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
ui: 'switch',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'minRatio',
|
|
||||||
label: '最小宽高比',
|
|
||||||
type: 'number',
|
|
||||||
default: 0.333333,
|
|
||||||
min: 0.06,
|
|
||||||
max: 16,
|
|
||||||
ui: 'number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'maxRatio',
|
|
||||||
label: '最大宽高比',
|
|
||||||
type: 'number',
|
|
||||||
default: 3,
|
|
||||||
min: 0.06,
|
|
||||||
max: 16,
|
|
||||||
ui: 'number',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export default {
|
|||||||
label: '参考图片',
|
label: '参考图片',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
required: true,
|
required: true,
|
||||||
maxCount: 10,
|
|
||||||
ui: 'imageUpload',
|
ui: 'imageUpload',
|
||||||
|
maxCount: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
@ -20,14 +20,6 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
ui: 'textarea',
|
ui: 'textarea',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'resolution',
|
|
||||||
label: '分辨率',
|
|
||||||
type: 'select',
|
|
||||||
default: '2k',
|
|
||||||
options: ['1k', '2k', '4k'],
|
|
||||||
ui: 'resolution',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'aspectRatio',
|
name: 'aspectRatio',
|
||||||
label: '比例',
|
label: '比例',
|
||||||
@ -36,5 +28,13 @@ export default {
|
|||||||
options: ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
|
options: ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
|
||||||
ui: 'proportion',
|
ui: 'proportion',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'resolution',
|
||||||
|
label: '分辨率',
|
||||||
|
type: 'select',
|
||||||
|
default: '2k',
|
||||||
|
options: ['1k', '2k', '4k'],
|
||||||
|
ui: 'resolution',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,18 +10,12 @@ export default {
|
|||||||
label: '参考图片',
|
label: '参考图片',
|
||||||
type: 'image',
|
type: 'image',
|
||||||
required: true,
|
required: true,
|
||||||
maxCount: 3,
|
|
||||||
ui: 'imageUpload',
|
ui: 'imageUpload',
|
||||||
|
maxCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
label: '编辑指令',
|
label: '提示词',
|
||||||
type: 'string',
|
|
||||||
ui: 'textarea',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'negativePrompt',
|
|
||||||
label: '反向提示词',
|
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
ui: 'textarea',
|
ui: 'textarea',
|
||||||
@ -42,11 +36,11 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'imageNum',
|
name: 'imageNum',
|
||||||
label: '生成数量',
|
label: '生成张数',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
default: '1',
|
default: 1,
|
||||||
options: ['1', '2', '3', '4', '5', '6'],
|
options: [1, 2, 3, 4, 5, 6],
|
||||||
ui: 'select',
|
ui: 'quantity',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export default {
|
|||||||
name: '通义万相2.0',
|
name: '通义万相2.0',
|
||||||
tag: '文生图',
|
tag: '文生图',
|
||||||
inputType: 'text',
|
inputType: 'text',
|
||||||
|
maxImages: 6,
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
@ -11,13 +12,6 @@ export default {
|
|||||||
required: true,
|
required: true,
|
||||||
ui: 'textarea',
|
ui: 'textarea',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'negativePrompt',
|
|
||||||
label: '反向提示词',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
ui: 'textarea',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'size',
|
name: 'size',
|
||||||
label: '分辨率',
|
label: '分辨率',
|
||||||
@ -34,18 +28,11 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'imageNum',
|
name: 'imageNum',
|
||||||
label: '生成数量',
|
label: '生成张数',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
default: '1',
|
default: 1,
|
||||||
options: ['1', '2', '3', '4', '5', '6'],
|
options: [1, 2, 3, 4, 5, 6],
|
||||||
ui: 'select',
|
ui: 'quantity',
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'promptExtend',
|
|
||||||
label: '提示词智能扩展',
|
|
||||||
type: 'boolean',
|
|
||||||
default: true,
|
|
||||||
ui: 'switch',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,10 +22,10 @@ export default {
|
|||||||
{
|
{
|
||||||
name: 'outputFormat',
|
name: 'outputFormat',
|
||||||
label: '输出格式',
|
label: '输出格式',
|
||||||
type: 'select',
|
type: 'string',
|
||||||
default: 'png',
|
default: 'png',
|
||||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||||
ui: 'select',
|
ui: 'hidden',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { fetchPlatformModels as fetchModelsRaw } from '@/apis/display'
|
import { fetchPlatformModels as fetchModelsRaw } from '@/apis/display'
|
||||||
|
|
||||||
const CACHE_PREFIX = 'platform_models_'
|
const CACHE_PREFIX = 'platform_models_'
|
||||||
|
const CACHE_TTL = 30 * 1000 // 30秒有效期
|
||||||
function getTodayDateString() {
|
|
||||||
const today = new Date()
|
|
||||||
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCacheKey(code) {
|
function getCacheKey(code) {
|
||||||
return `${CACHE_PREFIX}${code}`
|
return `${CACHE_PREFIX}${code}`
|
||||||
@ -18,7 +14,8 @@ function getFromCache(code) {
|
|||||||
if (!stored) return null
|
if (!stored) return null
|
||||||
|
|
||||||
const data = JSON.parse(stored)
|
const data = JSON.parse(stored)
|
||||||
if (data.storageDate !== getTodayDateString()) {
|
// 清理旧格式缓存(无 timestamp 字段)或过期缓存
|
||||||
|
if (!data.timestamp || Date.now() - data.timestamp > CACHE_TTL) {
|
||||||
localStorage.removeItem(key)
|
localStorage.removeItem(key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -33,7 +30,6 @@ function saveToCache(code, models) {
|
|||||||
const key = getCacheKey(code)
|
const key = getCacheKey(code)
|
||||||
localStorage.setItem(key, JSON.stringify({
|
localStorage.setItem(key, JSON.stringify({
|
||||||
models,
|
models,
|
||||||
storageDate: getTodayDateString(),
|
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -53,28 +49,41 @@ export function getPlatformCode(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取平台模型列表(带 localStorage 每日缓存)
|
const pendingRequests = new Map() // 并发请求去重
|
||||||
|
|
||||||
|
// 获取平台模型列表(localStorage 缓存,30秒有效)
|
||||||
export async function fetchPlatformModels(code) {
|
export async function fetchPlatformModels(code) {
|
||||||
const cached = getFromCache(code)
|
const cached = getFromCache(code)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`从缓存加载平台模型列表: ${code}`)
|
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 已有进行中的请求则复用,避免并发重复请求
|
||||||
const result = await fetchModelsRaw(code)
|
if (pendingRequests.has(code)) {
|
||||||
|
return pendingRequests.get(code)
|
||||||
if (result.code === 0 && result.data?.models) {
|
|
||||||
saveToCache(code, result.data.models)
|
|
||||||
return result.data.models
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('获取模型列表失败:', result.message)
|
|
||||||
return []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取模型列表失败:', error)
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchModelsRaw(code)
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data?.models) {
|
||||||
|
saveToCache(code, result.data.models)
|
||||||
|
return result.data.models
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('获取模型列表失败:', result.message)
|
||||||
|
return []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模型列表失败:', error)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
pendingRequests.delete(code)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
pendingRequests.set(code, promise)
|
||||||
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据模型名称查找 model_id
|
// 根据模型名称查找 model_id
|
||||||
@ -84,7 +93,7 @@ export async function getModelId(type, modelName) {
|
|||||||
const code = getPlatformCode(type)
|
const code = getPlatformCode(type)
|
||||||
const models = await fetchPlatformModels(code)
|
const models = await fetchPlatformModels(code)
|
||||||
|
|
||||||
const found = models.find(m => m.name === modelName)
|
const found = models.find(m => m.name === modelName || m.display_name === modelName)
|
||||||
return found?.id || ''
|
return found?.id || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ElNotification } from 'element-plus'
|
import { ElNotification } from 'element-plus'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { useDisplayStore } from '@/stores'
|
import { useDisplayStore, useUserStore } from '@/stores'
|
||||||
import { createTask } from '@/utils/createTask'
|
import { createTask } from '@/utils/createTask'
|
||||||
import { userError } from '@/utils/tokenError'
|
import { userError } from '@/utils/tokenError'
|
||||||
import { requestCreateTask, requestTaskStatus } from '@/apis/display'
|
import { requestCreateTask, requestTaskStatus } from '@/apis/display'
|
||||||
@ -77,8 +77,17 @@ export async function generate(data, generateData) {
|
|||||||
|
|
||||||
useDisplay.isSubGerenate = true
|
useDisplay.isSubGerenate = true
|
||||||
|
|
||||||
// 会话 ID,用于创建任务时的预扣费标识
|
// 从登录态获取 sessionId
|
||||||
const sessionId = crypto.randomUUID()
|
const sessionId = useUserStore().userInfo.sessionId
|
||||||
|
if (!sessionId) {
|
||||||
|
ElNotification({
|
||||||
|
title: '生成失败',
|
||||||
|
message: h('i', { style: 'color: teal' }, '用户身份已过期,请重新登录'),
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
useDisplay.isSubGerenate = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 通过 createTask 获取 body 内容(RunningHub workflow payload)
|
// 通过 createTask 获取 body 内容(RunningHub workflow payload)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user