mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-11 21:54:26 +08:00
视频生成移动端页面重构完成
This commit is contained in:
@@ -3,3 +3,4 @@
|
||||
1. 把当前页面 JS 代码全部抽离,然后是采用 Pinia 重构
|
||||
2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码
|
||||
3. 尽量做到代码的复用性,不要重复造轮子
|
||||
4. 移动端的 css 和 js 分别放到对应的 mobile 目录下,不要覆盖 PC 端的代码
|
||||
|
||||
117
web/src/assets/css/mobile/suno.scss
Normal file
117
web/src/assets/css/mobile/suno.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
83
web/src/assets/css/mobile/video.scss
Normal file
83
web/src/assets/css/mobile/video.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
353
web/src/store/mobile/suno.js
Normal file
353
web/src/store/mobile/suno.js
Normal 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,
|
||||
}
|
||||
})
|
||||
397
web/src/store/mobile/video.js
Normal file
397
web/src/store/mobile/video.js
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
<!-- 选择器弹窗 -->
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user