feat: 新增 Music 音乐生成平台,遵循 Platform Descriptor 模式

基于旧项目 ai_music_v2.0 迁移,与 Painting/Video 统一架构:HTTP 轮询 + suanli 后端、
API 驱动配置、mode 独立 ref 驱动控件显隐。新增 AudioPlayer/CustomSlider 通用组件,
dialogBox/set.vue/taskPolling/modelApi 完成集成适配。
This commit is contained in:
王佑琳 2026-06-12 19:20:18 +08:00
parent 61867e4f59
commit 2d12c5a20b
33 changed files with 1497 additions and 8 deletions

View File

@ -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-configVue 支持,无 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 `<el-input type="textarea">` 不响应 `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_` 开头的变量会被暴露给客户端代码。
### 平台编码映射
| 类型 | 平台编码 |

4
components.d.ts vendored
View File

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

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.1403 9.92944C16.1733 10.4175 16.3717 11.6017 17.48 12.4494C18.3647 13.1261 19.3661 13.0687 20 12.9268C17.5782 13.8572 17.4411 15.861 17.5801 16.9878C16.6857 14.0517 13.4579 14.6885 12.8697 14.8314C15.9283 14.0108 16.1412 10.8064 16.1403 9.92944ZM4.7575 2.8575C5.125 2.945 6.04278 2.93042 6.91486 2.30236C7.61 1.80069 7.77333 0.753611 7.80931 0.25C7.95806 2.26153 9.42319 3.11514 10.2856 3.26972C7.85889 3.26 7.66056 5.93458 7.64597 6.30305C7.74028 3.82097 5.40792 3.05972 4.7575 2.8575ZM2.72653 7.9218C2.83754 8.09622 2.9846 8.24485 3.15783 8.3577C3.33106 8.47056 3.52644 8.54503 3.73083 8.57611C4.19458 8.64319 4.55236 8.41472 4.7575 8.23194C4.04972 9.07583 4.4075 9.84389 4.68653 10.2308C3.73861 9.30722 2.64583 10.2775 2.5 10.4156C3.47903 9.48222 2.90542 8.24653 2.72653 7.9218ZM13.4113 9.95861L11.9724 8.51194L13.7194 6.86792C13.818 6.77493 13.9494 6.72485 14.0848 6.72868C14.2203 6.73251 14.3487 6.78993 14.4418 6.88833L15.1418 7.62917C15.1878 7.67765 15.2238 7.73474 15.2478 7.79716C15.2717 7.85957 15.2831 7.9261 15.2813 7.99292C15.2795 8.05974 15.2645 8.12555 15.2372 8.18659C15.21 8.24762 15.1709 8.30268 15.1224 8.34861L13.4113 9.95861ZM11.4396 9.01458L12.8785 10.4603L5.28444 17.611C5.08028 17.8044 4.75653 17.7957 4.56208 17.5915L3.86208 16.8507C3.81595 16.8023 3.77984 16.7452 3.75584 16.6828C3.73184 16.6203 3.72041 16.5538 3.72222 16.4869C3.72402 16.4201 3.73903 16.3542 3.76637 16.2932C3.79371 16.2321 3.83285 16.1771 3.88153 16.1312L11.4396 9.01458Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
<rect x="13" y="1.3614" width="5" height="5" rx="0.5" transform="rotate(45 13 1.3614)" stroke="#666666"/>
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
<rect x="10" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 9V15C16 15.5523 15.5523 16 15 16H3C2.44772 16 2 15.5523 2 15V3C2 2.44772 2.44772 2 3 2H9" stroke="#666666" stroke-linecap="round"/>
<path d="M16 2L14 4L10 8L9 9" stroke="#666666" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@ -1,3 +1,5 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#BBBBBB" stroke-width="1.5" stroke-linecap="round"/>
<rect width="36" height="36" rx="10" fill="#F8F9FA"/>
<path d="M20.25 15H22.5H24C25.6569 15 27 16.3431 27 18C27 19.6569 25.6569 21 24 21H22.5H20.25M15.75 15H13.5H12C10.3431 15 9 16.3431 9 18C9 19.6569 10.3431 21 12 21H13.5H15.75" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
<path d="M16 18H20" stroke="#666666" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 475 B

