diff --git a/CLAUDE.md b/CLAUDE.md index c5a07c5..5a6584d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 常用命令 ```bash -pnpm dev # 启动 Vite 开发服务器 +pnpm install # 安装依赖 +pnpm dev # 启动 Vite 开发服务器(默认 http://localhost:5173) pnpm build # 生产构建 pnpm preview # 预览生产构建 npx eslint . # 代码检查(@antfu/eslint-config,Vue 支持,无 TypeScript) @@ -128,7 +129,7 @@ props: (config) => ({ }) ``` -**自注册:** 每个平台文件底部调用 `registerPlatform('Painting', definePaintingPlatform)`,在 import 时自动注册。dialogBox 通过 `createPlatform(props.type)` 获取实例。 +**自注册:** 每个平台文件底部调用 `registerPlatform('Painting', definePaintingPlatform)`,在 import 时自动注册。dialogBox 通过 `createPlatform(props.type)` 获取实例。注册表内部将 key 统一转为小写,因此 `'Painting'` 和 `'painting'` 等效。 ### dialogBox 通用编排壳 @@ -258,7 +259,7 @@ Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加 - **VirtualScroller 存在独立测试页**:`src/components/virtual-scroller/test.html` + `test-data.js`,可用于验证虚拟滚动行为。 - **VirtualScroller 残留备份文件**:`src/components/virtual-scroller/VirtualScroller copy.vue` 是备份副本,不应被引用或修改。 - **Element Plus `` 不响应 `autosize` 动态变化**:`ElInput` 只在 mount 时读取 `autosize`,prop 更新时不会重新计算高度。因此 `dialogBox` 中 `Sender` 必须绑定 `:key="useDisplay.Sender_variant"`,通过强制重挂载来使新的 `autosize`(`minRows`/`maxRows`)生效。 -- **Video modelSelector pattern→modelType 映射**:`getModelType()` 在 `modelSelector.vue` 中将 pattern tag 映射为 modelType——"文生视频"→`text`,"首尾帧"→`image`,"数字人"→`digitalHuman`。该值用于 imageUploader 的 `showEndFrame` 判断和上传槽位数量。 +- **Video modelSelector pattern→modelType 映射**:`getModelType()` 在 `modelSelector.vue` 中将 pattern tag 映射为 modelType——"文生视频"→`text`,"图生视频"→`imageToVideo`,"首尾帧"→`image`,"数字人"→`digitalHuman`,"全能参考"→`allReference`,"主体参考"→`subjectReference`。该值用于 imageUploader 的标签文本和上传槽位数量。 - **Video `imageUploadLimit()` 累加逻辑**:对于有多个 `imageUpload` 参数的模型(如首尾帧模型的 `firstImageUrl` + `lastImageUrl`),应累加所有 `imageUpload` 参数的 `maxCount`,而非只取第一个。否则首尾帧模型只显示一个上传槽位,尾帧上传无法触发。 - **`buildTaskBody` 参考图映射**:Video 平台在 `buildTaskBody` 中需将 `referenceImages` 按索引顺序写入 `imageUpload` 参数(`referenceImages[0]` → `firstImageUrl`,`referenceImages[1]` → `lastImageUrl`),否则图片数据不会包含在任务请求中。 @@ -333,6 +334,24 @@ Video 的 `getDefaultModel()` 返回 `''`,不硬编码 UUID;模型列表加 **注意**:前缀字符串本身来自环境变量(如 `VITE_API_TASK_PREFIX=/suanli`),不是硬编码。`request.js` 在初始化时读取这些变量,构建 prefix→target 映射表。 +### 环境变量速查 + +```bash +# .env.development +VITE_BASE = '/' # 应用基础路径 +VITE_API_PREFIX = '/api' # 主服务前缀 +VITE_API_BASE_URL = 'http://...' # 主服务(默认目标) +VITE_API_PAY_PREFIX = '/pay' # 支付服务前缀 +VITE_API_PAY_TARGET = 'http://...' # 支付服务目标 +VITE_API_TASK_PREFIX = '/suanli' # 任务服务前缀 +VITE_API_TASK_TARGET = 'http://...' # 任务服务目标 +VITE_API_WORKFLOW_UPLOAD = 'http://...' # 图片上传地址(imageUploader 组件 action) +VITE_OPEN_DEVTOOLS = false # 是否开启开发者工具 +FILE_OPEN_PREVIEW = true # 是否开启 KKFileView 预览 +``` + +`vite.config.js` 中 `envPrefix: ['VITE', 'FILE']`,因此只有以 `VITE_` 和 `FILE_` 开头的变量会被暴露给客户端代码。 + ### 平台编码映射 | 类型 | 平台编码 | diff --git a/components.d.ts b/components.d.ts index f1a82e6..42acee8 100644 --- a/components.d.ts +++ b/components.d.ts @@ -11,12 +11,12 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AudioPlayer: typeof import('./src/components/AudioPlayer/index.vue')['default'] Canvas: typeof import('./src/components/canvas/index.vue')['default'] - copy: typeof import('./src/components/virtual-scroller/VirtualScroller copy.vue')['default'] + CustomSlider: typeof import('./src/components/CustomSlider/index.vue')['default'] DialogBox: typeof import('./src/components/dialogBox/index.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] - 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'] diff --git a/src/assets/dialog/beautify.svg b/src/assets/dialog/beautify.svg new file mode 100644 index 0000000..ba05e00 --- /dev/null +++ b/src/assets/dialog/beautify.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/dialog/commonMode.svg b/src/assets/dialog/commonMode.svg new file mode 100644 index 0000000..8779074 --- /dev/null +++ b/src/assets/dialog/commonMode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/dialog/editMode.svg b/src/assets/dialog/editMode.svg new file mode 100644 index 0000000..9e5d839 --- /dev/null +++ b/src/assets/dialog/editMode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/dialog/lock.svg b/src/assets/dialog/lock.svg index 093947a..bba9c97 100644 --- a/src/assets/dialog/lock.svg +++ b/src/assets/dialog/lock.svg @@ -1,3 +1,5 @@ - + + + diff --git a/src/assets/dialog/lyrics.svg b/src/assets/dialog/lyrics.svg new file mode 100644 index 0000000..6bfdde1 --- /dev/null +++ b/src/assets/dialog/lyrics.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/dialog/professionalMode.svg b/src/assets/dialog/professionalMode.svg new file mode 100644 index 0000000..fe85134 --- /dev/null +++ b/src/assets/dialog/professionalMode.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/dialog/randomSeed.svg b/src/assets/dialog/randomSeed.svg new file mode 100644 index 0000000..3454060 --- /dev/null +++ b/src/assets/dialog/randomSeed.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/dialog/remixMode.svg b/src/assets/dialog/remixMode.svg new file mode 100644 index 0000000..2258de4 --- /dev/null +++ b/src/assets/dialog/remixMode.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/dialog/restore.svg b/src/assets/dialog/restore.svg new file mode 100644 index 0000000..7535a62 --- /dev/null +++ b/src/assets/dialog/restore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/display/background1.png b/src/assets/display/background1.png new file mode 100644 index 0000000..9e74188 Binary files /dev/null and b/src/assets/display/background1.png differ diff --git a/src/assets/display/background2.png b/src/assets/display/background2.png new file mode 100644 index 0000000..d8d3971 Binary files /dev/null and b/src/assets/display/background2.png differ diff --git a/src/assets/display/background3.png b/src/assets/display/background3.png new file mode 100644 index 0000000..43c1162 Binary files /dev/null and b/src/assets/display/background3.png differ diff --git a/src/assets/display/background4.png b/src/assets/display/background4.png new file mode 100644 index 0000000..4f2afae Binary files /dev/null and b/src/assets/display/background4.png differ diff --git a/src/assets/display/image1.png b/src/assets/display/image1.png new file mode 100644 index 0000000..acb4995 Binary files /dev/null and b/src/assets/display/image1.png differ diff --git a/src/assets/display/image2.png b/src/assets/display/image2.png new file mode 100644 index 0000000..7ab643e Binary files /dev/null and b/src/assets/display/image2.png differ diff --git a/src/assets/display/image3.png b/src/assets/display/image3.png new file mode 100644 index 0000000..a65d867 Binary files /dev/null and b/src/assets/display/image3.png differ diff --git a/src/assets/display/image4.png b/src/assets/display/image4.png new file mode 100644 index 0000000..9c53f61 Binary files /dev/null and b/src/assets/display/image4.png differ diff --git a/src/assets/display/time.svg b/src/assets/display/time.svg new file mode 100644 index 0000000..6750b2a --- /dev/null +++ b/src/assets/display/time.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/AudioPlayer/index.vue b/src/components/AudioPlayer/index.vue new file mode 100644 index 0000000..779138f --- /dev/null +++ b/src/components/AudioPlayer/index.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/src/components/CustomSlider/index.vue b/src/components/CustomSlider/index.vue new file mode 100644 index 0000000..ef0abbb --- /dev/null +++ b/src/components/CustomSlider/index.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/components/dialogBox/index.vue b/src/components/dialogBox/index.vue index 8af49df..19e82e3 100644 --- a/src/components/dialogBox/index.vue +++ b/src/components/dialogBox/index.vue @@ -83,6 +83,7 @@ import { generate } from '@/utils/taskPolling' // 确保平台包被加载(触发自注册) import '@/platforms/painting/index.js' import '@/platforms/video/index.js' +import '@/platforms/music/index.js' const props = defineProps({ isGenerate: { type: Boolean, default: false }, diff --git a/src/platforms/music/controls/lyricsInput.vue b/src/platforms/music/controls/lyricsInput.vue new file mode 100644 index 0000000..ac0e4d1 --- /dev/null +++ b/src/platforms/music/controls/lyricsInput.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/platforms/music/controls/modeSelector.vue b/src/platforms/music/controls/modeSelector.vue new file mode 100644 index 0000000..ab09121 --- /dev/null +++ b/src/platforms/music/controls/modeSelector.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/platforms/music/controls/pureMusicGroup.vue b/src/platforms/music/controls/pureMusicGroup.vue new file mode 100644 index 0000000..df02f45 --- /dev/null +++ b/src/platforms/music/controls/pureMusicGroup.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/src/platforms/music/controls/timeControl.vue b/src/platforms/music/controls/timeControl.vue new file mode 100644 index 0000000..6ba723d --- /dev/null +++ b/src/platforms/music/controls/timeControl.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/src/platforms/music/imageUploader.vue b/src/platforms/music/imageUploader.vue new file mode 100644 index 0000000..619ad77 --- /dev/null +++ b/src/platforms/music/imageUploader.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/platforms/music/index.js b/src/platforms/music/index.js new file mode 100644 index 0000000..4a7a46b --- /dev/null +++ b/src/platforms/music/index.js @@ -0,0 +1,278 @@ +import { markRaw, reactive, ref, computed } from 'vue' +import { registerPlatform } from '@/platforms/registry.js' +import { fetchPlatformModels, getPlatformCode, getModelConfig, getModelId, preloadModelConfigs } from '@/utils/modelApi' +import { syncParamValues, checkShowWhen } from '@/utils/modelConfigHelper.js' +import ModelSelector from './modelSelector.vue' +import ImageUploader from './imageUploader.vue' +import ModeSelector from './controls/modeSelector.vue' +import PureMusicGroup from './controls/pureMusicGroup.vue' +import LyricsInput from './controls/lyricsInput.vue' +import TimeControl from './controls/timeControl.vue' +import ParamGroup from '@/components/ParamGroup/index.vue' +import Select from '@/components/Select/index.vue' +import { ElMessage } from 'element-plus' + +// 由专用控件处理的 ui 类型 +const handledUis = ['textarea', 'proportion', 'imageUpload', 'hidden', 'quantity'] + +export function defineMusicPlatform() { + const model = ref('') + const modelType = ref('text') + const mode = ref('常用模式') + const modelConfig = ref(null) + const paramValues = reactive({}) + const promptPlaceholder = ref('描述你想生成的音乐风格和感觉。') + const referenceAudio = ref([]) + const models = ref([]) + + // 音乐专用 ref + const quantity = ref(1) + const duration = ref('Auto') + const lyrics = ref('') + const randomSeed = ref('') + const pureMusic = ref(true) + + const code = computed(() => getPlatformCode('Music')) + + async function loadModels() { + models.value = await fetchPlatformModels(code.value) + if (!model.value && models.value.length) { + const first = models.value.find(m => !m.disabled) + if (first) model.value = first.id + } + if (models.value.length) { + const ids = models.value.map(m => m.id) + preloadModelConfigs(ids) + } + } + + async function loadConfig(modelName) { + const modelId = await getModelId('Music', modelName) + if (!modelId) return null + const config = await getModelConfig(modelId) + syncMusicDefaults(config) + } + + // 音乐平台的 syncDefaults 包装 + function syncMusicDefaults(config) { + modelConfig.value = config + if (!config) return + + config.params.forEach((p) => { + if (!(p.name in paramValues)) { + paramValues[p.name] = p.default ?? '' + } + }) + + // 同步专用 ref + const modeParam = config.params.find(p => p.name === 'mode' || p.ui === 'mode') + if (modeParam) mode.value = modeParam.default || '常用模式' + + const qtyParam = config.params.find(p => p.ui === 'quantity') + if (qtyParam) quantity.value = qtyParam.default || 1 + + const durParam = config.params.find(p => p.name === 'duration') + if (durParam) duration.value = durParam.default || 'Auto' + + const lyricsParam = config.params.find(p => p.name === 'lyrics') + if (lyricsParam) lyrics.value = lyricsParam.default || '' + + const seedParam = config.params.find(p => p.name === 'randomSeed' || p.name === 'seed') + if (seedParam) randomSeed.value = seedParam.default || '' + + const pmParam = config.params.find(p => p.name === 'pureMusic') + if (pmParam) pureMusic.value = pmParam.default !== undefined ? pmParam.default : true + + if (config.promptPlaceholder) { + promptPlaceholder.value = config.promptPlaceholder + } + } + + function getDefaultModel() { return '' } + + function imageUploadLimit() { + if (!modelConfig.value) return 0 + return modelConfig.value.params + .filter(p => p.ui === 'imageUpload') + .reduce((sum, p) => sum + (p.maxCount || 1), 0) + } + + function validateBeforeSubmit() { + if (!model.value) return '请选择模型' + if (mode.value === '专业模式' && !referenceAudio.value.length) return '请上传参考音频' + return null + } + + function getUploaderBindings() { + return { modelType: mode.value === '专业模式' ? 'image' : 'text', imagesCount: referenceAudio.value.length } + } + + function showImageUploader() { + return mode.value === '专业模式' + } + + function isImageRequired() { + return mode.value === '专业模式' + } + + function buildTaskBody({ prompt, referenceImages }) { + syncMusicParamValues() + // 将 prompt 写入 paramValues(如果 config 中有 prompt 参数) + const promptParam = modelConfig.value?.params?.find(p => p.ui === 'textarea') + if (promptParam) paramValues[promptParam.name] = prompt + + // 将参考音频映射到 imageUpload 参数 + if (modelConfig.value) { + const imageUploadParams = modelConfig.value.params.filter(p => p.ui === 'imageUpload') + imageUploadParams.forEach((p, i) => { + if (referenceAudio.value[i]) { + paramValues[p.name] = referenceAudio.value[i].url + } + }) + } + return { ...paramValues } + } + + function syncMusicParamValues() { + if (!modelConfig.value) return + const config = modelConfig.value + + const qtyParam = config.params.find(p => p.ui === 'quantity') + if (qtyParam) paramValues[qtyParam.name] = quantity.value + + const durParam = config.params.find(p => p.name === 'duration') + if (durParam) paramValues[durParam.name] = duration.value + + const lyricsParam = config.params.find(p => p.name === 'lyrics') + if (lyricsParam) paramValues[lyricsParam.name] = lyrics.value + + const seedParam = config.params.find(p => p.name === 'randomSeed' || p.name === 'seed') + if (seedParam) paramValues[seedParam.name] = randomSeed.value + + const pmParam = config.params.find(p => p.name === 'pureMusic') + if (pmParam) paramValues[pmParam.name] = pureMusic.value + + const modeParam = config.params.find(p => p.name === 'mode' || p.ui === 'mode') + if (modeParam) paramValues[modeParam.name] = mode.value + } + + function fillFromResult(resultData) { + if (resultData.mode !== undefined) mode.value = resultData.mode + if (resultData.prompt !== undefined) paramValues.prompt = resultData.prompt + if (resultData.duration !== undefined) duration.value = resultData.duration + if (resultData.lyrics !== undefined) lyrics.value = resultData.lyrics + if (resultData.randomSeed !== undefined) randomSeed.value = resultData.randomSeed + if (resultData.quantity !== undefined) quantity.value = resultData.quantity + if (resultData.pureMusic !== undefined) pureMusic.value = resultData.pureMusic + } + + const controls = [ + { + name: 'modeSelector', + component: markRaw(ModeSelector), + beforeModel: true, + show: (config) => !!config?.params?.find(p => p.name === 'mode' || p.ui === 'mode'), + props: (config) => { + const modeParam = config?.params?.find(p => p.name === 'mode' || p.ui === 'mode') + return { + modelValue: mode.value, + 'onUpdate:modelValue': (v) => { mode.value = v }, + options: modeParam?.options || [] + } + } + }, + { + name: 'pureMusicGroup', + component: markRaw(PureMusicGroup), + beforeModel: false, + show: (config) => mode.value === '常用模式' && !!config?.params?.find(p => p.name === 'pureMusic'), + props: (config) => ({ + modelValue: pureMusic.value, + 'onUpdate:modelValue': (v) => { pureMusic.value = v }, + lyrics: lyrics.value, + 'onUpdate:lyrics': (v) => { lyrics.value = v } + }) + }, + { + name: 'lyricsInput', + component: markRaw(LyricsInput), + beforeModel: false, + show: (config) => mode.value === '专业模式' && !!config?.params?.find(p => p.name === 'lyrics'), + props: (config) => ({ + modelValue: lyrics.value, + 'onUpdate:modelValue': (v) => { lyrics.value = v } + }) + }, + { + name: 'timeControl', + component: markRaw(TimeControl), + beforeModel: false, + show: (config) => mode.value === '常用模式' && !!config?.params?.find(p => p.name === 'duration'), + props: (config) => { + const durParam = config?.params?.find(p => p.name === 'duration') + return { + modelValue: duration.value, + 'onUpdate:modelValue': (v) => { duration.value = v }, + min: durParam?.min || 10, + max: durParam?.max || 240 + } + } + }, + { + name: 'quantity', + component: markRaw(Select), + beforeModel: false, + show: (config) => !!config?.params?.find(p => p.ui === 'quantity'), + props: (config) => { + const qtyParam = config?.params?.find(p => p.ui === 'quantity') + const maxQty = Math.max(...(qtyParam?.options || [1])) + const limited = mode.value === '专业模式' ? 1 : maxQty + return { + modelValue: quantity.value, + 'onUpdate:modelValue': (v) => { quantity.value = v }, + options: Array.from({ length: limited }, (_, i) => ({ value: i + 1, label: `${i + 1}条` })) + } + } + }, + { + name: 'paramGroup', + component: markRaw(ParamGroup), + beforeModel: false, + show: (config) => { + if (!config) return false + return config.params.some((p) => { + if (handledUis.includes(p.ui)) return false + if (['mode', 'pureMusic', 'lyrics', 'duration', 'quantity'].includes(p.name)) return false + if (!checkShowWhen(p, { ...paramValues, mode: mode.value })) return false + return true + }) + }, + props: (config) => ({ + config, + paramValues, + excludeNames: ['mode', 'pureMusic', 'lyrics', 'duration', 'quantity'] + }) + } + ] + + return { + id: 'Music', + label: 'AI音乐2026', + ModelSelector: markRaw(ModelSelector), + modelSelectorProps: () => ({ models: models.value }), + controls, + ImageUploader: markRaw(ImageUploader), + state: { + model, modelType, mode, modelConfig, paramValues, + promptPlaceholder, referenceAudio, models, + quantity, duration, lyrics, randomSeed, pureMusic + }, + model, modelType, mode, modelConfig, promptPlaceholder, + loadModels, loadConfig, getDefaultModel, + imageUploadLimit, validateBeforeSubmit, + getUploaderBindings, showImageUploader, isImageRequired, + buildTaskBody, fillFromResult + } +} + +registerPlatform('Music', defineMusicPlatform) diff --git a/src/platforms/music/modelSelector.vue b/src/platforms/music/modelSelector.vue new file mode 100644 index 0000000..4312462 --- /dev/null +++ b/src/platforms/music/modelSelector.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/utils/modelApi.js b/src/utils/modelApi.js index d4368a4..b78262b 100644 --- a/src/utils/modelApi.js +++ b/src/utils/modelApi.js @@ -44,6 +44,8 @@ export function getPlatformCode(type) { return 'ai_painting_talk' case 'Video': return 'ai_video_talk' + case 'Music': + return 'ai_music_talk' default: return 'ai_painting_talk' } diff --git a/src/utils/taskPolling.js b/src/utils/taskPolling.js index 28801e3..a272ca6 100644 --- a/src/utils/taskPolling.js +++ b/src/utils/taskPolling.js @@ -10,6 +10,8 @@ export function getChargeType(chargeType) { return 1 case 'Video': return 4 + case 'Music': + return 5 default: return 2 } diff --git a/src/views/home/display/components/set.vue b/src/views/home/display/components/set.vue index f5f8709..3faf5cb 100644 --- a/src/views/home/display/components/set.vue +++ b/src/views/home/display/components/set.vue @@ -5,7 +5,7 @@
- {{ props.item.generateData.prompt || '生成图片' }} + {{ props.item.generateData.prompt || (props.item.type === 'Music' ? '生成音频' : '生成图片') }}
@@ -85,6 +85,19 @@
+ +
+
+ + +
+
+ +
+
+
+
+
@@ -193,6 +206,7 @@ const isCollected = (url) => { const generateStatusText = computed(() => { if (props.item.status === 'generate') { + if (props.item.type === 'Music') return '音乐生成中...' return '正在生成中...' } return '' @@ -291,7 +305,7 @@ const addCollection = async (url) => { const copyPrompt = async () => { try { - const promptText = props.item.generateData.prompt || '生成图片' + const promptText = props.item.generateData.prompt || (props.item.type === 'Music' ? '生成音频' : '生成图片') await navigator.clipboard.writeText(promptText) ElMessage.success('提示词已复制到剪贴板') } catch (error) {