diff --git a/components.d.ts b/components.d.ts index fc2d743..cbf39d5 100644 --- a/components.d.ts +++ b/components.d.ts @@ -47,7 +47,5 @@ declare module 'vue' { VideoImageUploader: typeof import('./src/components/dialogBox/videoImageUploader/index.vue')['default'] VirtualScroller: typeof import('./src/components/virtual-scroller/VirtualScroller.vue')['default'] 'VirtualScroller copy': typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default'] - 'VirtualScroller copy 2': typeof import('./src/components/virtual-scroller/VirtualScroller copy 2.vue')['default'] - 'VirtualScroller copy 3': typeof import('./src/components/virtual-scroller/VirtualScroller copy 3.vue')['default'] } } diff --git a/docs/superpowers/plans/2026-06-09-platform-architecture-plan.md b/docs/superpowers/plans/2026-06-09-platform-architecture-plan.md new file mode 100644 index 0000000..09d9709 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-platform-architecture-plan.md @@ -0,0 +1,1211 @@ +# 平台化架构重构实现计划 + +> **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 + + + + + +``` + +- [ ] **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 + + + + + +``` + +- [ ] **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 注册一行 diff --git a/docs/superpowers/specs/2026-06-09-platform-architecture-design.md b/docs/superpowers/specs/2026-06-09-platform-architecture-design.md new file mode 100644 index 0000000..2bc60ac --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-platform-architecture-design.md @@ -0,0 +1,187 @@ +# 平台化架构设计 + +## 目标 + +将 Painting / Video 两套硬编码分支重构为统一的平台描述符架构,使加入新平台只需新建一个文件夹并实现标准接口,零改动 dialogBox。 + +## 背景 + +当前 `dialogBox/index.vue`(792 行)通过 `v-if="type === 'Painting'"` / `v-if="type === 'Video'"` 承载两套完全不同的逻辑: + +- **模型列表**:Painting 走后端 API,Video 走静态 JSON +- **参数 schema**:Painting 走本地 JS 文件,Video 走远程 workflow JSON +- **UI 控件**:Painting 有 proportion/dimension/quality/quantity,Video 有 pattern/proportion/time +- **任务 body**:Painting 扁平 modelParams,Video `{ workflowId, nodeInfoList }` + +后续模型参数后端化后,所有平台将统一为"API 获取模型列表 + API 获取参数 schema"模式。 + +## 核心设计:平台描述符模式 + +每个平台封装为一个文件夹,导出 `definePlatform()` 工厂函数,返回标准接口对象。dialogBox 退化为纯渲染引擎。 + +### 平台接口 + +```js +// src/platforms//index.js +export function definePlatform() { + // 响应式状态(各平台自定义) + const model = ref(defaultValue) + const modelType = ref(defaultValue) + const state = reactive({ ... }) + + // 模型选择器组件 + const ModelSelector = markRaw(Component) + + // 参数控件列表(有序,决定渲染顺序) + const controls = [ + { + name: 'proportion', + component: markRaw(ProportionComponent), + show: (config) => config?.params?.some(p => p.ui === 'proportion'), + props: (config) => ({ /* 额外 props */ }), + }, + // ... + ] + + // 图片上传器(可选) + const ImageUploader = markRaw(Component) | null + + return { + id: 'painting', // 平台标识 + label: 'AI绘画2026', // 显示标题 + ModelSelector, // 模型选择器组件 + controls, // 有序控件列表 + ImageUploader, // 图片上传器组件(可选) + state, // 平台自定义响应式状态 + model, // 当前模型 ref + modelType, // 当前模型类型 ref + + async loadModels() { }, // 获取模型列表 + async loadConfig(modelName) { }, // 获取模型参数配置 + buildTaskBody(state) { }, // 构造请求 body + getDefaultModel() { }, // 默认模型名称 + isImageRequired(state) { }, // 是否必须上传图片 + } +} +``` + +### 控件绑定约定 + +dialogBox 渲染控件时自动处理 `name` 与 `state` 的 v-model 绑定: + +- `modelValue` → `state[name]` +- `onUpdate:modelValue` → `state[name] = v` + +控件 descriptor 仅需定义 `show` 条件(基于 modelConfig 上下文)和额外 `props`。 + +### 平台注册表 + +```js +// src/platforms/registry.js +import { definePaintingPlatform } from './painting/index.js' +import { defineVideoPlatform } from './video/index.js' + +const registry = { Painting, Video } + +export function createPlatform(type) { + const factory = registry[type] + if (!factory) throw new Error(`未找到平台: ${type}`) + return factory() +} +``` + +### dialogBox 角色变化 + +dialogBox 接收 `type` prop → 调用 `createPlatform(type)` → 获得 descriptor → 据 descriptor 渲染一切: + +1. 渲染 `` +2. 遍历 `platform.controls`,`show` 返回 true 的渲染 `` +3. `handleStart()` 委托给 `platform.buildTaskBody(state)` → 调用 `taskPolling.generate(body)` +4. `watch(model)` 委托给 `platform.loadConfig(name)` + +## 数据流 + +```mermaid +flowchart TD + subgraph dialogBox["dialogBox 编排层"] + A["platform.loadModels()"] --> B[模型选择器渲染] + B --> C["watch(model) → platform.loadConfig(name)"] + C --> D[计算 visibleControls] + D --> E["v-for 渲染 controls(自动 v-model)"] + E --> F["handleStart → platform.buildTaskBody()"] + F --> G["taskPolling.generate(body)"] + end + + subgraph platform["platform 包"] + H["loadModels() → API"] + I["loadConfig(name) → API"] + J["buildTaskBody() → 扁平 body"] + end + + G --> K[POST /suanli/v1/tasks] + K --> L[轮询 → displayStore 更新虚拟滚动列表] +``` + +## 目录结构 + +``` +src/ +├── platforms/ # 平台包(新增) +│ ├── painting/ +│ │ ├── index.js # definePlatform() +│ │ ├── modelSelector.vue +│ │ ├── imageUploader.vue +│ │ └── controls/ +│ │ ├── proportion.vue +│ │ ├── dimension.vue +│ │ ├── quality.vue +│ │ └── quantity.vue +│ ├── video/ +│ │ ├── index.js +│ │ ├── modelSelector.vue +│ │ ├── imageUploader.vue +│ │ └── controls/ +│ │ ├── pattern.vue +│ │ ├── proportion.vue +│ │ └── time.vue +│ └── registry.js +│ +├── components/ +│ ├── dialogBox/index.vue # 精简后 ~200 行 +│ ├── Popover/ # 共享基础组件(不变) +│ ├── Select/ # 共享基础组件(不变) +│ ├── Img/ # 共享基础组件(不变) +│ └── virtual-scroller/ # 共享基础组件(不变) +│ +├── apis/ # API 层(不变) +├── utils/ +│ ├── taskPolling.js # 任务轮询(不变) +│ ├── request.js # Axios(不变) +│ └── modelApi.js # 平台 API 封装 +│ +├── config/ +│ ├── models/ # 逐步废弃 +│ ├── runninghub/ # 逐步废弃 +│ └── plugins.js # 不变 +``` + +### 迁移路径 + +| 步骤 | 内容 | 影响范围 | +|------|------|----------| +| 1 | 新建 `src/platforms/` + `registry.js`,不删旧代码 | 纯新增 | +| 2 | Painting 迁入 descriptor,dialogBox 切换读取路径 | dialogBox 精简 | +| 3 | Video 迁入 descriptor | dialogBox 继续精简 | +| 4 | 删除旧代码:`config/models/`、`config/runninghub/`、`modelConfig.js` | 清理 | +| 5 | 后端化:各平台 `loadConfig()` 改为调 API | 仅改 descriptor 内部 | + +每步独立提交,方便回滚。 + +## 不变更的部分 + +- `taskPolling.js`:任务创建和轮询逻辑通用,不变 +- `displayStore`:虚拟滚动列表状态通用,不变 +- `Popover`、`Select`、`Img`、`virtual-scroller`:共享 UI 组件 +- `home/index.vue`:仍然传 `type` prop,不变 +- `apis/`、`request.js`:HTTP 层不变 +- `config/plugins.js`、router、stores:不变 diff --git a/docs/模型参数后端化方案.md b/docs/模型参数后端化方案.md index 9919c44..da7121d 100644 --- a/docs/模型参数后端化方案.md +++ b/docs/模型参数后端化方案.md @@ -1,648 +1,190 @@ -# 模型参数配置后端化方案 +# 欢迎使用 RunningHub API,轻松调用 RunningHub 标准模型API -## 1. 背景与目标 +## 开始使用 -当前模型参数配置硬编码在前端 `src/config/models/*.js` 中,新增模型或修改参数需要前端发版,业务层无法感知参数定义。 +### 注册用户 -目标:将参数配置迁移到业务层,管理员后台配置模型参数,前端通过 API 按模型 ID 动态获取,实现**零发版上线新模型配置**。 +先注册成为RunningHub网站的用户,并充值钱包。标准模型API仅支持企业级-共享API Key -``` -页面加载: - GET /suanli/v1/platforms/{code}/models → 模型列表(现有接口,不变) - GET /suanli/v1/platforms/{code}/models/params → 批量拉全部模型参数(新接口) - 前端存入 Map +### 获取您的 API Key -切换模型: - config = paramsMap.get(modelId) → 内存读取,0 网络请求 +RunningHub 为每位用户自动生成一个独特的 32 位 API KEY -兜底(缓存 miss 时): - GET /suanli/v1/models/{model_id}/params → 单个模型参数 +请妥善保存您的 API KEY,不要外泄,后续步骤将依赖此密钥进行操作 + +### 提交请求 + +提交 API 请求。RunningHub API 已为您处理 API Key,您只需提交请求即可 + +```curl +curl --location --request POST 'https://www.runninghub.cn/openapi/v2/rhart-video/ltx-2.3/text-to-video' \ +--header "Content-Type: application/json" \ +--header "Authorization: Bearer ${RUNNINGHUB_API_KEY}" \ +--data-raw '{ + "prompt": "第一视角无人机跟拍视角,主体为一只自由飞翔的鸟,镜头快速划过城市上空,与飞鸟同步高速飞行,沉浸式第一视角,流畅丝滑运镜,低空急速掠过楼宇街道,强烈速度感与飞行沉浸感,电影级动态光影,稳定不抖动,4K 超清画质,飞鸟为主视觉,城市背景虚化,氛围感拉满。", + "resolution": "720p", + "aspectRatio": "16:9", + "duration": 5 +}' ``` ---- +#### 请求参数说明 -# 第一部分:后端方案 +| 参数说明 | 类型 | 必填/可选 | AI 应用程序生成的结果。 | +| --- | --- | --- | --- | +| `prompt` | String | 必填 | | +| `resolution` | String | 必填 | 枚举值: [1080p, 720p, 480p] | +| `aspectRatio` | String | 必填 | 枚举值: [16:9, 9:16] | +| `duration` | Int | 必填 | 输入范围值: 5 - 15 | -## 2. API 设计 +#### 响应示例 -### 2.1 批量获取平台下所有模型参数(主接口) - -``` -GET /suanli/v1/platforms/{platform_code}/models/params +```json +{ + "taskId": "2013508786110730241", + "status": "RUNNING", + "errorCode": "", + "errorMessage": "", + "results": null, + "clientId": "f828b9af25161bc066ef152db7b29ccc", + "promptTips": "{\"result\": true, \"error\": null, \"outputs_to_execute\": [\"4\"], \"node_errors\": {}}" +} ``` -**Response:** +#### 响应字段说明 + +| 参数说明 | 类型 | AI 应用程序生成的结果。 | +| --- | --- | --- | +| `taskId` | String | 任务ID,用于后续查询任务状态 | +| `status` | String | 当前任务状态,常见状态:QUEUED (排队中), RUNNING (运行中), SUCCESS (成功), FAILED (失败) | +| `errorCode` | String | 错误码,仅在失败时返回 | +| `errorMessage` | String | 错误具体信息 | +| `results` | List | 生成结果(提交时为 null) | +| ├ `url` | String | 重要提醒:该链接有效期仅为 24 小时。任务生成结束后,请务必在此时间窗口内将视频文件下载或转存至您的服务器。逾期后链接将永久失效且无法恢复。 | +| ├ `nodeId` | String | 生成该结果的工作流节点 ID | +| ├ `outputType` | String | 文件扩展名 (如 png, mp4, txt) | +| └ `text` | String | 如果输出是纯文本,内容将显示在此字段 | +| `clientId` | String | 客户端会话ID,用于标识本次连接 | +| `promptTips` | String (JSON) | ComfyUI 后端的校验信息,包含需执行的节点ID等调试信息 | + +### 查询结果与 Webhook + +如果在提交时添加了 "webhookUrl": "https://example.com/webhook" 请求体参数,RunningHub 会在任务完成时向您的URL发送POST请求 + +#### 请求示例 + +```curl +curl --location --request POST 'https://www.runninghub.cn/openapi/v2/query' \ +--header "Content-Type: application/json" \ +--header "Authorization: Bearer ${RUNNINGHUB_API_KEY}" \ +--data-raw '{ + "taskId": "${RUNNINGHUB_TASKID}" +}' +``` + +#### 响应示例 + +```json +{ + "taskId": "2013508786110730241", + "status": "SUCCESS", + "errorCode": "", + "errorMessage": "", + "failedReason": {}, + "usage": { + "consumeMoney": null, + "consumeCoins": null, + "taskCostTime": "0", + "thirdPartyConsumeMoney": null + }, + "results": [ + { + "url": "https://rh-images-1252422369.cos.ap-beijing.myqcloud.com/b04e28cad0ee39193921a30a2eb4dc00/output/ComfyUI_00001_plhjr_1768892915.png", + "nodeId": "2", + "outputType": "png", + "text": null + } + ], + "clientId": "", + "promptTips": "" +} +``` + +#### 响应字段说明 + +| 参数说明 | 类型 | AI 应用程序生成的结果。 | +| --- | --- | --- | +| `taskId` | String | 任务 ID | +| `status` | String | 任务最终状态,SUCCESS 表示生成成功 | +| `results` | List | 生成结果列表,包含图片、视频或文本等输出 | +| ├ `url` | String | 重要提醒:该链接有效期仅为 24 小时。任务生成结束后,请务必在此时间窗口内将视频文件下载或转存至您的服务器。逾期后链接将永久失效且无法恢复。 | +| ├ `nodeId` | String | 生成该结果的工作流节点 ID | +| ├ `outputType` | String | 文件扩展名 (如 png, mp4, txt) | +| └ `text` | String | 如果输出是纯文本,内容将显示在此字段 | +| `errorCode` | String | 错误码 (如有) | +| `errorMessage` | String | 错误信息 (如有) | +| `failedReason` | Object | ComfyUI 相关的失败原因 | +| `usage` | Object | 任务消耗信息 | +| ├ `thirdPartyConsumeMoney` | String | 三方API消费金额 | +| ├ `consumeMoney` | String | 运行时长消耗金额 | +| ├ `consumeCoins` | String | 运行消耗的RH币 | +| └ `taskCostTime` | String | 运行耗时(ComfyUI 工作流运行时长) | +### 文件上传 + +资源文件(如 imageUrls)参数支持传入文件 URL 或 Base64 Data URI。 + +#### 公共 URL + +直接传递可公开访问的 URL: + +```json +{ + "imageUrls": [ + "https://example.com/image.png" + ] +} +``` + +#### Base64 data URI + +以 Base64 格式嵌入图片: + +```json +{ + "images": [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + ] +} +``` + +#### RH 上传接口 + +上传本地文件以获取一个 URL。 + +**Endpoint:** `https://www.runninghub.cn/openapi/v2/media/upload/binary` + +**请求** + +```curl +curl --location --request POST 'https://www.runninghub.cn/openapi/v2/media/upload/binary' \ +--header 'Authorization: Bearer [Your API KEY]' \ +--form 'file=@/path/to/image.png' +``` + +**响应** ```json { "code": 0, + "message": "success", "data": { - "models": [ - { - "id": "uuid-of-flux-2", - "input_type": "text", - "max_images": 4, - "params": [ - { - "name": "prompt", - "label": "提示词", - "type": "string", - "required": true, - "ui": "textarea" - }, - { - "name": "aspectRatio", - "label": "比例", - "type": "select", - "default": "1:1", - "ui": "proportion", - "options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"], - "show_when": null - }, - { - "name": "customWidth", - "label": "自定义宽度", - "type": "number", - "default": 1024, - "min": 512, - "max": 2048, - "ui": "hidden", - "show_when": { "aspectRatio": "custom" } - }, - { - "name": "resolution", - "label": "分辨率", - "type": "select", - "default": "1k", - "ui": "resolution", - "options": ["1k", "2k", "4k"] - } - ] - }, - { - "id": "uuid-of-qwen-2.0", - "input_type": "text", - "params": [ - { - "name": "prompt", - "label": "提示词", - "type": "string", - "required": true, - "ui": "textarea" - }, - { - "name": "size", - "label": "尺寸", - "type": "string", - "default": "1024*1024", - "ui": "dimension", - "dimension": { - "delimiter": "*", - "width": { "min": 512, "max": 2048 }, - "height": { "min": 512, "max": 2048 } - } - }, - { - "name": "imageNum", - "label": "生成张数", - "type": "select", - "default": 1, - "ui": "quantity", - "options": [1, 2, 3, 4, 5, 6] - } - ] - } - ] + "type": "image", + "download_url": "xxxx.png", + "fileName": "openapi/xxxx.png", + "size": "3490" } } ``` -### 2.2 单个模型参数(兜底接口) +**备注:** 上传后获得的链接有效期为 1 天,超期将无法通过 URL 直接访问。 -``` -GET /suanli/v1/models/{model_id}/params -``` - -**Response:** `data` 为单个模型对象(不含 `models` 数组包装),其余结构同 2.1。 - -### 2.3 参数字段规范 - -每个 param 对象的字段定义: - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `name` | string | 是 | 参数 key,提交任务时作为 body 字段名 | -| `label` | string | 是 | 中文显示名 | -| `type` | string | 是 | `string` / `number` / `boolean` / `select` / `image` | -| `default` | any | 是 | 默认值,类型必须与 `type` 一致 | -| `required` | bool | 否 | 是否必填,默认 false | -| `ui` | string | 是 | UI 组件标识(见 2.4 映射表) | -| `options` | array | 否 | `type=select` 时的可选项列表 | -| `min` | number | 否 | `type=number` 时的最小值 | -| `max` | number | 否 | `type=number` 时的最大值 | -| `max_count` | number | 否 | `ui=imageUpload` 时的最大上传张数 | -| `dimension` | object | 否 | `ui=dimension` 时的尺寸配置 | -| `show_when` | object | 否 | 条件显示,如 `{"aspectRatio": "custom"}` | - -**`dimension` 对象(仅 `ui=dimension` 时出现):** - -| 字段 | 类型 | 说明 | -|------|------|------| -| `delimiter` | string | W/H 分隔符,固定 `*` | -| `width.min` | number | 宽度最小值 | -| `width.max` | number | 宽度最大值 | -| `height.min` | number | 高度最小值 | -| `height.max` | number | 高度最大值 | - -### 2.4 `ui` 字段枚举 - -| `ui` 值 | 前端渲染组件 | 说明 | -|---------|-------------|------| -| `textarea` | Sender 内置 textarea | 提示词输入框 | -| `proportion` | `paintingProportion` | 比例选择 Popover(含 resolution + custom 尺寸) | -| `resolution` | `paintingProportion` 内部 | 分辨率子选项 | -| `dimension` | `DimensionInput` | 组合模式 W×H(如 `1024*1024`) | -| `dimensionWidth` | `DimensionInput` | 拆分模式:独立宽度(须与 `dimensionHeight` 配对) | -| `dimensionHeight` | `DimensionInput` | 拆分模式:独立高度(须与 `dimensionWidth` 配对) | -| `select` | `Select` | 通用下拉选择(如 quality) | -| `quantity` | `Quantity` | 生成张数选择器 | -| `imageUpload` | `ImageUploader` | 参考图上传 | -| `hidden` | 无 | 不渲染,静默写入默认值 | - -> `dimensionWidth` + `dimensionHeight` 必须成对出现,前端自动关联到同一个 DimensionInput 组件。 - ---- - -## 3. 数据库设计 - -### 3.1 表结构 - -`model_params` 是独立的配置表,与 `models` 表分离,通过 `model_id` 关联: - -``` -┌─────────────────────┐ ┌──────────────────────────┐ -│ models(平台模型表) │ │ model_params(参数配置表) │ -│ │ 1:N │ │ -│ id (UUID) │◄────────│ model_id (FK) │ -│ display_name │ │ name, label, type │ -│ platform_code │ │ ui, default_val │ -│ tags │ │ options, min, max... │ -│ input_type │ │ sort_order │ -│ max_images │ │ created_at / updated_at │ -└─────────────────────┘ └──────────────────────────┘ -``` - -```sql -CREATE TABLE model_params ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - model_id VARCHAR(64) NOT NULL, -- 关联 models.id(UUID) - name VARCHAR(64) NOT NULL, -- 参数 key - label VARCHAR(64) NOT NULL, -- 中文显示名 - type VARCHAR(16) NOT NULL, -- string | number | boolean | select | image - default_val JSON NOT NULL, -- 默认值 - required TINYINT DEFAULT 0, - ui VARCHAR(32) NOT NULL, -- UI 组件标识 - options JSON DEFAULT NULL, - min_val INT DEFAULT NULL, - max_val INT DEFAULT NULL, - max_count INT DEFAULT NULL, - show_when JSON DEFAULT NULL, - sort_order INT DEFAULT 0, - dimension JSON DEFAULT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_model_id (model_id) -); -``` - -`dimension` JSON 格式: -```json -{ - "delimiter": "*", - "width": { "min": 512, "max": 2048 }, - "height": { "min": 512, "max": 2048 } -} -``` - -### 3.2 关键约束 - -- `model_params` 与 `models` 完全解耦,各自独立维护 -- 一个 `model_id` 可有多条参数行,`sort_order` 控制前端渲染顺序 -- 模型可以没有参数配置(`model_params` 中无记录),前端兼容处理(不渲染额外 UI) -- `model_id` 由管理员手动关联,不是自动生成 - -### 3.3 初始数据迁移 - -将当前 `src/config/models/*.js` 中 8 个模型的参数转为 INSERT 语句。`model_id` 须对应 `models` 表中该模型的 UUID。完整示例见第 7 节。 - ---- - -## 4. 管理员配置后台 - -``` -操作流程: -┌──────────────────────────────────────────────────────────┐ -│ 1. 进入「模型参数配置」页面 │ -│ 2. 选择平台 + 模型(从 models 表读取,按 platform_code 过滤) │ -│ 3. 为该模型添加/编辑参数行,每行配置: │ -│ - 参数名 (name) - 中文标签 (label) │ -│ - 数据类型 (type) - UI 组件 (ui) │ -│ - 默认值 (default) - 可选项 (options) │ -│ - 数值范围 (min/max) - 尺寸配置 (dimension) │ -│ - 条件显示 (show_when) │ -│ 4. 拖拽调整参数排序 (sort_order) │ -│ 5. 保存后前端缓存 TTL 过期自动生效,也可通知用户手动刷新 │ -└──────────────────────────────────────────────────────────┘ -``` - ---- - -## 5. 接口实现伪代码 - -```python -# GET /suanli/v1/platforms/{platform_code}/models/params -def get_platform_model_params(platform_code): - models = db.query( - "SELECT id, input_type, max_images FROM models WHERE platform_code = ?", - platform_code - ) - result = [] - for m in models: - params = db.query( - "SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order", - m.id - ) - result.append({ - "id": m.id, - "input_type": m.input_type, - "max_images": m.max_images, - "params": [format_param(p) for p in params], - }) - return {"code": 0, "data": {"models": result}} - - -# GET /suanli/v1/models/{model_id}/params -def get_model_params(model_id): - m = db.query("SELECT id, input_type, max_images FROM models WHERE id = ?", model_id) - if not m: - return {"code": 404, "msg": "模型不存在"} - params = db.query( - "SELECT * FROM model_params WHERE model_id = ? ORDER BY sort_order", - model_id - ) - return { - "code": 0, - "data": { - "id": m.id, - "input_type": m.input_type, - "max_images": m.max_images, - "params": [format_param(p) for p in params], - } - } - - -def format_param(p): - return { - "name": p.name, - "label": p.label, - "type": p.type, - "default": json.loads(p.default_val), - "required": bool(p.required), - "ui": p.ui, - "options": json.loads(p.options) if p.options else None, - "min": p.min_val, - "max": p.max_val, - "max_count": p.max_count, - "show_when": json.loads(p.show_when) if p.show_when else None, - "dimension": json.loads(p.dimension) if p.dimension else None, - } -``` - ---- - -## 6. 响应示例汇总 - -### 6.1 即梦 4.6(拆分维度 `dimensionWidth` + `dimensionHeight`) - -```json -{ - "id": "jimeng-uuid", - "input_type": "text", - "params": [ - { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, - { "name": "width", "label": "宽度", "type": "number", "default": 1024, "min": 900, "max": 6197, "ui": "dimensionWidth" }, - { "name": "height", "label": "高度", "type": "number", "default": 1024, "min": 768, "max": 4096, "ui": "dimensionHeight" }, - { "name": "forceSingle", "label": "强制单张", "type": "boolean", "default": false, "ui": "hidden" } - ] -} -``` - -### 6.2 通义万相 2.0(组合维度 `dimension` + `quantity`) - -```json -{ - "id": "qwen-uuid", - "input_type": "text", - "params": [ - { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, - { - "name": "size", "label": "尺寸", "type": "string", "default": "1024*1024", "ui": "dimension", - "dimension": { "delimiter": "*", "width": { "min": 512, "max": 2048 }, "height": { "min": 512, "max": 2048 } } - }, - { "name": "imageNum", "label": "生成张数", "type": "select", "default": 1, "ui": "quantity", "options": [1, 2, 3, 4, 5, 6] } - ] -} -``` - -### 6.3 GPT-Image-2(`proportion` + `resolution` + `select`) - -```json -{ - "id": "gpt-image-uuid", - "input_type": "text", - "params": [ - { "name": "prompt", "label": "提示词", "type": "string", "required": true, "ui": "textarea" }, - { - "name": "aspectRatio", "label": "比例", "type": "select", "default": "1:1", "ui": "proportion", - "options": ["1:1", "4:3", "3:2", "16:9", "3:4", "2:3", "9:16", "custom"] - }, - { "name": "customWidth", "label": "自定义宽度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } }, - { "name": "customHight", "label": "自定义高度", "type": "number", "default": 1024, "min": 512, "max": 2048, "ui": "hidden", "show_when": { "aspectRatio": "custom" } }, - { "name": "resolution", "label": "分辨率", "type": "select", "default": "1k", "ui": "resolution", "options": ["1k", "2k", "4k"] }, - { "name": "quality", "label": "质量", "type": "select", "default": "medium", "ui": "select", "options": ["low", "medium", "high"] } - ] -} -``` - ---- - -# 第二部分:前端改动 - -## 7. 缓存策略 - -三级防护,全部位于客户端浏览器: - -``` -请求流程: - 用户操作 - → L1 内存缓存(Map,会话级,10min TTL) - → L2 localStorage 缓存(持久化,10min TTL) - → 请求冷却检查(localStorage,30s 冷却期) - → API 请求 -``` - -| 层级 | 存储位置 | 生命周期 | 说明 | -|------|---------|---------|------| -| L1 | JS 内存 Map | 标签页关闭即销毁 | 同会话内切换模型 0 延迟 | -| L2 | localStorage | 持久化,跨会话/刷新 | 刷新页面后直接命中,无需请求 | -| 冷却期 | localStorage | 独立于缓存 | 限制 API 调用频率最低 30s/次 | - -**正常场景**:首次访问 → API 请求 → 写入 L1 + L2。刷新页面 → L2 命中,0 请求。 - -**极端场景**:清掉 localStorage 连刷 10 次 → 请求冷却生效,实际只有 1 次请求到达后端。 - ---- - -## 8. 新增文件 - -### 8.1 API 层 - -```js -// src/apis/display/index.js(追加以下两个函数) - -// 批量获取平台所有模型参数 -export const fetchPlatformModelParams = (platformCode) => - service.get(`/suanli/v1/platforms/${platformCode}/models/params`) - -// 获取单个模型参数(兜底) -export const fetchModelParams = (modelId) => - service.get(`/suanli/v1/models/${modelId}/params`) -``` - -### 8.2 缓存层 - -```js -// src/utils/modelParams.js(新文件) - -import { fetchPlatformModelParams } from '@/apis/display' - -const CACHE_TTL = 10 * 60 * 1000 // 缓存有效期:10 分钟 -const COOLDOWN = 30 * 1000 // 请求冷却期:30 秒 -const STORAGE_PREFIX = 'model_params_' - -const memoryCache = new Map() // L1: { platformCode → { data, timestamp } } -const pendingRequests = new Map() // 并发去重 - -function getStorageCache(platformCode) { - try { - const raw = localStorage.getItem(STORAGE_PREFIX + platformCode) - if (!raw) return null - const { data, timestamp } = JSON.parse(raw) - if (Date.now() - timestamp < CACHE_TTL) { - return new Map(data) - } - } catch { /* ignore */ } - return null -} - -function setStorageCache(platformCode, map) { - try { - localStorage.setItem(STORAGE_PREFIX + platformCode, JSON.stringify({ - data: [...map], - timestamp: Date.now(), - })) - } catch { /* ignore */ } -} - -function getCooldownRemaining(platformCode) { - try { - const raw = localStorage.getItem(STORAGE_PREFIX + platformCode + '_lastFetch') - if (!raw) return 0 - const elapsed = Date.now() - parseInt(raw) - return elapsed < COOLDOWN ? COOLDOWN - elapsed : 0 - } catch { return 0 } -} - -function setFetchTimestamp(platformCode) { - try { - localStorage.setItem(STORAGE_PREFIX + platformCode + '_lastFetch', Date.now().toString()) - } catch { /* ignore */ } -} - -export function clearModelParamsCache(platformCode) { - memoryCache.delete(platformCode) - localStorage.removeItem(STORAGE_PREFIX + platformCode) -} - -export async function getModelParamsMap(platformCode) { - // 1. L1 内存缓存 - const mem = memoryCache.get(platformCode) - if (mem && Date.now() - mem.timestamp < CACHE_TTL) { - return mem.data - } - - // 2. L2 localStorage 缓存 - const storage = getStorageCache(platformCode) - if (storage) { - memoryCache.set(platformCode, { data: storage, timestamp: Date.now() }) - return storage - } - - // 3. 并发去重 - if (pendingRequests.has(platformCode)) { - return pendingRequests.get(platformCode) - } - - // 4. 请求冷却 - const cooldown = getCooldownRemaining(platformCode) - if (cooldown > 0) { - const promise = new Promise(resolve => { - setTimeout(() => { - pendingRequests.delete(platformCode) - resolve(getModelParamsMap(platformCode)) - }, cooldown) - }) - pendingRequests.set(platformCode, promise) - return promise - } - - // 5. 发起请求 - setFetchTimestamp(platformCode) - const promise = fetchPlatformModelParams(platformCode) - .then(res => { - const map = new Map() - for (const m of res.data.models) { - map.set(m.id, m) - } - memoryCache.set(platformCode, { data: map, timestamp: Date.now() }) - setStorageCache(platformCode, map) - pendingRequests.delete(platformCode) - return map - }) - .catch(err => { - pendingRequests.delete(platformCode) - throw err - }) - - pendingRequests.set(platformCode, promise) - return promise -} -``` - ---- - -## 9. 改造文件 - -### 9.1 dialogBox/index.vue - -**数据流变化:** - -``` -当前: - model.value (display_name) - → getModelConfig(modelName) // 静态 JS 文件查找 - → modelConfig (computed) - → UI 渲染 - -新方案: - modelId (选中模型的 UUID) - → modelConfig = paramsMap.get(modelId) // 从预取 Map 查找 - → UI 渲染 -``` - -**核心代码改造:** - -```js -// 当前 -const modelConfig = computed(() => { - return props.type === 'Painting' ? getModelConfig(model.value) : null -}) - -// 新方案 -const paramsMap = ref(new Map()) -const modelConfig = computed(() => { - if (props.type !== 'Painting') return null - return paramsMap.value.get(currentModelId.value) || null -}) - -onMounted(async () => { - const map = await getModelParamsMap('ai_painting_talk') - paramsMap.value = map -}) -``` - -**其他改动点:** - -- `modelConfig` watcher 中维度初始化改用统一 `parseDimension()` 替代 `dimension.parse()` -- `fillParamsFromResult` 中维度恢复同理 -- `displayNameMap` 逻辑移除 - -### 9.2 model/painting.vue(模型选择器) - -- 选中值从 `display_name` 改为 `model_id` -- emit 从 `update:modelValue(displayName)` 改为 `update:modelValue(modelId)` -- 同时 emit `update:typeValue(inputType)` 保持不变 - -### 9.3 dimension 处理统一化 - -移除每个模型配置中自定义的 `parse`/`format` 函数,前端统一处理: - -```js -// src/components/dialogBox/index.vue 中集中定义 -function parseDimension(raw, delimiter = '*') { - const parts = (raw || '1024*1024').split(delimiter) - return { - width: parseInt(parts[0]) || 1024, - height: parseInt(parts[1]) || 1024, - } -} - -function formatDimension(w, h, delimiter = '*') { - return `${w}${delimiter}${h}` -} -``` - ---- - -## 10. 迁移步骤 - -| 阶段 | 负责方 | 内容 | -|------|--------|------| -| **1. 后端准备** | 后端 | 创建 `model_params` 表 → 录入 8 个模型配置 → 实现批量+单个接口 → 联调验证 | -| **2. 前端适配** | 前端 | 新增 API 函数 + 缓存层 → 改造 dialogBox + model 选择器 → 保留静态配置为 fallback | -| **3. 清理** | 前端 | 确认全量走新接口 → 删除 `src/config/models/*.js` → 删除 `displayNameMap`、`getModelConfig` | - ---- - -## 11. 边界情况 - -### 11.1 接口失败降级 - -迁移期间接口失败时回退静态配置: - -```js -const modelConfig = computed(() => { - if (paramsMap.value.size > 0) { - return paramsMap.value.get(currentModelId.value) || null - } - return getModelConfig(model.value) // fallback -}) -``` - -### 11.2 缓存主动刷新 - -管理员修改配置后,前端提供「刷新配置」按钮调用 `clearModelParamsCache()` 立即生效。 - -### 11.3 模型无参数配置 - -若 `model_id` 在 `model_params` 表中无记录,前端仅渲染 prompt 输入框(textarea),不显示其他 UI 组件。 - -### 11.4 `show_when` 条件显示 - -当前仅支持 `{ "aspectRatio": "custom" }` 条件。后续如需扩展,前后端同步约定新的条件字段和取值。 - -### 11.5 向后兼容 - -- 新增接口路径 `models/params`,不影响现有模型列表接口 -- Video 路径不受影响(继续使用 `runninghub.Playload()` 适配器) -- Painting 的 `modelParams` 扁平提交格式不变 diff --git a/src/components/virtual-scroller/README.md b/src/components/virtual-scroller/README.md deleted file mode 100644 index 96618ac..0000000 --- a/src/components/virtual-scroller/README.md +++ /dev/null @@ -1,222 +0,0 @@ -# VirtualScroller 虚拟滚动组件 - -一个高性能的虚拟滚动组件,支持未知高度子组件渲染和滚动方向反转功能。 - -## 特性 - -- 🚀 **高性能虚拟滚动** - 仅渲染可视区域内的元素,支持大数据量渲染 -- 🔄 **滚动方向反转** - 通过双重 CSS 旋转实现向上滚动效果 -- 📏 **未知高度支持** - 动态测量子组件高度,无需预设固定高度 -- 🎯 **精确滚动控制** - 提供滚动到指定索引、顶部、底部等 API -- 📱 **响应式设计** - 适配不同屏幕尺寸 -- ⚡ **60fps 流畅滚动** - 优化的渲染策略确保流畅体验 - -## 安装 - -组件位于 `src/components/virtual-scroller/` 目录下,无需额外安装依赖。 - -## 基础用法 - -```vue - - - -``` - -## Props - -| 属性名 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `data` | `Array` | `[]` | 数据源数组(必填) | -| `itemKey` | `string \| Function` | `'id'` | 用于标识每个项目的键名或函数 | -| `estimatedHeight` | `number` | `100` | 预估的项目高度(像素) | -| `buffer` | `number` | `3` | 可视区域外预渲染的项目数量 | -| `height` | `string \| number` | `'100%'` | 滚动容器高度 | -| `width` | `string \| number` | `'100%'` | 滚动容器宽度 | -| `renderMode` | `'default' \| 'top'` | `'default'` | 渲染模式,`top` 模式会自动滚动到页面底部 | - -## Events - -| 事件名 | 参数 | 说明 | -|--------|------|------| -| `scroll` | `event: Event` | 滚动事件,包含 `distanceToPageTop`、`distanceToPageBottom`、`isAtPageTop`、`isAtPageBottom` 属性 | -| `scroll-start` | - | 滚动到**页面顶部**时触发 | -| `scroll-end` | - | 滚动到**页面底部**时触发 | - -## Expose Methods - -通过 `ref` 可以访问以下方法: - -```vue - -``` - -| 方法名 | 参数 | 返回值 | 说明 | -|--------|------|--------|------| -| `scrollToIndex` | `index: number, behavior?: ScrollBehavior` | `void` | 滚动到指定索引的项目 | -| `scrollToBottom` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面底部**(最新数据) | -| `scrollToTop` | `behavior?: ScrollBehavior` | `void` | 滚动到**页面顶部**(最旧数据) | -| `getScrollElement` | - | `HTMLElement \| null` | 获取滚动容器 DOM 元素 | -| `getVisibleIndices` | - | `number[]` | 获取当前可视项目的索引数组 | -| `resetMeasurements` | - | `void` | 重置所有高度测量缓存 | -| `isAtPageBottom` | - | `boolean` | 判断是否在页面底部 | -| `isAtPageTop` | - | `boolean` | 判断是否在页面顶部 | - -## 滚动方向反转原理 - -组件通过 CSS `transform: rotate(180deg)` 实现滚动方向反转: - -1. **容器旋转**:滚动容器应用 `transform: rotate(180deg)` -2. **内容反向旋转**:子组件内部应用 `transform: rotate(180deg)` 抵消旋转 - -### 坐标映射关系 - -由于容器旋转 180 度,坐标系统发生反转: - -| 页面概念 | 组件内部 scrollTop | -|----------|-------------------| -| 页面顶部(最旧数据) | `scrollTop = scrollHeight - clientHeight` | -| 页面底部(最新数据) | `scrollTop = 0` | - -### 滚轮方向处理 - -组件内部处理了滚轮方向映射: -- 用户**向上**滚动滚轮 → 页面内容**向上**滚动 -- 用户**向下**滚动滚轮 → 页面内容**向下**滚动 - -## 使用示例 - -```vue - - - -``` - -## 性能优化建议 - -1. **合理设置 `estimatedHeight`**:预估高度越接近实际高度,重排越少 -2. **适当调整 `buffer`**:较大的 buffer 会预渲染更多元素,减少白屏但增加内存占用 -3. **使用唯一的 `itemKey`**:确保每个项目有唯一标识,避免不必要的重渲染 -4. **避免复杂计算**:在插槽中避免复杂计算,使用计算属性或缓存 - -## 常见问题 - -### Q: 子组件显示倒置怎么办? - -A: 在子组件上添加 `style="transform: rotate(180deg)"` 来抵消容器的旋转。 - -### Q: 如何判断是否滚动到页面底部? - -A: 使用 `isAtPageBottom()` 方法或监听 `scroll-end` 事件。 - -### Q: scrollToBottom 和 scrollToTop 的方向? - -A: -- `scrollToBottom()` - 滚动到**页面底部**(最新数据位置) -- `scrollToTop()` - 滚动到**页面顶部**(最旧数据位置) - -## 浏览器兼容性 - -- Chrome >= 64 -- Firefox >= 69 -- Safari >= 13.1 -- Edge >= 79 - -需要浏览器支持 `ResizeObserver` API。 diff --git a/src/components/virtual-scroller/VirtualScroller copy 2.vue b/src/components/virtual-scroller/VirtualScroller copy 2.vue deleted file mode 100644 index 55f5a2b..0000000 --- a/src/components/virtual-scroller/VirtualScroller copy 2.vue +++ /dev/null @@ -1,615 +0,0 @@ - - - - - diff --git a/src/components/virtual-scroller/VirtualScroller copy 3.vue b/src/components/virtual-scroller/VirtualScroller copy 3.vue deleted file mode 100644 index dde08dd..0000000 --- a/src/components/virtual-scroller/VirtualScroller copy 3.vue +++ /dev/null @@ -1,615 +0,0 @@ - - - - - diff --git a/src/components/virtual-scroller/VirtualScroller copy.vue b/src/components/virtual-scroller/VirtualScroller copy.vue index 4ecd85e..1a28515 100644 --- a/src/components/virtual-scroller/VirtualScroller copy.vue +++ b/src/components/virtual-scroller/VirtualScroller copy.vue @@ -1,27 +1,41 @@