fix: 新需求 对接火山seedance

This commit is contained in:
old burden 2026-03-27 15:28:19 +08:00
parent 8959b775a4
commit abb8d279c4
2 changed files with 848 additions and 0 deletions

View File

@ -0,0 +1,412 @@
<template>
<div class="rich-editor-root">
<!-- 工具栏 -->
<div class="toolbar">
<button class="tool-btn" type="button" @click="execCommand('bold')"><b>B</b></button>
<button class="tool-btn" type="button" @click="execCommand('italic')"><i>I</i></button>
<button class="tool-btn" type="button" @click="openImagePicker">{{ $t('common.insertImage') || '插入图片' }}</button>
<button class="tool-btn" type="button" @click="clear">清空</button>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
class="hidden-input"
type="file"
accept="image/*"
multiple
@change="handleSelectFiles"
/>
<!-- 编辑器区域 -->
<div
ref="editorRef"
class="user-input rich-editor"
contenteditable="true"
:data-placeholder="placeholder || '请输入文本...'"
@input="handleInput"
@keyup="handleKeyup"
@click="handleEditorClick"
@keydown="handleKeydown"
></div>
<!-- @ 图片选择面板 -->
<div v-if="mentionVisible" class="mention-panel">
<div
v-for="(item, idx) in mentionImageList"
:key="item.url"
class="mention-item"
@mousedown.prevent="insertMentionImage(item)"
>
<img :src="item.url" class="mention-thumb" />
<span class="mention-label">@{{ idx + 1 }}</span>
</div>
<div v-if="mentionImageList.length === 0" class="mention-empty">
{{ $t('common.noImageToMention') || '暂无可引用图片' }}
</div>
</div>
</div>
</template>
<script>
import { nextTick, onMounted, ref, watch } from 'vue'
import { uploadFile } from '@/utils/file'
export default {
name: 'RichTextEditor',
props: {
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入文本生成视频...'
},
uploadedImages: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'text-change', 'image-upload'],
setup(props, { emit }) {
const editorRef = ref(null)
const fileInputRef = ref(null)
const savedSelectionRange = ref(null)
const mentionVisible = ref(false)
const mentionImageList = ref([])
const MAX_IMAGE_COUNT = 4
//
const execCommand = (command) => {
if (!editorRef.value) return
editorRef.value.focus()
document.execCommand(command, false, null)
handleInput()
}
//
const openImagePicker = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// -
const handleSelectFiles = async (event) => {
const files = event.target.files
if (!files.length) return
for (let file of files) {
if (file.type.startsWith('image/')) {
try {
//
const loading = window.$message ? window.$message.loading('上传中...') : null
//
const res = await uploadFile({
url: '/api/cos/upload', // 使COS
file: file,
name: 'file'
})
if (res && res.code === 200 && res.data) {
const imageUrl = typeof res.data === 'string' ? res.data : (res.data.url || res.data)
insertImage(imageUrl, file.name)
// @
emit('image-upload', { url: imageUrl, name: file.name })
} else {
console.error('上传失败:', res)
alert('图片上传失败')
}
} catch (error) {
console.error('上传图片出错:', error)
alert('图片上传失败: ' + (error.message || '未知错误'))
}
}
}
// input
event.target.value = ''
}
//
const insertImage = (url, name = 'image') => {
if (!editorRef.value) return
editorRef.value.focus()
const selection = window.getSelection()
if (!selection) return
const img = document.createElement('img')
img.src = url
img.alt = name
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.style.margin = '4px 0'
img.setAttribute('data-image-name', name)
const range = selection.getRangeAt(0)
range.deleteContents()
range.insertNode(img)
//
const space = document.createTextNode(' ')
range.setStartAfter(img)
range.setEndAfter(img)
range.insertNode(space)
handleInput()
}
//
const handleInput = () => {
if (!editorRef.value) return
const content = editorRef.value.innerHTML
emit('update:modelValue', content)
emit('text-change', content)
}
//
const handleKeyup = (e) => {
if (e.key === '@') {
showMentionPanel()
} else if (e.key === 'Escape' && mentionVisible.value) {
mentionVisible.value = false
}
}
const handleKeydown = (e) => {
if (e.key === '@') {
//
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
savedSelectionRange.value = selection.getRangeAt(0).cloneRange()
}
}
}
// @
const showMentionPanel = () => {
if (props.uploadedImages && props.uploadedImages.length > 0) {
mentionImageList.value = props.uploadedImages.slice(0, MAX_IMAGE_COUNT)
mentionVisible.value = true
}
}
// @
const insertMentionImage = (imageItem) => {
if (!editorRef.value || !savedSelectionRange.value) {
mentionVisible.value = false
return
}
editorRef.value.focus()
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
selection.addRange(savedSelectionRange.value)
}
const img = document.createElement('img')
img.src = imageItem.url
img.alt = '@图片'
img.style.maxWidth = '60px'
img.style.height = 'auto'
img.style.verticalAlign = 'middle'
img.setAttribute('data-mention', 'true')
const range = savedSelectionRange.value
range.deleteContents()
range.insertNode(img)
//
const space = document.createTextNode(' ')
range.setStartAfter(img)
range.setEndAfter(img)
range.insertNode(space)
mentionVisible.value = false
handleInput()
}
//
const clear = () => {
if (!editorRef.value) return
editorRef.value.innerHTML = ''
handleInput()
}
//
const handleEditorClick = () => {
if (mentionVisible.value) {
mentionVisible.value = false
}
}
// props
watch(() => props.modelValue, (newValue) => {
if (editorRef.value && editorRef.value.innerHTML !== newValue) {
editorRef.value.innerHTML = newValue || ''
}
})
//
watch(() => props.uploadedImages, (newImages) => {
mentionImageList.value = newImages || []
}, { immediate: true })
onMounted(() => {
if (editorRef.value) {
editorRef.value.innerHTML = props.modelValue || ''
//
editorRef.value.addEventListener('paste', (e) => {
const items = e.clipboardData.items
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const blob = items[i].getAsFile()
const reader = new FileReader()
reader.onload = (event) => {
insertImage(event.target.result, 'pasted-image')
}
reader.readAsDataURL(blob)
e.preventDefault()
break
}
}
})
}
})
return {
editorRef,
fileInputRef,
mentionVisible,
mentionImageList,
execCommand,
openImagePicker,
handleSelectFiles,
handleInput,
handleKeyup,
handleKeydown,
handleEditorClick,
insertMentionImage,
clear
}
}
}
</script>
<style lang="less" scoped>
.rich-editor-root {
position: relative;
margin-bottom: 16px;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 8px;
padding: 8px;
background: #1a1b20;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
.tool-btn {
padding: 6px 12px;
background: rgba(255,255,255,0.1);
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-size: 14px;
&:hover {
background: rgba(255,255,255,0.2);
}
}
}
.rich-editor {
min-height: 120px;
padding: 12px;
background: #1a1b20;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
line-height: 1.6;
font-size: 14px;
outline: none;
overflow-y: auto;
&:empty:before {
content: attr(data-placeholder);
color: #666;
pointer-events: none;
}
img {
max-width: 100%;
height: auto;
margin: 4px 0;
border-radius: 4px;
}
}
.mention-panel {
position: absolute;
top: 100%;
left: 0;
background: #1f2128;
border: 1px solid #3a3d47;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
width: 280px;
.mention-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.1);
&:hover {
background: #2a2d38;
}
&:last-child {
border-bottom: none;
}
.mention-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
margin-right: 12px;
}
.mention-label {
color: #a1a4b3;
font-size: 13px;
}
}
.mention-empty {
padding: 20px;
text-align: center;
color: #666;
font-size: 13px;
}
}
.hidden-input {
display: none;
}
</style>

