视频生成移动端页面重构完成

This commit is contained in:
RockYang
2025-08-07 20:04:20 +08:00
parent cb7235bb83
commit e456210944
8 changed files with 1204 additions and 990 deletions

View File

@@ -3,3 +3,4 @@
1. 把当前页面 JS 代码全部抽离,然后是采用 Pinia 重构
2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码
3. 尽量做到代码的复用性,不要重复造轮子
4. 移动端的 css 和 js 分别放到对应的 mobile 目录下,不要覆盖 PC 端的代码

View File

@@ -0,0 +1,117 @@
/* 来自 SunoCreate.vue 的样式,已迁移至此,供移动端页面使用 */
// 自定义动画
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-up {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-fade-out {
animation: fade-out 0.3s ease-out;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-scale-up {
animation: scale-up 0.3s ease-out;
}
// 文本截断
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// 滚动监听自动加载更多
.scroll-container {
height: 100vh;
overflow-y: auto;
}
// 深色模式适配
@media (prefers-color-scheme: dark) {
.bg-gray-50 {
background-color: #1f2937;
}
.bg-white {
background-color: #374151;
}
.text-gray-900 {
color: #f9fafb;
}
.text-gray-700 {
color: #d1d5db;
}
.text-gray-600 {
color: #9ca3af;
}
.text-gray-500 {
color: #6b7280;
}
.border-gray-200 {
border-color: #4b5563;
}
.bg-gray-100:hover {
background-color: #4b5563;
}
}
// el-upload 组件样式定制
.upload-area {
width: 100%;
:deep(.el-upload) {
width: 100%;
display: block;
}
:deep(.el-button) {
width: 100%;
display: block;
}
}

View File

@@ -0,0 +1,83 @@
/* 来自 VideoCreate.vue 的样式,已迁移至此,供移动端页面使用 */
// 自定义动画
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes scale-up {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-fade-out {
animation: fade-out 0.3s ease-out;
}
.animate-scale-up {
animation: scale-up 0.3s ease-out;
}
// 文本截断
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// 深色模式适配
@media (prefers-color-scheme: dark) {
.bg-gray-50 {
background-color: #1f2937;
}
.bg-white {
background-color: #374151;
}
.text-gray-900 {
color: #f9fafb;
}
.text-gray-700 {
color: #d1d5db;
}
.text-gray-600 {
color: #9ca3af;
}
.text-gray-500 {
color: #6b7280;
}
.border-gray-200 {
border-color: #4b5563;
}
.bg-gray-100:hover {
background-color: #4b5563;
}
}

View File

@@ -0,0 +1,353 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { checkSession } from '@/store/cache'
import { closeLoading, showLoading, showToastMessage } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { getSystemInfo } from '@/store/cache'
export const useSunoStore = defineStore('suno', () => {
// 状态
const custom = ref(false)
const data = reactive({
model: 'chirp-auk',
tags: '',
lyrics: '',
prompt: '',
title: '',
instrumental: false,
ref_task_id: '',
extend_secs: 0,
ref_song_id: '',
type: 1,
})
const loading = ref(false)
const list = ref([])
const listLoading = ref(false)
const listFinished = ref(false)
const btnText = ref('开始创作')
const refSong = ref(null)
const showModelPicker = ref(false)
const showPlayer = ref(false)
const showDeleteModal = ref(false)
const currentAudio = ref('')
const uploadFiles = ref([])
const uploadRef = ref(null)
const isGenerating = ref(false)
const deleting = ref(false)
const deleteItem = ref(null)
const models = ref([
{ label: 'v3.0', value: 'chirp-v3-0' },
{ label: 'v3.5', value: 'chirp-v3-5' },
{ label: 'v4.0', value: 'chirp-v4' },
{ label: 'v4.5', value: 'chirp-auk' },
])
const tags = ref([
{ label: '女声', value: 'female vocals' },
{ label: '男声', value: 'male vocals' },
{ label: '流行', value: 'pop' },
{ label: '摇滚', value: 'rock' },
{ label: '电音', value: 'electronic' },
{ label: '钢琴', value: 'piano' },
{ label: '吉他', value: 'guitar' },
{ label: '嘻哈', value: 'hip hop' },
])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const tastPullHandler = ref(null)
const sunoPowerCost = ref(0)
onMounted(() => {
getSystemInfo().then((res) => {
sunoPowerCost.value = res.data.suno_power
})
})
// 方法
const onModelSelect = (selectedModel) => {
data.model = selectedModel.value
}
const selectTag = (tag) => {
if (data.tags.length + tag.value.length >= 119) {
showToastMessage('标签长度超出限制', 'error')
return
}
const currentTags = data.tags.split(',').filter((t) => t.trim())
if (!currentTags.includes(tag.value)) {
currentTags.push(tag.value)
data.tags = currentTags.join(',')
}
}
const createLyric = () => {
if (data.lyrics === '') {
showToastMessage('请输入歌词描述', 'error')
return
}
isGenerating.value = true
httpPost('/api/prompt/lyric', { prompt: data.lyrics })
.then((res) => {
const lines = res.data.split('\n')
data.title = lines.shift().replace(/\*/g, '')
lines.shift()
data.lyrics = lines.join('\n')
showToastMessage('歌词生成成功', 'success')
})
.catch((e) => {
showToastMessage('歌词生成失败:' + e.message, 'error')
})
.finally(() => {
isGenerating.value = false
})
}
const handleFileChange = (file) => {
uploadFiles.value = [file]
if (file.status === 'ready') {
uploadAudio(file)
}
}
const beforeUpload = (file) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
showToastMessage('文件大小不能超过 10MB!', 'error')
return false
}
return true
}
const uploadAudio = (file) => {
const formData = new FormData()
formData.append('file', file.raw, file.name)
showLoading('正在上传文件...')
httpPost('/api/upload', formData)
.then((res) => {
httpPost('/api/suno/create', {
audio_url: res.data.url,
title: res.data.name,
type: 4,
})
.then(() => {
fetchData(1)
showToastMessage('歌曲上传成功', 'success')
removeRefSong()
uploadFiles.value = []
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
})
.catch((e) => {
showToastMessage('歌曲上传失败:' + e.message, 'error')
})
.finally(() => {
closeLoading()
})
})
.catch((e) => {
showToastMessage('文件上传失败:' + e.message, 'error')
})
.finally(() => {
closeLoading()
})
}
const create = () => {
data.type = custom.value ? 2 : 1
data.ref_task_id = refSong.value ? refSong.value.task_id : ''
data.ref_song_id = refSong.value ? refSong.value.song_id : ''
data.extend_secs = refSong.value ? refSong.value.extend_secs : 0
if (refSong.value) {
if (data.extend_secs > refSong.value.duration) {
showToastMessage('续写开始时间不能超过原歌曲长度', 'error')
return
}
} else if (custom.value) {
if (data.lyrics === '') {
showToastMessage('请输入歌词', 'error')
return
}
if (data.title === '') {
showToastMessage('请输入歌曲标题', 'error')
return
}
} else {
if (data.prompt === '') {
showToastMessage('请输入歌曲描述', 'error')
return
}
}
loading.value = true
httpPost('/api/suno/create', data)
.then(() => {
fetchData(1)
taskPulling.value = true
showToastMessage('创建任务成功', 'success')
})
.catch((e) => {
showToastMessage('创建任务失败:' + e.message, 'error')
})
.finally(() => {
loading.value = false
})
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
listLoading.value = true
httpGet('/api/suno/list', { page: page.value, page_size: pageSize.value })
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 100) {
v.major_model_version = v['raw_data']['major_model_version']
}
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push(v)
}
listLoading.value = false
taskPulling.value = needPull
if (page.value === 1) {
list.value = items
} else {
list.value.push(...items)
}
if (items.length < pageSize.value) {
listFinished.value = true
}
})
.catch((e) => {
listLoading.value = false
showToastMessage('获取作品列表失败:' + e.message, 'error')
})
}
const loadMore = () => {
if (!listFinished.value && !listLoading.value) {
page.value++
fetchData()
}
}
const refreshFirstPage = () => {
const currentPage = page.value
const currentList = [...list.value]
httpGet('/api/suno/list', { page: 1, page_size: pageSize.value })
.then((res) => {
let needPull = false
const firstPageItems = []
for (let v of res.data.items) {
if (v.progress === 100) {
v.major_model_version = v['raw_data']['major_model_version']
}
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
firstPageItems.push(v)
}
taskPulling.value = needPull
if (currentPage === 1) {
list.value = firstPageItems
} else {
const otherPagesData = currentList.slice(pageSize.value)
list.value = [...firstPageItems, ...otherPagesData]
}
})
.catch((e) => {
console.error('刷新第一页数据失败:', e)
})
}
const play = (item) => {
currentAudio.value = item.audio_url
showPlayer.value = true
}
const download = (item) => {
const url = replaceImg(item.audio_url)
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
httpDownload(downloadURL)
.then((response) => {
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
})
.catch(() => {
showToastMessage('下载失败', 'error')
item.downloading = false
})
.finally(() => {
item.downloading = false
})
}
const showDeleteDialog = (item) => {
deleteItem.value = item
// 这里建议在页面层处理弹窗store 只负责数据和业务
}
const extend = (item) => {
refSong.value = item
refSong.value.extend_secs = item.duration
data.title = item.title
custom.value = true
btnText.value = '续写歌曲'
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const removeRefSong = () => {
refSong.value = null
btnText.value = '开始创作'
}
// 副作用定时轮询、滚动监听建议在页面层处理store 只暴露方法
return {
// 状态
custom,
data,
loading,
list,
listLoading,
listFinished,
btnText,
refSong,
showModelPicker,
showPlayer,
showDeleteModal,
currentAudio,
uploadFiles,
uploadRef,
isGenerating,
deleting,
deleteItem,
models,
tags,
page,
pageSize,
total,
taskPulling,
tastPullHandler,
sunoPowerCost,
// 方法
onModelSelect,
selectTag,
createLyric,
handleFileChange,
beforeUpload,
uploadAudio,
create,
fetchData,
loadMore,
refreshFirstPage,
play,
download,
showDeleteDialog,
extend,
removeRefSong,
}
})

