本地修改

This commit is contained in:
sjy
2026-07-24 17:56:42 +08:00
parent 0e20981383
commit af1dd20789
16 changed files with 654 additions and 439 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CVSmzRXM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
<script type="module" crossorigin src="/assets/index-Bkv0KkOY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IrbjYl3V.css">
</head>
<body>
<div id="root"></div>
+5 -2
View File
@@ -674,8 +674,11 @@ export async function splitCustom(taskSetId: string, params: any): Promise<any>
return api.post(`/shot-replications/task-sets/${taskSetId}/split-custom`, params);
}
// 拆镜列表
export async function Removelist(taskSetId: string): Promise<any> {
return api.get(`/shot-replications/task-sets/${taskSetId}/segments`);
export async function Removelist(taskSetId: string, page: number = 1, pageSize: number = 20): Promise<any> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
return api.get(`/shot-replications/task-sets/${taskSetId}/segments?${params.toString()}`);
}
// 生成视频
export async function removeCreate(recordId: string, params: any): Promise<any> {
@@ -241,7 +241,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onHistorySelect?.(items);
setHistoryModalVisible(false);
}}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : ['image', 'video']}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount}
@@ -253,6 +253,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration}
hideLimitHint={hideLimitHint}
defaultResourceType={mediaType === 'image' ? 'image' : ''}
/>
<PrivatePortraitAssetPicker
@@ -1,5 +1,5 @@
import React from 'react';
import { Alert, Input, Radio, Space, Tag, Typography } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { Alert, Input, Menu, Radio, Space, Tag, Typography } from 'antd';
import type {
JsonValue,
VideoPromptSchemaFieldConfig,
@@ -31,9 +31,9 @@ function isRecord(value: unknown): value is Record<string, JsonValue> {
function ReadonlyBlock({ value }: { value: JsonValue }) {
const text = value === 'true' ? '是' : value === 'false' ? '否' : stringifySchemaValue(value);
return shouldUseSchemaTextArea(value) ? (
<TextArea value={text} rows={Math.min(6, Math.max(2, Math.ceil(text.length / 42)))} disabled style={{ borderRadius: 8, color: '#64748b' }} />
<TextArea value={text} rows={Math.min(6, Math.max(2, Math.ceil(text.length / 42)))} disabled style={{ borderRadius: 10, color: '#64748b', border: '1px solid #e2e8f0', background: '#f8fafc' }} />
) : (
<Input value={text} disabled style={{ borderRadius: 8, color: '#64748b' }} />
<Input value={text} disabled style={{ borderRadius: 10, color: '#64748b', border: '1px solid #e2e8f0', background: '#f8fafc' }} />
);
}
@@ -44,7 +44,7 @@ function EditableInput({ value, maxLength, onChange }: { value: JsonValue; maxLe
maxLength,
showCount: !!maxLength,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(event.target.value),
style: { borderRadius: 8 },
style: { borderRadius: 10, border: '1px solid #e2e8f0', transition: 'all 0.2s ease' },
};
if (shouldUseSchemaTextArea(value)) {
@@ -68,13 +68,78 @@ function BooleanRadio({ value, onChange }: { value: JsonValue; onChange: (nextVa
);
}
// function renderFieldTag(field: VideoPromptSchemaFieldConfig) {
// return field.editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
// }
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, schemaConfigSnapshot, onChange }) => {
const safeValue = isRecord(value) ? value : {};
const sections = normalizeSchemaSections(schemaConfigSnapshot);
const [selectedKey, setSelectedKey] = useState<string>('');
const contentRef = useRef<HTMLDivElement>(null);
const handleMenuSelect = (info: { key: string }) => {
setSelectedKey(info.key);
const element = document.getElementById(`section-${info.key}`);
if (element && contentRef.current) {
const containerRect = contentRef.current.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scrollTop = elementRect.top - containerRect.top + contentRef.current.scrollTop;
contentRef.current.scrollTo({ top: scrollTop, behavior: 'smooth' });
}
};
useEffect(() => {
const validSections = sections.filter((section) => section.key in safeValue);
if (validSections.length > 0 && !selectedKey) {
setSelectedKey(validSections[0].key);
}
}, [sections, safeValue, selectedKey]);
// 监听滚动,自动更新菜单选中状态
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const handleScroll = () => {
const containerRect = container.getBoundingClientRect();
const halfHeight = containerRect.height / 2;
let currentSectionKey = '';
let minDistance = Infinity;
for (const section of sections) {
if (!(section.key in safeValue)) continue;
const element = document.getElementById(`section-${section.key}`);
if (!element) continue;
const elementRect = element.getBoundingClientRect();
const elementTop = elementRect.top;
const elementBottom = elementRect.bottom;
// 检查元素是否在可视区域内
const isVisible = elementBottom > containerRect.top && elementTop < containerRect.bottom;
if (isVisible) {
// 计算元素中心点相对于容器中心点的距离
const elementCenter = (elementTop + elementBottom) / 2;
const containerCenter = containerRect.top + halfHeight;
const distance = Math.abs(elementCenter - containerCenter);
// 选择最接近容器中心的元素
if (distance < minDistance) {
minDistance = distance;
currentSectionKey = section.key;
}
}
}
if (currentSectionKey && currentSectionKey !== selectedKey) {
setSelectedKey(currentSectionKey);
}
};
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [sections, safeValue]);
if (!hasSchemaConfigSnapshot(schemaConfigSnapshot)) {
return (
@@ -104,18 +169,17 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
if (!fields.length) return null;
return (
<div key={section.key} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
{/* {renderFieldTag(section)} */}
</Space>
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
<div key={section.key} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
</div>
<div style={{ padding: '16px', backgroundColor: '#fff', borderRadius: 12, border: '1px solid #e2e8f0', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
{fields.map((field) => (
<div key={field.key} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 4 }}>
<div key={field.key} style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 6 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{/* {renderFieldTag(field)} */}
</Space>
</div>
{field.editable ? (
field.type === 'boolean' ? (
<BooleanRadio
@@ -145,26 +209,26 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
const displayFields = [{ key: '时间段', label: '时间段', enabled: true, editable: false } as VideoPromptSchemaFieldConfig, ...fields];
return (
<div key={section.key} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
<Tag color="default"></Tag>
</Space>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: '#fff' }}>
<div key={section.key} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
<Tag style={{ borderRadius: 4, fontSize: 11, padding: '2px 8px', background: '#f1f5f9', color: '#64748b', border: 'none' }}></Tag>
</div>
<div style={{ padding: '16px', backgroundColor: '#fff', borderRadius: 12, border: '1px solid #e2e8f0', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
{rows.length === 0 ? (
<Text style={{ color: '#94a3b8', fontSize: 12 }}></Text>
) : (
rows.map((item, index) => {
const row = isRecord(item) ? item : {};
return (
<div key={`${section.key}-${index}`} style={{ marginBottom: index === rows.length - 1 ? 0 : 14, paddingBottom: index === rows.length - 1 ? 0 : 14, borderBottom: index === rows.length - 1 ? 'none' : '1px dashed #e2e8f0' }}>
<Text strong style={{ display: 'block', marginBottom: 8, color: '#475569', fontSize: 12 }}> {index + 1} </Text>
<div key={`${section.key}-${index}`} style={{ marginBottom: index === rows.length - 1 ? 0 : 16, paddingBottom: index === rows.length - 1 ? 0 : 16, borderBottom: index === rows.length - 1 ? 'none' : '1px dashed #e2e8f0' }}>
<Text strong style={{ display: 'block', marginBottom: 10, color: '#475569', fontSize: 13 }}> {index + 1} </Text>
{displayFields.map((field) => (
<div key={field.key} style={{ marginBottom: 10 }}>
<Space style={{ marginBottom: 4 }}>
<div key={field.key} style={{ marginBottom: 14 }}>
<div style={{ marginBottom: 6 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{/* {renderFieldTag(field)} */}
</Space>
</div>
{field.editable ? (
field.type === 'boolean' ? (
<BooleanRadio
@@ -195,11 +259,12 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
const renderPrimitiveSection = (section: VideoPromptSchemaSectionConfig) => {
if (!(section.key in safeValue)) return null;
return (
<div key={section.key} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text>
{/* {renderFieldTag(section)} */}
</Space>
<div key={section.key} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
</div>
<div style={{ padding: '16px', backgroundColor: '#fff', borderRadius: 12, border: '1px solid #e2e8f0', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
{section.editable ? (
<EditableInput
value={safeValue[section.key]}
@@ -214,24 +279,57 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
<ReadonlyBlock value={safeValue[section.key]} />
)}
</div>
</div>
);
};
return (
<div>
{/* <Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="仅可修改后台 Schema 配置允许编辑的字段"
description="视频规格、时间段、流程条目数量、输出规格、质量控制、合规控制等锁定内容由服务端最终校验。"
/> */}
{sections.map((section) => {
const menuItems = sections
.filter((section) => section.key in safeValue)
.map((section) => ({
key: section.key,
label: section.label,
}));
const renderSection = (section: VideoPromptSchemaSectionConfig) => {
if (!(section.key in safeValue)) return null;
if (section.type === 'object') return renderObjectSection(section);
if (section.type === 'flow') return renderFlowSection(section);
return renderPrimitiveSection(section);
})}
};
return (
<div style={{ display: 'flex', flexDirection: 'row', maxHeight: 500 }}>
{/* 左侧菜单 */}
<div style={{ width: 180, flexShrink: 0, background: '#fff', borderRight: '1px solid #e2e8f0', overflowY: 'auto' }}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
onClick={handleMenuSelect}
style={{ height: '100%', borderRight: 0 }}
theme="light"
items={menuItems.map(item => ({
...item,
style: {
borderRadius: 8,
margin: '4px 8px',
padding: '8px 12px',
color: '#64748b',
fontSize: 13,
transition: 'all 0.2s ease',
maxHeight: 500,
},
}))}
/>
</div>
{/* 右侧内容 */}
<div ref={contentRef} style={{ flex: 1, padding: '20px', overflowY: 'auto', background: '#f8fafc' }}>
{sections.map((section) => (
<div key={section.key} id={`section-${section.key}`}>
{renderSection(section)}
</div>
))}
</div>
</div>
);
};
@@ -27,6 +27,7 @@ interface UploadResourceHistoryPickerProps {
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
defaultResourceType?: MediaTypeFilter;
}
const buildPreviewUrl = (url: string) => {
@@ -122,8 +123,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
defaultResourceType,
}) => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [resourceType, setResourceType] = useState<MediaTypeFilter>(defaultResourceType || '');
const [keyword, setKeyword] = useState('');
const [groupPage, setGroupPage] = useState(1);
const [groupPageSize, setGroupPageSize] = useState(10);
@@ -233,6 +235,14 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
if (open) setCheckedMap(new Map());
}, [open]);
// 当 defaultResourceType 变化时,同步更新 resourceType 状态
useEffect(() => {
if (open) {
setResourceType(defaultResourceType || '');
setGroupPage(1);
}
}, [open, defaultResourceType]);
const toggle = async (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中');
@@ -463,7 +473,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
pageSize={itemPageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
// pageSizeOptions={[8, 16, 32, 64]}/
onChange={(nextPage, nextSize) => {
setItemPage(nextPage);
setItemPageSize(nextSize);
+23
View File
@@ -430,6 +430,29 @@ body {
border-color: #6366f1 !important;
}
/* ── Video Player Overlay ──────────────── */
.video-container {
position: relative;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
opacity: 1;
cursor: pointer;
}
.video-overlay svg {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
/* ── Mobile Bottom Nav ─────────────────── */
.mobile-bottom-nav {
display: none;
+2 -8
View File
@@ -1078,13 +1078,6 @@ const AIChatPage: React.FC = () => {
console.log(newMessage);
// 设置加载状态
setLoading(true);
@@ -1943,7 +1936,6 @@ const AIChatPage: React.FC = () => {
}
const link = document.createElement('a');
link.href = downloadUrl;
console.log(downloadUrl);
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
@@ -2804,6 +2796,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector
accept="image/*"
multiple={false}
mediaType="image"
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'first');
@@ -2896,6 +2889,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector
accept="image/*"
multiple={false}
mediaType="image"
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'last');
+13 -6
View File
@@ -2334,6 +2334,7 @@ const GeneratePage: React.FC = () => {
))}
<UploadSelector
accept="image/*,video/*"
mediaType={mediaType}
onLocalSelect={(files) => {
files.forEach(async (file) => {
await handlePasteUpload(file);
@@ -2667,12 +2668,17 @@ const GeneratePage: React.FC = () => {
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
{engineOptions.ratios.map((ratio) => {
const [w, h] = ratio.split(':').map(Number);
const maxSize = 18;
const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
return (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio as AspectRatio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
flex: '0 0 calc(12.5% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
@@ -2691,10 +2697,10 @@ const GeneratePage: React.FC = () => {
}}
>
<div style={{
width: 18,
height: 18,
width: rectWidth,
height: rectHeight,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
borderRadius: 3,
marginBottom: 2,
}} />
<span style={{
@@ -2707,7 +2713,8 @@ const GeneratePage: React.FC = () => {
{ratio}
</span>
</button>
))}
);
})}
</div>
</div>
+43 -31
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip, Select } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined, WarningOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -150,7 +150,9 @@ function InitialInfo() {
const isV2 = flowVersion === 'v2';
const baseSteps = isV2
// 使用 useMemo 缓存 baseSteps,避免每次渲染都创建新数组
const baseSteps = useMemo(() => {
return isV2
? [
{ id: 1, title: '素材与项目信息', description: '固定素材和项目描述', childId: 1 },
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
@@ -163,41 +165,34 @@ function InitialInfo() {
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
];
}, [isV2]);
// 合并基础步骤和API返回的状态
const steps = baseSteps.map((step, index) => ({
// 使用 useMemo 缓存 steps,避免每次渲染都创建新数组
const steps = useMemo(() => {
return baseSteps.map((step, index) => ({
...step,
status: apiSteps[index]?.status || '',
id: apiSteps[index]?.id || index,
output: apiSteps[index]?.output || '',
input: apiSteps[index]?.input || {},
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
errorMessage: apiSteps[index]?.errorMessage || '',
}));
}, [baseSteps, apiSteps]);
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
// 当steps更新时,默认只展开生成中的步骤
const lastProcessingKeys = useRef<string>('');
useEffect(() => {
const completedFailedKeys = new Set<string>();
const processingKeys = steps
.filter(step => step.status === 'processing')
.map(step => String(step.childId));
activeKey.forEach(key => {
const step = steps.find(s => String(s.childId) === key);
if (step && (step.status === 'completed' || step.status === 'failed')) {
completedFailedKeys.add(key);
const processingKeysStr = JSON.stringify(processingKeys);
if (processingKeysStr !== lastProcessingKeys.current) {
lastProcessingKeys.current = processingKeysStr;
setActiveKey(processingKeys);
}
});
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
completedFailedKeys.add(String(steps[i].childId));
break;
}
}
if (completedFailedKeys.size > 0) {
setActiveKey(Array.from(completedFailedKeys));
} else {
setActiveKey([]);
}
}, [apiSteps]);
}, [steps]);
// console.log('steps', steps);
@@ -894,13 +889,17 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
<div className="medio_box video-container" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default', position: 'relative' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
{taskDetail?.material?.materialVideoUrl ? (
<>
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}></div>
)}
@@ -945,12 +944,14 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<div className="medio_box video-container" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer', position: 'relative' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</div>
</div>
</div>
@@ -1642,14 +1643,25 @@ function InitialInfo() {
<>
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 16, position: 'relative', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)' }}>
{step.status === 'completed' && taskDetail?.finalVideoUrl ? (
<video src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} style={{ width: '100%', height: 180, objectFit: 'cover' }} controls />
<video src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} style={{ width: '100%', height: 180 }} controls />
) : step.status === 'processing' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ width: 20, height: 20, borderRadius: '50%', border: '2px solid #e2e8f0', borderTopColor: '#6366f1', animation: 'spinSlow 1s linear infinite' }} />
<span style={{ color: '#acacacff', fontSize: 12 }}>5</span>
<span style={{ color: '#6366f1', fontSize: 14 }}>...</span>
</div>
) : step.status === 'failed' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 14 }}></div>
<>
<div style={{ width: '100%', height: 180, display: 'flex',flexDirection:'column', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 14 }}>
<p></p>
<p>{step.errorMessage || '未知错误'}</p>
</div>
</>
) : (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}></div>
)}
+20 -7
View File
@@ -899,6 +899,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span style={{ color: '#f94444' }}></span>
</p>
{videoUrl ? (
<div style={{ position: 'relative' }}>
@@ -1015,6 +1016,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span></span>
</p>
{imageUrl ? (
<div style={{ position: 'relative' }}>
@@ -1142,6 +1144,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p>
<Input
value={originalProductName}
@@ -1163,6 +1166,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p>
<Input
value={ownProductName}
@@ -1184,6 +1188,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p>
<div style={{ position: 'relative' }}>
<TextArea
@@ -1208,7 +1213,9 @@ const GenerateConver: React.FC = () => {
</div>
<div style={{ marginBottom: 16, display: 'grid', gap: 10 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: '#475569' }}></p>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: '#475569' }}>
<span style={{ color: '#f94444' }}></span>
</p>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button
@@ -1399,12 +1406,17 @@ const GenerateConver: React.FC = () => {
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
{engineOptions.ratios.map((ratio) => {
const [w, h] = ratio.split(':').map(Number);
const maxSize = 18;
const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
return (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
flex: '0 0 calc(12.5% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
@@ -1423,10 +1435,10 @@ const GenerateConver: React.FC = () => {
}}
>
<div style={{
width: 18,
height: 18,
width: rectWidth,
height: rectHeight,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
borderRadius: 3,
marginBottom: 2,
}} />
<span style={{
@@ -1439,7 +1451,8 @@ const GenerateConver: React.FC = () => {
{ratio}
</span>
</button>
))}
);
})}
</div>
</div>
+94 -50
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Modal, Popconfirm, Select, Spin, Table, Tag, Tooltip, message } from 'antd';
import { ArrowLeftOutlined, DeleteOutlined, PlusOutlined, XOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
@@ -29,6 +29,40 @@ function buildAssetUrl(url?: string): string {
return `${API_BASE}${url}`;
}
interface VideoCellProps {
record: any;
onPreview: (url: string) => void;
}
const VideoCell = React.memo(({ record, onPreview }: VideoCellProps) => {
const videoUrl = record.segmentVideoUrl;
if (videoUrl) {
return (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9', cursor: 'pointer' }}
onClick={() => onPreview(buildAssetUrl(videoUrl))}>
<video
src={buildAssetUrl(videoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'cover', pointerEvents: 'none' }}
/>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 0, height: 0, borderTop: '8px solid transparent', borderBottom: '8px solid transparent', borderLeft: '14px solid #6366f1', marginLeft: 2 }} />
</div>
</div>
</div>
);
}
const splitMeta = getShotSplitStatusMeta(record.splitStatus || record.split_status);
return (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9', cursor: 'pointer' }}>
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 12 }}>
{splitMeta.active ? <Spin size="small" style={{ color: '#f59e0b' }} /> : null}
<span style={{ color: splitMeta.failure ? '#ef4444' : (splitMeta.active ? '#f59e0b' : '#94a3b8') }}>{splitMeta.label}</span>
</div>
</div>
);
});
function RemoveInfo() {
const { creatID } = useParams<{ creatID: string }>();
const navigate = useNavigate();
@@ -63,6 +97,9 @@ function RemoveInfo() {
const [creditRules, setCreditRules] = useState<any[]>([]);
const [taskDetail, setTaskDetail] = useState<any>(null);
const [tableData, setTableData] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [splitLoading, setSplitLoading] = useState(false);
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
@@ -134,12 +171,18 @@ function RemoveInfo() {
const fetchSegments = useCallback(async () => {
if (!creatID) return;
try {
const res = await Removelist(creatID);
const res = await Removelist(creatID, currentPage, pageSize);
setTableData(res.items || []);
setTotal(res.total || 0);
} catch {
message.error('获取拆镜列表失败');
}
}, [creatID]);
}, [creatID, currentPage, pageSize]);
const handleVideoPreview = useCallback((url: string) => {
setPreviewVideoUrl(url);
setPreviewModalVisible(true);
}, []);
const handleReanalyze = useCallback(async () => {
if (!creatID) return;
@@ -454,11 +497,11 @@ function RemoveInfo() {
return splitStatus === 'completed' && !!(record?.segmentVideoUrl || record?.segment_video_url);
};
const columns: any[] = [
const columns = useMemo(() => [
{
title: '片段',
width: 100,
fixed: 'left',
fixed: 'left' as const,
align: 'center' as const,
render: (_: any, record: any) => (
<div>
@@ -475,35 +518,7 @@ function RemoveInfo() {
width: 150,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9', cursor: 'pointer' }}
onClick={() => {
if (record.segmentVideoUrl) {
setPreviewVideoUrl(buildAssetUrl(record.segmentVideoUrl));
setPreviewModalVisible(true);
}
}}>
{record.segmentVideoUrl ? (
<>
<video
src={buildAssetUrl(record.segmentVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'cover', pointerEvents: 'none' }}
/>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 0, height: 0, borderTop: '8px solid transparent', borderBottom: '8px solid transparent', borderLeft: '14px solid #6366f1', marginLeft: 2 }} />
</div>
</div>
</>
) : (() => {
const splitMeta = getShotSplitStatusMeta(record.splitStatus || record.split_status);
return (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 12 }}>
{splitMeta.active ? <Spin size="small" style={{ color: '#f59e0b' }} /> : null}
<span style={{ color: splitMeta.failure ? '#ef4444' : (splitMeta.active ? '#f59e0b' : '#94a3b8') }}>{splitMeta.label}</span>
</div>
);
})()}
</div>
<VideoCell record={record} onPreview={handleVideoPreview} />
),
},
{
@@ -671,7 +686,7 @@ function RemoveInfo() {
{
title: '操作',
width: 200,
fixed: 'right',
fixed: 'right' as const,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -727,7 +742,7 @@ function RemoveInfo() {
</div>
),
},
];
], []);
return (
<>
@@ -1033,7 +1048,17 @@ function RemoveInfo() {
<Table
columns={columns}
dataSource={tableData}
pagination={false}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
bordered={false}
rowKey="id"
scroll={{ y: 300 }}
@@ -1111,12 +1136,23 @@ function RemoveInfo() {
<span style={{ fontSize: 18, fontWeight: 600, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}></span>
{currentSegmentName && <span style={{ fontSize: 14, color: '#64748b' }}>- {currentSegmentName}</span>}
</div>
<Button
type="text"
icon={<XOutlined />}
onClick={handleCloseDrawer}
style={{ padding: 0, color: '#64748b', fontSize: 14 }}
/>
<button
onClick={(e) => { e.stopPropagation(); handleCloseDrawer(); }}
style={{
width: 24,
height: 24,
border: 'none',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: 20,
}}
>
×
</button>
</div>
}
placement="right"
@@ -1196,7 +1232,9 @@ function RemoveInfo() {
</div>
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}>
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}></label>
<label style={{ fontWeight: 500, color: '#334155', marginBottom: 8, display: 'block', fontSize: 14 }}>
<span style={{ color: '#f94444' }}></span>
</label>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button
@@ -1387,12 +1425,17 @@ function RemoveInfo() {
flexWrap: 'wrap',
gap: 4,
}}>
{engineOptions.ratios.map((ratio) => (
{engineOptions.ratios.map((ratio) => {
const [w, h] = ratio.split(':').map(Number);
const maxSize = 18;
const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
return (
<button
key={ratio}
onClick={() => setVideoAspectRatio(ratio)}
style={{
flex: '0 0 calc(14.28% - 4px)',
flex: '0 0 calc(12.5% - 4px)',
minWidth: 44,
height: 52,
borderRadius: 6,
@@ -1411,10 +1454,10 @@ function RemoveInfo() {
}}
>
<div style={{
width: 18,
height: 18,
width: rectWidth,
height: rectHeight,
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
borderRadius: 2,
borderRadius: 3,
marginBottom: 2,
}} />
<span style={{
@@ -1427,7 +1470,8 @@ function RemoveInfo() {
{ratio}
</span>
</button>
))}
);
})}
</div>
</div>
+1
View File
@@ -564,6 +564,7 @@ export default function VideoFrameExtractor() {
<Table
columns={[
{
title: '产品名称',
dataIndex: 'title',
key: 'title',
render: (text: string) => (
+40 -31
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Button, Typography, Collapse, Space, Modal, Input, Table, message, Tooltip, Select } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -118,7 +118,9 @@ function InitialInfo() {
const isV2 = flowVersion === 'v2';
const baseSteps = isV2
// 使用 useMemo 缓存 baseSteps,避免每次渲染都创建新数组
const baseSteps = useMemo(() => {
return isV2
? [
{ id: 1, title: '素材与项目信息', description: '固定片段素材和项目描述', childId: 1 },
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
@@ -131,41 +133,35 @@ function InitialInfo() {
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
];
}, [isV2]);
// 合并基础步骤和API返回的状态
const steps = baseSteps.map((step, index) => ({
// 使用 useMemo 缓存 steps,避免每次渲染都创建新数组
const steps = useMemo(() => {
return baseSteps.map((step, index) => ({
...step,
status: apiSteps[index]?.status || '',
id: apiSteps[index]?.id || index,
output: apiSteps[index]?.output || '',
input: apiSteps[index]?.input || {},
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
errorMessage: apiSteps[index]?.errorMessage || '',
}));
}, [baseSteps, apiSteps]);
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开
// 当steps更新时,默认只展开生成中的步骤
const lastProcessingKeys = useRef<string>('');
useEffect(() => {
const completedFailedKeys = new Set<string>();
const processingKeys = steps
.filter(step => step.status === 'processing')
.map(step => String(step.childId));
activeKey.forEach(key => {
const step = steps.find(s => String(s.childId) === key);
if (step && (step.status === 'completed' || step.status === 'failed')) {
completedFailedKeys.add(key);
const processingKeysStr = JSON.stringify(processingKeys);
if (processingKeysStr !== lastProcessingKeys.current) {
lastProcessingKeys.current = processingKeysStr;
setActiveKey(processingKeys);
}
});
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].status === 'completed' || steps[i].status === 'failed') {
completedFailedKeys.add(String(steps[i].childId));
break;
}
}
if (completedFailedKeys.size > 0) {
setActiveKey(Array.from(completedFailedKeys));
} else {
setActiveKey([]);
}
}, [apiSteps]);
}, [steps]);
@@ -785,13 +781,17 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
<div className="medio_box video-container" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default', position: 'relative' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
{taskDetail?.material?.materialVideoUrl ? (
<>
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}></div>
)}
@@ -835,12 +835,14 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span>
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<div className="medio_box video-container" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer', position: 'relative' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</div>
</div>
</div>
@@ -1548,14 +1550,21 @@ function InitialInfo() {
<>
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 16, position: 'relative', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', border: '1px solid rgba(99, 102, 241, 0.08)', boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)' }}>
{step.status === 'completed' && taskDetail?.finalVideoUrl ? (
<video src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} style={{ width: '100%', height: 180, objectFit: 'cover' }} controls />
<video src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} style={{ width: '100%', height: 180 }} controls />
) : step.status === 'processing' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}>
<div style={{ width: 20, height: 20, borderRadius: '50%', border: '2px solid #e2e8f0', borderTopColor: '#6366f1', animation: 'spinSlow 1s linear infinite' }} />
<span style={{ color: '#acacacff', fontSize: 12 }}>5</span>
<span style={{ color: '#6366f1', fontSize: 14 }}>...</span>
</div>
) : step.status === 'failed' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 14 }}></div>
<>
<div style={{ width: '100%', height: 180, display: 'flex',flexDirection:'column', alignItems: 'center', justifyContent: 'center', color: '#ef4444', fontSize: 14 }}>
<p></p>
<p>{step.errorMessage || '未知错误'}</p>
</div>
</>
) : (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}></div>
)}