mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-26 04:54:28 +08:00
完成移动端邀请页面功能
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mobile-sd">
|
||||
<van-form @submit="generate">
|
||||
<van-cell-group inset>
|
||||
<van-cell-group class="px-3 pt-3 pb-4">
|
||||
<div>
|
||||
<van-field
|
||||
v-model="selectedModel"
|
||||
|
||||
@@ -376,10 +376,10 @@ const params = ref({
|
||||
model: models[0].value,
|
||||
chaos: 0,
|
||||
stylize: 0,
|
||||
seed: 0,
|
||||
seed: -1,
|
||||
img_arr: [],
|
||||
raw: false,
|
||||
iw: 0,
|
||||
iw: 0.7,
|
||||
prompt: '',
|
||||
neg_prompt: '',
|
||||
tile: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mobile-sd">
|
||||
<van-form @submit="generate">
|
||||
<van-cell-group inset>
|
||||
<van-cell-group class="px-3 pt-3 pb-4">
|
||||
<div>
|
||||
<van-field
|
||||
v-model="params.sampler"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="img-wall container">
|
||||
<div class="img-wall p-3">
|
||||
<div class="content">
|
||||
<van-tabs v-model:active="activeName" animated sticky>
|
||||
<van-tab title="MJ" name="mj">
|
||||
<CustomTabs v-model="activeName" @tab-click="handleTabClick">
|
||||
<TabPane name="mj" label="Midjourney">
|
||||
<van-list
|
||||
v-model:error="data['mj'].error"
|
||||
v-model:loading="data['mj'].loading"
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
<van-tab title="SD" name="sd">
|
||||
</TabPane>
|
||||
<TabPane name="sd" label="Stable Diffusion">
|
||||
<van-list
|
||||
v-model:error="data['sd'].error"
|
||||
v-model:loading="data['sd'].loading"
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
<van-tab title="DALL" name="dall">
|
||||
</TabPane>
|
||||
<TabPane name="dall" label="DALL">
|
||||
<van-list
|
||||
v-model:error="data['dall'].error"
|
||||
v-model:loading="data['dall'].loading"
|
||||
@@ -62,8 +62,8 @@
|
||||
</div>
|
||||
</van-cell>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</TabPane>
|
||||
</CustomTabs>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -83,6 +83,8 @@ import Clipboard from 'clipboard'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { showConfirmDialog, showFailToast, showImagePreview, showNotify } from 'vant'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import TabPane from '@/components/ui/CustomTabPane.vue'
|
||||
|
||||
const activeName = ref('mj')
|
||||
const data = ref({
|
||||
@@ -117,6 +119,13 @@ const data = ref({
|
||||
|
||||
const prompt = ref('')
|
||||
const clipboard = ref(null)
|
||||
|
||||
// 处理 tab 点击事件
|
||||
const handleTabClick = (tabName, index) => {
|
||||
// 可以在这里添加额外的 tab 切换逻辑
|
||||
console.log('Tab clicked:', tabName, index)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clipboard.value = new Clipboard('.copy-prompt-wall')
|
||||
clipboard.value.on('success', () => {
|
||||
|
||||
693
web/src/views/mobile/pages/JimengCreate.vue
Normal file
693
web/src/views/mobile/pages/JimengCreate.vue
Normal file
@@ -0,0 +1,693 @@
|
||||
<template>
|
||||
<div class="mobile-jimeng-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="即梦AI" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 功能分类选择 -->
|
||||
<div class="category-section">
|
||||
<van-tabs v-model="activeCategory" @change="onCategoryChange">
|
||||
<van-tab title="图像生成" name="image_generation">
|
||||
<div class="tab-content">
|
||||
<!-- 生成模式切换 -->
|
||||
<van-cell title="生成模式">
|
||||
<template #value>
|
||||
<van-switch v-model="useImageInput" size="24" @change="onInputModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="图生图人像写真" :value="useImageInput ? '开启' : '关闭'" />
|
||||
|
||||
<!-- 文生图 -->
|
||||
<div v-if="activeFunction === 'text_to_image'" class="function-panel">
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="请输入图片描述,越详细越好"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="textToImageParams.size"
|
||||
label="图片尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
|
||||
<van-cell title="创意度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="textToImageParams.scale"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="0.5"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell title="智能优化提示词">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="textToImageParams.use_pre_llm" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 图生图 -->
|
||||
<div v-if="activeFunction === 'image_to_image'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageToImageParams.image_input"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的图片效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageToImageParams.size"
|
||||
label="图片尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="图像编辑" name="image_editing">
|
||||
<div class="tab-content">
|
||||
<!-- 图像编辑 -->
|
||||
<div v-if="activeFunction === 'image_edit'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageEditParams.image_urls"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="编辑提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的编辑效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-cell title="编辑强度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="imageEditParams.scale"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 图像特效 -->
|
||||
<div v-if="activeFunction === 'image_effects'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageEffectsParams.image_input1"
|
||||
:max-count="1"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="imageEffectsParams.template_id"
|
||||
label="特效模板"
|
||||
readonly
|
||||
is-link
|
||||
@click="showTemplatePicker = true"
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageEffectsParams.size"
|
||||
label="输出尺寸"
|
||||
readonly
|
||||
is-link
|
||||
@click="showSizePicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="视频生成" name="video_generation">
|
||||
<div class="tab-content">
|
||||
<!-- 生成模式切换 -->
|
||||
<van-cell title="生成模式">
|
||||
<template #value>
|
||||
<van-switch v-model="useImageInput" size="24" @change="onInputModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="图生视频" :value="useImageInput ? '开启' : '关闭'" />
|
||||
|
||||
<!-- 文生视频 -->
|
||||
<div v-if="activeFunction === 'text_to_video'" class="function-panel">
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的视频内容"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="textToVideoParams.aspect_ratio"
|
||||
label="视频比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图生视频 -->
|
||||
<div v-if="activeFunction === 'image_to_video'" class="function-panel">
|
||||
<van-uploader
|
||||
v-model="imageToVideoParams.image_urls"
|
||||
:max-count="2"
|
||||
:after-read="onImageUpload"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
multiple
|
||||
>
|
||||
<van-button icon="plus" type="primary" block> 上传图片 </van-button>
|
||||
</van-uploader>
|
||||
|
||||
<van-field
|
||||
v-model="currentPrompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="描述你想要的视频效果"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="imageToVideoParams.aspect_ratio"
|
||||
label="视频比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="submit-section">
|
||||
<van-button type="primary" size="large" @click="submitTask" :loading="submitting" block>
|
||||
立即生成 ({{ currentPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in currentList" :key="item.id" class="work-item">
|
||||
<van-card
|
||||
:title="getFunctionName(item.type)"
|
||||
:desc="item.prompt"
|
||||
:thumb="item.img_url || item.video_url"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag :type="getTaskType(item.type)" size="small">
|
||||
{{ getFunctionName(item.type) }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.power" type="warning" size="small">
|
||||
{{ item.power }}算力
|
||||
</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.status === 'completed'" size="small" @click="playMedia(item)">
|
||||
{{ item.type.includes('video') ? '播放' : '查看' }}
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.status === 'completed'"
|
||||
size="small"
|
||||
@click="downloadFile(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button v-if="item.status === 'failed'" size="small" @click="retryTask(item.id)">
|
||||
重试
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 各种选择器弹窗 -->
|
||||
<van-popup v-model:show="showSizePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="imageSizeOptions"
|
||||
@confirm="onSizeConfirm"
|
||||
@cancel="showSizePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showAspectRatioPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="videoAspectRatioOptions"
|
||||
@confirm="onAspectRatioConfirm"
|
||||
@cancel="showAspectRatioPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showTemplatePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="imageEffectsTemplateOptions"
|
||||
@confirm="onTemplateConfirm"
|
||||
@cancel="showTemplatePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 媒体预览弹窗 -->
|
||||
<van-popup
|
||||
v-model:show="showMediaDialog"
|
||||
position="center"
|
||||
:style="{ width: '90%', height: '60%' }"
|
||||
>
|
||||
<div class="media-preview">
|
||||
<img
|
||||
v-if="currentMediaUrl && !currentMediaUrl.includes('video')"
|
||||
:src="currentMediaUrl"
|
||||
style="width: 100%; height: 100%; object-fit: contain"
|
||||
/>
|
||||
<video
|
||||
v-else-if="currentMediaUrl"
|
||||
:src="currentMediaUrl"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; height: 100%"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const activeCategory = ref('image_generation')
|
||||
const useImageInput = ref(false)
|
||||
const submitting = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const listFinished = ref(false)
|
||||
const currentList = ref([])
|
||||
const showMediaDialog = ref(false)
|
||||
const currentMediaUrl = ref('')
|
||||
|
||||
// 选择器相关
|
||||
const showSizePicker = ref(false)
|
||||
const showAspectRatioPicker = ref(false)
|
||||
const showTemplatePicker = ref(false)
|
||||
|
||||
// 当前提示词
|
||||
const currentPrompt = ref('')
|
||||
|
||||
// 功能参数
|
||||
const textToImageParams = ref({
|
||||
size: '1024x1024',
|
||||
scale: 7.5,
|
||||
use_pre_llm: false,
|
||||
})
|
||||
|
||||
const imageToImageParams = ref({
|
||||
image_input: [],
|
||||
size: '1024x1024',
|
||||
})
|
||||
|
||||
const imageEditParams = ref({
|
||||
image_urls: [],
|
||||
scale: 0.5,
|
||||
})
|
||||
|
||||
const imageEffectsParams = ref({
|
||||
image_input1: [],
|
||||
template_id: '',
|
||||
size: '1024x1024',
|
||||
})
|
||||
|
||||
const textToVideoParams = ref({
|
||||
aspect_ratio: '16:9',
|
||||
})
|
||||
|
||||
const imageToVideoParams = ref({
|
||||
image_urls: [],
|
||||
aspect_ratio: '16:9',
|
||||
})
|
||||
|
||||
// 选项数据
|
||||
const imageSizeOptions = ['512x512', '768x768', '1024x1024', '1024x1536', '1536x1024']
|
||||
|
||||
const videoAspectRatioOptions = ['16:9', '9:16', '1:1', '4:3']
|
||||
|
||||
const imageEffectsTemplateOptions = [
|
||||
'acrylic_ornaments',
|
||||
'angel_figurine',
|
||||
'felt_3d_polaroid',
|
||||
'watercolor_illustration',
|
||||
]
|
||||
|
||||
// 页面数据
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const currentPowerCost = ref(0)
|
||||
const taskPulling = ref(true)
|
||||
const tastPullHandler = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const activeFunction = computed(() => {
|
||||
if (activeCategory.value === 'image_generation') {
|
||||
return useImageInput.value ? 'image_to_image' : 'text_to_image'
|
||||
} else if (activeCategory.value === 'image_editing') {
|
||||
return 'image_edit' // 可以根据需要添加更多编辑功能
|
||||
} else if (activeCategory.value === 'video_generation') {
|
||||
return useImageInput.value ? 'image_to_video' : 'text_to_video'
|
||||
}
|
||||
return 'text_to_image'
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onCategoryChange = (name) => {
|
||||
activeCategory.value = name
|
||||
useImageInput.value = false
|
||||
}
|
||||
|
||||
const onInputModeChange = () => {
|
||||
// 重置相关参数
|
||||
currentPrompt.value = ''
|
||||
}
|
||||
|
||||
const onImageUpload = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传图片...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
showToast('图片上传成功')
|
||||
return res.data.url
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('图片上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const submitTask = () => {
|
||||
if (!currentPrompt.value.trim()) {
|
||||
showToast('请输入提示词')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const params = {
|
||||
type: activeFunction.value,
|
||||
prompt: currentPrompt.value,
|
||||
}
|
||||
|
||||
// 根据功能类型添加相应参数
|
||||
if (activeFunction.value === 'text_to_image') {
|
||||
Object.assign(params, textToImageParams.value)
|
||||
} else if (activeFunction.value === 'image_to_image') {
|
||||
Object.assign(params, imageToImageParams.value)
|
||||
} else if (activeFunction.value === 'image_edit') {
|
||||
Object.assign(params, imageEditParams.value)
|
||||
} else if (activeFunction.value === 'image_effects') {
|
||||
Object.assign(params, imageEffectsParams.value)
|
||||
} else if (activeFunction.value === 'text_to_video') {
|
||||
Object.assign(params, textToVideoParams.value)
|
||||
} else if (activeFunction.value === 'image_to_video') {
|
||||
Object.assign(params, imageToVideoParams.value)
|
||||
}
|
||||
|
||||
httpPost('/api/jimeng/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
currentPrompt.value = ''
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
submitting.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchData = (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
listLoading.value = true
|
||||
httpGet('/api/jimeng/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.status === 'in_queue' || v.status === 'generating') {
|
||||
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
|
||||
showToast('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const playMedia = (item) => {
|
||||
currentMediaUrl.value = item.img_url || item.video_url
|
||||
showMediaDialog.value = true
|
||||
}
|
||||
|
||||
const downloadFile = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.img_url || item.video_url
|
||||
link.download = item.title || 'file'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const retryTask = (id) => {
|
||||
httpPost('/api/jimeng/retry', { id })
|
||||
.then(() => {
|
||||
showToast('重试任务成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('重试任务失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/jimeng/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 工具方法
|
||||
const getFunctionName = (type) => {
|
||||
const nameMap = {
|
||||
text_to_image: '文生图',
|
||||
image_to_image: '图生图',
|
||||
image_edit: '图像编辑',
|
||||
image_effects: '图像特效',
|
||||
text_to_video: '文生视频',
|
||||
image_to_video: '图生视频',
|
||||
}
|
||||
return nameMap[type] || type
|
||||
}
|
||||
|
||||
const getTaskType = (type) => {
|
||||
return type.includes('video') ? 'warning' : 'primary'
|
||||
}
|
||||
|
||||
// 选择器确认方法
|
||||
const onSizeConfirm = (value) => {
|
||||
if (activeFunction.value === 'text_to_image') {
|
||||
textToImageParams.value.size = value
|
||||
} else if (activeFunction.value === 'image_to_image') {
|
||||
imageToImageParams.value.size = value
|
||||
} else if (activeFunction.value === 'image_effects') {
|
||||
imageEffectsParams.value.size = value
|
||||
}
|
||||
showSizePicker.value = false
|
||||
}
|
||||
|
||||
const onAspectRatioConfirm = (value) => {
|
||||
if (activeFunction.value === 'text_to_video') {
|
||||
textToVideoParams.value.aspect_ratio = value
|
||||
} else if (activeFunction.value === 'image_to_video') {
|
||||
imageToVideoParams.value.aspect_ratio = value
|
||||
}
|
||||
showAspectRatioPicker.value = false
|
||||
}
|
||||
|
||||
const onTemplateConfirm = (value) => {
|
||||
imageEffectsParams.value.template_id = value
|
||||
showTemplatePicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-jimeng-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
|
||||
.function-panel {
|
||||
.van-uploader {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
689
web/src/views/mobile/pages/SunoCreate.vue
Normal file
689
web/src/views/mobile/pages/SunoCreate.vue
Normal file
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div class="mobile-suno-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="音乐创作" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 创作表单 -->
|
||||
<div class="create-form">
|
||||
<!-- 模式切换 -->
|
||||
<div class="mode-switch">
|
||||
<van-cell-group>
|
||||
<van-cell title="创作模式">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="custom" size="24" @change="onModeChange" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="自定义模式" :value="custom ? '开启' : '关闭'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<div class="model-select">
|
||||
<van-field
|
||||
v-model="selectedModelLabel"
|
||||
label="模型"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModelPicker = true"
|
||||
placeholder="请选择模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 纯音乐开关 -->
|
||||
<div class="pure-music">
|
||||
<van-cell title="纯音乐">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="data.instrumental" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</div>
|
||||
|
||||
<!-- 自定义模式内容 -->
|
||||
<div v-if="custom">
|
||||
<!-- 歌词输入 -->
|
||||
<div v-if="!data.instrumental" class="lyrics-section">
|
||||
<van-field
|
||||
v-model="data.lyrics"
|
||||
label="歌词"
|
||||
type="textarea"
|
||||
placeholder="请在这里输入你自己写的歌词..."
|
||||
rows="6"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="createLyric"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成歌词
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 音乐风格 -->
|
||||
<div class="style-section">
|
||||
<van-field
|
||||
v-model="data.tags"
|
||||
label="音乐风格"
|
||||
type="textarea"
|
||||
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..."
|
||||
rows="3"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
/>
|
||||
<!-- 风格标签选择 -->
|
||||
<div class="style-tags">
|
||||
<van-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.value"
|
||||
type="primary"
|
||||
plain
|
||||
size="medium"
|
||||
@click="selectTag(tag)"
|
||||
class="mr-2 mb-2"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲名称 -->
|
||||
<div class="title-section">
|
||||
<van-field
|
||||
v-model="data.title"
|
||||
label="歌曲名称"
|
||||
placeholder="请输入歌曲名称..."
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简单模式内容 -->
|
||||
<div v-else>
|
||||
<van-field
|
||||
v-model="data.prompt"
|
||||
label="歌曲描述"
|
||||
type="textarea"
|
||||
placeholder="例如:一首关于爱情的摇滚歌曲..."
|
||||
rows="6"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 续写歌曲 -->
|
||||
<div v-if="refSong" class="ref-song">
|
||||
<van-cell title="续写歌曲">
|
||||
<template #value>
|
||||
<van-button type="danger" size="small" @click="removeRefSong"> 移除 </van-button>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="歌曲名称" :value="refSong.title" />
|
||||
<van-field
|
||||
v-model="refSong.extend_secs"
|
||||
label="续写开始时间(秒)"
|
||||
type="number"
|
||||
placeholder="从第几秒开始续写"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传音乐 -->
|
||||
<div class="upload-section">
|
||||
<div class="upload-area">
|
||||
<van-uploader
|
||||
v-model="uploadFiles"
|
||||
:max-count="1"
|
||||
:after-read="uploadAudio"
|
||||
accept=".wav,.mp3"
|
||||
:preview-size="80"
|
||||
:preview-image="false"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" size="24" />
|
||||
<span>上传音乐文件</span>
|
||||
<small>支持 .wav, .mp3 格式</small>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<div class="submit-section">
|
||||
<van-button type="primary" size="large" @click="create" :loading="loading" block>
|
||||
{{ btnText }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in list" :key="item.id" class="work-item">
|
||||
<van-card
|
||||
:title="item.title || '未命名歌曲'"
|
||||
:desc="item.tags || item.prompt"
|
||||
:thumb="item.cover_url"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag v-if="item.major_model_version" type="primary">
|
||||
{{ item.major_model_version }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.type === 4" type="success">用户上传</van-tag>
|
||||
<van-tag v-if="item.type === 3" type="warning">完整歌曲</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.progress === 100" size="small" @click="play(item)">
|
||||
播放
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.progress === 100"
|
||||
size="small"
|
||||
@click="download(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择弹窗 -->
|
||||
<van-popup v-model:show="showModelPicker" position="bottom" round>
|
||||
<van-picker
|
||||
:columns="modelOptions"
|
||||
@confirm="onModelConfirm"
|
||||
@cancel="showModelPicker = false"
|
||||
title="选择模型"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 音乐播放器 -->
|
||||
<van-popup v-model:show="showPlayer" position="bottom" round :style="{ height: '40%' }">
|
||||
<div class="player-content">
|
||||
<div class="player-header">
|
||||
<h3>正在播放</h3>
|
||||
<van-icon name="cross" @click="showPlayer = false" />
|
||||
</div>
|
||||
<audio v-if="currentAudio" :src="currentAudio" controls autoplay class="w-full" />
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
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 currentAudio = ref('')
|
||||
const uploadFiles = ref([])
|
||||
const isGenerating = ref(false)
|
||||
|
||||
// 模型选项
|
||||
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 modelOptions = models.value.map((item) => item.label)
|
||||
|
||||
// 计算当前选中的模型标签
|
||||
const selectedModelLabel = computed(() => {
|
||||
const selectedModel = models.value.find((item) => item.value === data.value.model)
|
||||
return selectedModel ? selectedModel.label : ''
|
||||
})
|
||||
|
||||
// 风格标签
|
||||
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)
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
tastPullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(1)
|
||||
}
|
||||
}, 5000)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tastPullHandler.value) {
|
||||
clearInterval(tastPullHandler.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const onModeChange = () => {
|
||||
if (!custom.value) {
|
||||
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) {
|
||||
showToast('标签长度超出限制')
|
||||
return
|
||||
}
|
||||
const currentTags = data.value.tags.split(',').filter((t) => t.trim())
|
||||
if (!currentTags.includes(tag.value)) {
|
||||
currentTags.push(tag.value)
|
||||
data.value.tags = currentTags.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
const createLyric = () => {
|
||||
if (data.value.lyrics === '') {
|
||||
showToast('请输入歌词描述')
|
||||
return
|
||||
}
|
||||
isGenerating.value = true
|
||||
httpPost('/api/prompt/lyric', { prompt: data.value.lyrics })
|
||||
.then((res) => {
|
||||
const lines = res.data.split('\n')
|
||||
data.value.title = lines.shift().replace(/\*/g, '')
|
||||
lines.shift()
|
||||
data.value.lyrics = lines.join('\n')
|
||||
showToast('歌词生成成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('歌词生成失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
isGenerating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const uploadAudio = (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传文件...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
httpPost('/api/suno/create', {
|
||||
audio_url: res.data.url,
|
||||
title: res.data.name,
|
||||
type: 4,
|
||||
})
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
showToast('歌曲上传成功')
|
||||
removeRefSong()
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('歌曲上传失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('文件上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
showToast('续写开始时间不能超过原歌曲长度')
|
||||
return
|
||||
}
|
||||
} else if (custom.value) {
|
||||
if (data.value.lyrics === '') {
|
||||
showToast('请输入歌词')
|
||||
return
|
||||
}
|
||||
if (data.value.title === '') {
|
||||
showToast('请输入歌曲标题')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (data.value.prompt === '') {
|
||||
showToast('请输入歌曲描述')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
httpPost('/api/suno/create', data.value)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.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
|
||||
showToast('获取作品列表失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const play = (item) => {
|
||||
currentAudio.value = item.audio_url
|
||||
showPlayer.value = true
|
||||
}
|
||||
|
||||
const download = (item) => {
|
||||
item.downloading = true
|
||||
const link = document.createElement('a')
|
||||
link.href = item.audio_url
|
||||
link.download = item.title || 'song.mp3'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
item.downloading = false
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/suno/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const removeRefSong = () => {
|
||||
refSong.value = null
|
||||
btnText.value = '开始创作'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-suno-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.mode-switch {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pure-music {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lyrics-section,
|
||||
.style-section,
|
||||
.title-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.style-tags {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ref-song {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.upload-area {
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 12px;
|
||||
color: #6c757d;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--van-primary-color);
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.van-icon {
|
||||
margin-bottom: 8px;
|
||||
color: var(--van-primary-color);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.player-content {
|
||||
padding: 20px;
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.van-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色主题适配
|
||||
:deep(.van-theme-dark) {
|
||||
.mobile-suno-create {
|
||||
background: #1a1a1a;
|
||||
|
||||
.create-form {
|
||||
background: #2a2a2a;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ref-song {
|
||||
background: #333;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.upload-area .upload-placeholder {
|
||||
background: #333;
|
||||
border-color: #555;
|
||||
|
||||
&:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--van-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-list .work-item {
|
||||
background: #2a2a2a;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
792
web/src/views/mobile/pages/VideoCreate.vue
Normal file
792
web/src/views/mobile/pages/VideoCreate.vue
Normal file
@@ -0,0 +1,792 @@
|
||||
<template>
|
||||
<div class="mobile-video-create">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<van-nav-bar title="视频生成" left-arrow @click-left="goBack" fixed placeholder />
|
||||
</div>
|
||||
|
||||
<!-- 视频类型切换 -->
|
||||
<div class="video-type-tabs">
|
||||
<van-tabs v-model="activeVideoType" @change="onVideoTypeChange">
|
||||
<van-tab title="Luma视频" name="luma">
|
||||
<div class="tab-content">
|
||||
<!-- Luma 视频参数 -->
|
||||
<div class="params-container">
|
||||
<!-- 提示词输入 -->
|
||||
<van-field
|
||||
v-model="lumaParams.prompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
placeholder="请在此输入视频提示词,用逗号分割"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 提示词生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="generatePrompt"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成AI视频提示词
|
||||
</van-button>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<van-cell title="使用图片辅助生成">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaUseImageMode" size="24" @change="toggleLumaImageMode" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="lumaUseImageMode" class="image-upload-section">
|
||||
<div class="image-upload-row">
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="lumaStartImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadLumaStartImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>起始帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="lumaEndImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadLumaEndImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>结束帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luma 特有参数 -->
|
||||
<van-cell title="循环参考图">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaParams.loop" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<van-cell title="提示词优化">
|
||||
<template #right-icon>
|
||||
<van-switch v-model="lumaParams.expand_prompt" size="24" />
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 算力显示 -->
|
||||
<van-cell title="当前可用算力" :value="`${availablePower}`" />
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="createLumaVideo"
|
||||
:loading="generating"
|
||||
block
|
||||
class="mt-4"
|
||||
>
|
||||
立即生成 ({{ lumaPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="可灵视频" name="keling">
|
||||
<div class="tab-content">
|
||||
<!-- KeLing 视频参数 -->
|
||||
<div class="params-container">
|
||||
<!-- 画面比例 -->
|
||||
<van-field
|
||||
v-model="kelingParams.aspect_ratio"
|
||||
label="画面比例"
|
||||
readonly
|
||||
is-link
|
||||
@click="showAspectRatioPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<van-field
|
||||
v-model="kelingParams.model"
|
||||
label="模型选择"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModelPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 视频时长 -->
|
||||
<van-field
|
||||
v-model="kelingParams.duration"
|
||||
label="视频时长"
|
||||
readonly
|
||||
is-link
|
||||
@click="showDurationPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 生成模式 -->
|
||||
<van-field
|
||||
v-model="kelingParams.mode"
|
||||
label="生成模式"
|
||||
readonly
|
||||
is-link
|
||||
@click="showModePicker = true"
|
||||
/>
|
||||
|
||||
<!-- 创意程度 -->
|
||||
<van-cell title="创意程度">
|
||||
<template #value>
|
||||
<van-slider
|
||||
v-model="kelingParams.cfg_scale"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 运镜控制 -->
|
||||
<van-field
|
||||
v-model="kelingParams.camera_control.type"
|
||||
label="运镜控制"
|
||||
readonly
|
||||
is-link
|
||||
@click="showCameraControlPicker = true"
|
||||
/>
|
||||
|
||||
<!-- 图片辅助生成开关 -->
|
||||
<van-cell title="使用图片辅助生成">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="kelingUseImageMode"
|
||||
size="24"
|
||||
@change="toggleKelingImageMode"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
|
||||
<!-- 图片上传区域 -->
|
||||
<div v-if="kelingUseImageMode" class="image-upload-section">
|
||||
<div class="image-upload-row">
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="kelingStartImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadKelingStartImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>起始帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
<div class="image-upload-item">
|
||||
<van-uploader
|
||||
v-model="kelingEndImage"
|
||||
:max-count="1"
|
||||
:after-read="uploadKelingEndImage"
|
||||
accept=".jpg,.png,.jpeg"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<van-icon name="plus" />
|
||||
<span>结束帧</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词输入 -->
|
||||
<van-field
|
||||
v-model="kelingParams.prompt"
|
||||
label="提示词"
|
||||
type="textarea"
|
||||
:placeholder="kelingUseImageMode ? '描述视频画面细节' : '请在此输入视频提示词'"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 提示词生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="generatePrompt"
|
||||
:loading="isGenerating"
|
||||
block
|
||||
class="mt-2"
|
||||
>
|
||||
生成专业视频提示词
|
||||
</van-button>
|
||||
|
||||
<!-- 排除内容 -->
|
||||
<van-field
|
||||
v-model="kelingParams.negative_prompt"
|
||||
label="不希望出现的内容"
|
||||
type="textarea"
|
||||
placeholder="请在此输入你不希望出现在视频上的内容"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<!-- 算力显示 -->
|
||||
<van-cell title="当前可用算力" :value="`${availablePower}`" />
|
||||
|
||||
<!-- 生成按钮 -->
|
||||
<van-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="createKelingVideo"
|
||||
:loading="generating"
|
||||
block
|
||||
class="mt-4"
|
||||
>
|
||||
立即生成 ({{ kelingPowerCost }}算力)
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="works-list">
|
||||
<van-list
|
||||
v-model:loading="listLoading"
|
||||
:finished="listFinished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div v-for="item in currentList" :key="item.id" class="work-item">
|
||||
<van-card :title="item.title || '未命名视频'" :desc="item.prompt" :thumb="item.cover_url">
|
||||
<template #tags>
|
||||
<van-tag v-if="item.raw_data?.task_type" type="primary">
|
||||
{{ item.raw_data.task_type }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.raw_data?.model" type="success">
|
||||
{{ item.raw_data.model }}
|
||||
</van-tag>
|
||||
<van-tag v-if="item.raw_data?.duration" type="warning">
|
||||
{{ item.raw_data.duration }}秒
|
||||
</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button v-if="item.progress === 100" size="small" @click="playVideo(item)">
|
||||
播放
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="item.progress === 100"
|
||||
size="small"
|
||||
@click="downloadVideo(item)"
|
||||
:loading="item.downloading"
|
||||
>
|
||||
下载
|
||||
</van-button>
|
||||
<van-button size="small" type="danger" @click="removeJob(item)"> 删除 </van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
|
||||
<!-- 各种选择器弹窗 -->
|
||||
<van-popup v-model:show="showAspectRatioPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="aspectRatioOptions"
|
||||
@confirm="onAspectRatioConfirm"
|
||||
@cancel="showAspectRatioPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showModelPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="modelOptions"
|
||||
@confirm="onModelConfirm"
|
||||
@cancel="showModelPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showDurationPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="durationOptions"
|
||||
@confirm="onDurationConfirm"
|
||||
@cancel="showDurationPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showModePicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="modeOptions"
|
||||
@confirm="onModeConfirm"
|
||||
@cancel="showModePicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<van-popup v-model:show="showCameraControlPicker" position="bottom">
|
||||
<van-picker
|
||||
:columns="cameraControlOptions"
|
||||
@confirm="onCameraControlConfirm"
|
||||
@cancel="showCameraControlPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<!-- 视频预览弹窗 -->
|
||||
<van-popup
|
||||
v-model:show="showVideoDialog"
|
||||
position="center"
|
||||
:style="{ width: '90%', height: '60%' }"
|
||||
>
|
||||
<div class="video-preview">
|
||||
<video
|
||||
v-if="currentVideoUrl"
|
||||
:src="currentVideoUrl"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; height: 100%"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { checkSession } from '@/store/cache'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
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 showAspectRatioPicker = ref(false)
|
||||
const showModelPicker = ref(false)
|
||||
const showDurationPicker = ref(false)
|
||||
const showModePicker = ref(false)
|
||||
const showCameraControlPicker = ref(false)
|
||||
|
||||
// 选项数据
|
||||
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 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 onVideoTypeChange = (name) => {
|
||||
activeVideoType.value = name
|
||||
}
|
||||
|
||||
const generatePrompt = () => {
|
||||
isGenerating.value = true
|
||||
// TODO: 实现提示词生成逻辑
|
||||
setTimeout(() => {
|
||||
isGenerating.value = false
|
||||
showToast('提示词生成功能开发中')
|
||||
}, 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
|
||||
})
|
||||
}
|
||||
|
||||
const uploadLumaEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
lumaParams.value.image_tail = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKelingStartImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.value.image = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadKelingEndImage = (file) => {
|
||||
uploadImage(file, (url) => {
|
||||
kelingParams.value.image_tail = url
|
||||
})
|
||||
}
|
||||
|
||||
const uploadImage = (file, callback) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showToast({ message: '正在上传图片...', duration: 0 })
|
||||
|
||||
httpPost('/api/upload', formData)
|
||||
.then((res) => {
|
||||
callback(res.data.url)
|
||||
showToast('图片上传成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('图片上传失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const createLumaVideo = () => {
|
||||
if (!lumaParams.value.prompt.trim()) {
|
||||
showToast('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
const params = {
|
||||
...lumaParams.value,
|
||||
task_type: 'luma',
|
||||
}
|
||||
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + e.message)
|
||||
})
|
||||
.finally(() => {
|
||||
generating.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const createKelingVideo = () => {
|
||||
if (!kelingParams.value.prompt.trim()) {
|
||||
showToast('请输入视频提示词')
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
const params = {
|
||||
...kelingParams.value,
|
||||
task_type: 'keling',
|
||||
}
|
||||
|
||||
httpPost('/api/video/create', params)
|
||||
.then(() => {
|
||||
fetchData(1)
|
||||
taskPulling.value = true
|
||||
showToast('创建任务成功')
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('创建任务失败:' + 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
|
||||
showToast('获取作品列表失败:' + 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
|
||||
showToast('开始下载')
|
||||
}
|
||||
|
||||
const removeJob = (item) => {
|
||||
showDialog({
|
||||
title: '确认删除',
|
||||
message: '此操作将会删除任务相关文件,继续操作吗?',
|
||||
showCancelButton: true,
|
||||
})
|
||||
.then(() => {
|
||||
httpGet('/api/video/remove', { id: item.id })
|
||||
.then(() => {
|
||||
showToast('任务删除成功')
|
||||
fetchData(1)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast('任务删除失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// 选择器确认方法
|
||||
const onAspectRatioConfirm = (value) => {
|
||||
kelingParams.value.aspect_ratio = value
|
||||
showAspectRatioPicker.value = false
|
||||
}
|
||||
|
||||
const onModelConfirm = (value) => {
|
||||
kelingParams.value.model = value
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
const onDurationConfirm = (value) => {
|
||||
kelingParams.value.duration = value
|
||||
showDurationPicker.value = false
|
||||
}
|
||||
|
||||
const onModeConfirm = (value) => {
|
||||
kelingParams.value.mode = value
|
||||
showModePicker.value = false
|
||||
}
|
||||
|
||||
const onCameraControlConfirm = (value) => {
|
||||
kelingParams.value.camera_control.type = value
|
||||
showCameraControlPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mobile-video-create {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
|
||||
.page-header {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.video-type-tabs {
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.tab-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.params-container {
|
||||
.image-upload-section {
|
||||
margin: 16px 0;
|
||||
|
||||
.image-upload-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.image-upload-item {
|
||||
flex: 1;
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
background: #f5f5f5;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
|
||||
.van-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.works-list {
|
||||
margin: 12px;
|
||||
|
||||
.work-item {
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user