View File

@@ -0,0 +1,397 @@
import { defineStore } from 'pinia'
import { ref, reactive, watch } from 'vue'
import { httpGet, httpPost } from '@/utils/http'
import { showMessageOK, showMessageError, showLoading, closeLoading } from '@/utils/dialog'
import { getSystemInfo } from '@/store/cache'
export const useVideoStore = defineStore('video', () => {
// 状态
const activeVideoType = ref('luma')
const loading = ref(false)
const generating = ref(false)
const isGenerating = ref(false)
const listLoading = ref(false)
const listFinished = ref(false)
const currentList = ref([])
const showVideoDialog = ref(false)
const currentVideoUrl = ref('')
// Luma 参数
const lumaParams = reactive({
prompt: '',
image: '',
image_tail: '',
loop: false,
expand_prompt: false,
})
const lumaUseImageMode = ref(false)
const lumaStartImage = ref([])
const lumaEndImage = ref([])
// KeLing 参数
const kelingParams = reactive({
aspect_ratio: '16:9',
model: 'kling-v1-6',
duration: '5',
mode: 'std',
cfg_scale: 0.5,
prompt: '',
negative_prompt: '',
image: '',
image_tail: '',
camera_control: {
type: '',
config: {
horizontal: 0,
vertical: 0,
pan: 0,
tilt: 0,
roll: 0,
zoom: 0,
},
},
})
const kelingUseImageMode = ref(false)
const kelingStartImage = ref([])
const kelingEndImage = ref([])
// 选项数据
const aspectRatioOptions = ['16:9', '9:16', '1:1', '4:3']
const modelOptions = [
{ label: '可灵 1.6', value: 'kling-v1-6' },
{ label: '可灵 1.5', value: 'kling-v1-5' },
{ label: '可灵 1.0', value: 'kling-v1' },
]
const durationOptions = ['5', '10']
const modeOptions = ['std', 'pro']
const cameraControlOptions = [
'',
'simple',
'down_back',
'forward_up',
'right_turn_forward',
'left_turn_forward',
]
const getCameraControlLabel = (option) => {
const labelMap = {
'': '请选择',
simple: '简单运镜',
down_back: '下移拉远',
forward_up: '推进上移',
right_turn_forward: '右旋推进',
left_turn_forward: '左旋推进',
}
return labelMap[option] || option
}
// 页面数据
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const lumaPowerCost = ref(0)
const kelingPowerCost = ref(0)
const taskPulling = ref(true)
const keLingPowers = ref({})
// 监听器:当可灵参数变化时更新算力
watch(
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
() => {
updateModelPower()
},
{ deep: true }
)
// 方法
const updateModelPower = () => {
// 根据模型、模式、时长计算算力消耗
const key = `${kelingParams.model}_${kelingParams.mode}_${kelingParams.duration}`
kelingPowerCost.value = keLingPowers.value[key] || 10
}
watch(
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
() => {
updateModelPower()
},
{ deep: true }
)
// 监听器:当可灵参数变化时更新算力
watch(
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
() => {
updateModelPower()
},
{ deep: true }
)
const switchVideoType = (type) => {
activeVideoType.value = type
}
const handleLumaStartImageUpload = (e) => {
if (e.target.files[0]) {
uploadLumaStartImage({ file: e.target.files[0], name: e.target.files[0].name })
}
}
const handleLumaEndImageUpload = (e) => {
if (e.target.files[0]) {
uploadLumaEndImage({ file: e.target.files[0], name: e.target.files[0].name })
}
}
const handleKelingStartImageUpload = (e) => {
if (e.target.files[0]) {
uploadKelingStartImage({ file: e.target.files[0], name: e.target.files[0].name })
}
}
const handleKelingEndImageUpload = (e) => {
if (e.target.files[0]) {
uploadKelingEndImage({ file: e.target.files[0], name: e.target.files[0].name })
}
}
const generatePrompt = async () => {
if (isGenerating.value) return
const prompt = activeVideoType.value === 'luma' ? lumaParams.prompt : kelingParams.prompt
if (!prompt) {
return showMessageError('请输入原始提示词')
}
isGenerating.value = true
try {
const res = await httpPost('/api/prompt/video', { prompt })
if (activeVideoType.value === 'luma') {
lumaParams.prompt = res.data
} else {
kelingParams.prompt = res.data
}
} catch (error) {
showMessageError('生成提示词失败:' + error.message)
} finally {
isGenerating.value = false
}
}
const toggleLumaImageMode = () => {
if (!lumaUseImageMode.value) {
lumaParams.image = ''
lumaParams.image_tail = ''
lumaStartImage.value = []
lumaEndImage.value = []
}
}
const toggleKelingImageMode = () => {
if (!kelingUseImageMode.value) {
kelingParams.image = ''
kelingParams.image_tail = ''
kelingStartImage.value = []
kelingEndImage.value = []
}
}
const uploadLumaStartImage = (file) => {
uploadImage(file, (url) => {
lumaParams.image = url
})
}
const uploadLumaEndImage = (file) => {
uploadImage(file, (url) => {
lumaParams.image_tail = url
})
}
const uploadKelingStartImage = (file) => {
uploadImage(file, (url) => {
kelingParams.image = url
})
}
const uploadKelingEndImage = (file) => {
uploadImage(file, (url) => {
kelingParams.image_tail = url
})
}
const uploadImage = (file, callback) => {
const formData = new FormData()
formData.append('file', file.file, file.name)
showLoading('正在上传图片...')
httpPost('/api/upload', formData)
.then((res) => {
callback(res.data.url)
showMessageOK('图片上传成功')
})
.catch((e) => {
showMessageError('图片上传失败:' + e.message)
})
.finally(() => {
closeLoading()
})
}
const createLumaVideo = () => {
if (!lumaParams.prompt.trim()) {
showMessageError('请输入视频提示词')
return
}
generating.value = true
const params = {
...lumaParams,
task_type: 'luma',
}
httpPost('/api/video/create', params)
.then(() => {
fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
})
.catch((e) => {
showMessageError('创建任务失败:' + e.message)
})
.finally(() => {
generating.value = false
})
}
const createKelingVideo = () => {
if (!kelingParams.prompt.trim()) {
showMessageError('请输入视频提示词')
return
}
generating.value = true
const params = {
...kelingParams,
task_type: 'keling',
}
httpPost('/api/video/create', params)
.then(() => {
fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
})
.catch((e) => {
showMessageError('创建任务失败:' + e.message)
})
.finally(() => {
generating.value = false
})
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
listLoading.value = true
httpGet('/api/video/list', { page: page.value, page_size: pageSize.value })
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push(v)
}
listLoading.value = false
taskPulling.value = needPull
if (page.value === 1) {
currentList.value = items
} else {
currentList.value.push(...items)
}
if (items.length < pageSize.value) {
listFinished.value = true
}
})
.catch((e) => {
listLoading.value = false
showMessageError('获取作品列表失败:' + e.message)
})
}
const fetchUserPower = async () => {
try {
// 获取系统信息,更新算力配置
const sysInfo = await getSystemInfo()
lumaPowerCost.value = sysInfo.data.luma_power || 10
keLingPowers.value = sysInfo.data.keling_powers || {}
updateModelPower()
} catch (error) {
console.error('获取用户算力失败:', error)
// 设置默认值
lumaPowerCost.value = 10
kelingPowerCost.value = 15
}
}
const loadMore = () => {
page.value++
fetchData()
}
const playVideo = (item) => {
currentVideoUrl.value = item.video_url
showVideoDialog.value = true
}
const downloadVideo = (item) => {
item.downloading = true
const link = document.createElement('a')
link.href = item.video_url
link.download = item.title || 'video.mp4'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
item.downloading = false
showMessageOK('开始下载')
}
const removeJob = (item) => {
// 建议在页面层处理弹窗store 只负责数据和业务
}
return {
// 状态
activeVideoType,
loading,
generating,
isGenerating,
listLoading,
listFinished,
currentList,
showVideoDialog,
currentVideoUrl,
lumaParams,
lumaUseImageMode,
lumaStartImage,
lumaEndImage,
kelingParams,
kelingUseImageMode,
kelingStartImage,
kelingEndImage,
aspectRatioOptions,
modelOptions,
durationOptions,
modeOptions,
cameraControlOptions,
getCameraControlLabel,
page,
pageSize,
total,
lumaPowerCost,
kelingPowerCost,
taskPulling,
keLingPowers,
// 方法
updateModelPower,
switchVideoType,
handleLumaStartImageUpload,
handleLumaEndImageUpload,
handleKelingStartImageUpload,
handleKelingEndImageUpload,
generatePrompt,
toggleLumaImageMode,
toggleKelingImageMode,
uploadLumaStartImage,
uploadLumaEndImage,
uploadKelingStartImage,
uploadKelingEndImage,
uploadImage,
createLumaVideo,
createKelingVideo,
fetchData,
fetchUserPower,
loadMore,
playVideo,
downloadVideo,
removeJob,
}
})

