重构 Painting 模型参数架构:每模型独立配置、动态参数表单、移除 workflow 适配
- 新增 src/config/models/ 每模型独立参数 schema(8 个模型) - 新增 src/components/dialogBox/params/ 动态参数控件 - 模型选择器改为从 API 获取并按 tag 分组 - dialogBox 参数区改为根据模型 config 动态渲染控件 - createTask.js Painting 直接返回扁平 modelParams,Video 保留旧 workflow - 删除旧的 proportion/painting.vue 和 quantity 组件 - 更新 CLAUDE.md 架构文档
This commit is contained in:
parent
791c56a46b
commit
239b32fb95
78
CLAUDE.md
78
CLAUDE.md
@ -18,6 +18,11 @@ Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + Less + pn
|
||||
|
||||
AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端(suanli)和第三方 AI 平台(RunningHub),提交生成任务并轮询结果。
|
||||
|
||||
**Painting 和 Video 走两套不同的任务构造路径:**
|
||||
|
||||
- **Painting(新架构)**:本地模型参数 schema → 动态表单 → 扁平 API body 提交
|
||||
- **Video(旧架构)**:远程 workflow JSON → RunningHub Playload 适配器 → `{ workflowId, nodeInfoList }` body 提交
|
||||
|
||||
### 关键目录
|
||||
|
||||
```
|
||||
@ -30,23 +35,58 @@ src/
|
||||
├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑
|
||||
│ ├── auth/ # 认证相关(登录、token 校验、用户信息)
|
||||
│ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除
|
||||
├── components/ # 通用组件
|
||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口),含模型选择、比例、上传等子组件
|
||||
├── components/
|
||||
│ ├── dialogBox/ # 生成参数输入面板(核心交互入口)
|
||||
│ │ ├── model/ # 模型选择器(painting 按 tag 分组,video 按 pattern 分组)
|
||||
│ │ ├── params/ # 动态参数控件(Painting 新架构):ProportionSelect、ResolutionSelect、SelectInput 等
|
||||
│ │ ├── proportion/ # 比例选择器(仅 video.vue,Video 旧架构)
|
||||
│ │ ├── imageUploader/ # 图片上传(Painting)
|
||||
│ │ └── videoImageUploader/ # 视频图片上传(Video)
|
||||
│ ├── virtual-scroller/# 虚拟滚动列表组件(自定义实现,reverse 模式)
|
||||
│ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘)
|
||||
├── views/ # 页面(home、login)
|
||||
├── utils/
|
||||
│ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL
|
||||
│ ├── websocket.js # 任务生成入口:组装参数 → 调用 API → 20s 轮询直至完成/失败
|
||||
│ ├── modelApi.js # 模型业务层:localStorage 每日缓存 + 模型名称→UUID 查找
|
||||
│ ├── createTask.js # 调用平台适配器 Playload() 构造任务 body
|
||||
│ ├── modelConfig.js # 从远程 JSON 加载 workflow 配置,localStorage 每日缓存
|
||||
│ ├── modelApi.js # 模型业务层:localStorage 每日缓存 + 模型名称→UUID 查找 + 平台编码映射
|
||||
│ ├── createTask.js # 任务 body 构造:Painting 返回 modelParams,Video 走 Playload 适配器
|
||||
│ ├── modelConfig.js # Video 旧架构:从远程 JSON 加载 workflow 配置(Video 专用)
|
||||
│ └── auth.ts # token 存取工具(localStorage)
|
||||
├── config/
|
||||
│ ├── index.js # 平台配置入口,导出 runninghub 适配器
|
||||
│ └── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析
|
||||
│ ├── index.js # 平台配置入口(目前仅导出 runninghub 供 Video 使用)
|
||||
│ ├── runninghub/ # RunningHub 平台适配器:Playload() 构造和 result() 解析(Video 专用)
|
||||
│ └── models/ # Painting 模型参数配置:每模型一个 JS 文件,定义 params schema
|
||||
```
|
||||
|
||||
### 模型参数配置(Painting 新架构)
|
||||
|
||||
`src/config/models/` 下每个模型一个 JS 文件,定义该模型的 API 参数 schema:
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: 'Flux 2',
|
||||
tag: '文生图', // 与 API 返回的 tag 对应,用于模型选择器分组
|
||||
inputType: 'text', // 'text' | 'image' | 'both' — 控制是否显示图片上传
|
||||
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)` 查找函数
|
||||
- `ui: 'textarea'` 的参数由 Sender 组件承载(prompt),`ui: 'imageUpload'` 由独立上传组件处理,其余渲染为 params/ 下的动态控件
|
||||
- 模型选择器从 API(`fetchPlatformModels`)获取模型列表,按 `tag` 字段分组
|
||||
|
||||
### API 层设计原则
|
||||
|
||||
- `src/apis/` 中的函数只做**纯 HTTP 调用**(`service.get/post/delete` 等),不包含缓存、localStorage、业务判断等逻辑
|
||||
@ -56,14 +96,22 @@ src/
|
||||
|
||||
### 核心数据流
|
||||
|
||||
1. 用户在 `dialogBox` 中设置参数(模型、提示词、比例、上传图片等)
|
||||
2. 点击生成 → `dialogBox:handleStart()` 组装 data(含 modelId、params、imgs、request)
|
||||
**Painting(新架构):**
|
||||
|
||||
1. 用户在 `dialogBox` 中设置参数——模型选择器从 API 获取模型列表按 tag 分组,参数控件根据模型 config 动态渲染
|
||||
2. 点击生成 → `dialogBox:handleStart()` 收集 `paramValues`(含 prompt)→ 组装 data(含 `modelParams` 扁平对象)
|
||||
3. 调用 `websocket.js:generate(data, generateData)`
|
||||
4. `generate()` 内部先通过 `createTask(data)` → `runninghub.Playload()` 构造 RunningHub workflow payload 作为 body
|
||||
5. 调用 `modelApi.getModelId(type, modelName)` 查找模型 UUID(带 localStorage 每日缓存)
|
||||
6. 调用 `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`(`{ model_id, body, request }`,携带 `X-Session-Id` 用于预扣费)
|
||||
7. 返回 task_id → `displayStore.addGeneratingItem()` 在前端列表插入"生成中"条目
|
||||
8. 每 20 秒轮询 `requestTaskStatus(taskId)` → GET `/suanli/v1/tasks/{task_id}`,completed 时调用 `updateItemToSuccess()` 更新列表
|
||||
4. `generate()` 内部通过 `createTask(data)` → 因 `type === 'Painting'` 直接返回 `data.modelParams` 作为 body
|
||||
5. 调用 `modelApi.getModelId(type, modelName)` 查找模型 UUID
|
||||
6. 调用 `requestCreateTask(body, sessionId)` → POST `/suanli/v1/tasks`(`{ model_id, body: modelParams, request }`)
|
||||
7. 返回 task_id → 轮询直至完成
|
||||
|
||||
**Video(旧架构,保留):**
|
||||
|
||||
1. 用户在 `dialogBox` 中设置参数(Pattern、videoModel、比例、时长)
|
||||
2. 点击生成 → `dialogBox:handleStart()` 组装 data(含旧 params 数组)
|
||||
3. `createTask(data)` → `runninghub.Playload(data)` → `fetchModelConfig()` 获取 workflow JSON → 返回 `{ workflowId, nodeInfoList }`
|
||||
4. 后续同 Painting 的步骤 5-7
|
||||
|
||||
### 接口速查
|
||||
|
||||
@ -72,7 +120,7 @@ src/
|
||||
| `requestCreateTask` | POST `/suanli/v1/tasks` | 创建生成任务 |
|
||||
| `requestTaskStatus` | GET `/suanli/v1/tasks/:id` | 查询单个任务状态 |
|
||||
| `requestTaskHistory` | GET `/suanli/v1/tasks/history` | 历史任务列表(支持 `user_id`/`platform_code`/`page`/`pageSize`) |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表 |
|
||||
| `fetchPlatformModels` | GET `/suanli/v1/platforms/:code/models` | 获取平台模型列表(返回 `{id, name, tag, disabled?}`) |
|
||||
| `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 |
|
||||
| `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 |
|
||||
|
||||
|
||||
9
components.d.ts
vendored
9
components.d.ts
vendored
@ -18,6 +18,10 @@ declare module 'vue' {
|
||||
DialogBox: typeof import('./src/components/dialogBox/index.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
IEpCalendar: typeof import('~icons/ep/calendar')['default']
|
||||
@ -28,13 +32,18 @@ declare module 'vue' {
|
||||
IEpStar: typeof import('~icons/ep/star')['default']
|
||||
ImageUploader: typeof import('./src/components/dialogBox/imageUploader/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']
|
||||
Pattern: typeof import('./src/components/dialogBox/pattern/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']
|
||||
ResolutionSelect: typeof import('./src/components/dialogBox/params/ResolutionSelect.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
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']
|
||||
Video: typeof import('./src/components/dialogBox/model/video.vue')['default']
|
||||
VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default']
|
||||
|
||||
@ -7,14 +7,14 @@
|
||||
<div class="sender-top">
|
||||
<div v-if="useDisplay.Sender_variant === 'default'" class="scroll-to-bottom-text" @click.stop="handleScrollToBottom">回到底部<img src="@/assets/dialog/ArrowDown.svg"></div>
|
||||
|
||||
<div v-show="modelType !== 'text'" class="upload-img-container">
|
||||
<div v-show="showImageUploader" class="upload-img-container">
|
||||
<div class="reference-diagram">
|
||||
<ImageUploader
|
||||
v-if="props.type === 'Painting'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:limit="4"
|
||||
@open-canvas="handleOpenCanvas"
|
||||
<ImageUploader
|
||||
v-if="props.type === 'Painting'"
|
||||
ref="referenceDiagramRef"
|
||||
v-model="referenceImages"
|
||||
:limit="imageUploadLimit"
|
||||
@open-canvas="handleOpenCanvas"
|
||||
/>
|
||||
<VideoImageUploader
|
||||
v-else-if="props.type === 'Video'"
|
||||
@ -31,13 +31,13 @@
|
||||
<template #prefix>
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Painting'" class="prefix-self-wrap">
|
||||
<paintingModel v-model="model" v-model:typeValue="modelType" />
|
||||
<paintingProportion
|
||||
v-model="proportion"
|
||||
v-model:resolution="resolution"
|
||||
:proportion-options="proportionOptions"
|
||||
:resolution-options="resolutionOptions"
|
||||
/>
|
||||
<Quantity v-model="quantity" />
|
||||
<template v-for="param in visibleParams" :key="param.name">
|
||||
<ProportionSelect v-if="param.ui === 'proportion'" v-model="paramValues[param.name]" :param="param" />
|
||||
<ResolutionSelect v-if="param.ui === 'resolution'" v-model="paramValues[param.name]" :param="param" />
|
||||
<SelectInput v-if="param.ui === 'select'" v-model="paramValues[param.name]" :param="param" />
|
||||
<NumberInput v-if="param.ui === 'number'" v-model="paramValues[param.name]" :param="param" />
|
||||
<SwitchInput v-if="param.ui === 'switch'" v-model="paramValues[param.name]" :param="param" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="useDisplay.Sender_variant !== 'default' && props.type === 'Video'" class="prefix-self-wrap">
|
||||
@ -73,21 +73,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import paintingProportion from './proportion/painting.vue'
|
||||
import videoProportion from './proportion/video.vue'
|
||||
import paintingModel from './model/painting.vue'
|
||||
import videoModel from './model/video.vue'
|
||||
import Quantity from './quantity/index.vue'
|
||||
import Pattern from './pattern/index.vue'
|
||||
import ImageUploader from './imageUploader/index.vue'
|
||||
import VideoImageUploader from './videoImageUploader/index.vue'
|
||||
import Time from './Time/index.vue'
|
||||
import ProportionSelect from './params/ProportionSelect.vue'
|
||||
import ResolutionSelect from './params/ResolutionSelect.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 { useDisplayStore } from '@/stores'
|
||||
import { generate } from '@/utils/websocket'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
import { getModelId, fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||
import { getModelConfig } from '@/config/models/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
isGenerate: {
|
||||
@ -111,12 +115,55 @@ const isgerenate = ref(false)
|
||||
const model = ref() // 模型
|
||||
const modelType = ref('text')
|
||||
|
||||
const modelDisplayConfig = ref(null)
|
||||
// 当前模型配置
|
||||
const modelConfig = computed(() => {
|
||||
return props.type === 'Painting' ? getModelConfig(model.value) : null
|
||||
})
|
||||
|
||||
// 模型参数值
|
||||
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(() => {
|
||||
if (props.type === 'Video') return modelType.value !== 'text'
|
||||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||||
})
|
||||
|
||||
const imageUploadLimit = computed(() => {
|
||||
if (!modelConfig.value) return 4
|
||||
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
|
||||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||||
})
|
||||
|
||||
const promptPlaceholder = ref('描述你想生成的画面和动作。') // 提示词占位符
|
||||
|
||||
const prompt = ref('') // 提示词
|
||||
const proportion = ref('16:9') // 比例
|
||||
const resolution = ref('1k') // 分辨率
|
||||
const proportion = ref('16:9') // 比例(Video 用)
|
||||
const resolution = ref('1k') // 分辨率(Video 用)
|
||||
const referenceImages = ref([])
|
||||
|
||||
// 绘画
|
||||
@ -140,44 +187,50 @@ const autoSizeConfig = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const loadModelConfig = async (modelName, currentModelType) => {
|
||||
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 配置(保留旧逻辑)
|
||||
const loadVideoModelConfig = async (modelName, currentModelType) => {
|
||||
try {
|
||||
const config = await fetchModelConfig(props.type, modelName, currentModelType)
|
||||
modelDisplayConfig.value = config
|
||||
|
||||
|
||||
if (config.display) {
|
||||
const display = config.display
|
||||
|
||||
if (display.promptPlaceholder) {
|
||||
promptPlaceholder.value = display.promptPlaceholder.default || '描述你想生成的画面和动作。'
|
||||
}
|
||||
|
||||
if (display.prompt && !isInitialized.value) {
|
||||
prompt.value = display.prompt.default || ''
|
||||
}
|
||||
|
||||
if (display.resolution) {
|
||||
resolution.value = display.resolution.default || '1k'
|
||||
resolutionOptions.value = display.resolution.options || []
|
||||
}
|
||||
|
||||
if (display.proportion) {
|
||||
proportion.value = display.proportion.default || '16:9'
|
||||
proportionOptions.value = display.proportion.options || []
|
||||
}
|
||||
|
||||
if (display.duration) {
|
||||
duration.value = display.duration.default || 5
|
||||
durationOptions.value = display.duration.options || []
|
||||
}
|
||||
}
|
||||
|
||||
isInitialized.value = true
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
console.error('加载模型配置失败:', error)
|
||||
return null
|
||||
console.error('加载视频模型配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +242,7 @@ const handleStart = async () => {
|
||||
ElMessage.primary('敬请期待 Seedance 2.0')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!props.isGenerate) {
|
||||
router.push({ name: 'home', query: { loading: false, Generate: true, type: currentType } })
|
||||
}
|
||||
@ -198,18 +251,22 @@ const handleStart = async () => {
|
||||
ElMessage.error('请输入提示词')
|
||||
return
|
||||
}
|
||||
if (modelType.value === 'image' && !referenceImages.value.length){
|
||||
if (showImageUploader.value && !referenceImages.value.length){
|
||||
ElMessage.warning('请上传图片')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isgerenate.value = true
|
||||
console.log('生成开始', isgerenate.value)
|
||||
const imgs = []
|
||||
referenceImages.value.forEach((img, index) => {
|
||||
imgs.push({ name: `image_${index + 1}`, url: img.url })
|
||||
})
|
||||
|
||||
|
||||
// 构建模型参数(Painting 用)
|
||||
const modelParams = { ...paramValues }
|
||||
if (prompt.value) modelParams.prompt = prompt.value
|
||||
|
||||
const generateData = {
|
||||
model: model.value,
|
||||
modelType: currentModelType,
|
||||
@ -219,25 +276,28 @@ const handleStart = async () => {
|
||||
quantity: quantity.value,
|
||||
resolution: resolution.value,
|
||||
duration: duration.value,
|
||||
videoPattern: videoPattern.value
|
||||
videoPattern: videoPattern.value,
|
||||
modelParams,
|
||||
}
|
||||
|
||||
|
||||
const modelId = await getModelId(currentType, model.value)
|
||||
|
||||
// Painting 用新架构扁平参数,Video 保留旧 params 数组
|
||||
const isPainting = currentType === 'Painting'
|
||||
const data = {
|
||||
type: currentType,
|
||||
modelType: currentModelType,
|
||||
AIGC: currentType,
|
||||
platform: 'runninghub',
|
||||
modelName: model.value,
|
||||
modelId: modelId || modelDisplayConfig.value?.modelId || '',
|
||||
quantity: quantity.value,
|
||||
params: [
|
||||
{ name: 'prompt', data: prompt.value},
|
||||
{ name: 'quantity', data: quantity.value},
|
||||
{ name: 'proportion', data: proportion.value},
|
||||
{ name: 'resolution', data: resolution.value},
|
||||
{ name: 'duration', data: duration.value}
|
||||
modelId: modelId || '',
|
||||
modelParams: isPainting ? modelParams : {},
|
||||
params: isPainting ? [] : [
|
||||
{ name: 'prompt', data: prompt.value },
|
||||
{ name: 'quantity', data: quantity.value },
|
||||
{ name: 'proportion', data: proportion.value },
|
||||
{ name: 'resolution', data: resolution.value },
|
||||
{ name: 'duration', data: duration.value },
|
||||
],
|
||||
imgs,
|
||||
request: JSON.stringify(generateData)
|
||||
@ -258,6 +318,7 @@ const fillParamsFromResult = (resultData) => {
|
||||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||
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)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@ -287,8 +348,11 @@ watch(() => useDisplay.isSubGerenate, (newValue) => {
|
||||
|
||||
watch([() => model.value, () => modelType.value], async ([newModel, newModelType]) => {
|
||||
console.log('模型或类型改变:', newModel, newModelType)
|
||||
if (newModel && newModelType) {
|
||||
await loadModelConfig(newModel, newModelType)
|
||||
if (!newModel) return
|
||||
if (props.type === 'Painting') {
|
||||
loadPaintingModelConfig(newModel)
|
||||
} else {
|
||||
await loadVideoModelConfig(newModel, newModelType)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -13,122 +13,87 @@
|
||||
|
||||
<script setup>
|
||||
import Select from '@/components/Select/index.vue'
|
||||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||
import { getModelConfig } from '@/config/models/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'flux'
|
||||
},
|
||||
typeValue: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
}
|
||||
modelValue: { type: String, default: 'Flux 2' },
|
||||
typeValue: { type: String, default: 'text' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||
|
||||
const paintingConfig = ref({
|
||||
generate: [],
|
||||
edit: [],
|
||||
vision: []
|
||||
})
|
||||
const platformModels = ref([])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
// 从 API 加载模型列表并按 tag 分组
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/painting.json`
|
||||
const response = await fetch(url)
|
||||
const data = await response.json()
|
||||
paintingConfig.value = data
|
||||
const code = getPlatformCode('Painting')
|
||||
const models = await fetchPlatformModels(code)
|
||||
platformModels.value = models || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch painting config:', error)
|
||||
console.error('加载平台模型列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
|
||||
fetchConfig()
|
||||
|
||||
watch(() => paintingConfig.value, (newConfig) => {
|
||||
const allModels = [
|
||||
...(newConfig.generate || []),
|
||||
...(newConfig.edit || []),
|
||||
...(newConfig.vision || [])
|
||||
]
|
||||
if (allModels.length > 0) {
|
||||
const enabledModels = allModels.filter(m => !m.disabled)
|
||||
if (enabledModels.length > 0) {
|
||||
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||
if (!currentModelExists) {
|
||||
const firstEnabled = enabledModels[0].value
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
// 模型列表加载后自动纠正不可用模型
|
||||
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')
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
}, { immediate: true })
|
||||
|
||||
// 按 tag 分组
|
||||
const modelGroups = computed(() => {
|
||||
const models = platformModels.value
|
||||
if (models.length === 0) return []
|
||||
|
||||
const groups = {}
|
||||
models.forEach(m => {
|
||||
const tag = m.tag || '其他'
|
||||
if (!groups[tag]) groups[tag] = []
|
||||
groups[tag].push({
|
||||
value: m.name,
|
||||
label: m.name,
|
||||
disabled: m.disabled || false,
|
||||
})
|
||||
})
|
||||
|
||||
return Object.entries(groups).map(([label, options]) => ({ label, options }))
|
||||
})
|
||||
|
||||
const model = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
const newType = getModelType(value)
|
||||
emit('update:typeValue', newType)
|
||||
}
|
||||
const config = getModelConfig(value)
|
||||
emit('update:typeValue', config?.inputType || 'text')
|
||||
},
|
||||
})
|
||||
|
||||
const generateModels = computed(() => paintingConfig.value.generate || [])
|
||||
const editModels = computed(() => paintingConfig.value.edit || [])
|
||||
const visionModels = computed(() => paintingConfig.value.vision || [])
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: '生成模型',
|
||||
options: generateModels.value
|
||||
},
|
||||
{
|
||||
label: '编辑模型',
|
||||
options: editModels.value
|
||||
},
|
||||
{
|
||||
label: '视觉理解模型',
|
||||
options: visionModels.value
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const getModelType = (value) => {
|
||||
if (generateModels.value.find(m => m.value === value)) {
|
||||
return 'text'
|
||||
}
|
||||
if (editModels.value.find(m => m.value === value)) {
|
||||
return 'image'
|
||||
}
|
||||
if (visionModels.value.find(m => m.value === value)) {
|
||||
return 'vision'
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getFirstEnabledModel = () => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const firstEnabled = allModels.find(m => !m.disabled)
|
||||
return firstEnabled ? firstEnabled.value : ''
|
||||
}
|
||||
|
||||
// 外部改变 modelValue 时校验是否可用
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
const allModels = [...generateModels.value, ...editModels.value, ...visionModels.value]
|
||||
const currentModel = allModels.find(m => m.value === newValue)
|
||||
const models = platformModels.value
|
||||
if (models.length === 0) return
|
||||
const currentModel = models.find(m => m.name === newValue)
|
||||
if (currentModel && currentModel.disabled) {
|
||||
const firstEnabled = getFirstEnabledModel()
|
||||
const firstEnabled = models.find(m => !m.disabled)
|
||||
if (firstEnabled) {
|
||||
emit('update:modelValue', firstEnabled)
|
||||
const newType = getModelType(firstEnabled)
|
||||
emit('update:typeValue', newType)
|
||||
emit('update:modelValue', firstEnabled.name)
|
||||
const config = getModelConfig(firstEnabled.name)
|
||||
emit('update:typeValue', config?.inputType || 'text')
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -139,24 +104,24 @@ watch(() => props.modelValue, (newValue) => {
|
||||
border-radius: 10px;
|
||||
border: 1px solid #E8E9EB;
|
||||
background: #f5f6f7;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #e9eaeb;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.select-text) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-menu) {
|
||||
max-height: 510px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
:deep(.dropdown-item) {
|
||||
min-width: 120px;
|
||||
|
||||
|
||||
&.active {
|
||||
background: rgba(0, 15, 51, 0.10);
|
||||
color: #000F33;
|
||||
|
||||
35
src/components/dialogBox/params/NumberInput.vue
Normal file
35
src/components/dialogBox/params/NumberInput.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<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>
|
||||
79
src/components/dialogBox/params/ProportionSelect.vue
Normal file
79
src/components/dialogBox/params/ProportionSelect.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<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>
|
||||
51
src/components/dialogBox/params/ResolutionSelect.vue
Normal file
51
src/components/dialogBox/params/ResolutionSelect.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<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>
|
||||
35
src/components/dialogBox/params/SelectInput.vue
Normal file
35
src/components/dialogBox/params/SelectInput.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<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>
|
||||
32
src/components/dialogBox/params/SwitchInput.vue
Normal file
32
src/components/dialogBox/params/SwitchInput.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@ -1,451 +0,0 @@
|
||||
<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 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' }
|
||||
]
|
||||
},
|
||||
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>
|
||||
@ -1,66 +0,0 @@
|
||||
<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
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const quantity = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const quantityOptions = [
|
||||
{ value: 1, label: '1 张' },
|
||||
{ value: 2, label: '2 张' },
|
||||
{ value: 3, label: '3 张' },
|
||||
{ value: 4, label: '4 张' }
|
||||
]
|
||||
</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>
|
||||
51
src/config/models/flux.js
Normal file
51
src/config/models/flux.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Flux 2 Dev — 文生图
|
||||
export default {
|
||||
name: 'Flux 2',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2', 'custom'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'customWidth',
|
||||
label: '宽度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 256,
|
||||
max: 1536,
|
||||
ui: 'number',
|
||||
showWhen: { aspectRatio: 'custom' },
|
||||
},
|
||||
{
|
||||
name: 'customHight',
|
||||
label: '高度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 256,
|
||||
max: 1536,
|
||||
ui: 'number',
|
||||
showWhen: { aspectRatio: 'custom' },
|
||||
},
|
||||
{
|
||||
name: 'outputFormat',
|
||||
label: '输出格式',
|
||||
type: 'select',
|
||||
default: 'png',
|
||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
48
src/config/models/gpt-image-i2i.js
Normal file
48
src/config/models/gpt-image-i2i.js
Normal file
@ -0,0 +1,48 @@
|
||||
// GPT-Image-2 — 图生图/图片编辑
|
||||
export default {
|
||||
name: 'GPT-Image-2 I2I',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 10,
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '编辑指令',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
maxCount: 10,
|
||||
ui: 'imageUpload',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: '画质',
|
||||
type: 'select',
|
||||
default: 'medium',
|
||||
options: ['low', 'medium', 'high'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
39
src/config/models/gpt-image.js
Normal file
39
src/config/models/gpt-image.js
Normal file
@ -0,0 +1,39 @@
|
||||
// GPT-Image-2 — 文生图
|
||||
export default {
|
||||
name: 'GPT-Image-2',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '1:2', '2:1', '1:3', '3:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '21:9', '9:21', '16:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: '画质',
|
||||
type: 'select',
|
||||
default: 'medium',
|
||||
options: ['low', 'medium', 'high'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
30
src/config/models/index.js
Normal file
30
src/config/models/index.js
Normal file
@ -0,0 +1,30 @@
|
||||
// 模型配置注册表 — 按模型名称查找参数 schema
|
||||
import flux from './flux.js'
|
||||
import zImage from './z-image.js'
|
||||
import jimeng from './jimeng.js'
|
||||
import qwen from './qwen.js'
|
||||
import gptImage from './gpt-image.js'
|
||||
import nanoPro from './nano-pro.js'
|
||||
import qwenEdit from './qwen-edit.js'
|
||||
import gptImageI2i from './gpt-image-i2i.js'
|
||||
|
||||
const configs = {
|
||||
'Flux 2': flux,
|
||||
'Z-Image Turbo': zImage,
|
||||
'即梦4.6': jimeng,
|
||||
'通义万相2.0': qwen,
|
||||
'GPT-Image-2': gptImage,
|
||||
'Nano Pro': nanoPro,
|
||||
'通义万相2.0 Pro': qwenEdit,
|
||||
'GPT-Image-2 I2I': gptImageI2i,
|
||||
}
|
||||
|
||||
/** 根据模型名称获取参数配置 */
|
||||
export function getModelConfig(modelName) {
|
||||
return configs[modelName] || null
|
||||
}
|
||||
|
||||
/** 获取所有模型配置 */
|
||||
export function getAllModelConfigs() {
|
||||
return configs
|
||||
}
|
||||
67
src/config/models/jimeng.js
Normal file
67
src/config/models/jimeng.js
Normal file
@ -0,0 +1,67 @@
|
||||
// 即梦 4.6 — 文生图
|
||||
export default {
|
||||
name: '即梦4.6',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'width',
|
||||
label: '宽度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 900,
|
||||
max: 6197,
|
||||
ui: 'number',
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
label: '高度',
|
||||
type: 'number',
|
||||
default: 1024,
|
||||
min: 768,
|
||||
max: 4096,
|
||||
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',
|
||||
},
|
||||
],
|
||||
}
|
||||
40
src/config/models/nano-pro.js
Normal file
40
src/config/models/nano-pro.js
Normal file
@ -0,0 +1,40 @@
|
||||
// Nano Pro — 图片编辑
|
||||
export default {
|
||||
name: 'Nano Pro',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 10,
|
||||
params: [
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
maxCount: 10,
|
||||
ui: 'imageUpload',
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '编辑指令',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'resolution',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
default: '2k',
|
||||
options: ['1k', '2k', '4k'],
|
||||
ui: 'resolution',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:2', '2:3', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
],
|
||||
}
|
||||
52
src/config/models/qwen-edit.js
Normal file
52
src/config/models/qwen-edit.js
Normal file
@ -0,0 +1,52 @@
|
||||
// 通义万相 2.0 Pro — 图片编辑
|
||||
export default {
|
||||
name: '通义万相2.0 Pro',
|
||||
tag: '图片编辑',
|
||||
inputType: 'image',
|
||||
maxImages: 3,
|
||||
params: [
|
||||
{
|
||||
name: 'imageUrls',
|
||||
label: '参考图片',
|
||||
type: 'image',
|
||||
required: true,
|
||||
maxCount: 3,
|
||||
ui: 'imageUpload',
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '编辑指令',
|
||||
type: 'string',
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'negativePrompt',
|
||||
label: '反向提示词',
|
||||
type: 'string',
|
||||
default: '',
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
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',
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
label: '生成数量',
|
||||
type: 'select',
|
||||
default: '1',
|
||||
options: ['1', '2', '3', '4', '5', '6'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
51
src/config/models/qwen.js
Normal file
51
src/config/models/qwen.js
Normal file
@ -0,0 +1,51 @@
|
||||
// 通义万相 2.0 — 文生图
|
||||
export default {
|
||||
name: '通义万相2.0',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'negativePrompt',
|
||||
label: '反向提示词',
|
||||
type: 'string',
|
||||
default: '',
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: '分辨率',
|
||||
type: 'select',
|
||||
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',
|
||||
},
|
||||
{
|
||||
name: 'imageNum',
|
||||
label: '生成数量',
|
||||
type: 'select',
|
||||
default: '1',
|
||||
options: ['1', '2', '3', '4', '5', '6'],
|
||||
ui: 'select',
|
||||
},
|
||||
{
|
||||
name: 'promptExtend',
|
||||
label: '提示词智能扩展',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
ui: 'switch',
|
||||
},
|
||||
],
|
||||
}
|
||||
31
src/config/models/z-image.js
Normal file
31
src/config/models/z-image.js
Normal file
@ -0,0 +1,31 @@
|
||||
// Z-Image Turbo — 文生图
|
||||
export default {
|
||||
name: 'Z-Image Turbo',
|
||||
tag: '文生图',
|
||||
inputType: 'text',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
label: '提示词',
|
||||
type: 'string',
|
||||
required: true,
|
||||
ui: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'aspectRatio',
|
||||
label: '比例',
|
||||
type: 'select',
|
||||
default: '1:1',
|
||||
options: ['1:1', '3:4', '4:3', '9:16', '16:9', '2:3', '3:2'],
|
||||
ui: 'proportion',
|
||||
},
|
||||
{
|
||||
name: 'outputFormat',
|
||||
label: '输出格式',
|
||||
type: 'select',
|
||||
default: 'png',
|
||||
options: ['png', 'jpeg', 'webp(lossless)', 'webp(lossy)'],
|
||||
ui: 'select',
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
import outPlatform from '@/config/index'
|
||||
|
||||
// 处理音频生成任务的数据并返回
|
||||
// 构造任务 body
|
||||
export async function createTask(data) {
|
||||
console.log(data)
|
||||
const payload = await outPlatform[data.platform].Playload(data)
|
||||
// Painting 使用新架构:直接使用动态模型参数
|
||||
if (data.type === 'Painting') {
|
||||
return data.modelParams || {}
|
||||
}
|
||||
|
||||
// Video 继续使用旧 workflow 适配器
|
||||
const payload = await outPlatform[data.platform].Playload(data)
|
||||
return payload
|
||||
}
|
||||
|
||||
@ -15,4 +19,4 @@ export async function getTask(result) {
|
||||
return { type: true, urls: urls }
|
||||
}
|
||||
return { type: false, message: result.data.exception_message || '生成失败' }
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user