feat(model):新增MiniMax-H3视频支持
This commit is contained in:
1 parent
26c53c2228
commit
5ce16daa46
29 files changed
+3736
-98
No files matched your search
@@ -0,0 +1,61 @@
|
||||
// 模型研发厂商共享常量(管理后台模型管理列表/筛选/表单共用)
|
||||
// value 与后端 mdl_models.vendor 枚举一一对应(api/admin/v1/model.go 的 v:"in:" 校验、
|
||||
// internal/logic/admin/model.go 的 validModelVendors)。
|
||||
// 新增厂商时需同步:后端枚举校验、租户端 web/tenant/src/constants/vendor.ts、本文件。
|
||||
// 注意与渠道供应商类型(constants/channel.ts,chn_channels.type)是两个维度:厂商 = 模型的研发公司,
|
||||
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
|
||||
|
||||
export const vendorOptions: { label: string; value: string }[] = [
|
||||
{ label: 'OpenAI', value: 'openai' },
|
||||
{ label: 'Anthropic', value: 'anthropic' },
|
||||
{ label: 'Google', value: 'google' },
|
||||
{ label: 'xAI', value: 'xai' },
|
||||
{ label: 'Mistral', value: 'mistral' },
|
||||
{ label: 'Cohere', value: 'cohere' },
|
||||
{ label: 'Meta', value: 'meta' },
|
||||
{ label: '阿里巴巴', value: 'alibaba' },
|
||||
{ label: '字节跳动', value: 'bytedance' },
|
||||
{ label: '深度求索', value: 'deepseek' },
|
||||
{ label: '智谱AI', value: 'zhipu' },
|
||||
{ label: '月之暗面', value: 'moonshot' },
|
||||
{ label: 'MiniMax', value: 'minimax' },
|
||||
{ label: '百度', value: 'baidu' },
|
||||
{ label: '腾讯', value: 'tencent' },
|
||||
{ label: '科大讯飞', value: 'xunfei' },
|
||||
{ label: '快手', value: 'kuaishou' },
|
||||
{ label: 'Midjourney', value: 'midjourney' },
|
||||
{ label: 'Suno', value: 'suno' },
|
||||
]
|
||||
|
||||
// value -> label 映射,表格列展示用
|
||||
export const vendorLabelMap: Record<string, string> = {}
|
||||
vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label })
|
||||
|
||||
// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设)
|
||||
export const vendorTagColor: Record<string, string> = {
|
||||
openai: 'green',
|
||||
anthropic: 'orange',
|
||||
google: 'arcoblue',
|
||||
xai: 'gray',
|
||||
mistral: 'orangered',
|
||||
cohere: 'cyan',
|
||||
meta: 'blue',
|
||||
alibaba: 'purple', // 图标用通义千问 mark(品牌紫),Tag 色随之取紫
|
||||
bytedance: 'arcoblue',
|
||||
deepseek: 'blue',
|
||||
zhipu: 'purple',
|
||||
moonshot: 'gray',
|
||||
minimax: 'red',
|
||||
baidu: 'blue',
|
||||
tencent: 'cyan',
|
||||
xunfei: 'arcoblue',
|
||||
kuaishou: 'orangered',
|
||||
midjourney: 'gray',
|
||||
suno: 'purple',
|
||||
}
|
||||
|
||||
// Arco Select 的 allow-search 自定义过滤:小写包含匹配
|
||||
export function filterVendorOption(inputValue: string, option: { label: string; value: string }) {
|
||||
const input = inputValue.toLowerCase()
|
||||
return option.label.toLowerCase().includes(input)
|
||||
}
|
||||
@@ -162,6 +162,7 @@ const pricingModeTagColor: Record<string, string> = {
|
||||
token: 'arcoblue',
|
||||
per_request: 'orangered',
|
||||
tiered: 'purple',
|
||||
per_second: 'green',
|
||||
}
|
||||
|
||||
// ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)=====
|
||||
@@ -177,18 +178,29 @@ function formatTokens(n: number | null | undefined): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显)
|
||||
// 按秒模式基准单价:优先 "*" 兜底价,否则取矩阵最小正价(与定价弹窗 perSecondBasePrice 口径一致)
|
||||
function perSecondBasePrice(row: any): number {
|
||||
const prices = Object.values(row.per_second_prices || {}).map((p) => Number(p) || 0).filter((p) => p > 0)
|
||||
if (prices.length === 0) return 0
|
||||
const wildcard = Number(row.per_second_prices?.['*']) || 0
|
||||
return wildcard > 0 ? wildcard : Math.min(...prices)
|
||||
}
|
||||
|
||||
// 定价主文本:本位币 输入/输出、单价/次、单价/秒、未定价(bil 层定价直显)
|
||||
function pricingMain(row: any): string {
|
||||
if (!row.pricing_mode) return '未定价'
|
||||
if (row.pricing_mode === 'per_request') {
|
||||
return `${formatBilling(row.per_request_price ?? 0, 4)}/次`
|
||||
}
|
||||
if (row.pricing_mode === 'per_second') {
|
||||
return `${formatBilling(perSecondBasePrice(row), 4)}/秒`
|
||||
}
|
||||
return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}`
|
||||
}
|
||||
|
||||
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
|
||||
// 定价模式副文本:按量 / 按次 / 阶梯 / 按秒(未定价返回空串)
|
||||
function pricingMode(row: any): string {
|
||||
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
|
||||
const m: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
|
||||
return row.pricing_mode ? (m[row.pricing_mode] || row.pricing_mode) : ''
|
||||
}
|
||||
|
||||
@@ -237,13 +249,16 @@ const columns: TableColumnData[] = [
|
||||
if (!record.pricing_mode) {
|
||||
return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价')
|
||||
}
|
||||
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
|
||||
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒' }
|
||||
const tags = [
|
||||
h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
|
||||
]
|
||||
if (record.pricing_mode === 'per_request') {
|
||||
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
|
||||
`${formatBilling(record.per_request_price ?? 0, 4)}/次`))
|
||||
} else if (record.pricing_mode === 'per_second') {
|
||||
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
|
||||
`${formatBilling(perSecondBasePrice(record), 4)}/秒`))
|
||||
} else {
|
||||
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
|
||||
`${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`))
|
||||
|
||||
@@ -49,6 +49,7 @@ const platformOptions = [
|
||||
{ label: 'Suno', value: 'suno' },
|
||||
{ label: '火山引擎', value: 'volcengine' },
|
||||
{ label: '阿里', value: 'ali' },
|
||||
{ label: 'MiniMax', value: 'minimax' },
|
||||
]
|
||||
|
||||
const statusTagColor: Record<string, string | undefined> = {
|
||||
@@ -74,6 +75,7 @@ const platformLabel: Record<string, string> = {
|
||||
suno: 'Suno',
|
||||
volcengine: '火山引擎',
|
||||
ali: '阿里',
|
||||
minimax: 'MiniMax',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 厂商元数据:与后端 mdl_models.vendor 枚举对齐(api/admin/v1/model.go、api/tenant/v1/marketplace.go
|
||||
// 的 v:"in:" 列表,以及 internal/logic/admin/model.go 的 validModelVendors)。
|
||||
// 新增厂商时需同步:后端枚举校验、本文件、web/admin/src/constants/vendor.ts,并补 src/assets/vendors/{slug}.svg。
|
||||
// 注意与渠道供应商(chn_channels.type)是两个维度:厂商 = 模型的研发公司。
|
||||
|
||||
export interface VendorMeta {
|
||||
// 枚举值(入库值)
|
||||
value: string
|
||||
// 显示名
|
||||
label: string
|
||||
// 品牌色(logo 芯片底色)
|
||||
color: string
|
||||
// logo 本身是多色图形(如 cohere 三色 mark),芯片用浅色底展示彩色 logo
|
||||
colored?: boolean
|
||||
}
|
||||
|
||||
export const vendorMetaList: VendorMeta[] = [
|
||||
{ value: 'openai', label: 'OpenAI', color: '#10a37f' },
|
||||
{ value: 'anthropic', label: 'Anthropic', color: '#d97757' },
|
||||
{ value: 'google', label: 'Google', color: '#4285f4' },
|
||||
{ value: 'xai', label: 'xAI', color: '#000000' },
|
||||
{ value: 'mistral', label: 'Mistral', color: '#fa520f' },
|
||||
{ value: 'cohere', label: 'Cohere', color: '#39594d', colored: true },
|
||||
{ value: 'meta', label: 'Meta', color: '#0866ff' },
|
||||
{ value: 'alibaba', label: '阿里巴巴', color: '#615ced' }, // 图标用通义千问 mark,底色取千问品牌紫
|
||||
{ value: 'bytedance', label: '字节跳动', color: '#325ab4' },
|
||||
{ value: 'deepseek', label: '深度求索', color: '#4d6bfe' },
|
||||
{ value: 'zhipu', label: '智谱AI', color: '#3e5cfa' },
|
||||
{ value: 'moonshot', label: '月之暗面', color: '#16191e' },
|
||||
{ value: 'minimax', label: 'MiniMax', color: '#1f1f1f' },
|
||||
{ value: 'baidu', label: '百度', color: '#2932e1' },
|
||||
{ value: 'tencent', label: '腾讯', color: '#0052d9' },
|
||||
{ value: 'xunfei', label: '科大讯飞', color: '#0f4cfd' },
|
||||
{ value: 'kuaishou', label: '快手', color: '#ff5000' },
|
||||
{ value: 'midjourney', label: 'Midjourney', color: '#111111' },
|
||||
{ value: 'suno', label: 'Suno', color: '#111111' },
|
||||
]
|
||||
|
||||
// 未收录厂商(vendor 非空但字典缺失)与未设置厂商(空串/null)的展示兜底
|
||||
export function getVendorMeta(vendor?: string | null): VendorMeta | null {
|
||||
if (!vendor) return null
|
||||
return vendorMetaList.find(item => item.value === vendor) ?? null
|
||||
}
|
||||
|
||||
// 厂商显示名:字典命中取 label,未收录回退原值,未设置返回「未分类」
|
||||
export function vendorLabel(vendor?: string | null): string {
|
||||
if (!vendor) return '未分类'
|
||||
return getVendorMeta(vendor)?.label ?? vendor
|
||||
}
|
||||
@@ -109,6 +109,7 @@ const platformLabel: Record<string, string> = {
|
||||
suno: 'Suno',
|
||||
volcengine: '火山引擎',
|
||||
ali: '阿里',
|
||||
minimax: 'MiniMax',
|
||||
}
|
||||
|
||||
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
|
||||
@@ -289,7 +290,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
|
||||
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'}]" container-class="w-[120px]" />
|
||||
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'},{value:'minimax',label:'MiniMax'}]" container-class="w-[120px]" />
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
|
||||
@@ -31,7 +31,9 @@ const duration = ref(5)
|
||||
|
||||
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
|
||||
|
||||
const resolutionPresets = [
|
||||
// MiniMax-H3 系列使用官方分辨率档位(768P/2K,Max 追加 480P;与后端计费矩阵键一致),
|
||||
// 其他模型维持像素串词汇——档位词汇不能混入其他厂商(会产生非法 resolution)
|
||||
const pixelResolutionPresets = [
|
||||
{ value: '854x480', label: '480p 横' },
|
||||
{ value: '480x854', label: '480p 竖' },
|
||||
{ value: '1280x720', label: '720p 横' },
|
||||
@@ -40,37 +42,58 @@ const resolutionPresets = [
|
||||
{ value: '1080x1920', label: '1080p 竖' },
|
||||
]
|
||||
|
||||
const isMiniMaxH3 = computed(() => selectedModel.value.toLowerCase().startsWith('minimax-h3'))
|
||||
|
||||
const resolutionPresets = computed(() => {
|
||||
if (isMiniMaxH3.value) {
|
||||
const presets = [
|
||||
{ value: '768P', label: '768P' },
|
||||
{ value: '2K', label: '2K' },
|
||||
]
|
||||
if (selectedModel.value.toLowerCase().startsWith('minimax-h3-max')) {
|
||||
presets.unshift({ value: '480P', label: '480P' })
|
||||
}
|
||||
return presets
|
||||
}
|
||||
return pixelResolutionPresets
|
||||
})
|
||||
|
||||
// 模型切换后当前分辨率不在该模型预设内时重置(避免像素串打到 MiniMax 或档位词汇打到其他厂商)
|
||||
watch(resolutionPresets, presets => {
|
||||
if (!presets.some(p => p.value === resolution.value)) {
|
||||
resolution.value = presets[0]?.value || ''
|
||||
}
|
||||
})
|
||||
|
||||
interface TaskInfo {
|
||||
id: string
|
||||
// OpenAI Videos 协议状态机:queued / in_progress / completed / failed
|
||||
status: string
|
||||
progress: string
|
||||
model: string
|
||||
url?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
completedAt?: number
|
||||
}
|
||||
const currentTask = ref<TaskInfo | null>(null)
|
||||
const polling = ref(false)
|
||||
// 成品视频地址:/v1/videos/{id}/content 需带 API Key 拉流,取回后转 blob URL 供播放/下载
|
||||
const videoUrl = ref('')
|
||||
// 任务状态轮询:页面隐藏时暂停(任务在服务端继续执行),恢复可见时立即补拉
|
||||
const taskPoller = createPoller(pollLoop, 3000, { immediate: true })
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
SUBMITTED: '已提交',
|
||||
IN_PROGRESS: '生成中',
|
||||
NOT_START: '排队中',
|
||||
QUEUED: '排队中',
|
||||
SUCCESS: '已完成',
|
||||
FAILURE: '失败',
|
||||
queued: '排队中',
|
||||
in_progress: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
SUBMITTED: 'badge-primary',
|
||||
IN_PROGRESS: 'badge-warning',
|
||||
NOT_START: 'badge-gray',
|
||||
QUEUED: 'badge-gray',
|
||||
SUCCESS: 'badge-success',
|
||||
FAILURE: 'badge-danger',
|
||||
queued: 'badge-gray',
|
||||
in_progress: 'badge-warning',
|
||||
completed: 'badge-success',
|
||||
failed: 'badge-danger',
|
||||
}
|
||||
|
||||
function applyResolutionPreset(val: string) {
|
||||
@@ -82,43 +105,43 @@ async function submitTask() {
|
||||
submitting.value = true
|
||||
currentTask.value = null
|
||||
stopPolling()
|
||||
releaseVideo()
|
||||
|
||||
try {
|
||||
const api = createPlaygroundApi(props.apiKey)
|
||||
const metadata: Record<string, any> = {}
|
||||
if (resolution.value) {
|
||||
metadata.resolution = resolution.value
|
||||
}
|
||||
if (duration.value) {
|
||||
metadata.duration = duration.value
|
||||
}
|
||||
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长
|
||||
const body: Record<string, any> = {
|
||||
model: selectedModel.value,
|
||||
prompt: prompt.value,
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
body.metadata = metadata
|
||||
if (resolution.value) {
|
||||
body.size = resolution.value
|
||||
}
|
||||
if (duration.value) {
|
||||
body.seconds = String(duration.value)
|
||||
}
|
||||
|
||||
const res = await api.post('/v1/video/generations', body, { timeout: 60_000 })
|
||||
const res = await api.post('/v1/videos', body, { timeout: 60_000 })
|
||||
const data = res.data
|
||||
|
||||
currentTask.value = {
|
||||
id: data.id,
|
||||
status: data.status || 'SUBMITTED',
|
||||
status: data.status || 'queued',
|
||||
progress: '',
|
||||
model: data.model || selectedModel.value,
|
||||
createdAt: data.created_at || Math.floor(Date.now() / 1000),
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') {
|
||||
if (data.status !== 'completed' && data.status !== 'failed') {
|
||||
startPolling()
|
||||
} else if (data.status === 'completed') {
|
||||
loadVideoContent()
|
||||
}
|
||||
} catch (e: any) {
|
||||
currentTask.value = {
|
||||
id: '',
|
||||
status: 'FAILURE',
|
||||
status: 'failed',
|
||||
progress: '',
|
||||
model: selectedModel.value,
|
||||
error: e?.message || '提交失败',
|
||||
@@ -144,20 +167,23 @@ async function pollLoop() {
|
||||
|
||||
try {
|
||||
const api = createPlaygroundApi(props.apiKey)
|
||||
const res = await api.get(`/v1/video/generations/${currentTask.value.id}`)
|
||||
const res = await api.get(`/v1/videos/${currentTask.value.id}`)
|
||||
const data = res.data
|
||||
|
||||
currentTask.value = {
|
||||
...currentTask.value,
|
||||
status: data.status,
|
||||
progress: data.progress || '',
|
||||
url: data.url || undefined,
|
||||
error: data.error || undefined,
|
||||
// OpenAI Videos 的 progress 是 0-100 整数,归一为百分比字符串供模板直用
|
||||
progress: data.progress != null ? `${data.progress}%` : currentTask.value.progress,
|
||||
error: data.error?.message || undefined,
|
||||
completedAt: data.completed_at || undefined,
|
||||
}
|
||||
|
||||
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
stopPolling()
|
||||
if (data.status === 'completed') {
|
||||
loadVideoContent()
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
@@ -165,21 +191,54 @@ async function pollLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时停止轮询,避免路由离开后仍持续请求任务状态
|
||||
// loadVideoContent 拉取成品视频:content 端点要求 API Key 鉴权,浏览器无法直接用 URL 播放,
|
||||
// 取回二进制后转 blob URL 供 <video> 播放与下载
|
||||
async function loadVideoContent() {
|
||||
if (!currentTask.value?.id || videoUrl.value) return
|
||||
try {
|
||||
const api = createPlaygroundApi(props.apiKey)
|
||||
const res = await api.get(`/v1/videos/${currentTask.value.id}/content`, {
|
||||
responseType: 'blob',
|
||||
timeout: 300_000,
|
||||
})
|
||||
videoUrl.value = URL.createObjectURL(res.data)
|
||||
} catch {
|
||||
// 拉取失败不改变任务状态,下载按钮可重试(downloadVideo 内再次触发加载)
|
||||
currentTask.value = {
|
||||
...currentTask.value,
|
||||
error: currentTask.value?.error || '视频加载失败,请点击下载重试',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// releaseVideo 释放 blob URL,避免内存泄漏
|
||||
function releaseVideo() {
|
||||
if (videoUrl.value) {
|
||||
URL.revokeObjectURL(videoUrl.value)
|
||||
videoUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时停止轮询并释放资源,避免路由离开后仍持续请求任务状态
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
releaseVideo()
|
||||
})
|
||||
|
||||
function resetTask() {
|
||||
stopPolling()
|
||||
releaseVideo()
|
||||
currentTask.value = null
|
||||
}
|
||||
|
||||
function downloadVideo() {
|
||||
if (!currentTask.value?.url) return
|
||||
if (!videoUrl.value) {
|
||||
loadVideoContent()
|
||||
return
|
||||
}
|
||||
const a = document.createElement('a')
|
||||
a.href = currentTask.value.url
|
||||
a.download = `video_${currentTask.value.id || 'output'}.mp4`
|
||||
a.href = videoUrl.value
|
||||
a.download = `video_${currentTask.value?.id || 'output'}.mp4`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -221,7 +280,8 @@ function downloadVideo() {
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">时长({{ duration }} 秒)</label>
|
||||
<n-input-number v-model:value="duration" :min="5" :max="15" :step="1" class="w-full" />
|
||||
<!-- min=4 覆盖 MiniMax-H3 的 4 秒档;其他模型档位由上游校验 -->
|
||||
<n-input-number v-model:value="duration" :min="4" :max="15" :step="1" class="w-full" />
|
||||
</div>
|
||||
<button class="btn btn-primary w-full" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
|
||||
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
|
||||
@@ -266,7 +326,7 @@ function downloadVideo() {
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="currentTask.status === 'IN_PROGRESS' || currentTask.status === 'SUBMITTED'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<div v-if="currentTask.status === 'in_progress' || currentTask.status === 'queued'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-500 transition-all duration-500"
|
||||
:style="{ width: currentTask.progress || '10%' }"
|
||||
@@ -274,16 +334,21 @@ function downloadVideo() {
|
||||
</div>
|
||||
|
||||
<!-- 视频结果 -->
|
||||
<div v-if="currentTask.status === 'SUCCESS' && currentTask.url" class="space-y-3">
|
||||
<video controls class="w-full" :src="currentTask.url" />
|
||||
<div v-if="currentTask.status === 'completed' && videoUrl" class="space-y-3">
|
||||
<video controls class="w-full" :src="videoUrl" />
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadVideo">
|
||||
<Icon name="arrowDown" size="sm" />
|
||||
下载视频
|
||||
</button>
|
||||
</div>
|
||||
<!-- 成品加载中(content 端点拉流转 blob) -->
|
||||
<div v-else-if="currentTask.status === 'completed'" class="flex items-center justify-center py-10">
|
||||
<div class="spinner h-6 w-6 text-primary-600"></div>
|
||||
<span class="ml-3 text-sm text-gray-500">视频加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="currentTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<div v-if="currentTask.status === 'failed'" class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<div class="flex items-center gap-2 text-red-700">
|
||||
<Icon name="xCircle" size="sm" />
|
||||
<span class="text-sm font-medium">生成失败</span>
|
||||
|
||||
Reference in new issue
Block a user