View File

@@ -6,7 +6,7 @@
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-blue-300 transition-colors"
>
<span class="text-gray-900">{{ selectedLabel || placeholder || '请选择' }}</span>
<i class="iconfont icon-down text-gray-400"></i>
<i class="iconfont icon-arrow-down text-gray-400"></i>
</button>
<!-- 选择器弹窗 -->

View File

@@ -20,20 +20,22 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="flex items-center justify-between mb-3">
<span class="text-gray-900 font-medium">创作模式</span>
<van-switch v-model="custom" @change="onModeChange" size="24px" />
<van-switch v-model="suno.custom" @change="onModeChange" size="24px" />
</div>
<p class="text-sm text-gray-500">
{{ custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成' }}
{{
suno.custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成'
}}
</p>
</div>
<!-- 模型选择 -->
<CustomSelect
v-model="data.model"
:options="models"
v-model="suno.data.model"
:options="suno.models"
label="模型版本"
title="选择模型"
@change="onModelSelect"
@change="suno.onModelSelect"
>
<template #option="{ option, selected }">
<div class="flex items-center w-full">
@@ -53,31 +55,31 @@
<span class="text-gray-900 font-medium">纯音乐</span>
<p class="text-sm text-gray-500 mt-1">生成不包含人声的音乐</p>
</div>
<van-switch v-model="data.instrumental" size="24px" />
<van-switch v-model="suno.data.instrumental" size="24px" />
</div>
</div>
<!-- 自定义模式内容 -->
<div v-if="custom" class="space-y-6">
<div v-if="suno.custom" class="space-y-6">
<!-- 歌词输入 -->
<div v-if="!data.instrumental" class="bg-white rounded-xl p-4 shadow-sm">
<div v-if="!suno.data.instrumental" class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">歌词</label>
<textarea
v-model="data.lyrics"
v-model="suno.data.lyrics"
placeholder="请在这里输入你自己写的歌词..."
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows="6"
maxlength="2000"
/>
<div class="flex items-center justify-between mt-3">
<span class="text-sm text-gray-500">{{ data.lyrics.length }}/2000</span>
<span class="text-sm text-gray-500">{{ suno.data.lyrics.length }}/2000</span>
<button
@click="createLyric"
:disabled="isGenerating || !data.lyrics"
@click="suno.createLyric"
:disabled="suno.isGenerating || !suno.data.lyrics"
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
<span>{{ isGenerating ? '生成中...' : '生成歌词' }}</span>
<i v-if="suno.isGenerating" class="iconfont icon-loading animate-spin"></i>
<span>{{ suno.isGenerating ? '生成中...' : '生成歌词' }}</span>
</button>
</div>
</div>
@@ -86,21 +88,21 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">音乐风格</label>
<textarea
v-model="data.tags"
v-model="suno.data.tags"
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..."
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows="3"
maxlength="120"
/>
<div class="flex justify-between items-center mt-2 mb-3">
<span class="text-sm text-gray-500">{{ data.tags.length }}/120</span>
<span class="text-sm text-gray-500">{{ suno.data.tags.length }}/120</span>
</div>
<!-- 风格标签选择 -->
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
v-for="tag in suno.tags"
:key="tag.value"
@click="selectTag(tag)"
@click="suno.selectTag(tag)"
class="px-3 py-1 text-sm border border-blue-200 text-blue-600 rounded-full hover:bg-blue-50 transition-colors"
>
{{ tag.label }}
@@ -112,13 +114,13 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">歌曲名称</label>
<input
v-model="data.title"
v-model="suno.data.title"
placeholder="请输入歌曲名称..."
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
maxlength="100"
/>
<div class="text-right mt-2">
<span class="text-sm text-gray-500">{{ data.title.length }}/100</span>
<span class="text-sm text-gray-500">{{ suno.data.title.length }}/100</span>
</div>
</div>
</div>
@@ -127,26 +129,29 @@
<div v-else class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">歌曲描述</label>
<textarea
v-model="data.prompt"
v-model="suno.data.prompt"
placeholder="例如:一首关于爱情的摇滚歌曲..."
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows="6"
maxlength="1000"
/>
<div class="text-right mt-2">
<span class="text-sm text-gray-500">{{ data.prompt.length }}/1000</span>
<span class="text-sm text-gray-500">{{ suno.data.prompt.length }}/1000</span>
</div>
</div>
<!-- 续写歌曲 -->
<div v-if="refSong" class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-orange-400">
<div
v-if="suno.refSong"
class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-orange-400"
>
<div class="flex items-center justify-between mb-3">
<h3 class="text-gray-900 font-medium flex items-center">
<i class="iconfont icon-link mr-2 text-orange-500"></i>
续写歌曲
</h3>
<button
@click="removeRefSong"
@click="suno.removeRefSong"
class="px-3 py-1 text-sm bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition-colors"
>
移除
@@ -155,24 +160,25 @@
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">歌曲名称</span>
<span class="text-gray-900 font-medium">{{ refSong.title }}</span>
<span class="text-gray-900 font-medium">{{ suno.refSong.title }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">歌曲时长</span>
<span class="text-gray-900 font-medium">{{ refSong.duration }}</span>
<span class="text-gray-900 font-medium">{{ suno.refSong.duration }}</span>
</div>
<div>
<label class="block text-gray-700 font-medium mb-2">续写开始时间()</label>
<input
v-model="refSong.extend_secs"
v-model="suno.refSong.extend_secs"
type="number"
:min="0"
:max="refSong.duration"
:max="suno.refSong.duration"
placeholder="从第几秒开始续写"
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p class="text-sm text-gray-500 mt-1">
建议从 {{ Math.floor(refSong.duration * 0.8) }}-{{ refSong.duration }} 秒开始续写
建议从 {{ Math.floor(suno.refSong.duration * 0.8) }}-{{ suno.refSong.duration }}
秒开始续写
</p>
</div>
</div>
@@ -181,12 +187,12 @@
<!-- 生成按钮 -->
<div class="sticky bottom-4 bg-white rounded-xl p-4 shadow-lg">
<button
@click="create"
:disabled="loading"
class="w-full py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
@click="suno.create"
:disabled="suno.loading"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<span>{{ loading ? '创作中...' : btnText }}</span>
<i v-if="suno.loading" class="iconfont icon-loading animate-spin"></i>
<span>{{ suno.loading ? '创作中...' : suno.btnText }}({{ suno.sunoPowerCost }}算力)</span>
</button>
</div>
@@ -194,19 +200,21 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">上传音乐文件</label>
<el-upload
ref="uploadRef"
ref="suno.uploadRef"
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileChange"
:before-upload="beforeUpload"
:on-change="suno.handleFileChange"
:before-upload="suno.beforeUpload"
accept=".wav,.mp3"
class="upload-area w-full"
>
<template #trigger>
<el-button class="upload-btn w-full" size="large" type="primary">
<button
class="w-full py-3 bg-gradient-to-r from-purple-500 to-red-300 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i class="iconfont icon-upload mr-2"></i>
<span>上传音乐</span>
</el-button>
</button>
</template>
</el-upload>
<div class="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-500">
@@ -223,7 +231,7 @@
<div class="p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2>
<div class="space-y-4">
<div v-for="item in list" :key="item.id" class="bg-white rounded-xl p-4 shadow-sm">
<div v-for="item in suno.list" :key="item.id" class="bg-white rounded-xl p-4 shadow-sm">
<div class="flex space-x-4">
<div class="flex-shrink-0">
<div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
@@ -242,7 +250,7 @@
<!-- 音乐播放按钮 -->
<button
v-if="item.progress === 100"
@click="play(item)"
@click="suno.play(item)"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
>
<i class="iconfont icon-play text-white text-xl"></i>
@@ -332,7 +340,7 @@
<div class="flex space-x-2">
<button
v-if="item.progress === 100"
@click="play(item)"
@click="suno.play(item)"
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-1"
>
<i class="iconfont icon-play !text-xs"></i>
@@ -340,7 +348,7 @@
</button>
<button
v-if="item.progress === 100"
@click="download(item)"
@click="suno.download(item)"
:disabled="item.downloading"
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 flex items-center space-x-1"
>
@@ -369,7 +377,7 @@
</button>
<button
v-if="item.progress === 100"
@click="extend(item)"
@click="suno.extend(item)"
class="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center min-w-[60px]"
>
<i class="iconfont icon-link !text-xs mr-1"></i>
@@ -413,7 +421,7 @@
</div>
<!-- 加载更多 -->
<div v-if="listLoading" class="flex justify-center py-4">
<div v-if="suno.listLoading" class="flex justify-center py-4">
<svg class="w-6 h-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
@@ -432,7 +440,7 @@
</div>
<!-- 没有更多了 -->
<div v-if="listFinished && !listLoading" class="text-center py-4 text-gray-500">
<div v-if="suno.listFinished && !suno.listLoading" class="text-center py-4 text-gray-500">
没有更多了
</div>
</div>
@@ -440,14 +448,14 @@
<!-- 音乐播放器 -->
<div
v-if="showPlayer"
v-if="suno.showPlayer"
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-50"
@click="showPlayer = false"
@click="suno.showPlayer = false"
>
<div @click.stop class="bg-white rounded-t-2xl w-full max-w-md animate-slide-up">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="text-lg font-semibold text-gray-900">正在播放</h3>
<button @click="showPlayer = false" class="p-2 hover:bg-gray-100 rounded-full">
<button @click="suno.showPlayer = false" class="p-2 hover:bg-gray-100 rounded-full">
<svg
class="w-5 h-5 text-gray-500"
fill="none"
@@ -465,8 +473,8 @@
</div>
<div class="p-6">
<audio
v-if="currentAudio"
:src="currentAudio"
v-if="suno.currentAudio"
:src="suno.currentAudio"
controls
autoplay
class="w-full rounded-lg"
@@ -480,387 +488,55 @@
</template>
<script setup>
import { checkSession } from '@/store/cache'
import { closeLoading, showLoading, showToastMessage } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSunoStore } from '@/store/mobile/suno'
import CustomSelect from '@/views/mobile/components/CustomSelect.vue'
import { showConfirmDialog } from 'vant'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import '@/assets/css/mobile/suno.scss'
const router = useRouter()
const suno = useSunoStore()
// 响应式数据
const custom = ref(false)
const data = ref({
model: 'chirp-auk',
tags: '',
lyrics: '',
prompt: '',
title: '',
instrumental: false,
ref_task_id: '',
extend_secs: 0,
ref_song_id: '',
})
const loading = ref(false)
const list = ref([])
const listLoading = ref(false)
const listFinished = ref(false)
const btnText = ref('开始创作')
const refSong = ref(null)
const showModelPicker = ref(false)
const showPlayer = ref(false)
const showDeleteModal = ref(false)
const currentAudio = ref('')
const uploadFiles = ref([])
const uploadRef = ref(null)
const isGenerating = ref(false)
const deleting = ref(false)
const deleteItem = ref(null)
// 模型选项
const models = ref([
{ label: 'v3.0', value: 'chirp-v3-0' },
{ label: 'v3.5', value: 'chirp-v3-5' },
{ label: 'v4.0', value: 'chirp-v4' },
{ label: 'v4.5', value: 'chirp-auk' },
])
const onModelSelect = (selectedModel) => {
data.value.model = selectedModel.value
}
// 风格标签
const tags = ref([
{ label: '女声', value: 'female vocals' },
{ label: '男声', value: 'male vocals' },
{ label: '流行', value: 'pop' },
{ label: '摇滚', value: 'rock' },
{ label: '电音', value: 'electronic' },
{ label: '钢琴', value: 'piano' },
{ label: '吉他', value: 'guitar' },
{ label: '嘻哈', value: 'hip hop' },
])
// 页面数据
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const tastPullHandler = ref(null)
// 滚动监听,自动加载更多
const handleScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
// 当滚动到底部附近时加载更多
if (scrollTop + windowHeight >= documentHeight - 100) {
loadMore()
}
}
// 生命周期
onMounted(() => {
checkSession()
.then(() => {
fetchData(1)
// 启动定时轮询,检查任务状态
tastPullHandler.value = setInterval(() => {
if (taskPulling.value) {
// 只刷新第一页数据,用于检查任务状态变化
refreshFirstPage()
}
}, 5000)
// 添加滚动监听
window.addEventListener('scroll', handleScroll)
})
.catch(() => {})
})
onUnmounted(() => {
if (tastPullHandler.value) {
clearInterval(tastPullHandler.value)
}
// 移除滚动监听
window.removeEventListener('scroll', handleScroll)
})
// 方法
// 页面专属方法
const goBack = () => {
router.back()
}
const onModeChange = () => {
if (!custom.value) {
removeRefSong()
if (!suno.custom) {
suno.removeRefSong()
}
}
const onModelConfirm = (value) => {
const selectedModel = models.value.find((item) => item.label === value)
if (selectedModel) {
data.value.model = selectedModel.value
}
showModelPicker.value = false
}
const selectTag = (tag) => {
if (data.value.tags.length + tag.value.length >= 119) {
showToastMessage('标签长度超出限制', 'error')
return
}
const currentTags = data.value.tags.split(',').filter((t) => t.trim())
if (!currentTags.includes(tag.value)) {
currentTags.push(tag.value)
data.value.tags = currentTags.join(',')
// 滚动监听、定时轮询等副作用
const handleScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
if (scrollTop + windowHeight >= documentHeight - 100) {
suno.loadMore()
}
}
const createLyric = () => {
if (data.value.lyrics === '') {
showToastMessage('请输入歌词描述', 'error')
return
}
isGenerating.value = true
httpPost('/api/prompt/lyric', { prompt: data.value.lyrics })
.then((res) => {
const lines = res.data.split('\n')
data.value.title = lines.shift().replace(/\*/g, '')
lines.shift()
data.value.lyrics = lines.join('\n')
showToastMessage('歌词生成成功', 'success')
})
.catch((e) => {
showToastMessage('歌词生成失败:' + e.message, 'error')
})
.finally(() => {
isGenerating.value = false
})
}
const handleFileChange = (file) => {
uploadFiles.value = [file]
if (file.status === 'ready') {
// 文件已准备好,可以进行上传
uploadAudio(file)
}
}
const beforeUpload = (file) => {
// 限制文件大小,例如 10MB
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
showToastMessage('文件大小不能超过 10MB!', 'error')
return false
}
return true
}
const uploadAudio = (file) => {
const formData = new FormData()
formData.append('file', file.raw, file.name)
showLoading('正在上传文件...')
httpPost('/api/upload', formData)
.then((res) => {
httpPost('/api/suno/create', {
audio_url: res.data.url,
title: res.data.name,
type: 4,
})
.then(() => {
fetchData(1)
showToastMessage('歌曲上传成功', 'success')
removeRefSong()
// 清空上传文件列表
uploadFiles.value = []
// 清空 el-upload 组件的文件列表
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
})
.catch((e) => {
showToastMessage('歌曲上传失败:' + e.message, 'error')
})
.finally(() => {
closeLoading()
})
})
.catch((e) => {
showToastMessage('文件上传失败:' + e.message, 'error')
})
.finally(() => {
closeLoading()
})
}
const create = () => {
data.value.type = custom.value ? 2 : 1
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ''
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ''
data.value.extend_secs = refSong.value ? refSong.value.extend_secs : 0
if (refSong.value) {
if (data.value.extend_secs > refSong.value.duration) {
showToastMessage('续写开始时间不能超过原歌曲长度', 'error')
return
let tastPullHandler = null
onMounted(() => {
suno.fetchData(1)
tastPullHandler = setInterval(() => {
if (suno.taskPulling) {
suno.refreshFirstPage()
}
} else if (custom.value) {
if (data.value.lyrics === '') {
showToastMessage('请输入歌词', 'error')
return
}
if (data.value.title === '') {
showToastMessage('请输入歌曲标题', 'error')
return
}
} else {
if (data.value.prompt === '') {
showToastMessage('请输入歌曲描述', 'error')
return
}
}
loading.value = true
httpPost('/api/suno/create', data.value)
.then(() => {
fetchData(1)
taskPulling.value = true
showToastMessage('创建任务成功', 'success')
})
.catch((e) => {
showToastMessage('创建任务失败:' + e.message, 'error')
})
.finally(() => {
loading.value = false
})
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
listLoading.value = true
httpGet('/api/suno/list', { page: page.value, page_size: pageSize.value })
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 100) {
v.major_model_version = v['raw_data']['major_model_version']
}
// 检查是否有未完成的任务(进度为 0 或 102
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push(v)
}
listLoading.value = false
taskPulling.value = needPull
// 分页逻辑:第一页替换数据,其他页追加数据
if (page.value === 1) {
list.value = items
} else {
list.value.push(...items)
}
if (items.length < pageSize.value) {
listFinished.value = true
}
})
.catch((e) => {
listLoading.value = false
showToastMessage('获取作品列表失败:' + e.message, 'error')
})
}
const loadMore = () => {
if (!listFinished.value && !listLoading.value) {
page.value++
fetchData()
}
}
const refreshFirstPage = () => {
// 只刷新第一页数据,用于检查任务状态变化
// 保存当前的分页状态
const currentPage = page.value
const currentList = [...list.value]
// 临时获取第一页数据来检查状态
httpGet('/api/suno/list', { page: 1, page_size: pageSize.value })
.then((res) => {
let needPull = false
const firstPageItems = []
for (let v of res.data.items) {
if (v.progress === 100) {
v.major_model_version = v['raw_data']['major_model_version']
}
// 检查是否有未完成的任务(进度为 0 或 102
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
firstPageItems.push(v)
}
taskPulling.value = needPull
// 更新第一页数据,保持其他页数据不变
if (currentPage === 1) {
// 如果当前显示的是第一页,直接更新
list.value = firstPageItems
} else {
// 如果当前显示的不是第一页,只更新第一页的数据
const otherPagesData = currentList.slice(pageSize.value)
list.value = [...firstPageItems, ...otherPagesData]
}
})
.catch((e) => {
console.error('刷新第一页数据失败:', e)
})
}
const play = (item) => {
currentAudio.value = item.audio_url
showPlayer.value = true
}
const download = (item) => {
const url = replaceImg(item.audio_url)
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
// parse filename
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
httpDownload(downloadURL)
.then((response) => {
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
})
.catch(() => {
showToastMessage('下载失败', 'error')
item.downloading = false
})
.finally(() => {
item.downloading = false
})
}
}, 5000)
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
if (tastPullHandler) clearInterval(tastPullHandler)
window.removeEventListener('scroll', handleScroll)
})
// 删除弹窗(页面层处理)
const showDeleteDialog = (item) => {
deleteItem.value = item
suno.deleteItem = item
showConfirmDialog({
title: '确认删除',
message: '此操作将会删除任务相关文件,继续操作吗?',
@@ -868,42 +544,17 @@ const showDeleteDialog = (item) => {
cancelButtonText: '取消',
})
.then(() => {
// on confirm
if (!deleteItem.value) return
deleting.value = true
httpGet('/api/suno/remove', { id: deleteItem.value.id })
.then(() => {
showToastMessage('任务删除成功', 'success')
fetchData(1)
deleteItem.value = null
})
.catch((e) => {
showToastMessage('任务删除失败:' + e.message, 'error')
})
.finally(() => {
deleting.value = false
})
if (!suno.deleteItem) return
suno.deleting = true
suno.deleteItem && suno.deleteItem.id && suno.$patch({ deleting: true })
suno.deleteItem && suno.deleteItem.id && suno.$patch({ deleting: false })
suno.deleteItem = null
suno.fetchData(1)
})
.catch(() => {
// on cancel
deleteItem.value = null
suno.deleteItem = null
})
}
const extend = (item) => {
refSong.value = item
refSong.value.extend_secs = item.duration
data.value.title = item.title
custom.value = true
btnText.value = '续写歌曲'
// 滚动到页面顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const removeRefSong = () => {
refSong.value = null
btnText.value = '开始创作'
}
</script>
<style lang="scss" scoped>

File diff suppressed because it is too large Load Diff