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

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 重构 1. 把当前页面 JS 代码全部抽离,然后是采用 Pinia 重构
2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码 2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码
3. 尽量做到代码的复用性,不要重复造轮子 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" 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> <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> </button>
<!-- 选择器弹窗 --> <!-- 选择器弹窗 -->

View File

@@ -20,20 +20,22 @@
<div class="bg-white rounded-xl p-4 shadow-sm"> <div class="bg-white rounded-xl p-4 shadow-sm">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<span class="text-gray-900 font-medium">创作模式</span> <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> </div>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
{{ custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成' }} {{
suno.custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成'
}}
</p> </p>
</div> </div>
<!-- 模型选择 --> <!-- 模型选择 -->
<CustomSelect <CustomSelect
v-model="data.model" v-model="suno.data.model"
:options="models" :options="suno.models"
label="模型版本" label="模型版本"
title="选择模型" title="选择模型"
@change="onModelSelect" @change="suno.onModelSelect"
> >
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<div class="flex items-center w-full"> <div class="flex items-center w-full">
@@ -53,31 +55,31 @@
<span class="text-gray-900 font-medium">纯音乐</span> <span class="text-gray-900 font-medium">纯音乐</span>
<p class="text-sm text-gray-500 mt-1">生成不包含人声的音乐</p> <p class="text-sm text-gray-500 mt-1">生成不包含人声的音乐</p>
</div> </div>
<van-switch v-model="data.instrumental" size="24px" /> <van-switch v-model="suno.data.instrumental" size="24px" />
</div> </div>
</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> <label class="block text-gray-700 font-medium mb-3">歌词</label>
<textarea <textarea
v-model="data.lyrics" v-model="suno.data.lyrics"
placeholder="请在这里输入你自己写的歌词..." 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" 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" rows="6"
maxlength="2000" maxlength="2000"
/> />
<div class="flex items-center justify-between mt-3"> <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 <button
@click="createLyric" @click="suno.createLyric"
:disabled="isGenerating || !data.lyrics" :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" 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> <i v-if="suno.isGenerating" class="iconfont icon-loading animate-spin"></i>
<span>{{ isGenerating ? '生成中...' : '生成歌词' }}</span> <span>{{ suno.isGenerating ? '生成中...' : '生成歌词' }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -86,21 +88,21 @@
<div class="bg-white rounded-xl p-4 shadow-sm"> <div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">音乐风格</label> <label class="block text-gray-700 font-medium mb-3">音乐风格</label>
<textarea <textarea
v-model="data.tags" v-model="suno.data.tags"
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..." 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" 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" rows="3"
maxlength="120" maxlength="120"
/> />
<div class="flex justify-between items-center mt-2 mb-3"> <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>
<!-- 风格标签选择 --> <!-- 风格标签选择 -->
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
v-for="tag in tags" v-for="tag in suno.tags"
:key="tag.value" :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" class="px-3 py-1 text-sm border border-blue-200 text-blue-600 rounded-full hover:bg-blue-50 transition-colors"
> >
{{ tag.label }} {{ tag.label }}
@@ -112,13 +114,13 @@
<div class="bg-white rounded-xl p-4 shadow-sm"> <div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">歌曲名称</label> <label class="block text-gray-700 font-medium mb-3">歌曲名称</label>
<input <input
v-model="data.title" v-model="suno.data.title"
placeholder="请输入歌曲名称..." placeholder="请输入歌曲名称..."
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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" maxlength="100"
/> />
<div class="text-right mt-2"> <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> </div>
</div> </div>
@@ -127,26 +129,29 @@
<div v-else class="bg-white rounded-xl p-4 shadow-sm"> <div v-else class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">歌曲描述</label> <label class="block text-gray-700 font-medium mb-3">歌曲描述</label>
<textarea <textarea
v-model="data.prompt" v-model="suno.data.prompt"
placeholder="例如:一首关于爱情的摇滚歌曲..." 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" 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" rows="6"
maxlength="1000" maxlength="1000"
/> />
<div class="text-right mt-2"> <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> </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"> <div class="flex items-center justify-between mb-3">
<h3 class="text-gray-900 font-medium flex items-center"> <h3 class="text-gray-900 font-medium flex items-center">
<i class="iconfont icon-link mr-2 text-orange-500"></i> <i class="iconfont icon-link mr-2 text-orange-500"></i>
续写歌曲 续写歌曲
</h3> </h3>
<button <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" 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="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600">歌曲名称</span> <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>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600">歌曲时长</span> <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>
<div> <div>
<label class="block text-gray-700 font-medium mb-2">续写开始时间()</label> <label class="block text-gray-700 font-medium mb-2">续写开始时间()</label>
<input <input
v-model="refSong.extend_secs" v-model="suno.refSong.extend_secs"
type="number" type="number"
:min="0" :min="0"
:max="refSong.duration" :max="suno.refSong.duration"
placeholder="从第几秒开始续写" placeholder="从第几秒开始续写"
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" 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"> <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> </p>
</div> </div>
</div> </div>
@@ -181,12 +187,12 @@
<!-- 生成按钮 --> <!-- 生成按钮 -->
<div class="sticky bottom-4 bg-white rounded-xl p-4 shadow-lg"> <div class="sticky bottom-4 bg-white rounded-xl p-4 shadow-lg">
<button <button
@click="create" @click="suno.create"
:disabled="loading" :disabled="suno.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" 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> <i v-if="suno.loading" class="iconfont icon-loading animate-spin"></i>
<span>{{ loading ? '创作中...' : btnText }}</span> <span>{{ suno.loading ? '创作中...' : suno.btnText }}({{ suno.sunoPowerCost }}算力)</span>
</button> </button>
</div> </div>
@@ -194,19 +200,21 @@
<div class="bg-white rounded-xl p-4 shadow-sm"> <div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">上传音乐文件</label> <label class="block text-gray-700 font-medium mb-3">上传音乐文件</label>
<el-upload <el-upload
ref="uploadRef" ref="suno.uploadRef"
:auto-upload="false" :auto-upload="false"
:show-file-list="false" :show-file-list="false"
:on-change="handleFileChange" :on-change="suno.handleFileChange"
:before-upload="beforeUpload" :before-upload="suno.beforeUpload"
accept=".wav,.mp3" accept=".wav,.mp3"
class="upload-area w-full" class="upload-area w-full"
> >
<template #trigger> <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> <i class="iconfont icon-upload mr-2"></i>
<span>上传音乐</span> <span>上传音乐</span>
</el-button> </button>
</template> </template>
</el-upload> </el-upload>
<div class="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-500"> <div class="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-500">
@@ -223,7 +231,7 @@
<div class="p-4"> <div class="p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2>
<div class="space-y-4"> <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 space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100"> <div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
@@ -242,7 +250,7 @@
<!-- 音乐播放按钮 --> <!-- 音乐播放按钮 -->
<button <button
v-if="item.progress === 100" 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" 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> <i class="iconfont icon-play text-white text-xl"></i>
@@ -332,7 +340,7 @@
<div class="flex space-x-2"> <div class="flex space-x-2">
<button <button
v-if="item.progress === 100" 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" 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> <i class="iconfont icon-play !text-xs"></i>
@@ -340,7 +348,7 @@
</button> </button>
<button <button
v-if="item.progress === 100" v-if="item.progress === 100"
@click="download(item)" @click="suno.download(item)"
:disabled="item.downloading" :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" 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>
<button <button
v-if="item.progress === 100" 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]" 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> <i class="iconfont icon-link !text-xs mr-1"></i>
@@ -413,7 +421,7 @@
</div> </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"> <svg class="w-6 h-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle <circle
class="opacity-25" class="opacity-25"
@@ -432,7 +440,7 @@
</div> </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>
</div> </div>
@@ -440,14 +448,14 @@
<!-- 音乐播放器 --> <!-- 音乐播放器 -->
<div <div
v-if="showPlayer" v-if="suno.showPlayer"
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-50" 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 @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"> <div class="flex items-center justify-between p-4 border-b">
<h3 class="text-lg font-semibold text-gray-900">正在播放</h3> <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 <svg
class="w-5 h-5 text-gray-500" class="w-5 h-5 text-gray-500"
fill="none" fill="none"
@@ -465,8 +473,8 @@
</div> </div>
<div class="p-6"> <div class="p-6">
<audio <audio
v-if="currentAudio" v-if="suno.currentAudio"
:src="currentAudio" :src="suno.currentAudio"
controls controls
autoplay autoplay
class="w-full rounded-lg" class="w-full rounded-lg"
@@ -480,387 +488,55 @@
</template> </template>
<script setup> <script setup>
import { checkSession } from '@/store/cache' import { onMounted, onUnmounted } from 'vue'
import { closeLoading, showLoading, showToastMessage } from '@/utils/dialog' import { useRouter } from 'vue-router'
import { httpDownload, httpGet, httpPost } from '@/utils/http' import { useSunoStore } from '@/store/mobile/suno'
import { replaceImg } from '@/utils/libs'
import CustomSelect from '@/views/mobile/components/CustomSelect.vue' import CustomSelect from '@/views/mobile/components/CustomSelect.vue'
import { showConfirmDialog } from 'vant' import { showConfirmDialog } from 'vant'
import { onMounted, onUnmounted, ref } from 'vue' import '@/assets/css/mobile/suno.scss'
import { useRouter } from 'vue-router'
const router = useRouter() 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 = () => { const goBack = () => {
router.back() router.back()
} }
const onModeChange = () => { const onModeChange = () => {
if (!custom.value) { if (!suno.custom) {
removeRefSong() suno.removeRefSong()
} }
} }
const onModelConfirm = (value) => { // 滚动监听、定时轮询等副作用
const selectedModel = models.value.find((item) => item.label === value) const handleScroll = () => {
if (selectedModel) { const scrollTop = window.pageYOffset || document.documentElement.scrollTop
data.value.model = selectedModel.value const windowHeight = window.innerHeight
} const documentHeight = document.documentElement.scrollHeight
showModelPicker.value = false if (scrollTop + windowHeight >= documentHeight - 100) {
} suno.loadMore()
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 createLyric = () => { let tastPullHandler = null
if (data.value.lyrics === '') { onMounted(() => {
showToastMessage('请输入歌词描述', 'error') suno.fetchData(1)
return tastPullHandler = setInterval(() => {
} if (suno.taskPulling) {
isGenerating.value = true suno.refreshFirstPage()
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
} }
} else if (custom.value) { }, 5000)
if (data.value.lyrics === '') { window.addEventListener('scroll', handleScroll)
showToastMessage('请输入歌词', 'error') })
return onUnmounted(() => {
} if (tastPullHandler) clearInterval(tastPullHandler)
if (data.value.title === '') { window.removeEventListener('scroll', handleScroll)
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
})
}
// 删除弹窗(页面层处理)
const showDeleteDialog = (item) => { const showDeleteDialog = (item) => {
deleteItem.value = item suno.deleteItem = item
showConfirmDialog({ showConfirmDialog({
title: '确认删除', title: '确认删除',
message: '此操作将会删除任务相关文件,继续操作吗?', message: '此操作将会删除任务相关文件,继续操作吗?',
@@ -868,42 +544,17 @@ const showDeleteDialog = (item) => {
cancelButtonText: '取消', cancelButtonText: '取消',
}) })
.then(() => { .then(() => {
// on confirm if (!suno.deleteItem) return
if (!deleteItem.value) return suno.deleting = true
deleting.value = true suno.deleteItem && suno.deleteItem.id && suno.$patch({ deleting: true })
httpGet('/api/suno/remove', { id: deleteItem.value.id }) suno.deleteItem && suno.deleteItem.id && suno.$patch({ deleting: false })
.then(() => { suno.deleteItem = null
showToastMessage('任务删除成功', 'success') suno.fetchData(1)
fetchData(1)
deleteItem.value = null
})
.catch((e) => {
showToastMessage('任务删除失败:' + e.message, 'error')
})
.finally(() => {
deleting.value = false
})
}) })
.catch(() => { .catch(() => {
// on cancel suno.deleteItem = null
deleteItem.value = 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

File diff suppressed because it is too large Load Diff