mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-26 04:54:28 +08:00
视频生成移动端页面重构完成
This commit is contained in:
@@ -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 端的代码
|
||||||
|
|||||||
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"
|
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>
|
||||||
|
|
||||||
<!-- 选择器弹窗 -->
|
<!-- 选择器弹窗 -->
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
suno.refreshFirstPage()
|
||||||
}
|
}
|
||||||
isGenerating.value = true
|
}, 5000)
|
||||||
httpPost('/api/prompt/lyric', { prompt: data.value.lyrics })
|
window.addEventListener('scroll', handleScroll)
|
||||||
.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) => {
|
onUnmounted(() => {
|
||||||
showToastMessage('歌词生成失败:' + e.message, 'error')
|
if (tastPullHandler) clearInterval(tastPullHandler)
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
})
|
})
|
||||||
.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) {
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 删除弹窗(页面层处理)
|
||||||
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>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
<!-- 视频类型切换 -->
|
<!-- 视频类型切换 -->
|
||||||
<div class="p-4 space-y-6">
|
<div class="p-4 space-y-6">
|
||||||
<!-- 视频类型选择 -->
|
<!-- 视频类型选择 -->
|
||||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
<div class="bg-white rounded-xl p-3 shadow-sm">
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
@click="switchVideoType('luma')"
|
@click="video.switchVideoType('luma')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex-1 py-3 px-4 rounded-lg font-medium transition-colors',
|
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
|
||||||
activeVideoType === 'luma'
|
video.activeVideoType === 'luma'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||||
]"
|
]"
|
||||||
@@ -31,10 +31,10 @@
|
|||||||
Luma视频
|
Luma视频
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="switchVideoType('keling')"
|
@click="video.switchVideoType('keling')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex-1 py-3 px-4 rounded-lg font-medium transition-colors',
|
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
|
||||||
activeVideoType === 'keling'
|
video.activeVideoType === 'keling'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||||
]"
|
]"
|
||||||
@@ -45,33 +45,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Luma 视频参数 -->
|
<!-- Luma 视频参数 -->
|
||||||
<div v-if="activeVideoType === 'luma'" class="space-y-6">
|
<div v-if="video.activeVideoType === 'luma'" class="space-y-6">
|
||||||
<!-- 提示词输入 -->
|
<!-- 提示词输入 -->
|
||||||
<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="lumaParams.prompt"
|
v-model="video.lumaParams.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="4"
|
rows="4"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
/>
|
/>
|
||||||
<div class="text-right mt-2">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-gray-500">{{ lumaParams.prompt.length }}/2000</span>
|
<van-button
|
||||||
</div>
|
@click="video.generatePrompt"
|
||||||
</div>
|
:disabled="video.isGenerating"
|
||||||
|
type="primary"
|
||||||
<!-- 提示词生成按钮 -->
|
size="small"
|
||||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
|
||||||
<button
|
|
||||||
@click="generatePrompt"
|
|
||||||
:disabled="isGenerating"
|
|
||||||
class="w-full py-3 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 justify-center space-x-2"
|
|
||||||
>
|
>
|
||||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
|
||||||
<span>{{ isGenerating ? '生成中...' : '生成AI视频提示词' }}</span>
|
</van-button>
|
||||||
</button>
|
<span class="text-sm text-gray-500">{{ video.lumaParams.prompt.length }}/2000</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片辅助生成开关 -->
|
<!-- 图片辅助生成开关 -->
|
||||||
@@ -81,12 +77,16 @@
|
|||||||
<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>
|
||||||
<el-switch v-model="lumaUseImageMode" @change="toggleLumaImageMode" size="default" />
|
<el-switch
|
||||||
|
v-model="video.lumaUseImageMode"
|
||||||
|
@change="video.toggleLumaImageMode"
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片上传区域 -->
|
<!-- 图片上传区域 -->
|
||||||
<div v-if="lumaUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
<div v-if="video.lumaUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
ref="lumaStartInput"
|
ref="lumaStartInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.png,.jpeg"
|
accept=".jpg,.png,.jpeg"
|
||||||
@change="handleLumaStartImageUpload"
|
@change="video.handleLumaStartImageUpload"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -104,18 +104,20 @@
|
|||||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="!lumaStartImage.length"
|
v-if="!video.lumaStartImage.length"
|
||||||
class="iconfont icon-upload text-blue-500 text-xl"
|
class="iconfont icon-upload text-blue-500 text-xl"
|
||||||
></i>
|
></i>
|
||||||
<span v-if="!lumaStartImage.length" class="text-gray-700 text-sm">起始帧</span>
|
<span v-if="!video.lumaStartImage.length" class="text-gray-700 text-sm"
|
||||||
|
>起始帧</span
|
||||||
|
>
|
||||||
<div v-else class="w-full h-full relative">
|
<div v-else class="w-full h-full relative">
|
||||||
<el-image
|
<el-image
|
||||||
:src="lumaStartImage[0]?.url || lumaStartImage[0]?.content"
|
:src="video.lumaStartImage[0]?.url || video.lumaStartImage[0]?.content"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="w-full h-full rounded"
|
class="w-full h-full rounded"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click.stop="lumaStartImage = []"
|
@click.stop="video.lumaStartImage = []"
|
||||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<i class="iconfont icon-close"></i>
|
<i class="iconfont icon-close"></i>
|
||||||
@@ -132,7 +134,7 @@
|
|||||||
ref="lumaEndInput"
|
ref="lumaEndInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.png,.jpeg"
|
accept=".jpg,.png,.jpeg"
|
||||||
@change="handleLumaEndImageUpload"
|
@change="video.handleLumaEndImageUpload"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -140,18 +142,20 @@
|
|||||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="!lumaEndImage.length"
|
v-if="!video.lumaEndImage.length"
|
||||||
class="iconfont icon-upload text-blue-500 text-xl"
|
class="iconfont icon-upload text-blue-500 text-xl"
|
||||||
></i>
|
></i>
|
||||||
<span v-if="!lumaEndImage.length" class="text-gray-700 text-sm">结束帧</span>
|
<span v-if="!video.lumaEndImage.length" class="text-gray-700 text-sm"
|
||||||
|
>结束帧</span
|
||||||
|
>
|
||||||
<div v-else class="w-full h-full relative">
|
<div v-else class="w-full h-full relative">
|
||||||
<el-image
|
<el-image
|
||||||
:src="lumaEndImage[0]?.url || lumaEndImage[0]?.content"
|
:src="video.lumaEndImage[0]?.url || video.lumaEndImage[0]?.content"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="w-full h-full rounded"
|
class="w-full h-full rounded"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click.stop="lumaEndImage = []"
|
@click.stop="video.lumaEndImage = []"
|
||||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<i class="iconfont icon-close"></i>
|
<i class="iconfont icon-close"></i>
|
||||||
@@ -168,55 +172,65 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-gray-900 font-medium">循环参考图</span>
|
<span class="text-gray-900 font-medium">循环参考图</span>
|
||||||
<el-switch v-model="lumaParams.loop" size="default" />
|
<el-switch v-model="video.lumaParams.loop" size="default" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-gray-900 font-medium">提示词优化</span>
|
<span class="text-gray-900 font-medium">提示词优化</span>
|
||||||
<el-switch v-model="lumaParams.expand_prompt" size="default" />
|
<el-switch v-model="video.lumaParams.expand_prompt" size="default" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 算力显示和生成按钮 -->
|
<!-- 算力显示和生成按钮 -->
|
||||||
<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-4">
|
|
||||||
<span class="text-gray-700">当前可用算力</span>
|
|
||||||
<span class="text-blue-600 font-semibold">{{ availablePower }}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
@click="createLumaVideo"
|
@click="video.createLumaVideo"
|
||||||
:disabled="generating"
|
:disabled="video.generating"
|
||||||
class="w-full py-4 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="generating" class="iconfont icon-loading animate-spin"></i>
|
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
|
||||||
<span>{{ generating ? '创作中...' : `立即生成 (${lumaPowerCost}算力)` }}</span>
|
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||||
|
<span>{{
|
||||||
|
video.generating ? '创作中...' : `立即生成 (${video.lumaPowerCost}算力)`
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KeLing 视频参数 -->
|
<!-- KeLing 视频参数 -->
|
||||||
<div v-if="activeVideoType === 'keling'" class="space-y-6">
|
<div v-if="video.activeVideoType === 'keling'" class="space-y-6">
|
||||||
<!-- 画面比例 -->
|
<!-- 画面比例 -->
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="kelingParams.aspect_ratio"
|
v-model="video.kelingParams.aspect_ratio"
|
||||||
:options="aspectRatioOptions.map((ratio) => ({ label: ratio, value: ratio }))"
|
:options="video.aspectRatioOptions.map((ratio) => ({ label: ratio, value: ratio }))"
|
||||||
label="画面比例"
|
label="画面比例"
|
||||||
title="选择比例"
|
title="选择比例"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 模型选择 -->
|
<!-- 模型选择 -->
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="kelingParams.model"
|
v-model="video.kelingParams.model"
|
||||||
:options="modelOptions.map((model) => ({ label: model, value: model }))"
|
:options="video.modelOptions"
|
||||||
label="模型选择"
|
label="模型选择"
|
||||||
|
placeholder="请选择模型"
|
||||||
title="选择模型"
|
title="选择模型"
|
||||||
/>
|
>
|
||||||
|
<template #option="{ option, selected }">
|
||||||
|
<div class="flex items-center w-full">
|
||||||
|
<span class="font-bold text-blue-600 mr-2">{{ option.label }}</span>
|
||||||
|
<span class="text-xs text-gray-400">({{ option.value }})</span>
|
||||||
|
<span v-if="selected" class="ml-auto text-green-500"
|
||||||
|
><i class="iconfont icon-success"></i
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CustomSelect>
|
||||||
|
|
||||||
<!-- 视频时长 -->
|
<!-- 视频时长 -->
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="kelingParams.duration"
|
v-model="video.kelingParams.duration"
|
||||||
:options="
|
:options="
|
||||||
durationOptions.map((duration) => ({ label: `${duration}秒`, value: duration }))
|
video.durationOptions.map((duration) => ({ label: `${duration}秒`, value: duration }))
|
||||||
"
|
"
|
||||||
label="视频时长"
|
label="视频时长"
|
||||||
title="选择时长"
|
title="选择时长"
|
||||||
@@ -224,9 +238,9 @@
|
|||||||
|
|
||||||
<!-- 生成模式 -->
|
<!-- 生成模式 -->
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="kelingParams.mode"
|
v-model="video.kelingParams.mode"
|
||||||
:options="
|
:options="
|
||||||
modeOptions.map((mode) => ({
|
video.modeOptions.map((mode) => ({
|
||||||
label: mode === 'std' ? '标准模式' : '专业模式',
|
label: mode === 'std' ? '标准模式' : '专业模式',
|
||||||
value: mode,
|
value: mode,
|
||||||
}))
|
}))
|
||||||
@@ -239,16 +253,16 @@
|
|||||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block text-gray-700 font-medium">创意程度</label>
|
<label class="block text-gray-700 font-medium">创意程度</label>
|
||||||
<el-slider v-model="kelingParams.cfg_scale" :min="0" :max="1" :step="0.1" />
|
<el-slider v-model="video.kelingParams.cfg_scale" :min="0" :max="1" :step="0.1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 运镜控制 -->
|
<!-- 运镜控制 -->
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="kelingParams.camera_control.type"
|
v-model="video.kelingParams.camera_control.type"
|
||||||
:options="
|
:options="
|
||||||
cameraControlOptions.map((option) => ({
|
video.cameraControlOptions.map((option) => ({
|
||||||
label: getCameraControlLabel(option),
|
label: video.getCameraControlLabel(option),
|
||||||
value: option,
|
value: option,
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
@@ -264,15 +278,15 @@
|
|||||||
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
|
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
|
||||||
</div>
|
</div>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="kelingUseImageMode"
|
v-model="video.kelingUseImageMode"
|
||||||
@change="toggleKelingImageMode"
|
@change="video.toggleKelingImageMode"
|
||||||
size="default"
|
size="default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片上传区域 -->
|
<!-- 图片上传区域 -->
|
||||||
<div v-if="kelingUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
<div v-if="video.kelingUseImageMode" class="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
@@ -282,7 +296,7 @@
|
|||||||
ref="kelingStartInput"
|
ref="kelingStartInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.png,.jpeg"
|
accept=".jpg,.png,.jpeg"
|
||||||
@change="handleKelingStartImageUpload"
|
@change="video.handleKelingStartImageUpload"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -290,18 +304,20 @@
|
|||||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="!kelingStartImage.length"
|
v-if="!video.kelingStartImage.length"
|
||||||
class="iconfont icon-upload text-blue-500 text-xl"
|
class="iconfont icon-upload text-blue-500 text-xl"
|
||||||
></i>
|
></i>
|
||||||
<span v-if="!kelingStartImage.length" class="text-gray-700 text-sm">起始帧</span>
|
<span v-if="!video.kelingStartImage.length" class="text-gray-700 text-sm"
|
||||||
|
>起始帧</span
|
||||||
|
>
|
||||||
<div v-else class="w-full h-full relative">
|
<div v-else class="w-full h-full relative">
|
||||||
<el-image
|
<el-image
|
||||||
:src="kelingStartImage[0]?.url || kelingStartImage[0]?.content"
|
:src="video.kelingStartImage[0]?.url || video.kelingStartImage[0]?.content"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="w-full h-full rounded"
|
class="w-full h-full rounded"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click.stop="kelingStartImage = []"
|
@click.stop="video.kelingStartImage = []"
|
||||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<i class="iconfont icon-close"></i>
|
<i class="iconfont icon-close"></i>
|
||||||
@@ -318,7 +334,7 @@
|
|||||||
ref="kelingEndInput"
|
ref="kelingEndInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jpg,.png,.jpeg"
|
accept=".jpg,.png,.jpeg"
|
||||||
@change="handleKelingEndImageUpload"
|
@change="video.handleKelingEndImageUpload"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -326,18 +342,20 @@
|
|||||||
class="flex flex-col items-center space-y-2 h-full justify-center"
|
class="flex flex-col items-center space-y-2 h-full justify-center"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="!kelingEndImage.length"
|
v-if="!video.kelingEndImage.length"
|
||||||
class="iconfont icon-upload text-blue-500 text-xl"
|
class="iconfont icon-upload text-blue-500 text-xl"
|
||||||
></i>
|
></i>
|
||||||
<span v-if="!kelingEndImage.length" class="text-gray-700 text-sm">结束帧</span>
|
<span v-if="!video.kelingEndImage.length" class="text-gray-700 text-sm"
|
||||||
|
>结束帧</span
|
||||||
|
>
|
||||||
<div v-else class="w-full h-full relative">
|
<div v-else class="w-full h-full relative">
|
||||||
<el-image
|
<el-image
|
||||||
:src="kelingEndImage[0]?.url || kelingEndImage[0]?.content"
|
:src="video.kelingEndImage[0]?.url || video.kelingEndImage[0]?.content"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="w-full h-full rounded"
|
class="w-full h-full rounded"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@click.stop="kelingEndImage = []"
|
@click.stop="video.kelingEndImage = []"
|
||||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center text-xs hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<i class="iconfont icon-close"></i>
|
<i class="iconfont icon-close"></i>
|
||||||
@@ -353,58 +371,55 @@
|
|||||||
<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="kelingParams.prompt"
|
v-model="video.kelingParams.prompt"
|
||||||
:placeholder="kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
|
:placeholder="video.kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
|
||||||
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="4"
|
rows="4"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
/>
|
/>
|
||||||
<div class="text-right mt-2">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-gray-500">{{ kelingParams.prompt.length }}/500</span>
|
<van-button
|
||||||
</div>
|
@click="video.generatePrompt"
|
||||||
</div>
|
:disabled="video.isGenerating"
|
||||||
|
type="primary"
|
||||||
<!-- 提示词生成按钮 -->
|
size="small"
|
||||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
|
||||||
<button
|
|
||||||
@click="generatePrompt"
|
|
||||||
:disabled="isGenerating"
|
|
||||||
class="w-full py-3 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 justify-center space-x-2"
|
|
||||||
>
|
>
|
||||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
|
||||||
<span>{{ isGenerating ? '生成中...' : '生成专业视频提示词' }}</span>
|
</van-button>
|
||||||
</button>
|
<span class="text-sm text-gray-500">{{ video.kelingParams.prompt.length }}/500</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 排除内容 -->
|
<!-- 排除内容 -->
|
||||||
<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="kelingParams.negative_prompt"
|
v-model="video.kelingParams.negative_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="3"
|
rows="3"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
/>
|
/>
|
||||||
<div class="text-right mt-2">
|
<div class="text-right mt-2">
|
||||||
<span class="text-sm text-gray-500">{{ kelingParams.negative_prompt.length }}/500</span>
|
<span class="text-sm text-gray-500"
|
||||||
|
>{{ video.kelingParams.negative_prompt.length }}/500</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 算力显示和生成按钮 -->
|
<!-- 算力显示和生成按钮 -->
|
||||||
<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-4">
|
|
||||||
<span class="text-gray-700">当前可用算力</span>
|
|
||||||
<span class="text-blue-600 font-semibold">{{ availablePower }}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
@click="createKelingVideo"
|
@click="video.createKelingVideo"
|
||||||
:disabled="generating"
|
:disabled="video.generating"
|
||||||
class="w-full py-4 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="generating" class="iconfont icon-loading animate-spin"></i>
|
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
|
||||||
<span>{{ generating ? '创作中...' : `立即生成 (${kelingPowerCost}算力)` }}</span>
|
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||||
|
<span>{{
|
||||||
|
video.generating ? '创作中...' : `立即生成 (${video.kelingPowerCost}算力)`
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,7 +429,11 @@
|
|||||||
<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 currentList" :key="item.id" class="bg-white rounded-xl p-4 shadow-sm">
|
<div
|
||||||
|
v-for="item in video.currentList"
|
||||||
|
: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">
|
||||||
@@ -433,7 +452,7 @@
|
|||||||
<!-- 视频播放按钮 -->
|
<!-- 视频播放按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="item.progress === 100"
|
v-if="item.progress === 100"
|
||||||
@click="playVideo(item)"
|
@click="video.playVideo(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>
|
||||||
@@ -457,9 +476,6 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="text-gray-900 font-medium truncate">
|
|
||||||
{{ item.title || '未命名视频' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-500 text-sm mt-1 line-clamp-2">
|
<p class="text-gray-500 text-sm mt-1 line-clamp-2">
|
||||||
{{ item.prompt }}
|
{{ item.prompt }}
|
||||||
</p>
|
</p>
|
||||||
@@ -510,7 +526,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="playVideo(item)"
|
@click="video.playVideo(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>
|
||||||
@@ -518,7 +534,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="item.progress === 100"
|
v-if="item.progress === 100"
|
||||||
@click="downloadVideo(item)"
|
@click="video.downloadVideo(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"
|
||||||
>
|
>
|
||||||
@@ -538,12 +554,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载更多 -->
|
<!-- 加载更多 -->
|
||||||
<div v-if="listLoading" class="flex justify-center py-4">
|
<div v-if="video.listLoading" class="flex justify-center py-4">
|
||||||
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 没有更多了 -->
|
<!-- 没有更多了 -->
|
||||||
<div v-if="listFinished && !listLoading" class="text-center py-4 text-gray-500">
|
<div v-if="video.listFinished && !video.listLoading" class="text-center py-4 text-gray-500">
|
||||||
没有更多了
|
没有更多了
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,21 +567,21 @@
|
|||||||
|
|
||||||
<!-- 视频预览弹窗 -->
|
<!-- 视频预览弹窗 -->
|
||||||
<div
|
<div
|
||||||
v-if="showVideoDialog"
|
v-if="video.showVideoDialog"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||||
@click="showVideoDialog = false"
|
@click="video.showVideoDialog = false"
|
||||||
>
|
>
|
||||||
<div @click.stop class="bg-white rounded-2xl w-full max-w-4xl max-h-[80vh] animate-scale-up">
|
<div @click.stop class="bg-white rounded-2xl w-full max-w-4xl max-h-[80vh] animate-scale-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="showVideoDialog = false" class="p-2 hover:bg-gray-100 rounded-full">
|
<button @click="video.showVideoDialog = false" class="p-2 hover:bg-gray-100 rounded-full">
|
||||||
<i class="iconfont icon-close text-gray-500"></i>
|
<i class="iconfont icon-close text-gray-500"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<video
|
<video
|
||||||
v-if="currentVideoUrl"
|
v-if="video.currentVideoUrl"
|
||||||
:src="currentVideoUrl"
|
:src="video.currentVideoUrl"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
class="w-full max-h-[60vh] rounded-lg"
|
class="w-full max-h-[60vh] rounded-lg"
|
||||||
@@ -579,346 +595,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showConfirmDialog } from 'vant'
|
import { useVideoStore } from '@/store/mobile/video'
|
||||||
import { httpGet, httpPost } from '@/utils/http'
|
|
||||||
import { checkSession } from '@/store/cache'
|
|
||||||
import CustomSelect from '@/views/mobile/components/CustomSelect.vue'
|
import CustomSelect from '@/views/mobile/components/CustomSelect.vue'
|
||||||
import { showMessageOK, showMessageError, showLoading, closeLoading } from '@/utils/dialog'
|
import { showConfirmDialog } from 'vant'
|
||||||
|
import '@/assets/css/mobile/video.scss'
|
||||||
|
import CustomSelectOption from '../components/CustomSelectOption.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const video = useVideoStore()
|
||||||
|
|
||||||
// 响应式数据
|
// 页面专属方法
|
||||||
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 = ref({
|
|
||||||
prompt: '',
|
|
||||||
image: '',
|
|
||||||
image_tail: '',
|
|
||||||
loop: false,
|
|
||||||
expand_prompt: false,
|
|
||||||
})
|
|
||||||
const lumaUseImageMode = ref(false)
|
|
||||||
const lumaStartImage = ref([])
|
|
||||||
const lumaEndImage = ref([])
|
|
||||||
|
|
||||||
// KeLing 参数
|
|
||||||
const kelingParams = ref({
|
|
||||||
aspect_ratio: '16:9',
|
|
||||||
model: 'v1.5',
|
|
||||||
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 = ['v1.0', 'v1.5']
|
|
||||||
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 availablePower = ref(0)
|
|
||||||
const lumaPowerCost = ref(0)
|
|
||||||
const kelingPowerCost = ref(0)
|
|
||||||
const taskPulling = ref(true)
|
|
||||||
const tastPullHandler = ref(null)
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
checkSession()
|
|
||||||
.then(() => {
|
|
||||||
fetchData(1)
|
|
||||||
fetchUserPower()
|
|
||||||
tastPullHandler.value = setInterval(() => {
|
|
||||||
if (taskPulling.value) {
|
|
||||||
fetchData(1)
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (tastPullHandler.value) {
|
|
||||||
clearInterval(tastPullHandler.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchVideoType = (type) => {
|
// 定时轮询等副作用
|
||||||
activeVideoType.value = type
|
let tastPullHandler = null
|
||||||
onVideoTypeChange(type)
|
onMounted(() => {
|
||||||
|
video.fetchData(1)
|
||||||
|
video.fetchUserPower()
|
||||||
|
tastPullHandler = setInterval(() => {
|
||||||
|
if (video.taskPulling) {
|
||||||
|
video.fetchData(1)
|
||||||
}
|
}
|
||||||
|
}, 5000)
|
||||||
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 = () => {
|
|
||||||
isGenerating.value = true
|
|
||||||
// TODO: 实现提示词生成逻辑
|
|
||||||
setTimeout(() => {
|
|
||||||
isGenerating.value = false
|
|
||||||
showMessageSuccess('提示词生成功能开发中')
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleLumaImageMode = () => {
|
|
||||||
if (!lumaUseImageMode.value) {
|
|
||||||
lumaParams.value.image = ''
|
|
||||||
lumaParams.value.image_tail = ''
|
|
||||||
lumaStartImage.value = []
|
|
||||||
lumaEndImage.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleKelingImageMode = () => {
|
|
||||||
if (!kelingUseImageMode.value) {
|
|
||||||
kelingParams.value.image = ''
|
|
||||||
kelingParams.value.image_tail = ''
|
|
||||||
kelingStartImage.value = []
|
|
||||||
kelingEndImage.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadLumaStartImage = (file) => {
|
|
||||||
uploadImage(file, (url) => {
|
|
||||||
lumaParams.value.image = url
|
|
||||||
})
|
})
|
||||||
}
|
onUnmounted(() => {
|
||||||
|
if (tastPullHandler) clearInterval(tastPullHandler)
|
||||||
const uploadLumaEndImage = (file) => {
|
|
||||||
uploadImage(file, (url) => {
|
|
||||||
lumaParams.value.image_tail = url
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const uploadKelingStartImage = (file) => {
|
|
||||||
uploadImage(file, (url) => {
|
|
||||||
kelingParams.value.image = url
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadKelingEndImage = (file) => {
|
|
||||||
uploadImage(file, (url) => {
|
|
||||||
kelingParams.value.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.value.prompt.trim()) {
|
|
||||||
showMessageError('请输入视频提示词')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
generating.value = true
|
|
||||||
const params = {
|
|
||||||
...lumaParams.value,
|
|
||||||
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.value.prompt.trim()) {
|
|
||||||
showMessageError('请输入视频提示词')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
generating.value = true
|
|
||||||
const params = {
|
|
||||||
...kelingParams.value,
|
|
||||||
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 = () => {
|
|
||||||
httpGet('/api/user/power')
|
|
||||||
.then((res) => {
|
|
||||||
availablePower.value = res.data.power || 0
|
|
||||||
lumaPowerCost.value = 10 // 示例值
|
|
||||||
kelingPowerCost.value = 15 // 示例值
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
const removeJob = (item) => {
|
||||||
showConfirmDialog({
|
showConfirmDialog({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
@@ -927,106 +635,10 @@ const removeJob = (item) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
httpGet('/api/video/remove', { id: item.id })
|
video.fetchData(1)
|
||||||
.then(() => {
|
|
||||||
showMessageOK('任务删除成功')
|
|
||||||
fetchData(1)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
showMessageError('任务删除失败:' + e.message)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
/* 自定义动画 */
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user