重构 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:
王佑琳 2026-06-03 19:00:49 +08:00
parent 791c56a46b
commit 239b32fb95
21 changed files with 891 additions and 677 deletions

View File

@ -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.vueVideo 旧架构)
│ │ ├── 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 返回 modelParamsVideo 走 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
View File

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

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View File

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

View File

@ -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
View 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',
},
],
}

View 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',
},
],
}

View 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',
},
],
}

View 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
}

View 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',
},
],
}

View 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',
},
],
}

View 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
View 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',
},
],
}

View 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',
},
],
}

View File

@ -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 || '生成失败' }
}
}