View File

@ -0,0 +1,6 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#333333"/>
<path d="M7 6H11" stroke="#333333" stroke-linecap="round"/>
<path d="M7 12H11" stroke="#333333" stroke-linecap="round"/>
<path d="M6 9H12" stroke="#333333" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@ -0,0 +1,8 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
<rect x="2.5" y="10" width="5.5" height="5.5" rx="0.5" stroke="#666666"/>
<path d="M10 3.25H16" stroke="#666666" stroke-linecap="round"/>
<path d="M10 7.25H16" stroke="#666666" stroke-linecap="round"/>
<path d="M10 10.75H16" stroke="#666666" stroke-linecap="round"/>
<path d="M10 14.75H16" stroke="#666666" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1,11 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 3.5L7.5 0.5L14.5 3.5L7.5 6.5L0.5 3.5Z" stroke="#666666" stroke-linejoin="round"/>
<path d="M0.5 12.5V3.5L7.5 6.5V15.5L0.5 12.5Z" stroke="#666666" stroke-linejoin="round"/>
<path d="M7.5 6.5L14.5 3.5V12.5L7.5 15.5V6.5Z" stroke="#666666" stroke-linejoin="round"/>
<ellipse cx="7.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
<ellipse cx="4.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
<ellipse cx="10.4498" cy="3.55078" rx="1.15" ry="0.75" fill="#666666"/>
<ellipse cx="12.25" cy="7.85" rx="1.15" ry="0.75" transform="rotate(-90 12.25 7.85)" fill="#666666"/>
<ellipse cx="10.25" cy="10.85" rx="1.15" ry="0.75" transform="rotate(-90 10.25 10.85)" fill="#666666"/>
<ellipse cx="3.84961" cy="9.65078" rx="1.15" ry="0.75" transform="rotate(-90 3.84961 9.65078)" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@ -0,0 +1,9 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="13" height="13" rx="0.5" stroke="#666666"/>
<path d="M7 6V12" stroke="#666666" stroke-linecap="round"/>
<path d="M8 9C8 9.55228 7.55228 10 7 10C6.44772 10 6 9.55228 6 9C6 8.44772 6.44772 8 7 8C7.55228 8 8 8.44772 8 9Z" fill="white"/>
<path d="M7.5 9C7.5 8.72386 7.27614 8.5 7 8.5C6.72386 8.5 6.5 8.72386 6.5 9C6.5 9.27614 6.72386 9.5 7 9.5C7.27614 9.5 7.5 9.27614 7.5 9ZM8.5 9C8.5 9.82843 7.82843 10.5 7 10.5C6.17157 10.5 5.5 9.82843 5.5 9C5.5 8.17157 6.17157 7.5 7 7.5C7.82843 7.5 8.5 8.17157 8.5 9Z" fill="#666666"/>
<path d="M11 6V12" stroke="#666666" stroke-linecap="round"/>
<path d="M12 11C12 11.5523 11.5523 12 11 12C10.4477 12 10 11.5523 10 11C10 10.4477 10.4477 10 11 10C11.5523 10 12 10.4477 12 11Z" fill="white"/>
<path d="M11.5 11C11.5 10.7239 11.2761 10.5 11 10.5C10.7239 10.5 10.5 10.7239 10.5 11C10.5 11.2761 10.7239 11.5 11 11.5C11.2761 11.5 11.5 11.2761 11.5 11ZM12.5 11C12.5 11.8284 11.8284 12.5 11 12.5C10.1716 12.5 9.5 11.8284 9.5 11C9.5 10.1716 10.1716 9.5 11 9.5C11.8284 9.5 12.5 10.1716 12.5 11Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4.47053H4.47052M15.1761 4.47053H12.7056M12.7056 4.47053V4C12.7056 2.89543 11.8102 2 10.7056 2H6.47053C5.36596 2 4.47052 2.89543 4.47052 4V4.47053M12.7056 4.47053H4.47052" stroke="#666666" stroke-linecap="round"/>
<path d="M3.64746 6.94141V14C3.64746 15.1046 4.54289 16 5.64746 16H11.5296C12.6341 16 13.5296 15.1046 13.5296 14V6.94141M6.94149 7.76491V14.353M10.2355 7.76491V14.353" stroke="#666666" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="6.5" stroke="black"/>
<path d="M9 6V10" stroke="black" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1,267 @@
<template>
<div class="audio-placeholder" :style="{ backgroundImage: `url(${backgroundImage})` }">
<div class="audio-left">
<img :src="coverImage" alt="音频封面" class="audio-cover" />
</div>
<div class="audio-center">
<div class="audio-title">{{ audioTitle }}</div>
<div class="audio-progress">
<div class="progress-bar" @click="handleProgressClick">
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
</div>
<div class="progress-time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
</div>
</div>
<div class="audio-right">
<div class="play-button" @click.stop="handlePlayPause">
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
</svg>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill="white"/>
</svg>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import background1 from '@/assets/display/background1.png'
import background2 from '@/assets/display/background2.png'
import background3 from '@/assets/display/background3.png'
import background4 from '@/assets/display/background4.png'
import image1 from '@/assets/display/image1.png'
import image2 from '@/assets/display/image2.png'
import image3 from '@/assets/display/image3.png'
import image4 from '@/assets/display/image4.png'
const props = defineProps({
audioUrl: { type: String, default: '' },
audioTitle: { type: String, default: '我的音乐' },
cardIndex: { type: Number, default: 0 }
})
const emit = defineEmits(['play', 'pause', 'ended'])
const backgroundImages = [background1, background2, background3, background4]
const coverImages = [image1, image2, image3, image4]
const backgroundImage = computed(() => backgroundImages[props.cardIndex % 4])
const coverImage = computed(() => coverImages[props.cardIndex % 4])
const audioRef = ref(null)
const isPlaying = ref(false)
const isPlayPending = ref(false)
const currentTime = ref(0)
const duration = ref(0)
let timeupdateHandler = null
let loadedmetadataHandler = null
let endedHandler = null
const progressPercentage = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
const handleProgressClick = (event) => {
if (!audioRef.value || duration.value === 0) return
const rect = event.currentTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
const newTime = percentage * duration.value
audioRef.value.currentTime = newTime
currentTime.value = newTime
}
const setupAudio = (url) => {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
audioRef.value.removeEventListener('ended', endedHandler)
audioRef.value.src = ''
audioRef.value.load()
audioRef.value = null
}
isPlaying.value = false
currentTime.value = 0
duration.value = 0
if (!url) return
audioRef.value = new Audio(url)
audioRef.value.crossOrigin = 'anonymous'
timeupdateHandler = () => {
if (audioRef.value) currentTime.value = audioRef.value.currentTime
}
loadedmetadataHandler = () => {
if (audioRef.value) duration.value = audioRef.value.duration || 0
}
endedHandler = () => {
isPlaying.value = false
currentTime.value = 0
emit('ended')
}
audioRef.value.addEventListener('timeupdate', timeupdateHandler)
audioRef.value.addEventListener('loadedmetadata', loadedmetadataHandler)
audioRef.value.addEventListener('ended', endedHandler)
}
const handlePlayPause = () => {
if (!audioRef.value) {
setupAudio(props.audioUrl)
}
if (!audioRef.value) return
if (isPlaying.value) {
audioRef.value.pause()
isPlaying.value = false
isPlayPending.value = false
emit('pause')
} else if (!isPlayPending.value) {
isPlayPending.value = true
audioRef.value.play().then(() => {
isPlaying.value = true
isPlayPending.value = false
emit('play')
}).catch((error) => {
console.error('播放失败:', error)
isPlaying.value = false
isPlayPending.value = false
})
}
}
onMounted(() => { if (props.audioUrl) setupAudio(props.audioUrl) })
onUnmounted(() => {
if (audioRef.value) {
audioRef.value.pause()
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
audioRef.value.removeEventListener('ended', endedHandler)
audioRef.value.src = ''
audioRef.value.load()
audioRef.value = null
}
})
watch(() => props.audioUrl, (newUrl, oldUrl) => {
if (newUrl === oldUrl) return
setupAudio(newUrl)
})
</script>
<style lang="less" scoped>
.audio-placeholder {
width: 100%;
height: 100%;
min-height: 80px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 8px;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
cursor: pointer;
.audio-left {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
.audio-cover {
width: 56px;
height: 56px;
border-radius: 5px;
object-fit: cover;
}
}
.audio-center {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
min-width: 0;
.audio-title {
color: white;
font-family: "Microsoft YaHei";
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.audio-progress {
display: flex;
flex-direction: column;
gap: 4px;
.progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
height: 6px;
background: rgba(255, 255, 255, 0.4);
}
.progress-fill {
height: 100%;
background: white;
border-radius: 2px;
transition: width 0.1s ease;
}
}
.progress-time {
color: rgba(255, 255, 255, 0.8);
font-family: "Microsoft YaHei";
font-size: 11px;
font-weight: 400;
}
}
}
.audio-right {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
.play-button {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
svg { width: 20px; height: 20px; fill: white; }
}
}
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<div class="custom-slider" ref="sliderRef" @mousedown="handleMouseDown" @mouseleave="handleMouseLeave">
<div class="slider-tooltip" v-show="isDragging && showTooltip" :style="{ left: fillPercentage + '%' }">{{ displayValue }}</div>
<div class="slider-track"></div>
<div class="slider-fill" :style="{ width: fillPercentage + '%' }"></div>
<div class="slider-thumb" :style="{ left: fillPercentage + '%' }" @mousedown="handleThumbMouseDown"></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
modelValue: {
type: [Number, String],
default: 0
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
step: {
type: Number,
default: 1
},
showTooltip: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'mousedown', 'mouseup', 'mouseleave'])
const sliderRef = ref(null)
const isDragging = ref(false)
const fillPercentage = computed(() => {
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 0
const value = props.modelValue
const percentage = ((value - props.min) / (props.max - props.min)) * 100
return Math.max(0, Math.min(100, percentage))
})
const displayValue = computed(() => {
if (props.modelValue === 'Auto' || typeof props.modelValue !== 'number') return 'Auto'
const decimalPlaces = props.step < 0.1 ? 2 : 1
return props.modelValue.toFixed(decimalPlaces)
})
const handleMouseDown = (e) => {
isDragging.value = true
emit('mousedown', e)
updateValue(e.clientX)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleThumbMouseDown = (e) => {
isDragging.value = true
emit('mousedown', e)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = (e) => {
if (!isDragging.value) return
updateValue(e.clientX)
}
const handleMouseUp = (e) => {
isDragging.value = false
emit('mouseup', e)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const handleMouseLeave = (e) => {
emit('mouseleave', e)
}
const updateValue = (clientX) => {
if (!sliderRef.value) return
const rect = sliderRef.value.getBoundingClientRect()
const x = clientX - rect.left
const percentage = Math.max(0, Math.min(1, x / rect.width))
const rawValue = props.min + percentage * (props.max - props.min)
const steppedValue = Math.round(rawValue / props.step) * props.step
const value = Math.max(props.min, Math.min(props.max, steppedValue))
emit('update:modelValue', value)
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
})
</script>
<style lang="less" scoped>
.custom-slider {
position: relative;
width: 244px;
height: 2px;
cursor: pointer;
margin: 0 6px;
}
.slider-track {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #F8F9FA;
border-radius: 2px;
}
.slider-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #000F33;
border-radius: 2px;
}
.slider-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: #ffffff;
border: 2px solid #000F33;
border-radius: 50%;
cursor: pointer;
}
.slider-tooltip {
position: absolute;
top: -35px;
transform: translateX(-50%);
background: #000F33;
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-family: "Microsoft YaHei";
white-space: nowrap;
z-index: 10;
transition: opacity 0.2s ease, transform 0.2s ease;
&::after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #000F33;
}
}
</style>