View File

@ -0,0 +1,436 @@
<template>
<div :class="prefixCls">
<div class="left">
<!-- 模式切换 -->
<div class="mode-tabs">
<button
:class="['mode-tab', { active: mode === 'text-to-video' }]"
@click="switchMode('text-to-video')">
文生视频
</button>
<button
:class="['mode-tab', { active: mode === 'image-to-video' }]"
@click="switchMode('image-to-video')">
图生视频
</button>
</div>
<!-- 上传区域 - 根据模式显示 -->
<div class="upload" v-if="mode === 'image-to-video'">
<div class="upload-title">
<div class="upload-title-left">
{{ $t('common.uploadFirstImage') || '上传参考图' }}
</div>
<div class="upload-title-tip">
{{ $t('common.uploadImageTip') || '支持PNG/JPG最大10MB' }}
</div>
</div>
<mf-image-upload
listType="picture-card"
uploadHeight="180px"
:show-file-list="false"
:content="$t('common.uploadFirstPlaceholder') || '点击上传参考图片'"
v-model="firstUrl" />
<!-- 可选上传尾帧 -->
<div class="last-frame" v-if="showLastFrame">
<div class="upload-title-left">
{{ $t('common.uploadLastPlaceholder') || '上传尾帧(可选)' }}
</div>
<mf-image-upload
listType="picture-card"
uploadHeight="120px"
:show-file-list="false"
:content="$t('common.uploadLastPlaceholder') || '点击上传尾帧'"
v-model="lastUrl" />
</div>
</div>
<!-- 模型选择 -->
<div class="model-select">
<div class="model-title">{{ $t('common.selectModel') || '选择模型' }}</div>
<a-select v-model="selectedModel" style="width: 100%;">
<a-option
v-for="option in modelOptions"
:key="option.value"
:value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<!-- 富文本编辑器 - 加大输入框 -->
<div class="rich-editor-container">
<RichTextEditor
v-model="editorContent"
:placeholder="$t('common.textVideoPlaceholder') || '描述视频内容,例如:一个女孩在海边跳舞...'"
:uploaded-images="uploadedImages"
@text-change="handleTextChange"
@image-upload="handleImageUpload"
/>
</div>
<mf-button
class="submit"
type="primary"
size="large"
:loading="generateLoading"
@click="generateVideo">
{{
price
? `${$t('common.createVideo', { price: price })}`
: $t('common.generateVideo')
}}
</mf-button>
<div class="submit-tip">
{{ $t('common.generateTip') || '生成视频需要消耗余额' }}
</div>
</div>
<!-- 右侧结果区 -->
<div class="right" :class="{ 'has-result': showResult }">
<div v-if="!showResult" class="empty-state">
<img src="/images/empty-video.png" alt="视频生成" />
<p>生成的视频将在这里显示</p>
</div>
<div v-else class="result-container">
<div class="result-video-wrapper">
<video
v-if="videoUrl"
:src="videoUrl"
controls
autoplay
class="result-video"
:poster="firstUrl?.url || ''">
您的浏览器不支持视频播放
</video>
</div>
<div class="result-actions">
<mf-button v-if="canCancel" @click="cancelTask" type="danger">
取消任务
</mf-button>
<mf-button @click="saveVideo" :disabled="!videoUrl">
<a-image src="/images/btn_save.png" /> 保存视频
</mf-button>
<mf-button @click="closeResult">
<a-image src="/images/btn_close.png" /> 关闭
</mf-button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import RichTextEditor from '@/components/RichTextEditor.vue'
export default {
name: 'VideoGen',
data() {
return {
prefixCls: 'video-gen',
mode: 'text-to-video', // 'text-to-video' 'image-to-video'
firstUrl: '',
lastUrl: '',
editorContent: '',
interval: null,
videoUrl: null,
videoLoading: false,
generateLoading: false,
videoId: null,
showResult: false,
price: null,
id: null,
modelOptions: [
{ label: 'Seedance 2.0', value: 'ep-20260326165811-dlkth' },
{ label: 'Seedance 2.0 Fast', value: 'ep-20260326170056-dkj9m' }
],
selectedModel: 'ep-20260326165811-dlkth',
uploadedImages: [],
showLastFrame: false,
maxPollAttempts: 40
}
},
components: {
RichTextEditor
},
computed: {
...mapGetters(['lang'])
},
mounted() {
this.loadPriceInfo()
},
methods: {
async loadPriceInfo() {
this.$axios({
url: 'api/manager/selectInfo',
method: 'GET',
data: { aiType: '21' }
}).then((res) => {
this.price = res.data?.price
this.id = res.data?.id
})
},
handleTextChange(content) {
this.editorContent = content
},
handleImageUpload(imageInfo) {
if (imageInfo && imageInfo.url) {
this.uploadedImages.push({
url: imageInfo.url,
name: imageInfo.name || 'image'
})
}
},
generateVideo() {
if (this.mode === 'image-to-video') {
const firstImageUrl = typeof this.firstUrl === 'object' ? this.firstUrl.url : this.firstUrl
if (!firstImageUrl) {
this.$message.error(this.$t('common.uploadFirstImageError') || '请上传参考图')
return
}
} else if (!this.editorContent.trim()) {
this.$message.error('请输入视频描述文本')
return
}
this.generateLoading = true
const params = {
text: this.editorContent || '一个优雅的女孩在阳光下跳舞',
functionType: '21',
model: this.selectedModel
}
//
if (this.mode === 'image-to-video') {
const firstImageUrl = typeof this.firstUrl === 'object' ? this.firstUrl.url : this.firstUrl
params.firstUrl = firstImageUrl
if (this.lastUrl) {
params.lastUrl = typeof this.lastUrl === 'object' ? this.lastUrl.url : this.lastUrl
}
}
this.$axios({
url: 'api/ai/imgToVideo',
method: 'POST',
data: params
}).then((res) => {
this.generateLoading = false
if (res.code == 200) {
this.videoId = res.data.id
this.showResult = true
this.getVideo(res.data.id)
} else if (res.code == -1) {
this.$confirm({
title: this.$t('common.notice'),
content: this.$t('common.balenceLow'),
onOk: () => this.$router.push('/recharge')
})
}
}).catch(() => {
this.generateLoading = false
})
},
getVideo(videoId) {
let attempts = 0
this.interval = setInterval(() => {
attempts++
if (attempts > this.maxPollAttempts) {
this.$message.warning('视频生成超时')
this.destroyInterval()
return
}
this.$axios({
url: `api/ai/${videoId}`,
method: 'GET'
}).then((res) => {
if (res.code == 200 && res.data.status === 'succeeded') {
this.videoUrl = res.data.content?.video_url || res.data.video_url
this.destroyInterval()
}
})
}, 3000)
},
destroyInterval() {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
},
saveVideo() {
if (!this.videoUrl) return
const link = document.createElement('a')
link.href = this.videoUrl
link.download = `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
},
closeResult() {
this.showResult = false
this.videoUrl = null
this.videoId = null
this.taskStatus = null
},
//
async cancelTask() {
if (!this.videoId) return
this.$confirm({
title: '取消任务',
content: '确定要取消当前视频生成任务吗?取消后余额将退回。',
okText: '确定取消',
cancelText: '关闭',
onOk: async () => {
try {
const res = await this.$axios({
url: `api/ai/${this.videoId}/cancel`,
method: 'POST'
})
if (res.code === 200) {
this.$message.success('任务已取消,余额已退回')
this.destroyInterval()
this.showResult = false
this.videoUrl = null
this.videoId = null
} else {
this.$message.error(res.msg || '取消失败')
}
} catch (error) {
this.$message.error('取消请求失败')
}
}
})
},
//
canCancel() {
return this.videoId && !this.videoUrl
}
},
beforeUnmount() {
this.destroyInterval()
}
}
</script>
<style lang="less" scoped>
.video-gen {
display: flex;
height: 100%;
gap: 20px;
padding: 20px;
background: #0f1014;
.left {
width: 480px;
background: #1a1b20;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
}
.page-title {
font-size: 22px;
color: #fff;
margin-bottom: 24px;
font-weight: 600;
.subtitle {
font-size: 14px;
color: #666;
margin-left: 12px;
font-weight: normal;
}
}
.model-select {
margin: 16px 0;
.model-title {
color: #ddd;
margin-bottom: 8px;
font-size: 14px;
}
}
.rich-editor-container {
flex: 1;
min-height: 320px;
margin: 16px 0;
display: flex;
flex-direction: column;
:deep(.rich-editor-root) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.rich-editor) {
flex: 1;
min-height: 280px !important;
font-size: 16px;
line-height: 1.75;
padding: 20px;
}
}
.submit {
margin-top: 16px;
}
.right {
flex: 1;
background: #000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&.has-result {
align-items: flex-start;
padding: 20px;
}
}
.empty-state {
text-align: center;
color: #666;
img {
width: 120px;
opacity: 0.6;
}
}
.result-video-wrapper {
width: 100%;
max-width: 720px;
}
.result-video {
width: 100%;
border-radius: 8px;
}
}
</style>