1212 lines
37 KiB
Markdown
1212 lines
37 KiB
Markdown
# 平台化架构重构实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将 dialogBox 中的 Painting/Video 硬分支重构为平台描述符模式,新平台只需新建文件夹实现标准接口。
|
||
|
||
**Architecture:** 每个平台导出 `definePlatform()` 工厂函数,返回 ModelSelector 组件 + controls 列表 + buildTaskBody 等标准接口。dialogBox 退化为纯渲染引擎,通过 `createPlatform(type)` 获取 descriptor 并委托所有平台特定逻辑。
|
||
|
||
**Tech Stack:** Vue 3 Composition API + Pinia + Vite
|
||
|
||
---
|
||
|
||
### Task 1: 创建平台注册表和基础设施
|
||
|
||
**Files:**
|
||
- Create: `src/platforms/registry.js`
|
||
|
||
- [ ] **Step 1: 创建 registry.js**
|
||
|
||
```js
|
||
// src/platforms/registry.js
|
||
|
||
/** 平台注册表:id → definePlatform 工厂函数 */
|
||
const registry = {}
|
||
|
||
/** 注册平台 */
|
||
export function registerPlatform(id, factory) {
|
||
registry[id] = factory
|
||
}
|
||
|
||
/** 根据平台类型创建平台实例 */
|
||
export function createPlatform(type) {
|
||
const factory = registry[type]
|
||
if (!factory) throw new Error(`未注册的平台: ${type}`)
|
||
return factory()
|
||
}
|
||
|
||
/** 获取所有已注册平台 ID */
|
||
export function getRegisteredPlatforms() {
|
||
return Object.keys(registry)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 验证文件无语法错误**
|
||
|
||
Run: `node -e "import('./src/platforms/registry.js').then(m => console.log('OK'))"` 或在 Vite dev 下验证
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add src/platforms/registry.js
|
||
git commit -m "feat: 新增平台注册表基础设施"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 创建 Painting 平台包
|
||
|
||
**Files:**
|
||
- Create: `src/platforms/painting/index.js`
|
||
- Create: `src/platforms/painting/modelSelector.vue`
|
||
- Create: `src/platforms/painting/controls/proportion.vue`
|
||
- Create: `src/platforms/painting/controls/dimension.vue`
|
||
- Create: `src/platforms/painting/controls/quality.vue`
|
||
- Create: `src/platforms/painting/controls/quantity.vue`
|
||
- Create: `src/platforms/painting/imageUploader.vue`
|
||
|
||
- [ ] **Step 1: 迁移 modelSelector(从 `dialogBox/model/painting.vue`)**
|
||
|
||
Copy `src/components/dialogBox/model/painting.vue` → `src/platforms/painting/modelSelector.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 2: 迁移 proportion(从 `dialogBox/proportion/painting.vue`)**
|
||
|
||
Copy `src/components/dialogBox/proportion/painting.vue` → `src/platforms/painting/controls/proportion.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 3: 迁移 dimension(从 `dialogBox/dimension/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/dimension/index.vue` → `src/platforms/painting/controls/dimension.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 4: 提取 quality 为独立组件(原 dialogBox inline)**
|
||
|
||
新建 `src/platforms/painting/controls/quality.vue`:
|
||
|
||
```vue
|
||
<template>
|
||
<Select
|
||
v-model="model"
|
||
:options="options"
|
||
width="auto"
|
||
class="quality-select"
|
||
>
|
||
<template #prefix>
|
||
<span class="quality-label">画质</span>
|
||
</template>
|
||
</Select>
|
||
</template>
|
||
|
||
<script setup>
|
||
import Select from '@/components/Select/index.vue'
|
||
|
||
const props = defineProps({
|
||
modelValue: { type: String, default: 'medium' },
|
||
options: { type: Array, default: () => [] },
|
||
})
|
||
|
||
const emit = defineEmits(['update:modelValue'])
|
||
const model = computed({
|
||
get: () => props.modelValue,
|
||
set: (v) => emit('update:modelValue', v),
|
||
})
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.quality-select {
|
||
:deep(.select-header) {
|
||
height: 40px;
|
||
padding: 0 15px;
|
||
border-radius: 10px;
|
||
border: 1px solid #E8E9EB;
|
||
background: #f5f6f7;
|
||
&:hover { background: #e9eaeb; }
|
||
}
|
||
:deep(.select-text) { font-size: 14px; }
|
||
}
|
||
.quality-label {
|
||
font-family: "Microsoft YaHei";
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 5: 迁移 quantity(从 `dialogBox/quantity/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/quantity/index.vue` → `src/platforms/painting/controls/quantity.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 6: 迁移 imageUploader(从 `dialogBox/imageUploader/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/imageUploader/index.vue` → `src/platforms/painting/imageUploader.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 7: 创建 Painting descriptor**
|
||
|
||
新建 `src/platforms/painting/index.js`:
|
||
|
||
```js
|
||
import { ref, reactive, computed, markRaw } from 'vue'
|
||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||
import { getModelConfig } from '@/config/models/index.js'
|
||
import PaintingModelSelector from './modelSelector.vue'
|
||
import PaintingProportion from './controls/proportion.vue'
|
||
import DimensionInput from './controls/dimension.vue'
|
||
import QualitySelect from './controls/quality.vue'
|
||
import Quantity from './controls/quantity.vue'
|
||
import ImageUploader from './imageUploader.vue'
|
||
import { registerPlatform } from '../registry.js'
|
||
|
||
function getDimConfig(config) {
|
||
if (!config) return null
|
||
const dimParam = config.params.find(p => p.ui === 'dimension')
|
||
if (dimParam) return { type: 'combined', config: dimParam.dimension, paramName: dimParam.name }
|
||
const wParam = config.params.find(p => p.ui === 'dimensionWidth')
|
||
const hParam = config.params.find(p => p.ui === 'dimensionHeight')
|
||
if (wParam && hParam) return { type: 'split', wParam, hParam }
|
||
return null
|
||
}
|
||
|
||
export function definePaintingPlatform() {
|
||
const model = ref('Flux 2')
|
||
const modelType = ref('text')
|
||
const proportion = ref('1:1')
|
||
const resolution = ref('2k')
|
||
const customWidth = ref(1024)
|
||
const customHight = ref(1024)
|
||
const dimWidth = ref(1024)
|
||
const dimHeight = ref(1024)
|
||
const quantity = ref(1)
|
||
const quality = ref('medium')
|
||
const modelConfig = ref(null)
|
||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||
const paramValues = reactive({})
|
||
|
||
const state = {
|
||
model, modelType,
|
||
proportion, resolution,
|
||
customWidth, customHight,
|
||
dimWidth, dimHeight,
|
||
quantity, quality,
|
||
paramValues, modelConfig,
|
||
}
|
||
|
||
function syncDefaults(config) {
|
||
modelConfig.value = config
|
||
if (!config) return
|
||
config.params.forEach(p => {
|
||
if (!(p.name in paramValues)) {
|
||
paramValues[p.name] = p.default ?? (p.name === 'outputFormat' ? 'png' : '')
|
||
}
|
||
})
|
||
const ratioParam = config.params.find(p => p.ui === 'proportion')
|
||
if (ratioParam) proportion.value = ratioParam.default || '1:1'
|
||
const resParam = config.params.find(p => p.ui === 'resolution')
|
||
if (resParam) resolution.value = resParam.default || '2k'
|
||
const qtyParam = config.params.find(p => p.ui === 'quantity')
|
||
if (qtyParam) quantity.value = qtyParam.default || 1
|
||
const cwParam = config.params.find(p => p.name === 'customWidth')
|
||
if (cwParam) customWidth.value = cwParam.default || 1024
|
||
const chParam = config.params.find(p => p.name === 'customHight')
|
||
if (chParam) customHight.value = chParam.default || 1024
|
||
const qualityParam = config.params.find(p => p.name === 'quality')
|
||
if (qualityParam) quality.value = qualityParam.default || 'medium'
|
||
const dc = getDimConfig(config)
|
||
if (dc?.type === 'split') {
|
||
dimWidth.value = dc.wParam.default || 1024
|
||
dimHeight.value = dc.hParam.default || 1024
|
||
} else if (dc?.type === 'combined') {
|
||
const dimParam = config.params.find(p => p.name === dc.paramName)
|
||
const raw = dimParam?.default || ''
|
||
const parsed = dc.config.parse(raw)
|
||
dimWidth.value = parsed.width
|
||
dimHeight.value = parsed.height
|
||
}
|
||
}
|
||
|
||
// 同步 UI refs → paramValues(由 dialogBox 在合适的时机调用)
|
||
function syncParamValues() {
|
||
const ratioParam = modelConfig.value?.params?.find(p => p.ui === 'proportion')
|
||
if (ratioParam) paramValues[ratioParam.name] = proportion.value
|
||
const resParam = modelConfig.value?.params?.find(p => p.ui === 'resolution')
|
||
if (resParam) paramValues[resParam.name] = resolution.value
|
||
const qtyParam = modelConfig.value?.params?.find(p => p.ui === 'quantity')
|
||
if (qtyParam) paramValues[qtyParam.name] = quantity.value
|
||
if (modelConfig.value?.params?.find(p => p.name === 'customWidth')) {
|
||
paramValues.customWidth = customWidth.value
|
||
}
|
||
if (modelConfig.value?.params?.find(p => p.name === 'customHight')) {
|
||
paramValues.customHight = customHight.value
|
||
}
|
||
if (modelConfig.value?.params?.find(p => p.name === 'quality')) {
|
||
paramValues.quality = quality.value
|
||
}
|
||
const dc = getDimConfig(modelConfig.value)
|
||
if (dc?.type === 'split') {
|
||
paramValues[dc.wParam.name] = dimWidth.value
|
||
paramValues[dc.hParam.name] = dimHeight.value
|
||
} else if (dc?.type === 'combined') {
|
||
paramValues[dc.paramName] = dc.config.format(dimWidth.value, dimHeight.value)
|
||
}
|
||
}
|
||
|
||
const controls = [
|
||
{
|
||
name: 'proportion',
|
||
component: markRaw(PaintingProportion),
|
||
show: (config) => !!config?.params?.find(p => p.ui === 'proportion'),
|
||
props: (config) => {
|
||
const ratioParam = config?.params?.find(p => p.ui === 'proportion')
|
||
const resParam = config?.params?.find(p => p.ui === 'resolution')
|
||
return {
|
||
modelValue: proportion.value,
|
||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||
resolution: resolution.value,
|
||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||
width: customWidth.value,
|
||
'onUpdate:width': (v) => { customWidth.value = v },
|
||
height: customHight.value,
|
||
'onUpdate:height': (v) => { customHight.value = v },
|
||
proportionOptions: ratioParam?.options
|
||
?.filter(o => o !== 'custom')
|
||
.map(o => ({ value: o, label: o })) || [],
|
||
resolutionOptions: resParam?.options
|
||
?.map(o => ({ value: o, label: o.toUpperCase() })) || [],
|
||
allowCustom: ratioParam?.options?.includes('custom') || false,
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'dimension',
|
||
component: markRaw(DimensionInput),
|
||
show: (config) => !!config?.params?.find(p => p.ui === 'dimension' || p.ui === 'dimensionWidth'),
|
||
props: (config) => {
|
||
const dc = getDimConfig(config)
|
||
return {
|
||
width: dimWidth.value,
|
||
'onUpdate:width': (v) => { dimWidth.value = v },
|
||
height: dimHeight.value,
|
||
'onUpdate:height': (v) => { dimHeight.value = v },
|
||
minW: dc?.config?.width?.min || dc?.wParam?.min || 256,
|
||
maxW: dc?.config?.width?.max || dc?.wParam?.max || 6197,
|
||
minH: dc?.config?.height?.min || dc?.hParam?.min || 256,
|
||
maxH: dc?.config?.height?.max || dc?.hParam?.max || 4096,
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'quality',
|
||
component: markRaw(QualitySelect),
|
||
show: (config) => !!config?.params?.find(p => p.name === 'quality'),
|
||
props: (config) => {
|
||
const q = config?.params?.find(p => p.name === 'quality')
|
||
return {
|
||
modelValue: quality.value,
|
||
'onUpdate:modelValue': (v) => { quality.value = v },
|
||
options: q?.options?.map(o => ({ value: o, label: o })) || [],
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'quantity',
|
||
component: markRaw(Quantity),
|
||
show: (config) => !!config?.params?.find(p => p.ui === 'quantity'),
|
||
props: (config) => {
|
||
const qtyParam = config?.params?.find(p => p.ui === 'quantity')
|
||
return {
|
||
modelValue: quantity.value,
|
||
'onUpdate:modelValue': (v) => { quantity.value = v },
|
||
max: qtyParam?.options?.length ? Math.max(...qtyParam.options) : 4,
|
||
}
|
||
},
|
||
},
|
||
]
|
||
|
||
const platform = {
|
||
id: 'painting',
|
||
label: 'AI绘画2026',
|
||
ModelSelector: markRaw(PaintingModelSelector),
|
||
modelSelectorProps: null,
|
||
controls,
|
||
ImageUploader: markRaw(ImageUploader),
|
||
state,
|
||
model,
|
||
modelType,
|
||
modelConfig,
|
||
promptPlaceholder,
|
||
|
||
async loadModels() {
|
||
const code = getPlatformCode('Painting')
|
||
return fetchPlatformModels(code)
|
||
},
|
||
|
||
async loadConfig(modelName) {
|
||
const config = getModelConfig(modelName)
|
||
syncDefaults(config)
|
||
return config
|
||
},
|
||
|
||
getDefaultModel() {
|
||
return 'Flux 2'
|
||
},
|
||
|
||
showImageUploader() {
|
||
if (modelType.value !== 'text') return true
|
||
return modelConfig.value?.inputType === 'image' || modelConfig.value?.inputType === 'both'
|
||
},
|
||
|
||
imageUploadLimit() {
|
||
if (!modelConfig.value) return 4
|
||
const imageParam = modelConfig.value.params.find(p => p.ui === 'imageUpload')
|
||
return imageParam?.maxCount || modelConfig.value.maxImages || 4
|
||
},
|
||
|
||
isImageRequired() {
|
||
return !!(modelConfig.value?.params?.find(p => p.ui === 'imageUpload'))
|
||
},
|
||
|
||
buildTaskBody(shared) {
|
||
syncParamValues()
|
||
const modelParams = { ...paramValues }
|
||
if (shared.prompt.value) modelParams.prompt = shared.prompt.value
|
||
return modelParams
|
||
},
|
||
|
||
// 从历史记录恢复参数到 UI state
|
||
fillFromResult(resultData) {
|
||
if (resultData.model !== undefined) model.value = resultData.model
|
||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||
if (resultData.customWidth !== undefined) customWidth.value = resultData.customWidth
|
||
if (resultData.customHight !== undefined) customHight.value = resultData.customHight
|
||
if (resultData.quantity !== undefined) quantity.value = resultData.quantity
|
||
if (resultData.modelParams !== undefined) Object.assign(paramValues, resultData.modelParams)
|
||
// 从恢复的 modelParams 同步 dimension/quality UI refs
|
||
const dc = getDimConfig(modelConfig.value)
|
||
if (dc?.type === 'split') {
|
||
if (paramValues[dc.wParam.name] !== undefined) dimWidth.value = paramValues[dc.wParam.name]
|
||
if (paramValues[dc.hParam.name] !== undefined) dimHeight.value = paramValues[dc.hParam.name]
|
||
} else if (dc?.type === 'combined') {
|
||
if (paramValues[dc.paramName]) {
|
||
const parsed = dc.config.parse(paramValues[dc.paramName])
|
||
dimWidth.value = parsed.width
|
||
dimHeight.value = parsed.height
|
||
}
|
||
}
|
||
if (paramValues.quality !== undefined) quality.value = paramValues.quality
|
||
},
|
||
}
|
||
|
||
return platform
|
||
}
|
||
|
||
// 自注册
|
||
registerPlatform('Painting', definePaintingPlatform)
|
||
```
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
```bash
|
||
git add src/platforms/painting/ src/platforms/registry.js
|
||
git commit -m "feat: 新增 Painting 平台包(descriptor + 控件迁移)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 创建 Video 平台包
|
||
|
||
**Files:**
|
||
- Create: `src/platforms/video/index.js`
|
||
- Create: `src/platforms/video/modelSelector.vue`
|
||
- Create: `src/platforms/video/controls/pattern.vue`
|
||
- Create: `src/platforms/video/controls/proportion.vue`
|
||
- Create: `src/platforms/video/controls/time.vue`
|
||
- Create: `src/platforms/video/imageUploader.vue`
|
||
|
||
- [ ] **Step 1: 迁移 modelSelector(从 `dialogBox/model/video.vue`)**
|
||
|
||
Copy `src/components/dialogBox/model/video.vue` → `src/platforms/video/modelSelector.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 2: 迁移 pattern(从 `dialogBox/pattern/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/pattern/index.vue` → `src/platforms/video/controls/pattern.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 3: 迁移 proportion(从 `dialogBox/proportion/video.vue`)**
|
||
|
||
Copy `src/components/dialogBox/proportion/video.vue` → `src/platforms/video/controls/proportion.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 4: 迁移 time(从 `dialogBox/Time/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/Time/index.vue` → `src/platforms/video/controls/time.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 5: 迁移 imageUploader(从 `dialogBox/videoImageUploader/index.vue`)**
|
||
|
||
Copy `src/components/dialogBox/videoImageUploader/index.vue` → `src/platforms/video/imageUploader.vue`,无需修改内容。
|
||
|
||
- [ ] **Step 6: 创建 Video descriptor**
|
||
|
||
新建 `src/platforms/video/index.js`:
|
||
|
||
```js
|
||
import { ref, reactive, markRaw } from 'vue'
|
||
import { fetchModelConfig } from '@/utils/modelConfig'
|
||
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||
import VideoModelSelector from './modelSelector.vue'
|
||
import Pattern from './controls/pattern.vue'
|
||
import VideoProportion from './controls/proportion.vue'
|
||
import Time from './controls/time.vue'
|
||
import VideoImageUploader from './imageUploader.vue'
|
||
import { registerPlatform } from '../registry.js'
|
||
|
||
export function defineVideoPlatform() {
|
||
const model = ref('LTX2.0')
|
||
const modelType = ref('text')
|
||
const proportion = ref('16:9')
|
||
const resolution = ref('1k')
|
||
const duration = ref(5)
|
||
const videoPattern = ref('文生视频')
|
||
|
||
const resolutionOptions = ref([
|
||
{ value: '1k', label: '标清 1K' },
|
||
{ value: '2k', label: '高清 2K' },
|
||
{ value: '4k', label: '超清 4K' },
|
||
])
|
||
const proportionOptions = ref([
|
||
{ value: '智能', label: '智能' },
|
||
{ value: '21:9', label: '21:9' },
|
||
{ value: '16:9', label: '16:9' },
|
||
{ value: '4:3', label: '4:3' },
|
||
{ value: '1:1', label: '1:1' },
|
||
{ value: '3:4', label: '3:4' },
|
||
{ value: '9:16', label: '9:16' },
|
||
])
|
||
const durationOptions = ref([])
|
||
const modelDisplayConfig = ref(null)
|
||
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||
|
||
const state = {
|
||
model, modelType,
|
||
proportion, resolution,
|
||
duration, videoPattern,
|
||
resolutionOptions, proportionOptions, durationOptions,
|
||
modelDisplayConfig,
|
||
}
|
||
|
||
async function loadConfig(modelName, modelTypeVal) {
|
||
const config = await fetchModelConfig('Video', modelName, modelTypeVal)
|
||
modelDisplayConfig.value = config
|
||
if (config?.display) {
|
||
const d = config.display
|
||
if (d.resolution) {
|
||
resolution.value = d.resolution.default || '1k'
|
||
resolutionOptions.value = d.resolution.options || []
|
||
}
|
||
if (d.proportion) {
|
||
proportion.value = d.proportion.default || '16:9'
|
||
proportionOptions.value = d.proportion.options || []
|
||
}
|
||
if (d.duration) {
|
||
duration.value = d.duration.default || 5
|
||
durationOptions.value = d.duration.options || []
|
||
}
|
||
if (d.promptPlaceholder) {
|
||
promptPlaceholder.value = d.promptPlaceholder.default || '描述你想生成的画面和动作。'
|
||
}
|
||
}
|
||
return config
|
||
}
|
||
|
||
const controls = [
|
||
{
|
||
name: 'pattern',
|
||
component: markRaw(Pattern),
|
||
show: () => true,
|
||
props: () => ({
|
||
modelValue: videoPattern.value,
|
||
'onUpdate:modelValue': (v) => { videoPattern.value = v },
|
||
}),
|
||
},
|
||
{
|
||
name: 'proportion',
|
||
component: markRaw(VideoProportion),
|
||
show: () => true,
|
||
props: () => ({
|
||
modelValue: proportion.value,
|
||
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||
resolution: resolution.value,
|
||
'onUpdate:resolution': (v) => { resolution.value = v },
|
||
proportionOptions: proportionOptions.value,
|
||
resolutionOptions: resolutionOptions.value,
|
||
}),
|
||
},
|
||
{
|
||
name: 'time',
|
||
component: markRaw(Time),
|
||
show: () => true,
|
||
props: () => ({
|
||
modelValue: duration.value,
|
||
'onUpdate:modelValue': (v) => { duration.value = v },
|
||
options: durationOptions.value,
|
||
}),
|
||
},
|
||
]
|
||
|
||
const platform = {
|
||
id: 'video',
|
||
label: 'AI视频2026',
|
||
ModelSelector: markRaw(VideoModelSelector),
|
||
modelSelectorProps: () => ({ videoPattern: videoPattern.value }),
|
||
controls,
|
||
ImageUploader: markRaw(VideoImageUploader),
|
||
state,
|
||
model,
|
||
modelType,
|
||
modelDisplayConfig,
|
||
promptPlaceholder,
|
||
|
||
async loadModels() {
|
||
const code = getPlatformCode('Video')
|
||
return fetchPlatformModels(code)
|
||
},
|
||
|
||
async loadConfig(modelName, modelTypeVal) {
|
||
return loadConfig(modelName, modelTypeVal)
|
||
},
|
||
|
||
getDefaultModel() {
|
||
return 'LTX2.0'
|
||
},
|
||
|
||
showImageUploader() {
|
||
return modelType.value !== 'text'
|
||
},
|
||
|
||
imageUploadLimit() {
|
||
return modelDisplayConfig.value?.display?.images || 1
|
||
},
|
||
|
||
isImageRequired() {
|
||
return modelType.value !== 'text'
|
||
},
|
||
|
||
buildTaskBody(shared) {
|
||
// 与 Painting 统一:直接返回扁平参数
|
||
const modelParams = {
|
||
prompt: shared.prompt.value,
|
||
proportion: proportion.value,
|
||
resolution: resolution.value,
|
||
duration: duration.value,
|
||
videoPattern: videoPattern.value,
|
||
}
|
||
return modelParams
|
||
},
|
||
|
||
fillFromResult(resultData) {
|
||
if (resultData.model !== undefined) model.value = resultData.model
|
||
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||
},
|
||
}
|
||
|
||
return platform
|
||
}
|
||
|
||
registerPlatform('Video', defineVideoPlatform)
|
||
```
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add src/platforms/video/
|
||
git commit -m "feat: 新增 Video 平台包(descriptor + 控件迁移)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 重写 dialogBox 为通用编排壳
|
||
|
||
**Files:**
|
||
- Modify: `src/components/dialogBox/index.vue`(重写)
|
||
- Modify: `src/utils/createTask.js`
|
||
|
||
- [ ] **Step 1: 简化 createTask.js 为纯透传**
|
||
|
||
修改 `src/utils/createTask.js`:
|
||
|
||
```js
|
||
// src/utils/createTask.js
|
||
// 所有平台 descriptor 的 buildTaskBody() 已直接返回扁平 modelParams,
|
||
// 此文件仅做透传,后续可直接移除
|
||
export async function createTask(data) {
|
||
return data.body
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 重写 dialogBox/index.vue**
|
||
|
||
用以下内容完整替换 `src/components/dialogBox/index.vue`:
|
||
|
||
```vue
|
||
<template>
|
||
<Transition name="slide-up">
|
||
<div class="input-container" :class="{ generate : !props.isGenerate }" @click="handleContainerClick">
|
||
<div v-if="!props.isGenerate" class="title">{{ platform.label }}</div>
|
||
|
||
<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="showUploader" class="upload-img-container">
|
||
<div class="reference-diagram">
|
||
<component
|
||
v-if="platform.ImageUploader"
|
||
:is="platform.ImageUploader"
|
||
ref="referenceDiagramRef"
|
||
v-model="referenceImages"
|
||
v-bind="uploaderBindings"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Sender
|
||
v-model="prompt"
|
||
:variant="useDisplay.Sender_variant"
|
||
:placeholder="platform.promptPlaceholder.value"
|
||
:submit-btn-disabled="isgerenate"
|
||
:auto-size="autoSizeConfig"
|
||
>
|
||
<template v-if="useDisplay.Sender_variant !== 'default'" #prefix>
|
||
<div class="prefix-self-wrap">
|
||
<component
|
||
:is="platform.ModelSelector"
|
||
:modelValue="platform.model.value"
|
||
@update:modelValue="platform.model.value = $event"
|
||
:typeValue="platform.modelType.value"
|
||
@update:typeValue="platform.modelType.value = $event"
|
||
v-bind="(platform.modelSelectorProps && platform.modelSelectorProps()) || {}"
|
||
/>
|
||
<template v-for="ctrl in visibleControls" :key="ctrl.name">
|
||
<component
|
||
:is="ctrl.component"
|
||
v-bind="ctrl.props(getCurrentConfig())"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<template #action-list>
|
||
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
|
||
<el-button v-if="isgerenate" round color="#626aef">
|
||
<i-ep-loading style="animation: spin 1s linear infinite;" />
|
||
</el-button>
|
||
<div v-else class="gerenate" :class="{ isprompt: prompt }" @click="handleStart">
|
||
<img v-if="!prompt" src="@/assets/dialog/darkArrow.svg" alt="">
|
||
<img v-else src="@/assets/dialog/writerArrow.svg" alt="">
|
||
<div v-show="useDisplay.Sender_variant !== 'default'">发送</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Sender>
|
||
|
||
</div>
|
||
</Transition>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { Sender } from 'vue-element-plus-x'
|
||
import { useDisplayStore } from '@/stores'
|
||
import { generate } from '@/utils/taskPolling'
|
||
import { useRouter } from 'vue-router'
|
||
import { createPlatform } from '@/platforms/registry.js'
|
||
import { getModelId } from '@/utils/modelApi'
|
||
|
||
// 确保平台包被加载(触发自注册)
|
||
import '@/platforms/painting/index.js'
|
||
import '@/platforms/video/index.js'
|
||
|
||
const props = defineProps({
|
||
isGenerate: { type: Boolean, default: false },
|
||
generate: { type: Boolean, default: false },
|
||
type: { type: String, default: 'Painting' },
|
||
})
|
||
|
||
const router = useRouter()
|
||
const useDisplay = useDisplayStore()
|
||
const isgerenate = ref(false)
|
||
const prompt = ref('')
|
||
const referenceImages = ref([])
|
||
|
||
const platform = computed(() => createPlatform(props.type))
|
||
|
||
const getCurrentConfig = () => {
|
||
// Painting: modelConfig, Video: modelDisplayConfig
|
||
return platform.value.modelConfig?.value ?? platform.value.modelDisplayConfig?.value ?? null
|
||
}
|
||
|
||
const visibleControls = computed(() => {
|
||
const config = getCurrentConfig()
|
||
return platform.value.controls.filter(c => c.show(config))
|
||
})
|
||
|
||
const showUploader = computed(() => {
|
||
return platform.value.showImageUploader()
|
||
})
|
||
|
||
const uploaderBindings = computed(() => {
|
||
const p = platform.value
|
||
if (p.id === 'painting') {
|
||
return { limit: p.imageUploadLimit() }
|
||
}
|
||
if (p.id === 'video') {
|
||
return { modelType: p.modelType.value, imagesCount: p.imageUploadLimit() }
|
||
}
|
||
return {}
|
||
})
|
||
|
||
const autoSizeConfig = computed(() => {
|
||
if (useDisplay.Sender_variant !== 'default') {
|
||
return { minRows: 5, maxRows: 9 }
|
||
}
|
||
return { minRows: 1, maxRows: 1 }
|
||
})
|
||
|
||
const handleStart = async () => {
|
||
const p = platform.value
|
||
|
||
if (props.type === 'Video' && p.model.value === 'Seedance 2.0') {
|
||
ElMessage.primary('敬请期待 Seedance 2.0')
|
||
return
|
||
}
|
||
|
||
if (!props.isGenerate) {
|
||
router.push({ name: 'home', query: { loading: false, Generate: true, type: props.type } })
|
||
}
|
||
if (!prompt.value) {
|
||
ElMessage.error('请输入提示词')
|
||
return
|
||
}
|
||
if (showUploader.value && p.isImageRequired() && !referenceImages.value.length) {
|
||
ElMessage.warning('请上传图片')
|
||
return
|
||
}
|
||
|
||
isgerenate.value = true
|
||
|
||
const modelId = await getModelId(props.type, p.model.value)
|
||
|
||
// 所有平台统一返回扁平 modelParams
|
||
const body = await p.buildTaskBody({ prompt, referenceImages })
|
||
|
||
const generateData = {
|
||
model: p.model.value,
|
||
modelType: p.modelType.value,
|
||
prompt: prompt.value,
|
||
referenceImages: referenceImages.value,
|
||
modelParams: body,
|
||
}
|
||
|
||
const data = {
|
||
type: props.type,
|
||
modelType: p.modelType.value,
|
||
modelName: p.model.value,
|
||
modelId: modelId || '',
|
||
body,
|
||
request: JSON.stringify(generateData),
|
||
}
|
||
|
||
await generate(data, generateData)
|
||
}
|
||
|
||
const fillParamsFromResult = (resultData) => {
|
||
if (!resultData) return
|
||
platform.value.fillFromResult(resultData)
|
||
if (resultData.prompt !== undefined) prompt.value = resultData.prompt
|
||
if (resultData.referenceImages !== undefined) referenceImages.value = resultData.referenceImages
|
||
}
|
||
|
||
defineExpose({ fillParamsFromResult, handleStart })
|
||
|
||
const handleContainerClick = () => {
|
||
if (useDisplay.Sender_variant === 'default') {
|
||
useDisplay.Sender_variant = 'updown'
|
||
}
|
||
}
|
||
|
||
const handleScrollToBottom = () => {
|
||
useDisplay.scrollToBottom()
|
||
}
|
||
|
||
watch(() => useDisplay.isSubGerenate, (v) => { isgerenate.value = v }, { immediate: true })
|
||
|
||
// 模型变更 → 加载配置
|
||
watch(
|
||
[() => platform.value.model.value, () => platform.value.modelType.value],
|
||
async ([newModel, newModelType]) => {
|
||
if (!newModel) return
|
||
if (platform.value.id === 'video') {
|
||
await platform.value.loadConfig(newModel, newModelType)
|
||
} else {
|
||
platform.value.loadConfig(newModel)
|
||
}
|
||
},
|
||
)
|
||
|
||
// 平台切换 → 设置默认模型 + 预加载模型列表
|
||
watch(() => props.type, (newType) => {
|
||
const p = createPlatform(newType)
|
||
p.model.value = p.getDefaultModel()
|
||
p.loadModels()
|
||
}, { immediate: true })
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
/* 保留原有所有样式不变 */
|
||
.input-container {
|
||
width: 50%;
|
||
max-width: 880px;
|
||
position: absolute;
|
||
bottom: 30px;
|
||
z-index: 100;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
border-radius: 10px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.sender-top {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
z-index: 101;
|
||
margin-bottom: 10px;
|
||
position: relative;
|
||
|
||
.scroll-to-bottom-text {
|
||
position: absolute;
|
||
bottom: 0;
|
||
right: 0;
|
||
z-index: 2;
|
||
padding: 10px;
|
||
border-radius: 10px;
|
||
background-color: #F8F9FA;
|
||
color: #666;
|
||
font-size: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 5px;
|
||
|
||
&:active { transform: scale(0.95); }
|
||
&:hover { background-color: #F0F1F2; }
|
||
}
|
||
|
||
.upload-img-container {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: start;
|
||
align-items: center;
|
||
z-index: 1;
|
||
gap: 16px;
|
||
padding-left: 20px;
|
||
|
||
.reference-diagram {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
.generate {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
position: relative;
|
||
border: none;
|
||
box-shadow: none;
|
||
:deep(.el-sender) {
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
}
|
||
|
||
.prefix-self-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 5px;
|
||
img { height: 50px; border-radius: 4px; }
|
||
}
|
||
|
||
.title {
|
||
background-color: #FFF;
|
||
color: #333;
|
||
text-align: center;
|
||
font-family: "Alibaba PuHuiTi";
|
||
font-size: 24px;
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
line-height: normal;
|
||
margin-bottom: 106px;
|
||
}
|
||
|
||
:deep(.el-sender) {
|
||
background-color: #F5F6F7;
|
||
border: none;
|
||
border-radius: 20px;
|
||
}
|
||
|
||
:deep(.el-sender:focus-within) {
|
||
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
:deep(.el-popover.el-popper) {
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.select {
|
||
background: #ffffff;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||
}
|
||
|
||
.upload-btn {
|
||
display: flex;
|
||
height: 40px;
|
||
padding: 0 15px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 5px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0, 0, 0, 0.10);
|
||
background: #ffffff;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
|
||
.upload-btn:hover { background: #E5E7EB; }
|
||
|
||
.circle-btn {
|
||
position: absolute;
|
||
right: 0px;
|
||
top: 0px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
z-index: 90;
|
||
transition: all 0.3s ease;
|
||
color: rgb(0, 0, 0);
|
||
font-size: 20px;
|
||
&:hover { transform: scale(1.1); }
|
||
&:active { transform: scale(0.95); }
|
||
}
|
||
|
||
.slide-up-enter-active,
|
||
.slide-up-leave-active {
|
||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.slide-up-enter-from {
|
||
opacity: 0;
|
||
transform: translate(-50%, 100%);
|
||
}
|
||
|
||
.slide-up-leave-to {
|
||
opacity: 0;
|
||
transform: translate(-50%, 100%);
|
||
}
|
||
|
||
.gerenate {
|
||
display: inline-flex;
|
||
height: 40px;
|
||
padding: 0 20px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 5px;
|
||
border-radius: 10px;
|
||
background: rgba(0, 15, 51, 0.10);
|
||
cursor: pointer;
|
||
color: #000F33;
|
||
text-align: center;
|
||
font-family: "Microsoft YaHei";
|
||
font-size: 14px;
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
line-height: normal;
|
||
}
|
||
|
||
.isprompt {
|
||
color: #ffffff;
|
||
background-color: #000F33;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: 验证 dialogBox 的 import 路径正确**
|
||
|
||
检查所有 import 路径是否存在:
|
||
- `@/platforms/registry.js` → `src/platforms/registry.js`
|
||
- `@/platforms/painting/index.js` → `src/platforms/painting/index.js`
|
||
- `@/platforms/video/index.js` → `src/platforms/video/index.js`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add src/components/dialogBox/index.vue src/utils/createTask.js
|
||
git commit -m "refactor: dialogBox 重构为通用平台编排壳,委托所有平台特定逻辑"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 删除旧代码
|
||
|
||
**Files:**
|
||
- Delete: `src/config/models/` 目录
|
||
- Delete: `src/config/runninghub/` 目录
|
||
- Delete: `src/utils/modelConfig.js`
|
||
- Delete: `src/components/dialogBox/model/painting.vue`
|
||
- Delete: `src/components/dialogBox/model/video.vue`
|
||
- Delete: `src/components/dialogBox/proportion/painting.vue`
|
||
- Delete: `src/components/dialogBox/proportion/video.vue`
|
||
- Delete: `src/components/dialogBox/dimension/index.vue`
|
||
- Delete: `src/components/dialogBox/quantity/index.vue`
|
||
- Delete: `src/components/dialogBox/pattern/index.vue`
|
||
- Delete: `src/components/dialogBox/Time/index.vue`
|
||
- Delete: `src/components/dialogBox/imageUploader/index.vue`
|
||
- Delete: `src/components/dialogBox/videoImageUploader/index.vue`
|
||
- Modify: `src/config/index.js`
|
||
|
||
- [ ] **Step 1: 删除 dialogBox 下的旧平台组件**
|
||
|
||
```bash
|
||
rm -rf src/components/dialogBox/model/
|
||
rm -rf src/components/dialogBox/proportion/
|
||
rm -rf src/components/dialogBox/dimension/
|
||
rm -rf src/components/dialogBox/quantity/
|
||
rm -rf src/components/dialogBox/pattern/
|
||
rm -rf src/components/dialogBox/Time/
|
||
rm -rf src/components/dialogBox/imageUploader/
|
||
rm -rf src/components/dialogBox/videoImageUploader/
|
||
```
|
||
|
||
- [ ] **Step 2: 删除旧 config 目录**
|
||
|
||
```bash
|
||
rm -rf src/config/models/
|
||
rm -rf src/config/runninghub/
|
||
rm src/config/index.js
|
||
# 注意:modelConfig.js 暂时保留,Video descriptor 用 fetchModelConfig() 获取 display 配置驱动 UI,
|
||
# 待 Video 后端化(API 返回参数 schema)后可删除
|
||
```
|
||
|
||
- [ ] **Step 3: 删除 config/index.js(不再被引用)**
|
||
|
||
```bash
|
||
rm src/config/index.js
|
||
```
|
||
|
||
原来 `config/index.js` 仅导出 runninghub 适配器供 `createTask.js` 使用,现在 createTask 已简化,此文件无用。
|
||
|
||
- [ ] **Step 4: 搜索并修复残留引用**
|
||
|
||
```bash
|
||
grep -r "config/models" src/ --include="*.js" --include="*.vue"
|
||
grep -r "components/dialogBox/model" src/ --include="*.js" --include="*.vue"
|
||
grep -r "components/dialogBox/proportion" src/ --include="*.js" --include="*.vue"
|
||
grep -r "utils/modelConfig" src/ --include="*.js" --include="*.vue"
|
||
```
|
||
|
||
修复任何残留引用,更新为新的 import 路径。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: 删除旧架构代码(config/models、runninghub、dialogBox 旧组件)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 验证构建与运行
|
||
|
||
- [ ] **Step 1: 启动开发服务器**
|
||
|
||
```bash
|
||
pnpm dev
|
||
```
|
||
|
||
- [ ] **Step 2: 检查 Painting 流程**
|
||
|
||
1. 打开首页 → 确认标题显示 "AI绘画2026"
|
||
2. 确认模型选择器显示模型列表(从 API 加载)
|
||
3. 切换模型 → 确认比例/分辨率/尺寸等控件正确显示/隐藏
|
||
4. 输入 prompt → 点击发送 → 确认请求发出
|
||
5. 确认生成结果在虚拟滚动列表中显示
|
||
|
||
- [ ] **Step 3: 检查 Video 流程**
|
||
|
||
1. 切换到 Video 模式 → 确认标题显示 "AI视频2026"
|
||
2. 确认 Pattern 选择器 + 模型选择器正常工作
|
||
3. 切换 Pattern → 确认模型列表更新
|
||
4. 输入 prompt → 点击发送 → 确认请求发出
|
||
|
||
- [ ] **Step 4: 检查平台切换**
|
||
|
||
1. 从 Painting 切换到 Video → 确认所有控件正确替换
|
||
2. 从 Video 切换到 Painting → 确认控件恢复
|
||
|
||
- [ ] **Step 5: 检查历史回填**
|
||
|
||
1. 点击历史记录中的某项 → 确认参数正确回填到当前平台控件
|
||
|
||
- [ ] **Step 6: 修复发现的问题并提交**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: 验证后修复平台切换和历史回填问题"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 审查后端化方案文档
|
||
|
||
- [ ] **Step 1: 阅读当前后端化方案**
|
||
|
||
读取 `docs/模型参数后端化方案.md`
|
||
|
||
- [ ] **Step 2: 对照新架构,指出文档需要变更的部分**
|
||
|
||
新架构下,各平台的 `loadConfig()` 在 descriptor 内实现,后端化只需改变 descriptor 的内部实现:
|
||
|
||
1. **统一 API 端点**:所有平台通过同一 API 获取模型列表和参数 schema,不再有 Painting 本地 JS / Video 远程 JSON 的差异
|
||
2. **参数元数据格式**:后端返回的模型参数需要包含 `ui` 字段(当前 Painting 在本地 config 中定义),以便 descriptor 的 `show()` 和 `props()` 正确工作
|
||
3. **文档需补充**:后端 API 返回的模型参数 schema 格式约定(每个参数需包含 `name`、`type`、`ui`、`default`、`options`、`min/max` 等字段)
|
||
4. **不再需要的内容**:文档中 RunningHub 原始 API 的 workflow/seed/width/height 等概念,在新架构中这些由 descriptor 内部消化,不再暴露给 API 调用方
|
||
|
||
- [ ] **Step 3: 将审查结论写入计划备注或直接修改 doc**
|
||
|
||
---
|
||
|
||
### 完成标准
|
||
|
||
- [ ] `pnpm build` 无错误
|
||
- [ ] Painting 流程:模型选择 → 参数调整 → 发送 → 结果展示
|
||
- [ ] Video 流程:Pattern → 模型选择 → 参数调整 → 发送 → 结果展示
|
||
- [ ] 平台切换:控件正确替换,无残留状态
|
||
- [ ] 历史回填:参数正确恢复到控件
|
||
- [ ] 新增平台只需 3 步:新建文件夹 → 实现 descriptor → registry 注册一行
|