send-stream
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -17,152 +17,73 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// 当前显示的内容(用于打字机效果)
|
||||
const displayedContent = ref('')
|
||||
// 目标内容(完整内容)
|
||||
const targetContent = ref('')
|
||||
// 动画帧 ID
|
||||
let animationFrameId = null
|
||||
// 打字机速度(字符/帧,可根据性能调整)
|
||||
const TYPING_SPEED = 3
|
||||
// 是否正在执行打字机动画
|
||||
const isTyping = ref(false)
|
||||
|
||||
/**
|
||||
* 高性能打字机效果渲染
|
||||
* 使用 requestAnimationFrame 实现平滑的逐字符显示
|
||||
*/
|
||||
function typewriterEffect() {
|
||||
if (!isTyping.value) return
|
||||
|
||||
const currentLength = displayedContent.value.length
|
||||
const targetLength = targetContent.value.length
|
||||
|
||||
if (currentLength >= targetLength) {
|
||||
// 已完成,停止动画
|
||||
isTyping.value = false
|
||||
displayedContent.value = targetContent.value
|
||||
return
|
||||
}
|
||||
|
||||
// 每次增加多个字符以提高性能
|
||||
const nextLength = Math.min(currentLength + TYPING_SPEED, targetLength)
|
||||
displayedContent.value = targetContent.value.slice(0, nextLength)
|
||||
|
||||
// 继续下一帧
|
||||
animationFrameId = requestAnimationFrame(typewriterEffect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始打字机效果
|
||||
*/
|
||||
function startTypewriter() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
|
||||
isTyping.value = true
|
||||
animationFrameId = requestAnimationFrame(typewriterEffect)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止打字机效果
|
||||
*/
|
||||
function stopTypewriter() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
isTyping.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算渲染的 HTML 内容
|
||||
*/
|
||||
// 当前渲染的内容(避免重复渲染)
|
||||
const currentContent = ref('')
|
||||
// 渲染的 HTML 内容
|
||||
const renderedContent = ref('')
|
||||
|
||||
/**
|
||||
* 更新渲染内容
|
||||
* 只有当内容真正改变时才更新,避免重复渲染
|
||||
*/
|
||||
function updateRenderedContent() {
|
||||
const content = displayedContent.value
|
||||
const content = currentContent.value
|
||||
|
||||
// 避免重复渲染相同内容
|
||||
if (content === renderedContent.value.replace(/<[^>]*>/g, '')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
renderedContent.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 渲染 markdown 为 HTML
|
||||
renderedContent.value = renderMarkdown(content)
|
||||
}
|
||||
|
||||
// 监听 displayedContent 变化,更新渲染
|
||||
watch(displayedContent, () => {
|
||||
updateRenderedContent()
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* 处理内容更新
|
||||
* 普通流式渲染:直接显示所有内容,不使用打字机效果
|
||||
*/
|
||||
function handleContentUpdate(newContent) {
|
||||
if (!newContent) {
|
||||
targetContent.value = ''
|
||||
displayedContent.value = ''
|
||||
stopTypewriter()
|
||||
currentContent.value = ''
|
||||
updateRenderedContent()
|
||||
return
|
||||
}
|
||||
|
||||
// 更新目标内容
|
||||
targetContent.value = newContent
|
||||
|
||||
if (props.isStreaming) {
|
||||
// 流式传输模式:使用打字机效果显示内容
|
||||
// 流式传输时,内容会逐步到达,使用打字机效果增强体验
|
||||
const currentLength = displayedContent.value.length
|
||||
const newLength = newContent.length
|
||||
|
||||
if (newLength !== currentLength) {
|
||||
// 内容发生变化
|
||||
if (newLength < currentLength) {
|
||||
// 内容被重置或缩短,直接显示新内容
|
||||
displayedContent.value = newContent
|
||||
stopTypewriter()
|
||||
} else {
|
||||
// 内容增加,使用打字机效果显示新增部分
|
||||
if (!isTyping.value) {
|
||||
startTypewriter()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 静态内容模式:直接显示全部内容,不使用打字机效果
|
||||
displayedContent.value = newContent
|
||||
stopTypewriter()
|
||||
}
|
||||
|
||||
// 更新当前内容
|
||||
currentContent.value = newContent
|
||||
updateRenderedContent()
|
||||
}
|
||||
|
||||
// 监听 content 变化
|
||||
// 监听 content 变化,使用防抖处理避免频繁更新
|
||||
let updateTimeout = null
|
||||
watch(() => props.content, (newContent) => {
|
||||
handleContentUpdate(newContent)
|
||||
}, { immediate: true })
|
||||
// 清除之前的定时器
|
||||
if (updateTimeout) {
|
||||
clearTimeout(updateTimeout)
|
||||
}
|
||||
|
||||
// 延迟更新,避免流式传输时频繁更新导致的性能问题
|
||||
updateTimeout = setTimeout(() => {
|
||||
handleContentUpdate(newContent)
|
||||
}, 50) // 50ms 防抖
|
||||
})
|
||||
|
||||
// 监听 isStreaming 变化
|
||||
watch(() => props.isStreaming, (newVal, oldVal) => {
|
||||
if (!newVal && oldVal) {
|
||||
// 流式传输结束,确保显示完整内容,停止打字机效果
|
||||
if (targetContent.value) {
|
||||
displayedContent.value = targetContent.value
|
||||
}
|
||||
stopTypewriter()
|
||||
} else if (newVal) {
|
||||
// 开始流式传输,如果内容有变化,启动打字机效果
|
||||
// 打字机效果会在 handleContentUpdate 中根据内容变化自动启动
|
||||
// 流式传输结束时,确保显示完整内容
|
||||
if (!newVal && oldVal && props.content) {
|
||||
currentContent.value = props.content
|
||||
updateRenderedContent()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
stopTypewriter()
|
||||
})
|
||||
// 立即渲染初始内容
|
||||
handleContentUpdate(props.content)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -55,7 +55,6 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{ path: '/realtime-hot', name: '实时热点推送', component: () => import('../views/realtime/RealtimeHot.vue') },
|
||||
{ path: '/mix-editor', name: '素材混剪', component: () => import('../views/mix/MixEditor.vue') },
|
||||
{ path: '/capcut-import', name: '剪映导入', component: () => import('../views/capcut/CapcutImport.vue') },
|
||||
{ path: '/help', name: '帮助', component: () => import('../views/misc/Help.vue') },
|
||||
{ path: '/download', name: '下载', component: () => import('../views/misc/Download.vue') },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import storage from '@/utils/storage'
|
||||
|
||||
const STORAGE_KEY = 'cosy_voice_profiles'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export const useVoiceCopyStore = defineStore('voiceCopy', {
|
||||
state: () => ({
|
||||
profiles: [],
|
||||
activeId: '',
|
||||
loaded: false,
|
||||
loading: false
|
||||
}),
|
||||
getters: {
|
||||
activeProfile(state) {
|
||||
@@ -15,48 +15,158 @@ export const useVoiceCopyStore = defineStore('voiceCopy', {
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
generateId() {
|
||||
return `${Date.now()}_${Math.floor(Math.random() * 1e6)}`
|
||||
},
|
||||
/**
|
||||
* 加载配音列表
|
||||
*/
|
||||
async load() {
|
||||
if (this.loaded) return
|
||||
const list = await storage.getJSON(STORAGE_KEY, [])
|
||||
this.profiles = Array.isArray(list) ? list : []
|
||||
if (!this.activeId && this.profiles.length) this.activeId = this.profiles[0].id
|
||||
this.loaded = true
|
||||
},
|
||||
async persist() {
|
||||
await storage.setJSON(STORAGE_KEY, this.profiles)
|
||||
if (this.loaded && !this.loading) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await VoiceService.getPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100 // 加载所有数据
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
this.profiles = (res.data.list || []).map((item) => ({
|
||||
...item,
|
||||
voiceId: item.voiceId || '',
|
||||
transcription: item.transcription || '',
|
||||
fileUrl: item.fileUrl || ''
|
||||
}))
|
||||
// 如果有数据且没有选中项,选中第一个
|
||||
if (!this.activeId && this.profiles.length > 0) {
|
||||
this.activeId = this.profiles[0].id
|
||||
}
|
||||
this.loaded = true
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配音列表失败:', error)
|
||||
message.error('加载失败,请稍后重试')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加配音
|
||||
*/
|
||||
async add(profile) {
|
||||
const id = this.generateId()
|
||||
const name = profile.name || `克隆语音-${this.profiles.length + 1}`
|
||||
const payload = { ...profile, id, name }
|
||||
this.profiles.unshift(payload)
|
||||
this.activeId = id
|
||||
await this.persist()
|
||||
return payload
|
||||
try {
|
||||
const res = await VoiceService.create({
|
||||
name: profile.name || `克隆语音-${this.profiles.length + 1}`,
|
||||
fileId: profile.fileId,
|
||||
autoTranscribe: profile.autoTranscribe || false,
|
||||
language: profile.language || 'zh-CN',
|
||||
gender: profile.gender || 'female',
|
||||
note: profile.note || ''
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
const newProfile = {
|
||||
...profile,
|
||||
id: res.data
|
||||
}
|
||||
this.profiles.unshift(newProfile)
|
||||
this.activeId = newProfile.id
|
||||
await this.load() // 重新加载以获取完整数据
|
||||
return newProfile
|
||||
} else {
|
||||
message.error(res.msg || '创建失败')
|
||||
throw new Error(res.msg || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新配音
|
||||
*/
|
||||
async update(profile) {
|
||||
const idx = this.profiles.findIndex(p => p.id === profile.id)
|
||||
if (idx === -1) return await this.add({ ...profile, id: '' })
|
||||
this.profiles[idx] = { ...profile }
|
||||
await this.persist()
|
||||
return this.profiles[idx]
|
||||
if (!profile.id) {
|
||||
return await this.add(profile)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await VoiceService.update({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
language: profile.language,
|
||||
gender: profile.gender,
|
||||
note: profile.note
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
const index = this.profiles.findIndex(p => p.id === profile.id)
|
||||
if (index > -1) {
|
||||
// 重新加载以获取最新数据
|
||||
await this.load()
|
||||
const updated = this.profiles.find(p => p.id === profile.id)
|
||||
return updated || profile
|
||||
}
|
||||
return profile
|
||||
} else {
|
||||
message.error(res.msg || '更新失败')
|
||||
throw new Error(res.msg || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制配音
|
||||
*/
|
||||
async duplicate(profile, name) {
|
||||
const copy = { ...profile, id: '', name }
|
||||
const copy = {
|
||||
...profile,
|
||||
id: null,
|
||||
name: name || `${profile.name}-副本`
|
||||
}
|
||||
return await this.add(copy)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除配音
|
||||
*/
|
||||
async remove(id) {
|
||||
this.profiles = this.profiles.filter(p => p.id !== id)
|
||||
if (this.activeId === id) this.activeId = this.profiles[0]?.id || ''
|
||||
await this.persist()
|
||||
try {
|
||||
const res = await VoiceService.delete(id)
|
||||
|
||||
if (res.code === 0) {
|
||||
this.profiles = this.profiles.filter(p => p.id !== id)
|
||||
if (this.activeId === id) {
|
||||
this.activeId = this.profiles[0]?.id || ''
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '删除失败')
|
||||
throw new Error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除配音失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 选择配音
|
||||
*/
|
||||
select(id) {
|
||||
this.activeId = id
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新数据
|
||||
*/
|
||||
async refresh() {
|
||||
this.loaded = false
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function extractVideoCover(
|
||||
const url = URL.createObjectURL(file)
|
||||
video.src = url
|
||||
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">生成数字人</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-1">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<section class="p-4 bg-white rounded shadow lg:col-span-1">
|
||||
<div class="space-y-3">
|
||||
<div class="text-gray-600 text-sm">形象、背景、脚本、分辨率、字幕等配置。</div>
|
||||
<button class="px-4 py-2 bg-purple-600 text-white rounded">生成视频</button>
|
||||
<div class="text-sm text-gray-600">形象、背景、脚本、分辨率、字幕等配置。</div>
|
||||
<button class="px-4 py-2 text-white bg-purple-600 rounded">生成视频</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-2">
|
||||
<section class="p-4 bg-white rounded shadow lg:col-span-2">
|
||||
<div class="text-gray-500">视频预览、任务队列、渲染进度</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,14 @@
|
||||
</template>
|
||||
上传素材
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
:disabled="selectedFileIds.length === 0"
|
||||
@click="handleOpenMixModal"
|
||||
>
|
||||
素材混剪
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="selectedFileIds.length > 0"
|
||||
type="primary"
|
||||
@@ -142,11 +150,47 @@
|
||||
@confirm="handleConfirmUpload"
|
||||
@cancel="handleUploadCancel"
|
||||
/>
|
||||
<a-modal
|
||||
v-model:open="mixModalVisible"
|
||||
title="素材混剪"
|
||||
centered
|
||||
:confirm-loading="mixing"
|
||||
ok-text="提交混剪"
|
||||
cancel-text="取消"
|
||||
@ok="handleMixConfirm"
|
||||
@cancel="handleMixCancel"
|
||||
>
|
||||
<div class="mix-modal__summary">
|
||||
<p>选中素材:{{ selectedFiles.length }} 个</p>
|
||||
<p>视频素材:{{ selectedVideoUrls.length }} 个</p>
|
||||
<p>背景音乐:{{ selectedAudioUrls.length }} 个</p>
|
||||
</div>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="视频标题" required>
|
||||
<a-input v-model:value="mixForm.title" placeholder="请输入生成视频标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="文案内容" required>
|
||||
<a-textarea
|
||||
v-model:value="mixForm.text"
|
||||
placeholder="请输入文案(每句话换行以便自动拆分)"
|
||||
:rows="4"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="生成成片数量" required>
|
||||
<a-input-number
|
||||
v-model:value="mixForm.produceCount"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
UploadOutlined,
|
||||
@@ -154,6 +198,7 @@ import {
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { MixService } from '@/api/mix'
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
|
||||
import { formatFileSize, formatDate } from '@/utils/file'
|
||||
|
||||
@@ -163,6 +208,8 @@ const fileList = ref([])
|
||||
const selectedFileIds = ref([])
|
||||
const uploadModalVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const mixModalVisible = ref(false)
|
||||
const mixing = ref(false)
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
@@ -370,6 +417,111 @@ const handleImageError = (e) => {
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
const selectedFiles = computed(() =>
|
||||
fileList.value.filter((file) => selectedFileIds.value.includes(file.id))
|
||||
)
|
||||
|
||||
const isVideoFile = (file) => {
|
||||
if (!file) return false
|
||||
if (file.isVideo) return true
|
||||
if (file.fileCategory === 'video') return true
|
||||
if (typeof file.fileType === 'string' && file.fileType.startsWith('video')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const isAudioFile = (file) => {
|
||||
if (!file) return false
|
||||
if (file.fileCategory === 'audio') return true
|
||||
if (file.fileType === 'audio') return true
|
||||
if (typeof file.fileType === 'string' && file.fileType.startsWith('audio')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedVideoUrls = computed(() =>
|
||||
selectedFiles.value.map((file) => (isVideoFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
|
||||
)
|
||||
|
||||
const selectedAudioUrls = computed(() =>
|
||||
selectedFiles.value.map((file) => (isAudioFile(file) ? file?.fileUrl || file?.previewUrl : null)).filter(Boolean)
|
||||
)
|
||||
|
||||
const mixForm = reactive({
|
||||
title: '',
|
||||
text: '',
|
||||
produceCount: 1
|
||||
})
|
||||
|
||||
const resetMixForm = () => {
|
||||
mixForm.title = ''
|
||||
mixForm.text = ''
|
||||
mixForm.produceCount = 1
|
||||
}
|
||||
|
||||
const handleOpenMixModal = () => {
|
||||
if (selectedFileIds.value.length === 0) {
|
||||
message.warning('请先选择至少一个素材')
|
||||
return
|
||||
}
|
||||
if (selectedVideoUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个视频素材')
|
||||
return
|
||||
}
|
||||
if (selectedAudioUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个背景音乐素材')
|
||||
return
|
||||
}
|
||||
mixModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleMixCancel = () => {
|
||||
mixModalVisible.value = false
|
||||
}
|
||||
|
||||
const handleMixConfirm = async () => {
|
||||
const title = mixForm.title.trim()
|
||||
const text = mixForm.text.trim()
|
||||
if (!title) {
|
||||
message.warning('请输入视频标题')
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
const produceCount = Math.max(1, Math.min(10, Number(mixForm.produceCount) || 1))
|
||||
if (selectedVideoUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个视频素材')
|
||||
return
|
||||
}
|
||||
if (selectedAudioUrls.value.length === 0) {
|
||||
message.warning('请至少选择一个背景音乐素材')
|
||||
return
|
||||
}
|
||||
mixing.value = true
|
||||
try {
|
||||
const { data } = await MixService.batchProduceAlignment({
|
||||
title,
|
||||
text,
|
||||
videoUrls: selectedVideoUrls.value,
|
||||
bgMusicUrls: selectedAudioUrls.value,
|
||||
produceCount
|
||||
})
|
||||
const jobIds = Array.isArray(data) ? data : []
|
||||
message.success(
|
||||
jobIds.length > 0
|
||||
? `混剪任务提交成功,JobId:${jobIds.join(', ')}`
|
||||
: '混剪任务提交成功'
|
||||
)
|
||||
mixModalVisible.value = false
|
||||
resetMixForm()
|
||||
} catch (error) {
|
||||
console.error('混剪失败:', error)
|
||||
message.error(error?.message || '混剪任务提交失败,请重试')
|
||||
} finally {
|
||||
mixing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadFileList()
|
||||
@@ -524,5 +676,20 @@ onMounted(() => {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.mix-modal__summary {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.mix-modal__summary p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">素材混剪</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-1">
|
||||
<div class="text-gray-600 text-sm">文案拆解与镜头建议。</div>
|
||||
</section>
|
||||
<section class="bg-white p-4 rounded shadow lg:col-span-2">
|
||||
<div class="text-gray-500">素材匹配与时间线,导出到剪映。</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user