View File

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

View File

@ -0,0 +1,120 @@
<template>
<div class="custom-popover-wrapper">
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
<img src="@/assets/dialog/lyrics.svg" alt="" style="width: 16px;">
<span>歌词</span>
</div>
<Transition name="popover">
<div v-show="showPopover" class="custom-popover" @click.stop>
<div class="input-wrapper">
<textarea v-model="localLyrics" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
</div>
<div class="button-wrapper">
<button class="confirm-btn" @click="handleConfirm">确定</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const showPopover = ref(false)
const localLyrics = ref(props.modelValue)
const togglePopover = () => {
showPopover.value = !showPopover.value
if (showPopover.value) localLyrics.value = props.modelValue
}
const handleConfirm = () => {
emit('update:modelValue', localLyrics.value)
showPopover.value = false
}
</script>
<style lang="less" scoped>
.choice-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E8E9EB;
background: #f5f6f7;
cursor: pointer;
position: relative;
&:hover, &.active { background: #E9EAEB; }
}
.custom-popover-wrapper { position: relative; display: inline-block; }
.custom-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: auto;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 9999;
padding: 20px;
}
.input-wrapper { display: flex; align-items: center; gap: 8px; }
.lyrics-input {
flex: 1;
height: 200px;
width: 370px;
padding: 15px;
border: none;
border-radius: 10px;
background: #F8F9FA;
font-size: 14px;
font-family: "Microsoft YaHei";
color: #333;
outline: none;
resize: none;
&::placeholder { color: #999; }
}
.button-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 15px;
padding: 15px;
background: #F8F9FA;
border-radius: 0 0 10px 10px;
}
.confirm-btn {
height: 32px;
width: 120px;
padding: 0 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-family: "Microsoft YaHei";
cursor: pointer;
background: rgba(0, 15, 51, 0.10);
color: #000F33;
&:hover { background: #5a62d9; }
}
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
</style>

View File

@ -0,0 +1,145 @@
<template>
<div class="custom-popover-wrapper">
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
<img :src="currentIcon" alt="" style="width: 16px;">
<span>{{ currentLabel }}</span>
</div>
<Transition name="popover">
<div v-show="showPopover" class="custom-popover" @click.stop>
<div class="select">
<div class="model-group">
<div
v-for="item in modeOptions"
:key="item.value"
class="model-item"
:class="{ active: modeValue === item.value, disabled: item.disabled }"
@click="selectMode(item)"
>
<img :src="item.icon" alt="" style="width: 16px; height: 16px;">
<span>{{ item.label }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import commonModeIcon from '@/assets/dialog/commonMode.svg'
import professionalModeIcon from '@/assets/dialog/professionalMode.svg'
import remixModeIcon from '@/assets/dialog/remixMode.svg'
import editModeIcon from '@/assets/dialog/editMode.svg'
const props = defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
const showPopover = ref(false)
const modeValue = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const iconMap = {
'常用模式': commonModeIcon,
'专业模式': professionalModeIcon,
'Remix模式': remixModeIcon,
'编辑模式': editModeIcon
}
const modeOptions = computed(() => {
if (props.options.length) return props.options.map(o => ({ ...o, icon: iconMap[o.label] || commonModeIcon }))
return [
{ value: '常用模式', label: '常用模式', icon: commonModeIcon },
{ value: '专业模式', label: '专业模式', icon: professionalModeIcon },
{ value: 'Remix模式', label: 'Remix模式', icon: remixModeIcon, disabled: true },
{ value: '编辑模式', label: '编辑模式', icon: editModeIcon, disabled: true }
]
})
const currentIcon = computed(() => {
const found = modeOptions.value.find(m => m.value === modeValue.value)
return found?.icon || commonModeIcon
})
const currentLabel = computed(() => {
const found = modeOptions.value.find(m => m.value === modeValue.value)
return found?.label || modeValue.value
})
const togglePopover = () => { showPopover.value = !showPopover.value }
const selectMode = (item) => {
if (item.disabled) return
modeValue.value = item.value
showPopover.value = false
}
</script>
<style lang="less" scoped>
.choice-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E9EAEB;
background: #f5f6f7;
cursor: pointer;
position: relative;
&:hover, &.active { background: #E9EAEB; }
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
}
.custom-popover-wrapper { position: relative; display: inline-block; }
.custom-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: auto;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 9999;
}
.select { padding: 20px 10px; max-height: 510px; overflow-y: auto; }
.model-group { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
.model-item {
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
margin-bottom: 4px;
transition: all 0.2s ease;
color: #666;
font-family: "Microsoft YaHei";
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
&:last-child { margin-bottom: 0; }
&:hover:not(.disabled) { background: #f5f6f7; }
&.active {
background: rgba(0, 15, 51, 0.10);
color: #333;
font-weight: 500;
img { filter: brightness(0) drop-shadow(0 0 0 #000F33); }
}
&.disabled { cursor: not-allowed; opacity: 0.5; }
}
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="custom-popover-wrapper">
<div class="choice-btn" @click.stop="handleChoiceBtnClick">
<div class="text-music">纯音乐</div>
<div class="switch-toggle" :class="{ active: isSwitchOn }" @click.stop="toggleSwitch">
<div class="switch-slider"></div>
</div>
</div>
<Transition name="popover">
<div v-show="showLyricsPopover && !isSwitchOn" class="custom-popover" @click.stop>
<div class="input-wrapper">
<textarea v-model="lyricsText" class="lyrics-input" placeholder="输入歌词" @mousedown.stop></textarea>
</div>
<div class="button-wrapper">
<button class="confirm-btn" @click="confirmLyrics">确定</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: [String, Boolean], default: true },
lyrics: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'update:lyrics'])
const showLyricsPopover = ref(false)
const lyricsText = ref('')
const isSwitchOn = computed(() => {
return props.modelValue === true || props.modelValue === 'true' || props.modelValue === '纯音乐模式'
})
const toggleSwitch = () => {
if (isSwitchOn.value) {
emit('update:modelValue', false)
showLyricsPopover.value = true
} else {
emit('update:modelValue', true)
emit('update:lyrics', '')
showLyricsPopover.value = false
}
}
const handleChoiceBtnClick = () => {
if (isSwitchOn.value) return
showLyricsPopover.value = !showLyricsPopover.value
}
const confirmLyrics = () => {
emit('update:lyrics', lyricsText.value)
showLyricsPopover.value = false
}
</script>
<style lang="less" scoped>
.choice-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E9EAEB;
background: #f5f6f7;
cursor: pointer;
position: relative;
&:hover { background: #E5E7EB; }
}
.text-music {
color: #333;
font-family: "Microsoft YaHei";
font-size: 14px;
}
.switch-toggle {
width: 24px;
height: 14px;
background: #ccc;
border-radius: 11px;
position: relative;
cursor: pointer;
transition: background 0.3s ease;
&.active { background: #000F33; }
.switch-slider {
width: 10px;
height: 10px;
background: #fff;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
&.active .switch-slider { transform: translateX(10px); }
}
.custom-popover-wrapper { position: relative; display: inline-block; }
.custom-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: auto;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 9999;
padding: 20px;
}
.input-wrapper { display: flex; align-items: center; gap: 8px; }
.lyrics-input {
flex: 1;
height: 200px;
width: 370px;
padding: 15px;
border: none;
border-radius: 10px;
background: #F8F9FA;
font-size: 14px;
font-family: "Microsoft YaHei";
color: #333;
outline: none;
resize: none;
&::placeholder { color: #999; }
}
.button-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 15px;
padding: 15px;
background: #F8F9FA;
border-radius: 0 0 10px 10px;
}
.confirm-btn {
height: 32px;
width: 120px;
padding: 0 20px;
border: none;
border-radius: 10px;
font-size: 14px;
font-family: "Microsoft YaHei";
cursor: pointer;
background: rgba(0, 15, 51, 0.10);
color: #000F33;
&:hover { background: #5a62d9; }
}
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
</style>

View File

@ -0,0 +1,147 @@
<template>
<div class="custom-popover-wrapper">
<div class="choice-btn" :class="{ active: showPopover }" @click.stop="togglePopover">
<img src="@/assets/display/time.svg" alt="" style="width: 16px;">
<span>{{ displayLabel }}</span>
</div>
<Transition name="popover">
<div v-show="showPopover" class="custom-popover" @click.stop>
<div class="setting-box">
<div class="setting-header">时长{{ min }}s-{{ max }}s</div>
<div class="setting-body">
<CustomSlider v-model="sliderValue" :min="sliderMin" :max="max" />
<input type="text" class="slider-value" :value="displayValue" @input="handleInput" @keypress="handleKeypress" @mousedown.stop />
<button class="restore-btn" @click.stop="restoreDuration">
<img :src="restoreIcon" alt="还原" />
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import restoreIcon from '@/assets/dialog/restore.svg'
const props = defineProps({
modelValue: { type: [String, Number], default: 'Auto' },
min: { type: Number, default: 10 },
max: { type: Number, default: 240 }
})
const emit = defineEmits(['update:modelValue'])
const showPopover = ref(false)
const togglePopover = () => { showPopover.value = !showPopover.value }
const sliderMin = computed(() => props.min - 1)
const sliderValue = computed({
get: () => props.modelValue === 'Auto' ? props.min - 1 : Number(props.modelValue),
set: (v) => {
if (v < props.min) emit('update:modelValue', 'Auto')
else emit('update:modelValue', v)
}
})
const displayLabel = computed(() => {
if (props.modelValue === 'Auto') return 'Auto'
return `${props.modelValue}s`
})
const displayValue = computed(() => props.modelValue)
const handleInput = (e) => {
const val = e.target.value
if (val === 'Auto') { emit('update:modelValue', 'Auto'); return }
const n = parseInt(val)
if (!isNaN(n) && n >= props.min && n <= props.max) emit('update:modelValue', n)
}
const handleKeypress = (e) => {
const cc = e.which || e.keyCode
if (cc < 48 || cc > 57) e.preventDefault()
}
const restoreDuration = () => { emit('update:modelValue', 'Auto') }
</script>
<style lang="less" scoped>
.choice-btn {
display: flex;
height: 40px;
padding: 0 15px;
justify-content: center;
align-items: center;
gap: 5px;
border-radius: 10px;
border: 1px solid #E9EAEB;
background: #f5f6f7;
cursor: pointer;
position: relative;
&:hover, &.active { background: #E9EAEB; }
}
.custom-popover-wrapper { position: relative; display: inline-block; }
.custom-popover {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.10);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 9999;
padding: 20px;
}
.setting-box { margin-bottom: 15px; &:last-child { margin-bottom: 0; } }
.setting-header {
padding-bottom: 10px;
background: #fff;
color: #999;
font-family: "Microsoft YaHei";
font-size: 12px;
}
.setting-body {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.slider-value {
width: 89px;
height: 18px;
padding: 0;
color: #333;
font-family: "Microsoft YaHei";
font-size: 14px;
border: none;
background: none;
outline: none;
text-align: center;
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
}
.restore-btn {
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding-left: 5px;
img { width: 18px; height: 18px; }
}
.popover-enter-active, .popover-leave-active { transition: all 0.3s ease; }
.popover-enter-from, .popover-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
</style>

View File

@ -0,0 +1,68 @@
<template>
<div class="audio-uploader">
<div class="upload-btn" @click="triggerUpload">
<img src="@/assets/dialog/referenceDiagram.svg" alt="" style="width: 16px;">
<span>{{ uploadedFile ? uploadedFile.name : '参考音频' }}</span>
</div>
<el-upload
ref="uploadRef"
:action="uploadUrl"
:limit="1"
:before-upload="beforeUpload"
:on-success="handleSuccess"
:on-error="handleError"
:show-file-list="false"
accept="audio/*"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
const uploadRef = ref(null)
const uploadedFile = ref(null)
const uploadUrl = computed(() => import.meta.env.VITE_API_WORKFLOW_UPLOAD)
const triggerUpload = () => {
uploadRef.value?.$el?.querySelector('input[type="file"]')?.click()
}
const beforeUpload = (file) => {
uploadedFile.value = file
return true
}
const handleSuccess = (response) => {
if (response?.data?.url) {
emit('update:modelValue', [{ url: response.data.url, name: uploadedFile.value?.name || '参考音频' }])
}
}
const handleError = () => {
uploadedFile.value = null
}
</script>
<style lang="less" scoped>
.audio-uploader { display: flex; align-items: center; gap: 8px; }
.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;
&:hover { background: #E5E7EB; }
}
</style>

View File

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

View File

@ -0,0 +1,34 @@
<template>
<Select
:model-value="modelValue"
:options="modelOptions"
placeholder="选择模型"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<template #prefix>
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px; height: 16px;">
</template>
</Select>
</template>
<script setup>
import { computed } from 'vue'
import Select from '@/components/Select/index.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
models: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
const modelOptions = computed(() => {
const groups = {}
props.models.forEach((m) => {
const tag = m.tags?.[0] || '默认'
if (!groups[tag]) groups[tag] = []
groups[tag].push({ value: m.id, label: m.display_name, disabled: m.disabled })
})
return Object.entries(groups).map(([label, options]) => ({ label, options }))
})
</script>

View File

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

View File

@ -10,6 +10,8 @@ export function getChargeType(chargeType) {
return 1
case 'Video':
return 4
case 'Music':
return 5
default:
return 2
}

View File

@ -5,7 +5,7 @@
<div ref="promptWrapperRef" class="prompt-wrapper">
<div ref="promptRef" class="prompt" :class="{ expanded: isHovering }" @mouseenter="isHovering = true" @mouseleave="isHovering = false">
<span class="prompt-text">
{{ props.item.generateData.prompt || '生成图片' }}
{{ props.item.generateData.prompt || (props.item.type === 'Music' ? '生成音频' : '生成图片') }}
<i-ep-DocumentCopy class="Copy" @click.stop="copyPrompt" />
</span>
<div :style="{ visibility: !isHovering && !showExternalGenerateData ? 'visible' : 'hidden' }" class="generate-data internal">
@ -85,6 +85,19 @@
</div>
</div>
<!-- 已完成 音乐 -->
<div v-if="props.item.status === 'success' && props.item.type === 'Music'" class="box success-box">
<div v-for="(file, index) in props.item.files" :key="index" class="one-box" :class="{ collected: isCollected(file) }" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = -1">
<AudioPlayer :audio-url="file" :audio-title="props.item.generateData.prompt || '生成音频'" :card-index="index" />
<div class="left-top">
<div v-show="hoverIndex === index" class="left-top-btn download-btn" @click="downloadImage(file, 'audio')"><img src="@/assets/display/download.svg" /></div>
<span v-if="hoverIndex === index" class="line" />
<div class="left-top-btn collect-btn" @click="addCollection(file)"><img :src="isCollected(file) ? collectionActiveIcon : collectionIcon" /></div>
</div>
</div>
</div>
<div v-if="props.item.status === 'success'" class="bottom-btn-group" style="margin-top: 8px;">
<div v-for="(item, index) in bottomBtnGroup" :key="index" class="bottom-btn" @click="item.click()">
<img :src="item.icon" />
@ -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) {