feat: 新增 Music 音乐生成平台,遵循 Platform Descriptor 模式
基于旧项目 ai_music_v2.0 迁移,与 Painting/Video 统一架构:HTTP 轮询 + suanli 后端、 API 驱动配置、mode 独立 ref 驱动控件显隐。新增 AudioPlayer/CustomSlider 通用组件, dialogBox/set.vue/taskPolling/modelApi 完成集成适配。
25
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 `<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
@ -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']
|
||||
|
||||
3
src/assets/dialog/beautify.svg
Normal 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 |
6
src/assets/dialog/commonMode.svg
Normal 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 |
4
src/assets/dialog/editMode.svg
Normal 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 |
@ -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 |
6
src/assets/dialog/lyrics.svg
Normal 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 |
8
src/assets/dialog/professionalMode.svg
Normal 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 |
11
src/assets/dialog/randomSeed.svg
Normal 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 |
9
src/assets/dialog/remixMode.svg
Normal 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 |
4
src/assets/dialog/restore.svg
Normal 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 |
BIN
src/assets/display/background1.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background3.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/display/background4.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/display/image1.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/assets/display/image2.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/display/image3.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
src/assets/display/image4.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
4
src/assets/display/time.svg
Normal 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 |
267
src/components/AudioPlayer/index.vue
Normal 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>
|
||||
168
src/components/CustomSlider/index.vue
Normal 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>
|
||||
@ -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 },
|
||||
|
||||
120
src/platforms/music/controls/lyricsInput.vue
Normal 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>
|
||||
145
src/platforms/music/controls/modeSelector.vue
Normal 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>
|
||||
167
src/platforms/music/controls/pureMusicGroup.vue
Normal 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>
|
||||
147
src/platforms/music/controls/timeControl.vue
Normal 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>
|
||||
68
src/platforms/music/imageUploader.vue
Normal 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>
|
||||
278
src/platforms/music/index.js
Normal 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)
|
||||
34
src/platforms/music/modelSelector.vue
Normal 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>
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ export function getChargeType(chargeType) {
|
||||
return 1
|
||||
case 'Video':
|
||||
return 4
|
||||
case 'Music':
|
||||
return 5
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||