mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-05-05 17:34:25 +08:00
视频生成移动端页面重构完成
This commit is contained in:
@@ -3,3 +3,4 @@
|
||||
1. 把当前页面 JS 代码全部抽离,然后是采用 Pinia 重构
|
||||
2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码
|
||||
3. 尽量做到代码的复用性,不要重复造轮子
|
||||
4. 移动端的 css 和 js 分别放到对应的 mobile 目录下,不要覆盖 PC 端的代码
|
||||
|
||||
117
web/src/assets/css/mobile/suno.scss
Normal file
117
web/src/assets/css/mobile/suno.scss
Normal file
@@ -0,0 +1,117 @@
|
||||
/* 来自 SunoCreate.vue 的样式,已迁移至此,供移动端页面使用 */
|
||||
|
||||
// 自定义动画
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fade-out 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-up {
|
||||
animation: scale-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
// 文本截断
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 滚动监听自动加载更多
|
||||
.scroll-container {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg-gray-50 {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
.bg-white {
|
||||
background-color: #374151;
|
||||
}
|
||||
.text-gray-900 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
.text-gray-700 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
.bg-gray-100:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
// el-upload 组件样式定制
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
:deep(.el-button) {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
83
web/src/assets/css/mobile/video.scss
Normal file
83
web/src/assets/css/mobile/video.scss
Normal file
@@ -0,0 +1,83 @@
|
||||
/* 来自 VideoCreate.vue 的样式,已迁移至此,供移动端页面使用 */
|
||||
|
||||
// 自定义动画
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fade-out 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-up {
|
||||
animation: scale-up 0.3s ease-out;
|
||||
}
|
||||
|
||||
// 文本截断
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg-gray-50 {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
.bg-white {
|
||||
background-color: #374151;
|
||||
}
|
||||
.text-gray-900 {
|
||||
color: #f9fafb;
|
||||
}
|
||||
.text-gray-700 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
.bg-gray-100:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
}
|
||||
353
web/src/store/mobile/suno.js
Normal file
353
web/src/store/mobile/suno.js
Normal file
@@ -0,0 +1,353 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { closeLoading, showLoading, showToastMessage } from '@/utils/dialog'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import { getSystemInfo } from '@/store/cache'
|
||||
|
||||
export const useSunoStore = defineStore('suno', () => {
|
||||
// 状态
|
||||
const custom = ref(false)
|
||||
const data = reactive({
|
||||
model: 'chirp-auk',
|
||||
tags: '',
|
||||
lyrics: '',
|
||||
prompt: '',
|
||||
title: '',
|
||||
instrumental: false,
|
||||
ref_task_id: '',
|
||||
extend_secs: 0,
|
||||
ref_song_id: '',
|
||||
type: 1,
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const btnText = ref('开始创作')
|
||||
const refSong = ref(null)
|
||||
const showModelPicker = ref(false)
|
||||
const showPlayer = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const currentAudio = ref('')
|
||||
const uploadFiles = ref([])
|
||||
const uploadRef = ref(null)
|
||||
const isGenerating = ref(false)
|
||||
const deleting = ref(false)
|
||||
const deleteItem = ref(null)
|
||||
const models = ref([
|
||||
{ label: 'v3.0', value: 'chirp-v3-0' },
|
||||
{ label: 'v3.5', value: 'chirp-v3-5' },
|
||||
{ label: 'v4.0', value: 'chirp-v4' },
|
||||
{ label: 'v4.5', value: 'chirp-auk' },
|
||||
])
|
||||
const tags = ref([
|
||||
{ label: '女声', value: 'female vocals' },
|
||||
{ label: '男声', value: 'male vocals' },
|
||||
{ label: '流行', value: 'pop' },
|
||||
{ label: '摇滚', value: 'rock' },
|
||||
{ label: '电音', value: 'electronic' },
|
||||
{ label: '钢琴', value: 'piano' },
|
||||
{ label: '吉他', value: 'guitar' },
|
||||
{ label: '嘻哈', value: 'hip hop' },
|
||||
])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
const sunoPowerCost = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
getSystemInfo().then((res) => {
|
||||
sunoPowerCost.value = res.data.suno_power
|
||||
})
|
||||
})
|
||||
|
||||
// 方法
|
||||
const onModelSelect = (selectedModel) => {
|
||||
data.model = selectedModel.value
|
||||
}
|
||||
const selectTag = (tag) => {
|
||||
if (data.tags.length + tag.value.length >= 119) {
|
||||
showToastMessage('标签长度超出限制', 'error')
|
||||
return
|
||||
}
|
||||
const currentTags = data.tags.split(',').filter((t) => t.trim())
|
||||
if (!currentTags.includes(tag.value)) {
|
||||
currentTags.push(tag.value)
|
||||
data.tags = currentTags.join(',')
|
||||
}
|
||||
}
|
||||
const createLyric = () => {
|
||||
if (data.lyrics === '') {
|
||||
showToastMessage('请输入歌词描述', 'error')
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
httpPost('/api/prompt/lyric', { prompt: data.lyrics })
|
||||
.then((res) => {
|
||||
const lines = res.data.split('\n')
|
||||
data.title = lines.shift().replace(/\*/g, '')
|
||||
lines.shift()
|
||||
data.lyrics = lines.join('\n')
|
||||
showToastMessage('歌词生成成功', 'success')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToastMessage('歌词生成失败:' + e.message, 'error')
|
||||
})
|
||||
.finally(() => {
|
||||
isGenerating.value = false
|
||||
})
|
||||
}
|
||||
const handleFileChange = (file) => {
|
||||
uploadFiles.value = [file]
|
||||
if (file.status === 'ready') {
|
||||
uploadAudio(file)
|
||||
}
|
||||
}
|
||||
const beforeUpload = (file) => {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
showToastMessage('文件大小不能超过 10MB!', 'error')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const uploadAudio = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.raw, file.name)
|
||||
showLoading('正在上传文件...')
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
httpPost('/api/suno/create', {
|
||||
audio_url: res.data.url,
|
||||
title: res.data.name,
|
||||
type: 4,
|
||||
})
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
showToastMessage('歌曲上传成功', 'success')
|
||||
removeRefSong()
|
||||
uploadFiles.value = []
|
||||
if (uploadRef.value) {
|
||||
uploadRef.value.clearFiles()
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
showToastMessage('歌曲上传失败:' + e.message, 'error')
|
||||
})
|
||||
.finally(() => {
|
||||
closeLoading()
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
showToastMessage('文件上传失败:' + e.message, 'error')
|
||||
})
|
||||
.finally(() => {
|
||||
closeLoading()
|
||||
})
|
||||
}
|
||||
const create = () => {
|
||||
data.type = custom.value ? 2 : 1
|
||||
data.ref_task_id = refSong.value ? refSong.value.task_id : ''
|
||||
data.ref_song_id = refSong.value ? refSong.value.song_id : ''
|
||||
data.extend_secs = refSong.value ? refSong.value.extend_secs : 0
|
||||
if (refSong.value) {
|
||||
if (data.extend_secs > refSong.value.duration) {
|
||||
showToastMessage('续写开始时间不能超过原歌曲长度', 'error')
|
||||
return
|
||||
}
|
||||
} else if (custom.value) {
|
||||
if (data.lyrics === '') {
|
||||
showToastMessage('请输入歌词', 'error')
|
||||
return
|
||||
}
|
||||
if (data.title === '') {
|
||||
showToastMessage('请输入歌曲标题', 'error')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (data.prompt === '') {
|
||||
showToastMessage('请输入歌曲描述', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
loading.value = true
|
||||
httpPost('/api/suno/create', data)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToastMessage('创建任务成功', 'success')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToastMessage('创建任务失败:' + e.message, 'error')
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/suno/list', { page: page.value, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
total.value = res.data.total
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
}
|
||||
listLoading.value = false
|
||||
taskPulling.value = needPull
|
||||
if (page.value === 1) {
|
||||
list.value = items
|
||||
} else {
|
||||
list.value.push(...items)
|
||||
}
|
||||
if (items.length < pageSize.value) {
|
||||
listFinished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
listLoading.value = false
|
||||
showToastMessage('获取作品列表失败:' + e.message, 'error')
|
||||
})
|
||||
}
|
||||
const loadMore = () => {
|
||||
if (!listFinished.value && !listLoading.value) {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
const refreshFirstPage = () => {
|
||||
const currentPage = page.value
|
||||
const currentList = [...list.value]
|
||||
httpGet('/api/suno/list', { page: 1, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
let needPull = false
|
||||
const firstPageItems = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
firstPageItems.push(v)
|
||||
}
|
||||
taskPulling.value = needPull
|
||||
if (currentPage === 1) {
|
||||
list.value = firstPageItems
|
||||
} else {
|
||||
const otherPagesData = currentList.slice(pageSize.value)
|
||||
list.value = [...firstPageItems, ...otherPagesData]
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('刷新第一页数据失败:', e)
|
||||
})
|
||||
}
|
||||
const play = (item) => {
|
||||
currentAudio.value = item.audio_url
|
||||
showPlayer.value = true
|
||||
}
|
||||
const download = (item) => {
|
||||
const url = replaceImg(item.audio_url)
|
||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
||||
const urlObj = new URL(url)
|
||||
const fileName = urlObj.pathname.split('/').pop()
|
||||
item.downloading = true
|
||||
httpDownload(downloadURL)
|
||||
.then((response) => {
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
item.downloading = false
|
||||
})
|
||||
.catch(() => {
|
||||
showToastMessage('下载失败', 'error')
|
||||
item.downloading = false
|
||||
})
|
||||
.finally(() => {
|
||||
item.downloading = false
|
||||
})
|
||||
}
|
||||
const showDeleteDialog = (item) => {
|
||||
deleteItem.value = item
|
||||
// 这里建议在页面层处理弹窗,store 只负责数据和业务
|
||||
}
|
||||
const extend = (item) => {
|
||||
refSong.value = item
|
||||
refSong.value.extend_secs = item.duration
|
||||
data.title = item.title
|
||||
custom.value = true
|
||||
btnText.value = '续写歌曲'
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
const removeRefSong = () => {
|
||||
refSong.value = null
|
||||
btnText.value = '开始创作'
|
||||
}
|
||||
|
||||
// 副作用(定时轮询、滚动监听)建议在页面层处理,store 只暴露方法
|
||||
|
||||
return {
|
||||
// 状态
|
||||
custom,
|
||||
data,
|
||||
loading,
|
||||
list,
|
||||
listLoading,
|
||||
listFinished,
|
||||
btnText,
|
||||
refSong,
|
||||
showModelPicker,
|
||||
showPlayer,
|
||||
showDeleteModal,
|
||||
currentAudio,
|
||||
uploadFiles,
|
||||
uploadRef,
|
||||
isGenerating,
|
||||
deleting,
|
||||
deleteItem,
|
||||
models,
|
||||
tags,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
taskPulling,
|
||||
tastPullHandler,
|
||||
sunoPowerCost,
|
||||
// 方法
|
||||
onModelSelect,
|
||||
selectTag,
|
||||
createLyric,
|
||||
handleFileChange,
|
||||
beforeUpload,
|
||||
uploadAudio,
|
||||
create,
|
||||
fetchData,
|
||||
loadMore,
|
||||
refreshFirstPage,
|
||||
play,
|
||||
download,
|
||||
showDeleteDialog,
|
||||
extend,
|
||||
removeRefSong,
|
||||
}
|
||||
})
|
||||
397
web/src/store/mobile/video.js
Normal file
397
web/src/store/mobile/video.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { showMessageOK, showMessageError, showLoading, closeLoading } from '@/utils/dialog'
|
||||
import { getSystemInfo } from '@/store/cache'
|
||||
|
||||
export const useVideoStore = defineStore('video', () => {
|
||||
// 状态
|
||||
const activeVideoType = ref('luma')
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const currentList = ref([])
|
||||
const showVideoDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
|
||||
// Luma 参数
|
||||
const lumaParams = reactive({
|
||||
prompt: '',
|
||||
image: '',
|
||||
image_tail: '',
|
||||
loop: false,
|
||||
expand_prompt: false,
|
||||
})
|
||||
const lumaUseImageMode = ref(false)
|
||||
const lumaStartImage = ref([])
|
||||
const lumaEndImage = ref([])
|
||||
|
||||
// KeLing 参数
|
||||
const kelingParams = reactive({
|
||||
aspect_ratio: '16:9',
|
||||
model: 'kling-v1-6',
|
||||
duration: '5',
|
||||
mode: 'std',
|
||||
cfg_scale: 0.5,
|
||||
prompt: '',
|
||||
negative_prompt: '',
|
||||
image: '',
|
||||
image_tail: '',
|
||||
camera_control: {
|
||||
type: '',
|
||||
config: {
|
||||
horizontal: 0,
|
||||
vertical: 0,
|
||||
pan: 0,
|
||||
tilt: 0,
|
||||
roll: 0,
|
||||
zoom: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
const kelingUseImageMode = ref(false)
|
||||
const kelingStartImage = ref([])
|
||||
const kelingEndImage = ref([])
|
||||
|
||||
// 选项数据
|
||||
const aspectRatioOptions = ['16:9', '9:16', '1:1', '4:3']
|
||||
const modelOptions = [
|
||||
{ label: '可灵 1.6', value: 'kling-v1-6' },
|
||||
{ label: '可灵 1.5', value: 'kling-v1-5' },
|
||||
{ label: '可灵 1.0', value: 'kling-v1' },
|
||||
]
|
||||
const durationOptions = ['5', '10']
|
||||
const modeOptions = ['std', 'pro']
|
||||
const cameraControlOptions = [
|
||||
'',
|
||||
'simple',
|
||||
'down_back',
|
||||
'forward_up',
|
||||
'right_turn_forward',
|
||||
'left_turn_forward',
|
||||
]
|
||||
const getCameraControlLabel = (option) => {
|
||||
const labelMap = {
|
||||
'': '请选择',
|
||||
simple: '简单运镜',
|
||||
down_back: '下移拉远',
|
||||
forward_up: '推进上移',
|
||||
right_turn_forward: '右旋推进',
|
||||
left_turn_forward: '左旋推进',
|
||||
}
|
||||
return labelMap[option] || option
|
||||
}
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const lumaPowerCost = ref(0)
|
||||
const kelingPowerCost = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const keLingPowers = ref({})
|
||||
|
||||
// 监听器:当可灵参数变化时更新算力
|
||||
watch(
|
||||
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
|
||||
() => {
|
||||
updateModelPower()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 方法
|
||||
const updateModelPower = () => {
|
||||
// 根据模型、模式、时长计算算力消耗
|
||||
const key = `${kelingParams.model}_${kelingParams.mode}_${kelingParams.duration}`
|
||||
kelingPowerCost.value = keLingPowers.value[key] || 10
|
||||
}
|
||||
watch(
|
||||
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
|
||||
() => {
|
||||
updateModelPower()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听器:当可灵参数变化时更新算力
|
||||
watch(
|
||||
() => [kelingParams.model, kelingParams.mode, kelingParams.duration],
|
||||
() => {
|
||||
updateModelPower()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const switchVideoType = (type) => {
|
||||
activeVideoType.value = type
|
||||
}
|
||||
const handleLumaStartImageUpload = (e) => {
|
||||
if (e.target.files[0]) {
|
||||
uploadLumaStartImage({ file: e.target.files[0], name: e.target.files[0].name })
|
||||
}
|
||||
}
|
||||
const handleLumaEndImageUpload = (e) => {
|
||||
if (e.target.files[0]) {
|
||||
uploadLumaEndImage({ file: e.target.files[0], name: e.target.files[0].name })
|
||||
}
|
||||
}
|
||||
const handleKelingStartImageUpload = (e) => {
|
||||
if (e.target.files[0]) {
|
||||
uploadKelingStartImage({ file: e.target.files[0], name: e.target.files[0].name })
|
||||
}
|
||||
}
|
||||
const handleKelingEndImageUpload = (e) => {
|
||||
if (e.target.files[0]) {
|
||||
uploadKelingEndImage({ file: e.target.files[0], name: e.target.files[0].name })
|
||||
}
|
||||
}
|
||||
|
||||
const generatePrompt = async () => {
|
||||
if (isGenerating.value) return
|
||||
|
||||
const prompt = activeVideoType.value === 'luma' ? lumaParams.prompt : kelingParams.prompt
|
||||
if (!prompt) {
|
||||
return showMessageError('请输入原始提示词')
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const res = await httpPost('/api/prompt/video', { prompt })
|
||||
if (activeVideoType.value === 'luma') {
|
||||
lumaParams.prompt = res.data
|
||||
} else {
|
||||
kelingParams.prompt = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
showMessageError('生成提示词失败:' + error.message)
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
const toggleLumaImageMode = () => {
|
||||
if (!lumaUseImageMode.value) {
|
||||
lumaParams.image = ''
|
||||
lumaParams.image_tail = ''
|
||||
lumaStartImage.value = []
|
||||
lumaEndImage.value = []
|
||||
}
|
||||
}
|
||||
const toggleKelingImageMode = () => {
|
||||
if (!kelingUseImageMode.value) {
|
||||
kelingParams.image = ''
|
||||
kelingParams.image_tail = ''
|
||||
kelingStartImage.value = []
|
||||
kelingEndImage.value = []
|
||||
}
|
||||
}
|
||||
const uploadLumaStartImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
lumaParams.image = url
|
||||
})
|
||||
}
|
||||
const uploadLumaEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
lumaParams.image_tail = url
|
||||
})
|
||||
}
|
||||
const uploadKelingStartImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.image = url
|
||||
})
|
||||
}
|
||||
const uploadKelingEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.image_tail = url
|
||||
})
|
||||
}
|
||||
const uploadImage = (file, callback) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showLoading('正在上传图片...')
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
callback(res.data.url)
|
||||
showMessageOK('图片上传成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('图片上传失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
closeLoading()
|
||||
})
|
||||
}
|
||||
const createLumaVideo = () => {
|
||||
if (!lumaParams.prompt.trim()) {
|
||||
showMessageError('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
const params = {
|
||||
...lumaParams,
|
||||
task_type: 'luma',
|
||||
}
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showMessageOK('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
generating.value = false
|
||||
})
|
||||
}
|
||||
const createKelingVideo = () => {
|
||||
if (!kelingParams.prompt.trim()) {
|
||||
showMessageError('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
const params = {
|
||||
...kelingParams,
|
||||
task_type: 'keling',
|
||||
}
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showMessageOK('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showMessageError('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
generating.value = false
|
||||
})
|
||||
}
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/video/list', { page: page.value, page_size: pageSize.value })
|
||||
.then((res) => {
|
||||
total.value = res.data.total
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
}
|
||||
listLoading.value = false
|
||||
taskPulling.value = needPull
|
||||
if (page.value === 1) {
|
||||
currentList.value = items
|
||||
} else {
|
||||
currentList.value.push(...items)
|
||||
}
|
||||
if (items.length < pageSize.value) {
|
||||
listFinished.value = true
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
listLoading.value = false
|
||||
showMessageError('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
const fetchUserPower = async () => {
|
||||
try {
|
||||
// 获取系统信息,更新算力配置
|
||||
const sysInfo = await getSystemInfo()
|
||||
lumaPowerCost.value = sysInfo.data.luma_power || 10
|
||||
keLingPowers.value = sysInfo.data.keling_powers || {}
|
||||
updateModelPower()
|
||||
} catch (error) {
|
||||
console.error('获取用户算力失败:', error)
|
||||
// 设置默认值
|
||||
lumaPowerCost.value = 10
|
||||
kelingPowerCost.value = 15
|
||||
}
|
||||
}
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
const playVideo = (item) => {
|
||||
currentVideoUrl.value = item.video_url
|
||||
showVideoDialog.value = true
|
||||
}
|
||||
const downloadVideo = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.video_url
|
||||
link.download = item.title || 'video.mp4'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showMessageOK('开始下载')
|
||||
}
|
||||
const removeJob = (item) => {
|
||||
// 建议在页面层处理弹窗,store 只负责数据和业务
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
activeVideoType,
|
||||
loading,
|
||||
generating,
|
||||
isGenerating,
|
||||
listLoading,
|
||||
listFinished,
|
||||
currentList,
|
||||
showVideoDialog,
|
||||
currentVideoUrl,
|
||||
lumaParams,
|
||||
lumaUseImageMode,
|
||||
lumaStartImage,
|
||||
lumaEndImage,
|
||||
kelingParams,
|
||||
kelingUseImageMode,
|
||||
kelingStartImage,
|
||||
kelingEndImage,
|
||||
aspectRatioOptions,
|
||||
modelOptions,
|
||||
durationOptions,
|
||||
modeOptions,
|
||||
cameraControlOptions,
|
||||
getCameraControlLabel,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
lumaPowerCost,
|
||||
kelingPowerCost,
|
||||
taskPulling,
|
||||
keLingPowers,
|
||||
// 方法
|
||||
updateModelPower,
|
||||
switchVideoType,
|
||||
handleLumaStartImageUpload,
|
||||
handleLumaEndImageUpload,
|
||||
handleKelingStartImageUpload,
|
||||
handleKelingEndImageUpload,
|
||||
generatePrompt,
|
||||
toggleLumaImageMode,
|
||||
toggleKelingImageMode,
|
||||
uploadLumaStartImage,
|
||||
uploadLumaEndImage,
|
||||
uploadKelingStartImage,
|
||||
uploadKelingEndImage,
|
||||
uploadImage,
|
||||
createLumaVideo,
|
||||
createKelingVideo,
|
||||
fetchData,
|
||||
fetchUserPower,
|
||||
loadMore,
|
||||
playVideo,
|
||||
downloadVideo,
|
||||
removeJob,
|
||||
}
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-blue-300 transition-colors"
|
||||
>
|
||||
<span class="text-gray-900">{{ selectedLabel || placeholder || '请选择' }}</span>
|
||||
<i class="iconfont icon-down text-gray-400"></i>
|
||||
<i class="iconfont icon-arrow-down text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- 选择器弹窗 -->
|
||||
|
||||
@@ -20,20 +20,22 @@
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-gray-900 font-medium">创作模式</span>
|
||||
<van-switch v-model="custom" @change="onModeChange" size="24px" />
|
||||
<van-switch v-model="suno.custom" @change="onModeChange" size="24px" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成' }}
|
||||
{{
|
||||
suno.custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<CustomSelect
|
||||
v-model="data.model"
|
||||
:options="models"
|
||||
v-model="suno.data.model"
|
||||
:options="suno.models"
|
||||
label="模型版本"
|
||||
title="选择模型"
|
||||
@change="onModelSelect"
|
||||
@change="suno.onModelSelect"
|
||||
>
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center w-full">
|
||||
@@ -53,31 +55,31 @@
|
||||
<span class="text-gray-900 font-medium">纯音乐</span>
|
||||
<p class="text-sm text-gray-500 mt-1">生成不包含人声的音乐</p>
|
||||
</div>
|
||||
<van-switch v-model="data.instrumental" size="24px" />
|
||||
<van-switch v-model="suno.data.instrumental" size="24px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模式内容 -->
|
||||
<div v-if="custom" class="space-y-6">
|
||||
<div v-if="suno.custom" class="space-y-6">
|
||||
<!-- 歌词输入 -->
|
||||
<div v-if="!data.instrumental" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div v-if="!suno.data.instrumental" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">歌词</label>
|
||||
<textarea
|
||||
v-model="data.lyrics"
|
||||
v-model="suno.data.lyrics"
|
||||
placeholder="请在这里输入你自己写的歌词..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="6"
|
||||
maxlength="2000"
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-sm text-gray-500">{{ data.lyrics.length }}/2000</span>
|
||||
<span class="text-sm text-gray-500">{{ suno.data.lyrics.length }}/2000</span>
|
||||
<button
|
||||
@click="createLyric"
|
||||
:disabled="isGenerating || !data.lyrics"
|
||||
@click="suno.createLyric"
|
||||
:disabled="suno.isGenerating || !suno.data.lyrics"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<span>{{ isGenerating ? '生成中...' : '生成歌词' }}</span>
|
||||
<i v-if="suno.isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<span>{{ suno.isGenerating ? '生成中...' : '生成歌词' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,21 +88,21 @@
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">音乐风格</label>
|
||||
<textarea
|
||||
v-model="data.tags"
|
||||
v-model="suno.data.tags"
|
||||
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="3"
|
||||
maxlength="120"
|
||||
/>
|
||||
<div class="flex justify-between items-center mt-2 mb-3">
|
||||
<span class="text-sm text-gray-500">{{ data.tags.length }}/120</span>
|
||||
<span class="text-sm text-gray-500">{{ suno.data.tags.length }}/120</span>
|
||||
</div>
|
||||
<!-- 风格标签选择 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
v-for="tag in suno.tags"
|
||||
:key="tag.value"
|
||||
@click="selectTag(tag)"
|
||||
@click="suno.selectTag(tag)"
|
||||
class="px-3 py-1 text-sm border border-blue-200 text-blue-600 rounded-full hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
{{ tag.label }}
|
||||
@@ -112,13 +114,13 @@
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">歌曲名称</label>
|
||||
<input
|
||||
v-model="data.title"
|
||||
v-model="suno.data.title"
|
||||
placeholder="请输入歌曲名称..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
maxlength="100"
|
||||
/>
|
||||
<div class="text-right mt-2">
|
||||
<span class="text-sm text-gray-500">{{ data.title.length }}/100</span>
|
||||
<span class="text-sm text-gray-500">{{ suno.data.title.length }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,26 +129,29 @@
|
||||
<div v-else class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">歌曲描述</label>
|
||||
<textarea
|
||||
v-model="data.prompt"
|
||||
v-model="suno.data.prompt"
|
||||
placeholder="例如:一首关于爱情的摇滚歌曲..."
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows="6"
|
||||
maxlength="1000"
|
||||
/>
|
||||
<div class="text-right mt-2">
|
||||
<span class="text-sm text-gray-500">{{ data.prompt.length }}/1000</span>
|
||||
<span class="text-sm text-gray-500">{{ suno.data.prompt.length }}/1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 续写歌曲 -->
|
||||
<div v-if="refSong" class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-orange-400">
|
||||
<div
|
||||
v-if="suno.refSong"
|
||||
class="bg-white rounded-xl p-4 shadow-sm border-l-4 border-orange-400"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-gray-900 font-medium flex items-center">
|
||||
<i class="iconfont icon-link mr-2 text-orange-500"></i>
|
||||
续写歌曲
|
||||
</h3>
|
||||
<button
|
||||
@click="removeRefSong"
|
||||
@click="suno.removeRefSong"
|
||||
class="px-3 py-1 text-sm bg-red-100 text-red-600 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
移除
|
||||
@@ -155,24 +160,25 @@
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">歌曲名称:</span>
|
||||
<span class="text-gray-900 font-medium">{{ refSong.title }}</span>
|
||||
<span class="text-gray-900 font-medium">{{ suno.refSong.title }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">歌曲时长:</span>
|
||||
<span class="text-gray-900 font-medium">{{ refSong.duration }}秒</span>
|
||||
<span class="text-gray-900 font-medium">{{ suno.refSong.duration }}秒</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-700 font-medium mb-2">续写开始时间(秒)</label>
|
||||
<input
|
||||
v-model="refSong.extend_secs"
|
||||
v-model="suno.refSong.extend_secs"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="refSong.duration"
|
||||
:max="suno.refSong.duration"
|
||||
placeholder="从第几秒开始续写"
|
||||
class="w-full px-4 py-3 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
建议从 {{ Math.floor(refSong.duration * 0.8) }}-{{ refSong.duration }} 秒开始续写
|
||||
建议从 {{ Math.floor(suno.refSong.duration * 0.8) }}-{{ suno.refSong.duration }}
|
||||
秒开始续写
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,12 +187,12 @@
|
||||
<!-- 生成按钮 -->
|
||||
<div class="sticky bottom-4 bg-white rounded-xl p-4 shadow-lg">
|
||||
<button
|
||||
@click="create"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
@click="suno.create"
|
||||
:disabled="suno.loading"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
|
||||
<span>{{ loading ? '创作中...' : btnText }}</span>
|
||||
<i v-if="suno.loading" class="iconfont icon-loading animate-spin"></i>
|
||||
<span>{{ suno.loading ? '创作中...' : suno.btnText }}({{ suno.sunoPowerCost }}算力)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -194,19 +200,21 @@
|
||||
<div class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<label class="block text-gray-700 font-medium mb-3">上传音乐文件</label>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
ref="suno.uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleFileChange"
|
||||
:before-upload="beforeUpload"
|
||||
:on-change="suno.handleFileChange"
|
||||
:before-upload="suno.beforeUpload"
|
||||
accept=".wav,.mp3"
|
||||
class="upload-area w-full"
|
||||
>
|
||||
<template #trigger>
|
||||
<el-button class="upload-btn w-full" size="large" type="primary">
|
||||
<button
|
||||
class="w-full py-3 bg-gradient-to-r from-purple-500 to-red-300 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i class="iconfont icon-upload mr-2"></i>
|
||||
<span>上传音乐</span>
|
||||
</el-button>
|
||||
</button>
|
||||
</template>
|
||||
</el-upload>
|
||||
<div class="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-500">
|
||||
@@ -223,7 +231,7 @@
|
||||
<div class="p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">我的作品</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-for="item in list" :key="item.id" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div v-for="item in suno.list" :key="item.id" class="bg-white rounded-xl p-4 shadow-sm">
|
||||
<div class="flex space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
|
||||
@@ -242,7 +250,7 @@
|
||||
<!-- 音乐播放按钮 -->
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="play(item)"
|
||||
@click="suno.play(item)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<i class="iconfont icon-play text-white text-xl"></i>
|
||||
@@ -332,7 +340,7 @@
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="play(item)"
|
||||
@click="suno.play(item)"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-1"
|
||||
>
|
||||
<i class="iconfont icon-play !text-xs"></i>
|
||||
@@ -340,7 +348,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="download(item)"
|
||||
@click="suno.download(item)"
|
||||
:disabled="item.downloading"
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 flex items-center space-x-1"
|
||||
>
|
||||
@@ -369,7 +377,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="extend(item)"
|
||||
@click="suno.extend(item)"
|
||||
class="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center min-w-[60px]"
|
||||
>
|
||||
<i class="iconfont icon-link !text-xs mr-1"></i>
|
||||
@@ -413,7 +421,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="listLoading" class="flex justify-center py-4">
|
||||
<div v-if="suno.listLoading" class="flex justify-center py-4">
|
||||
<svg class="w-6 h-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
@@ -432,7 +440,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 没有更多了 -->
|
||||
<div v-if="listFinished && !listLoading" class="text-center py-4 text-gray-500">
|
||||
<div v-if="suno.listFinished && !suno.listLoading" class="text-center py-4 text-gray-500">
|
||||
没有更多了
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,14 +448,14 @@
|
||||
|
||||
<!-- 音乐播放器 -->
|
||||
<div
|
||||
v-if="showPlayer"
|
||||
v-if="suno.showPlayer"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-50"
|
||||
@click="showPlayer = false"
|
||||
@click="suno.showPlayer = false"
|
||||
>
|
||||
<div @click.stop class="bg-white rounded-t-2xl w-full max-w-md animate-slide-up">
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-900">正在播放</h3>
|
||||
<button @click="showPlayer = false" class="p-2 hover:bg-gray-100 rounded-full">
|
||||
<button @click="suno.showPlayer = false" class="p-2 hover:bg-gray-100 rounded-full">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-500"
|
||||
fill="none"
|
||||
@@ -465,8 +473,8 @@
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<audio
|
||||
v-if="currentAudio"
|
||||
:src="currentAudio"
|
||||
v-if="suno.currentAudio"
|
||||
:src="suno.currentAudio"
|
||||
controls
|
||||
autoplay
|
||||
class="w-full rounded-lg"
|
||||
@@ -480,387 +488,55 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { closeLoading, showLoading, showToastMessage } from '@/utils/dialog'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSunoStore } from '@/store/mobile/suno'
|
||||
import CustomSelect from '@/views/mobile/components/CustomSelect.vue'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/css/mobile/suno.scss'
|
||||
|
||||
const router = useRouter()
|
||||
const suno = useSunoStore()
|
||||
|
||||
// 响应式数据
|
||||
const custom = ref(false)
|
||||
const data = ref({
|
||||
model: 'chirp-auk',
|
||||
tags: '',
|
||||
lyrics: '',
|
||||
prompt: '',
|
||||
title: '',
|
||||
instrumental: false,
|
||||
ref_task_id: '',
|
||||
extend_secs: 0,
|
||||
ref_song_id: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const btnText = ref('开始创作')
|
||||
const refSong = ref(null)
|
||||
const showModelPicker = ref(false)
|
||||
const showPlayer = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const currentAudio = ref('')
|
||||
const uploadFiles = ref([])
|
||||
const uploadRef = ref(null)
|
||||
const isGenerating = ref(false)
|
||||
const deleting = ref(false)
|
||||
const deleteItem = ref(null)
|
||||
|
||||
// 模型选项
|
||||
const models = ref([
|
||||
{ label: 'v3.0', value: 'chirp-v3-0' },
|
||||
{ label: 'v3.5', value: 'chirp-v3-5' },
|
||||
{ label: 'v4.0', value: 'chirp-v4' },
|
||||
{ label: 'v4.5', value: 'chirp-auk' },
|
||||
])
|
||||
|
||||
const onModelSelect = (selectedModel) => {
|
||||
data.value.model = selectedModel.value
|
||||
}
|
||||
|
||||
// 风格标签
|
||||
const tags = ref([
|
||||
{ label: '女声', value: 'female vocals' },
|
||||
{ label: '男声', value: 'male vocals' },
|
||||
{ label: '流行', value: 'pop' },
|
||||
{ label: '摇滚', value: 'rock' },
|
||||
{ label: '电音', value: 'electronic' },
|
||||
{ label: '钢琴', value: 'piano' },
|
||||
{ label: '吉他', value: 'guitar' },
|
||||
{ label: '嘻哈', value: 'hip hop' },
|
||||
])
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
|
||||
// 滚动监听,自动加载更多
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// 当滚动到底部附近时加载更多
|
||||
if (scrollTop + windowHeight >= documentHeight - 100) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
// 启动定时轮询,检查任务状态
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
// 只刷新第一页数据,用于检查任务状态变化
|
||||
refreshFirstPage()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// 添加滚动监听
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
// 移除滚动监听
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
// 方法
|
||||
// 页面专属方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onModeChange = () => {
|
||||
if (!custom.value) {
|
||||
removeRefSong()
|
||||
if (!suno.custom) {
|
||||
suno.removeRefSong()
|
||||
}
|
||||
}
|
||||
|
||||
const onModelConfirm = (value) => {
|
||||
const selectedModel = models.value.find((item) => item.label === value)
|
||||
if (selectedModel) {
|
||||
data.value.model = selectedModel.value
|
||||
}
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
const selectTag = (tag) => {
|
||||
if (data.value.tags.length + tag.value.length >= 119) {
|
||||
showToastMessage('标签长度超出限制', 'error')
|
||||
return
|
||||
}
|
||||
const currentTags = data.value.tags.split(',').filter((t) => t.trim())
|
||||
if (!currentTags.includes(tag.value)) {
|
||||
currentTags.push(tag.value)
|
||||
data.value.tags = currentTags.join(',')
|
||||
// 滚动监听、定时轮询等副作用
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
if (scrollTop + windowHeight >= documentHeight - 100) {
|
||||
suno.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
const createLyric = () => {
|
||||
if (data.value.lyrics === '') {
|
||||
showToastMessage('请输入歌词描述', 'error')
|
||||
return
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user