diff --git a/.claude/commands/refactor.md b/.claude/commands/refactor.md index 951fefa0..73932cb5 100644 --- a/.claude/commands/refactor.md +++ b/.claude/commands/refactor.md @@ -3,3 +3,4 @@ 1. 把当前页面 JS 代码全部抽离,然后是采用 Pinia 重构 2. 把当前页面 CSS 代码全部抽离,如果是 stylus 语法代码,则需要改成 SCSS 语法代码 3. 尽量做到代码的复用性,不要重复造轮子 +4. 移动端的 css 和 js 分别放到对应的 mobile 目录下,不要覆盖 PC 端的代码 diff --git a/web/src/assets/css/mobile/suno.scss b/web/src/assets/css/mobile/suno.scss new file mode 100644 index 00000000..f7cad1dd --- /dev/null +++ b/web/src/assets/css/mobile/suno.scss @@ -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; + } +} diff --git a/web/src/assets/css/mobile/video.scss b/web/src/assets/css/mobile/video.scss new file mode 100644 index 00000000..4473e66c --- /dev/null +++ b/web/src/assets/css/mobile/video.scss @@ -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; + } +} diff --git a/web/src/store/mobile/suno.js b/web/src/store/mobile/suno.js new file mode 100644 index 00000000..c701f06a --- /dev/null +++ b/web/src/store/mobile/suno.js @@ -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, + } +}) diff --git a/web/src/store/mobile/video.js b/web/src/store/mobile/video.js new file mode 100644 index 00000000..4e3e13c9 --- /dev/null +++ b/web/src/store/mobile/video.js @@ -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, + } +}) diff --git a/web/src/views/mobile/components/CustomSelect.vue b/web/src/views/mobile/components/CustomSelect.vue index b52fc5f6..905fadf3 100644 --- a/web/src/views/mobile/components/CustomSelect.vue +++ b/web/src/views/mobile/components/CustomSelect.vue @@ -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" > {{ selectedLabel || placeholder || '请选择' }} - + diff --git a/web/src/views/mobile/pages/SunoCreate.vue b/web/src/views/mobile/pages/SunoCreate.vue index fd0b38ab..e36f5a24 100644 --- a/web/src/views/mobile/pages/SunoCreate.vue +++ b/web/src/views/mobile/pages/SunoCreate.vue @@ -20,20 +20,22 @@
创作模式 - +

- {{ custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成' }} + {{ + suno.custom ? '自定义模式:可设置歌词、风格等详细参数' : '简单模式:通过描述快速生成' + }}