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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,13 +17,13 @@
<!-- 视频类型切换 -->
<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">
<button
@click="switchVideoType('luma')"
@click="video.switchVideoType('luma')"
:class="[
'flex-1 py-3 px-4 rounded-lg font-medium transition-colors',
activeVideoType === 'luma'
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
video.activeVideoType === 'luma'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
]"
@@ -31,10 +31,10 @@
Luma视频
</button>
<button
@click="switchVideoType('keling')"
@click="video.switchVideoType('keling')"
:class="[
'flex-1 py-3 px-4 rounded-lg font-medium transition-colors',
activeVideoType === 'keling'
'flex-1 py-2.5 px-4 rounded-lg font-medium transition-colors',
video.activeVideoType === 'keling'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
]"
@@ -45,33 +45,29 @@
</div>
<!-- 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">
<label class="block text-gray-700 font-medium mb-3">提示词</label>
<textarea
v-model="lumaParams.prompt"
v-model="video.lumaParams.prompt"
placeholder="请在此输入视频提示词,用逗号分割"
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows="4"
maxlength="2000"
/>
<div class="text-right mt-2">
<span class="text-sm text-gray-500">{{ lumaParams.prompt.length }}/2000</span>
</div>
</div>
<!-- 提示词生成按钮 -->
<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"
<div class="flex justify-between">
<van-button
@click="video.generatePrompt"
:disabled="video.isGenerating"
type="primary"
size="small"
>
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ isGenerating ? '生成中...' : '生成AI视频提示词' }}</span>
</button>
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
</van-button>
<span class="text-sm text-gray-500">{{ video.lumaParams.prompt.length }}/2000</span>
</div>
</div>
<!-- 图片辅助生成开关 -->
@@ -81,12 +77,16 @@
<span class="text-gray-900 font-medium">使用图片辅助生成</span>
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
</div>
<el-switch v-model="lumaUseImageMode" @change="toggleLumaImageMode" size="default" />
<el-switch
v-model="video.lumaUseImageMode"
@change="video.toggleLumaImageMode"
size="default"
/>
</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="relative">
<div
@@ -96,7 +96,7 @@
ref="lumaStartInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="handleLumaStartImageUpload"
@change="video.handleLumaStartImageUpload"
class="hidden"
/>
<div
@@ -104,18 +104,20 @@
class="flex flex-col items-center space-y-2 h-full justify-center"
>
<i
v-if="!lumaStartImage.length"
v-if="!video.lumaStartImage.length"
class="iconfont icon-upload text-blue-500 text-xl"
></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">
<el-image
:src="lumaStartImage[0]?.url || lumaStartImage[0]?.content"
:src="video.lumaStartImage[0]?.url || video.lumaStartImage[0]?.content"
fit="cover"
class="w-full h-full rounded"
/>
<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"
>
<i class="iconfont icon-close"></i>
@@ -132,7 +134,7 @@
ref="lumaEndInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="handleLumaEndImageUpload"
@change="video.handleLumaEndImageUpload"
class="hidden"
/>
<div
@@ -140,18 +142,20 @@
class="flex flex-col items-center space-y-2 h-full justify-center"
>
<i
v-if="!lumaEndImage.length"
v-if="!video.lumaEndImage.length"
class="iconfont icon-upload text-blue-500 text-xl"
></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">
<el-image
:src="lumaEndImage[0]?.url || lumaEndImage[0]?.content"
:src="video.lumaEndImage[0]?.url || video.lumaEndImage[0]?.content"
fit="cover"
class="w-full h-full rounded"
/>
<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"
>
<i class="iconfont icon-close"></i>
@@ -168,55 +172,65 @@
<div class="space-y-4">
<div class="flex items-center justify-between">
<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 class="flex items-center justify-between">
<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 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
@click="createLumaVideo"
:disabled="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"
@click="video.createLumaVideo"
:disabled="video.generating"
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>
<span>{{ generating ? '创作中...' : `立即生成 (${lumaPowerCost}算力)` }}</span>
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{
video.generating ? '创作中...' : `立即生成 (${video.lumaPowerCost}算力)`
}}</span>
</button>
</div>
</div>
<!-- KeLing 视频参数 -->
<div v-if="activeVideoType === 'keling'" class="space-y-6">
<div v-if="video.activeVideoType === 'keling'" class="space-y-6">
<!-- 画面比例 -->
<CustomSelect
v-model="kelingParams.aspect_ratio"
:options="aspectRatioOptions.map((ratio) => ({ label: ratio, value: ratio }))"
v-model="video.kelingParams.aspect_ratio"
:options="video.aspectRatioOptions.map((ratio) => ({ label: ratio, value: ratio }))"
label="画面比例"
title="选择比例"
/>
<!-- 模型选择 -->
<CustomSelect
v-model="kelingParams.model"
:options="modelOptions.map((model) => ({ label: model, value: model }))"
v-model="video.kelingParams.model"
:options="video.modelOptions"
label="模型选择"
placeholder="请选择模型"
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
v-model="kelingParams.duration"
v-model="video.kelingParams.duration"
:options="
durationOptions.map((duration) => ({ label: `${duration}秒`, value: duration }))
video.durationOptions.map((duration) => ({ label: `${duration}秒`, value: duration }))
"
label="视频时长"
title="选择时长"
@@ -224,9 +238,9 @@
<!-- 生成模式 -->
<CustomSelect
v-model="kelingParams.mode"
v-model="video.kelingParams.mode"
:options="
modeOptions.map((mode) => ({
video.modeOptions.map((mode) => ({
label: mode === 'std' ? '标准模式' : '专业模式',
value: mode,
}))
@@ -239,16 +253,16 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<div class="space-y-4">
<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>
<!-- 运镜控制 -->
<CustomSelect
v-model="kelingParams.camera_control.type"
v-model="video.kelingParams.camera_control.type"
:options="
cameraControlOptions.map((option) => ({
label: getCameraControlLabel(option),
video.cameraControlOptions.map((option) => ({
label: video.getCameraControlLabel(option),
value: option,
}))
"
@@ -264,15 +278,15 @@
<p class="text-sm text-gray-500 mt-1">上传起始帧和结束帧图片</p>
</div>
<el-switch
v-model="kelingUseImageMode"
@change="toggleKelingImageMode"
v-model="video.kelingUseImageMode"
@change="video.toggleKelingImageMode"
size="default"
/>
</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="relative">
<div
@@ -282,7 +296,7 @@
ref="kelingStartInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="handleKelingStartImageUpload"
@change="video.handleKelingStartImageUpload"
class="hidden"
/>
<div
@@ -290,18 +304,20 @@
class="flex flex-col items-center space-y-2 h-full justify-center"
>
<i
v-if="!kelingStartImage.length"
v-if="!video.kelingStartImage.length"
class="iconfont icon-upload text-blue-500 text-xl"
></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">
<el-image
:src="kelingStartImage[0]?.url || kelingStartImage[0]?.content"
:src="video.kelingStartImage[0]?.url || video.kelingStartImage[0]?.content"
fit="cover"
class="w-full h-full rounded"
/>
<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"
>
<i class="iconfont icon-close"></i>
@@ -318,7 +334,7 @@
ref="kelingEndInput"
type="file"
accept=".jpg,.png,.jpeg"
@change="handleKelingEndImageUpload"
@change="video.handleKelingEndImageUpload"
class="hidden"
/>
<div
@@ -326,18 +342,20 @@
class="flex flex-col items-center space-y-2 h-full justify-center"
>
<i
v-if="!kelingEndImage.length"
v-if="!video.kelingEndImage.length"
class="iconfont icon-upload text-blue-500 text-xl"
></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">
<el-image
:src="kelingEndImage[0]?.url || kelingEndImage[0]?.content"
:src="video.kelingEndImage[0]?.url || video.kelingEndImage[0]?.content"
fit="cover"
class="w-full h-full rounded"
/>
<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"
>
<i class="iconfont icon-close"></i>
@@ -353,58 +371,55 @@
<div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">提示词</label>
<textarea
v-model="kelingParams.prompt"
:placeholder="kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
v-model="video.kelingParams.prompt"
: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"
rows="4"
maxlength="500"
/>
<div class="text-right mt-2">
<span class="text-sm text-gray-500">{{ kelingParams.prompt.length }}/500</span>
</div>
</div>
<!-- 提示词生成按钮 -->
<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"
<div class="flex justify-between">
<van-button
@click="video.generatePrompt"
:disabled="video.isGenerating"
type="primary"
size="small"
>
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ isGenerating ? '生成中...' : '生成专业视频提示词' }}</span>
</button>
<i v-if="video.isGenerating" class="iconfont icon-loading animate-spin"></i>
<span class="ml-1">{{ video.isGenerating ? '' : '生成提示词' }}</span>
</van-button>
<span class="text-sm text-gray-500">{{ video.kelingParams.prompt.length }}/500</span>
</div>
</div>
<!-- 排除内容 -->
<div class="bg-white rounded-xl p-4 shadow-sm">
<label class="block text-gray-700 font-medium mb-3">不希望出现的内容</label>
<textarea
v-model="kelingParams.negative_prompt"
v-model="video.kelingParams.negative_prompt"
placeholder="请在此输入你不希望出现在视频上的内容"
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows="3"
maxlength="500"
/>
<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 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
@click="createKelingVideo"
:disabled="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"
@click="video.createKelingVideo"
:disabled="video.generating"
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>
<span>{{ generating ? '创作中...' : `立即生成 (${kelingPowerCost}算力)` }}</span>
<i v-if="video.generating" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{
video.generating ? '创作中...' : `立即生成 (${video.kelingPowerCost}算力)`
}}</span>
</button>
</div>
</div>
@@ -414,7 +429,11 @@
<div class="p-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2>
<div class="space-y-4">
<div v-for="item in 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-shrink-0">
<div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
@@ -433,7 +452,7 @@
<!-- 视频播放按钮 -->
<button
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"
>
<i class="iconfont icon-play text-white text-xl"></i>
@@ -457,9 +476,6 @@
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<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">
{{ item.prompt }}
</p>
@@ -510,7 +526,7 @@
<div class="flex space-x-2">
<button
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"
>
<i class="iconfont icon-play !text-xs"></i>
@@ -518,7 +534,7 @@
</button>
<button
v-if="item.progress === 100"
@click="downloadVideo(item)"
@click="video.downloadVideo(item)"
:disabled="item.downloading"
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 flex items-center space-x-1"
>
@@ -538,12 +554,12 @@
</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>
</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>
@@ -551,21 +567,21 @@
<!-- 视频预览弹窗 -->
<div
v-if="showVideoDialog"
v-if="video.showVideoDialog"
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 class="flex items-center justify-between p-4 border-b">
<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>
</button>
</div>
<div class="p-6">
<video
v-if="currentVideoUrl"
:src="currentVideoUrl"
v-if="video.currentVideoUrl"
:src="video.currentVideoUrl"
controls
autoplay
class="w-full max-h-[60vh] rounded-lg"
@@ -579,346 +595,38 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } from 'vant'
import { httpGet, httpPost } from '@/utils/http'
import { checkSession } from '@/store/cache'
import { useVideoStore } from '@/store/mobile/video'
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 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 = () => {
router.back()
}
const switchVideoType = (type) => {
activeVideoType.value = type
onVideoTypeChange(type)
// 定时轮询等副作用
let tastPullHandler = null
onMounted(() => {
video.fetchData(1)
video.fetchUserPower()
tastPullHandler = setInterval(() => {
if (video.taskPulling) {
video.fetchData(1)
}
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
}, 5000)
})
}
const uploadLumaEndImage = (file) => {
uploadImage(file, (url) => {
lumaParams.value.image_tail = url
onUnmounted(() => {
if (tastPullHandler) clearInterval(tastPullHandler)
})
}
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) => {
showConfirmDialog({
title: '确认删除',
@@ -927,106 +635,10 @@ const removeJob = (item) => {
cancelButtonText: '取消',
})
.then(() => {
httpGet('/api/video/remove', { id: item.id })
.then(() => {
showMessageOK('任务删除成功')
fetchData(1)
})
.catch((e) => {
showMessageError('任务删除失败:' + e.message)
})
video.fetchData(1)
})
.catch(() => {})
}
</script>
<style scoped>
/* 自定义动画 */
@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>
<style scoped></style>