本地修改

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>
<script type="module" crossorigin src="/assets/index-CVSmzRXM.js"></script> <script type="module" crossorigin src="/assets/index-Bkv0KkOY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css"> <link rel="stylesheet" crossorigin href="/assets/index-IrbjYl3V.css">
</head> </head>
<body> <body>
<div id="root"></div> <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); return api.post(`/shot-replications/task-sets/${taskSetId}/split-custom`, params);
} }
// 拆镜列表 // 拆镜列表
export async function Removelist(taskSetId: string): Promise<any> { export async function Removelist(taskSetId: string, page: number = 1, pageSize: number = 20): Promise<any> {
return api.get(`/shot-replications/task-sets/${taskSetId}/segments`); 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> { export async function removeCreate(recordId: string, params: any): Promise<any> {
@@ -241,7 +241,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onHistorySelect?.(items); onHistorySelect?.(items);
setHistoryModalVisible(false); 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} maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount} maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount} usedImageCount={usedImageCount}
@@ -253,6 +253,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
maxAudioDuration={maxAudioDuration} maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration} usedAudioDuration={usedAudioDuration}
hideLimitHint={hideLimitHint} hideLimitHint={hideLimitHint}
defaultResourceType={mediaType === 'image' ? 'image' : ''}
/> />
<PrivatePortraitAssetPicker <PrivatePortraitAssetPicker
@@ -1,5 +1,5 @@
import React from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Alert, Input, Radio, Space, Tag, Typography } from 'antd'; import { Alert, Input, Menu, Radio, Space, Tag, Typography } from 'antd';
import type { import type {
JsonValue, JsonValue,
VideoPromptSchemaFieldConfig, VideoPromptSchemaFieldConfig,
@@ -31,9 +31,9 @@ function isRecord(value: unknown): value is Record<string, JsonValue> {
function ReadonlyBlock({ value }: { value: JsonValue }) { function ReadonlyBlock({ value }: { value: JsonValue }) {
const text = value === 'true' ? '是' : value === 'false' ? '否' : stringifySchemaValue(value); const text = value === 'true' ? '是' : value === 'false' ? '否' : stringifySchemaValue(value);
return shouldUseSchemaTextArea(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, maxLength,
showCount: !!maxLength, showCount: !!maxLength,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(event.target.value), 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)) { 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 VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, schemaConfigSnapshot, onChange }) => {
const safeValue = isRecord(value) ? value : {}; const safeValue = isRecord(value) ? value : {};
const sections = normalizeSchemaSections(schemaConfigSnapshot); 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)) { if (!hasSchemaConfigSnapshot(schemaConfigSnapshot)) {
return ( return (
@@ -104,18 +169,17 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
if (!fields.length) return null; if (!fields.length) return null;
return ( return (
<div key={section.key} style={{ marginBottom: 18 }}> <div key={section.key} style={{ marginBottom: 20 }}>
<Space style={{ marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text> <div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
{/* {renderFieldTag(section)} */} <Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
</Space> </div>
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}> <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) => ( {fields.map((field) => (
<div key={field.key} style={{ marginBottom: 12 }}> <div key={field.key} style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 4 }}> <div style={{ marginBottom: 6 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text> <Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{/* {renderFieldTag(field)} */} </div>
</Space>
{field.editable ? ( {field.editable ? (
field.type === 'boolean' ? ( field.type === 'boolean' ? (
<BooleanRadio <BooleanRadio
@@ -145,26 +209,26 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
const displayFields = [{ key: '时间段', label: '时间段', enabled: true, editable: false } as VideoPromptSchemaFieldConfig, ...fields]; const displayFields = [{ key: '时间段', label: '时间段', enabled: true, editable: false } as VideoPromptSchemaFieldConfig, ...fields];
return ( return (
<div key={section.key} style={{ marginBottom: 18 }}> <div key={section.key} style={{ marginBottom: 20 }}>
<Space style={{ marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text> <div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
<Tag color="default"></Tag> <Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
</Space> <Tag style={{ borderRadius: 4, fontSize: 11, padding: '2px 8px', background: '#f1f5f9', color: '#64748b', border: 'none' }}></Tag>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: '#fff' }}> </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 ? ( {rows.length === 0 ? (
<Text style={{ color: '#94a3b8', fontSize: 12 }}></Text> <Text style={{ color: '#94a3b8', fontSize: 12 }}></Text>
) : ( ) : (
rows.map((item, index) => { rows.map((item, index) => {
const row = isRecord(item) ? item : {}; const row = isRecord(item) ? item : {};
return ( 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' }}> <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: 8, color: '#475569', fontSize: 12 }}> {index + 1} </Text> <Text strong style={{ display: 'block', marginBottom: 10, color: '#475569', fontSize: 13 }}> {index + 1} </Text>
{displayFields.map((field) => ( {displayFields.map((field) => (
<div key={field.key} style={{ marginBottom: 10 }}> <div key={field.key} style={{ marginBottom: 14 }}>
<Space style={{ marginBottom: 4 }}> <div style={{ marginBottom: 6 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text> <Text style={{ color: '#64748b', fontSize: 12 }}>{field.label}</Text>
{/* {renderFieldTag(field)} */} </div>
</Space>
{field.editable ? ( {field.editable ? (
field.type === 'boolean' ? ( field.type === 'boolean' ? (
<BooleanRadio <BooleanRadio
@@ -195,45 +259,79 @@ const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value
const renderPrimitiveSection = (section: VideoPromptSchemaSectionConfig) => { const renderPrimitiveSection = (section: VideoPromptSchemaSectionConfig) => {
if (!(section.key in safeValue)) return null; if (!(section.key in safeValue)) return null;
return ( return (
<div key={section.key} style={{ marginBottom: 18 }}> <div key={section.key} style={{ marginBottom: 20 }}>
<Space style={{ marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{section.label}</Text> <div style={{ width: 3, height: 16, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
{/* {renderFieldTag(section)} */} <Text strong style={{ color: '#334155', fontSize: 14, fontWeight: 600 }}>{section.label}</Text>
</Space> </div>
{section.editable ? ( <div style={{ padding: '16px', backgroundColor: '#fff', borderRadius: 12, border: '1px solid #e2e8f0', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<EditableInput {section.editable ? (
value={safeValue[section.key]} <EditableInput
maxLength={section.maxLength} value={safeValue[section.key]}
onChange={(nextText) => { maxLength={section.maxLength}
const next = clonePlain(safeValue); onChange={(nextText) => {
next[section.key] = nextText; const next = clonePlain(safeValue);
onChange(next); next[section.key] = nextText;
}} onChange(next);
/> }}
) : ( />
<ReadonlyBlock value={safeValue[section.key]} /> ) : (
)} <ReadonlyBlock value={safeValue[section.key]} />
)}
</div>
</div> </div>
); );
}; };
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 ( return (
<div> <div style={{ display: 'flex', flexDirection: 'row', maxHeight: 500 }}>
{/* <Alert {/* 左侧菜单 */}
type="info" <div style={{ width: 180, flexShrink: 0, background: '#fff', borderRight: '1px solid #e2e8f0', overflowY: 'auto' }}>
showIcon <Menu
style={{ marginBottom: 14 }} mode="inline"
message="仅可修改后台 Schema 配置允许编辑的字段" selectedKeys={[selectedKey]}
description="视频规格、时间段、流程条目数量、输出规格、质量控制、合规控制等锁定内容由服务端最终校验。" onClick={handleMenuSelect}
/> */} style={{ height: '100%', borderRight: 0 }}
{sections.map((section) => { theme="light"
if (!(section.key in safeValue)) return null; items={menuItems.map(item => ({
if (section.type === 'object') return renderObjectSection(section); ...item,
if (section.type === 'flow') return renderFlowSection(section); style: {
return renderPrimitiveSection(section); 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> </div>
); );
}; };
export default VideoPromptSchemaEditor; export default VideoPromptSchemaEditor;
@@ -27,6 +27,7 @@ interface UploadResourceHistoryPickerProps {
maxAudioDuration?: number; maxAudioDuration?: number;
usedAudioDuration?: number; usedAudioDuration?: number;
hideLimitHint?: boolean; hideLimitHint?: boolean;
defaultResourceType?: MediaTypeFilter;
} }
const buildPreviewUrl = (url: string) => { const buildPreviewUrl = (url: string) => {
@@ -122,8 +123,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
maxAudioDuration, maxAudioDuration,
usedAudioDuration, usedAudioDuration,
hideLimitHint, hideLimitHint,
defaultResourceType,
}) => { }) => {
const [resourceType, setResourceType] = useState<MediaTypeFilter>(''); const [resourceType, setResourceType] = useState<MediaTypeFilter>(defaultResourceType || '');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [groupPage, setGroupPage] = useState(1); const [groupPage, setGroupPage] = useState(1);
const [groupPageSize, setGroupPageSize] = useState(10); const [groupPageSize, setGroupPageSize] = useState(10);
@@ -233,6 +235,14 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
if (open) setCheckedMap(new Map()); if (open) setCheckedMap(new Map());
}, [open]); }, [open]);
// 当 defaultResourceType 变化时,同步更新 resourceType 状态
useEffect(() => {
if (open) {
setResourceType(defaultResourceType || '');
setGroupPage(1);
}
}, [open, defaultResourceType]);
const toggle = async (item: UploadResourceHistoryItem) => { const toggle = async (item: UploadResourceHistoryItem) => {
if (selectedIdSet.has(item.id)) { if (selectedIdSet.has(item.id)) {
message.warning('该素材已经在参考内容中'); message.warning('该素材已经在参考内容中');
@@ -463,7 +473,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
pageSize={itemPageSize} pageSize={itemPageSize}
total={total} total={total}
showSizeChanger showSizeChanger
pageSizeOptions={[10, 20, 50, 100]} // pageSizeOptions={[8, 16, 32, 64]}/
onChange={(nextPage, nextSize) => { onChange={(nextPage, nextSize) => {
setItemPage(nextPage); setItemPage(nextPage);
setItemPageSize(nextSize); setItemPageSize(nextSize);
+23
View File
@@ -430,6 +430,29 @@ body {
border-color: #6366f1 !important; 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 ─────────────────── */
.mobile-bottom-nav { .mobile-bottom-nav {
display: none; display: none;
+2 -8
View File
@@ -1078,13 +1078,6 @@ const AIChatPage: React.FC = () => {
console.log(newMessage);
// 设置加载状态 // 设置加载状态
setLoading(true); setLoading(true);
@@ -1943,7 +1936,6 @@ const AIChatPage: React.FC = () => {
} }
const link = document.createElement('a'); const link = document.createElement('a');
link.href = downloadUrl; link.href = downloadUrl;
console.log(downloadUrl);
link.download = previewType === 'image' ? 'image.png' : 'video.mp4'; link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link); document.body.appendChild(link);
@@ -2804,6 +2796,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector <UploadSelector
accept="image/*" accept="image/*"
multiple={false} multiple={false}
mediaType="image"
onLocalSelect={(files) => { onLocalSelect={(files) => {
if (files.length > 0) { if (files.length > 0) {
handleUpload(files[0], 'first'); handleUpload(files[0], 'first');
@@ -2896,6 +2889,7 @@ const AIChatPage: React.FC = () => {
<UploadSelector <UploadSelector
accept="image/*" accept="image/*"
multiple={false} multiple={false}
mediaType="image"
onLocalSelect={(files) => { onLocalSelect={(files) => {
if (files.length > 0) { if (files.length > 0) {
handleUpload(files[0], 'last'); handleUpload(files[0], 'last');
+48 -41
View File
@@ -2334,6 +2334,7 @@ const GeneratePage: React.FC = () => {
))} ))}
<UploadSelector <UploadSelector
accept="image/*,video/*" accept="image/*,video/*"
mediaType={mediaType}
onLocalSelect={(files) => { onLocalSelect={(files) => {
files.forEach(async (file) => { files.forEach(async (file) => {
await handlePasteUpload(file); await handlePasteUpload(file);
@@ -2667,47 +2668,53 @@ const GeneratePage: React.FC = () => {
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, gap: 4,
}}> }}>
{engineOptions.ratios.map((ratio) => ( {engineOptions.ratios.map((ratio) => {
<button const [w, h] = ratio.split(':').map(Number);
key={ratio} const maxSize = 18;
onClick={() => setVideoAspectRatio(ratio as AspectRatio)} const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
style={{ const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
flex: '0 0 calc(14.28% - 4px)', return (
minWidth: 44, <button
height: 52, key={ratio}
borderRadius: 6, onClick={() => setVideoAspectRatio(ratio as AspectRatio)}
border: videoAspectRatio === ratio style={{
? '2px solid #6366f1' flex: '0 0 calc(12.5% - 4px)',
: '1px solid #e5e7eb', minWidth: 44,
backgroundColor: videoAspectRatio === ratio height: 52,
? '#fff' borderRadius: 6,
: '#f9fafb', border: videoAspectRatio === ratio
cursor: 'pointer', ? '2px solid #6366f1'
display: 'flex', : '1px solid #e5e7eb',
flexDirection: 'column', backgroundColor: videoAspectRatio === ratio
justifyContent: 'center', ? '#fff'
alignItems: 'center', : '#f9fafb',
transition: 'all 0.2s', cursor: 'pointer',
}} display: 'flex',
> flexDirection: 'column',
<div style={{ justifyContent: 'center',
width: 18, alignItems: 'center',
height: 18, transition: 'all 0.2s',
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`, }}
borderRadius: 2, >
marginBottom: 2, <div style={{
}} /> width: rectWidth,
<span style={{ height: rectHeight,
fontSize: 9, border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
color: videoAspectRatio === ratio borderRadius: 3,
? '#6366f1' marginBottom: 2,
: '#6b7280', }} />
fontWeight: videoAspectRatio === ratio ? 600 : 400, <span style={{
}}> fontSize: 9,
{ratio} color: videoAspectRatio === ratio
</span> ? '#6366f1'
</button> : '#6b7280',
))} fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
);
})}
</div> </div>
</div> </div>
+66 -54
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 { 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 { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined, WarningOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -150,54 +150,49 @@ function InitialInfo() {
const isV2 = flowVersion === 'v2'; const isV2 = flowVersion === 'v2';
const baseSteps = isV2 // 使用 useMemo 缓存 baseSteps,避免每次渲染都创建新数组
? [ const baseSteps = useMemo(() => {
{ id: 1, title: '素材与项目信息', description: '固定素材和项目描述', childId: 1 }, return isV2
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 }, ? [
{ id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 }, { id: 1, title: '素材与项目信息', description: '固定素材和项目描述', childId: 1 },
] { id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
: [ { id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 },
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 }, ]
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 }, : [
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 }, { id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 }, { id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 }, { id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
]; { id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
];
}, [isV2]);
// 合并基础步骤和API返回的状态 // 使用 useMemo 缓存 steps,避免每次渲染都创建新数组
const steps = baseSteps.map((step, index) => ({ const steps = useMemo(() => {
...step, return baseSteps.map((step, index) => ({
status: apiSteps[index]?.status || '', ...step,
id: apiSteps[index]?.id || index, status: apiSteps[index]?.status || '',
output: apiSteps[index]?.output || '', id: apiSteps[index]?.id || index,
input: apiSteps[index]?.input || {}, output: apiSteps[index]?.output || '',
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '', input: apiSteps[index]?.input || {},
})); engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
errorMessage: apiSteps[index]?.errorMessage || '',
}));
}, [baseSteps, apiSteps]);
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开 // 当steps更新时,默认只展开生成中的步骤
const lastProcessingKeys = useRef<string>('');
useEffect(() => { useEffect(() => {
const completedFailedKeys = new Set<string>(); const processingKeys = steps
.filter(step => step.status === 'processing')
.map(step => String(step.childId));
activeKey.forEach(key => { const processingKeysStr = JSON.stringify(processingKeys);
const step = steps.find(s => String(s.childId) === key); if (processingKeysStr !== lastProcessingKeys.current) {
if (step && (step.status === 'completed' || step.status === 'failed')) { lastProcessingKeys.current = processingKeysStr;
completedFailedKeys.add(key); 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;
}
} }
}, [steps]);
if (completedFailedKeys.size > 0) {
setActiveKey(Array.from(completedFailedKeys));
} else {
setActiveKey([]);
}
}, [apiSteps]);
// console.log('steps', 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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span> </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 ? ( {taskDetail?.material?.materialVideoUrl ? (
<video <>
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)} src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} 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> <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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span> </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 <video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/> />
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</div> </div>
</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)' }}> <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 ? ( {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' ? ( ) : step.status === 'processing' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}> <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' }} /> <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> <span style={{ color: '#6366f1', fontSize: 14 }}>...</span>
</div> </div>
) : step.status === 'failed' ? ( ) : 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> <div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}></div>
)} )}
+48 -35
View File
@@ -899,6 +899,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}> <p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span style={{ color: '#f94444' }}></span>
</p> </p>
{videoUrl ? ( {videoUrl ? (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -1015,6 +1016,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}> <p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span></span>
</p> </p>
{imageUrl ? ( {imageUrl ? (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -1142,6 +1144,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}> <p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p> </p>
<Input <Input
value={originalProductName} value={originalProductName}
@@ -1163,6 +1166,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}> <p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p> </p>
<Input <Input
value={ownProductName} value={ownProductName}
@@ -1184,6 +1188,7 @@ const GenerateConver: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}> <p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#475569', marginBottom: 8 }}>
<span></span>
</p> </p>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<TextArea <TextArea
@@ -1208,7 +1213,9 @@ const GenerateConver: React.FC = () => {
</div> </div>
<div style={{ marginBottom: 16, display: 'grid', gap: 10 }}> <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={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}> <div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button <button
@@ -1399,39 +1406,44 @@ const GenerateConver: React.FC = () => {
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, gap: 4,
}}> }}>
{engineOptions.ratios.map((ratio) => ( {engineOptions.ratios.map((ratio) => {
<button const [w, h] = ratio.split(':').map(Number);
key={ratio} const maxSize = 18;
onClick={() => setVideoAspectRatio(ratio)} const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
style={{ const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
flex: '0 0 calc(14.28% - 4px)', return (
minWidth: 44, <button
height: 52, key={ratio}
borderRadius: 6, onClick={() => setVideoAspectRatio(ratio)}
border: videoAspectRatio === ratio style={{
? '2px solid #6366f1' flex: '0 0 calc(12.5% - 4px)',
: '1px solid #e5e7eb', minWidth: 44,
backgroundColor: videoAspectRatio === ratio height: 52,
? '#fff' borderRadius: 6,
: '#f9fafb', border: videoAspectRatio === ratio
cursor: 'pointer', ? '2px solid #6366f1'
display: 'flex', : '1px solid #e5e7eb',
flexDirection: 'column', backgroundColor: videoAspectRatio === ratio
justifyContent: 'center', ? '#fff'
alignItems: 'center', : '#f9fafb',
transition: 'all 0.2s', cursor: 'pointer',
}} display: 'flex',
> flexDirection: 'column',
<div style={{ justifyContent: 'center',
width: 18, alignItems: 'center',
height: 18, transition: 'all 0.2s',
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`, }}
borderRadius: 2, >
marginBottom: 2, <div style={{
}} /> width: rectWidth,
<span style={{ height: rectHeight,
fontSize: 9, border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
color: videoAspectRatio === ratio borderRadius: 3,
marginBottom: 2,
}} />
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#6366f1' ? '#6366f1'
: '#6b7280', : '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400, fontWeight: videoAspectRatio === ratio ? 600 : 400,
@@ -1439,7 +1451,8 @@ const GenerateConver: React.FC = () => {
{ratio} {ratio}
</span> </span>
</button> </button>
))} );
})}
</div> </div>
</div> </div>
+130 -86
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 { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Modal, Popconfirm, Select, Spin, Table, Tag, Tooltip, message } from 'antd'; 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'; import { ArrowLeftOutlined, DeleteOutlined, PlusOutlined, XOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
@@ -29,6 +29,40 @@ function buildAssetUrl(url?: string): string {
return `${API_BASE}${url}`; 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() { function RemoveInfo() {
const { creatID } = useParams<{ creatID: string }>(); const { creatID } = useParams<{ creatID: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -63,6 +97,9 @@ function RemoveInfo() {
const [creditRules, setCreditRules] = useState<any[]>([]); const [creditRules, setCreditRules] = useState<any[]>([]);
const [taskDetail, setTaskDetail] = useState<any>(null); const [taskDetail, setTaskDetail] = useState<any>(null);
const [tableData, setTableData] = useState<any[]>([]); 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 [loading, setLoading] = useState(false);
const [splitLoading, setSplitLoading] = useState(false); const [splitLoading, setSplitLoading] = useState(false);
const [autoSplitLoading, setAutoSplitLoading] = useState(false); const [autoSplitLoading, setAutoSplitLoading] = useState(false);
@@ -134,12 +171,18 @@ function RemoveInfo() {
const fetchSegments = useCallback(async () => { const fetchSegments = useCallback(async () => {
if (!creatID) return; if (!creatID) return;
try { try {
const res = await Removelist(creatID); const res = await Removelist(creatID, currentPage, pageSize);
setTableData(res.items || []); setTableData(res.items || []);
setTotal(res.total || 0);
} catch { } catch {
message.error('获取拆镜列表失败'); message.error('获取拆镜列表失败');
} }
}, [creatID]); }, [creatID, currentPage, pageSize]);
const handleVideoPreview = useCallback((url: string) => {
setPreviewVideoUrl(url);
setPreviewModalVisible(true);
}, []);
const handleReanalyze = useCallback(async () => { const handleReanalyze = useCallback(async () => {
if (!creatID) return; if (!creatID) return;
@@ -454,11 +497,11 @@ function RemoveInfo() {
return splitStatus === 'completed' && !!(record?.segmentVideoUrl || record?.segment_video_url); return splitStatus === 'completed' && !!(record?.segmentVideoUrl || record?.segment_video_url);
}; };
const columns: any[] = [ const columns = useMemo(() => [
{ {
title: '片段', title: '片段',
width: 100, width: 100,
fixed: 'left', fixed: 'left' as const,
align: 'center' as const, align: 'center' as const,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<div> <div>
@@ -475,35 +518,7 @@ function RemoveInfo() {
width: 150, width: 150,
align: 'center' as const, align: 'center' as const,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9', cursor: 'pointer' }} <VideoCell record={record} onPreview={handleVideoPreview} />
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>
), ),
}, },
{ {
@@ -671,7 +686,7 @@ function RemoveInfo() {
{ {
title: '操作', title: '操作',
width: 200, width: 200,
fixed: 'right', fixed: 'right' as const,
align: 'center' as const, align: 'center' as const,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -727,7 +742,7 @@ function RemoveInfo() {
</div> </div>
), ),
}, },
]; ], []);
return ( return (
<> <>
@@ -1033,7 +1048,17 @@ function RemoveInfo() {
<Table <Table
columns={columns} columns={columns}
dataSource={tableData} 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} bordered={false}
rowKey="id" rowKey="id"
scroll={{ y: 300 }} scroll={{ y: 300 }}
@@ -1103,7 +1128,7 @@ function RemoveInfo() {
onConfirm={handleCustomSplit} onConfirm={handleCustomSplit}
/> />
<Drawer <Drawer
title={ title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -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> <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>} {currentSegmentName && <span style={{ fontSize: 14, color: '#64748b' }}>- {currentSegmentName}</span>}
</div> </div>
<Button <button
type="text" onClick={(e) => { e.stopPropagation(); handleCloseDrawer(); }}
icon={<XOutlined />} style={{
onClick={handleCloseDrawer} width: 24,
style={{ padding: 0, color: '#64748b', fontSize: 14 }} height: 24,
/> border: 'none',
background: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: 20,
}}
>
×
</button>
</div> </div>
} }
placement="right" placement="right"
@@ -1196,7 +1232,9 @@ function RemoveInfo() {
</div> </div>
<div style={{ background: '#fff', borderRadius: 12, padding: 16, border: '1px solid rgba(99, 102, 241, 0.1)' }}> <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={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ flex: 1, position: 'relative', display: 'inline-block' }}> <div style={{ flex: 1, position: 'relative', display: 'inline-block' }}>
<button <button
@@ -1387,47 +1425,53 @@ function RemoveInfo() {
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, gap: 4,
}}> }}>
{engineOptions.ratios.map((ratio) => ( {engineOptions.ratios.map((ratio) => {
<button const [w, h] = ratio.split(':').map(Number);
key={ratio} const maxSize = 18;
onClick={() => setVideoAspectRatio(ratio)} const rectWidth = w >= h ? maxSize : Math.round(maxSize * (w / h));
style={{ const rectHeight = h >= w ? maxSize : Math.round(maxSize * (h / w));
flex: '0 0 calc(14.28% - 4px)', return (
minWidth: 44, <button
height: 52, key={ratio}
borderRadius: 6, onClick={() => setVideoAspectRatio(ratio)}
border: videoAspectRatio === ratio style={{
? '2px solid #6366f1' flex: '0 0 calc(12.5% - 4px)',
: '1px solid #e5e7eb', minWidth: 44,
backgroundColor: videoAspectRatio === ratio height: 52,
? '#fff' borderRadius: 6,
: '#f9fafb', border: videoAspectRatio === ratio
cursor: 'pointer', ? '2px solid #6366f1'
display: 'flex', : '1px solid #e5e7eb',
flexDirection: 'column', backgroundColor: videoAspectRatio === ratio
justifyContent: 'center', ? '#fff'
alignItems: 'center', : '#f9fafb',
transition: 'all 0.2s', cursor: 'pointer',
}} display: 'flex',
> flexDirection: 'column',
<div style={{ justifyContent: 'center',
width: 18, alignItems: 'center',
height: 18, transition: 'all 0.2s',
border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`, }}
borderRadius: 2, >
marginBottom: 2, <div style={{
}} /> width: rectWidth,
<span style={{ height: rectHeight,
fontSize: 9, border: `2px solid ${videoAspectRatio === ratio ? '#6366f1' : '#9ca3af'}`,
color: videoAspectRatio === ratio borderRadius: 3,
? '#6366f1' marginBottom: 2,
: '#6b7280', }} />
fontWeight: videoAspectRatio === ratio ? 600 : 400, <span style={{
}}> fontSize: 9,
{ratio} color: videoAspectRatio === ratio
</span> ? '#6366f1'
</button> : '#6b7280',
))} fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
);
})}
</div> </div>
</div> </div>
+1
View File
@@ -564,6 +564,7 @@ export default function VideoFrameExtractor() {
<Table <Table
columns={[ columns={[
{ {
title: '产品名称',
dataIndex: 'title', dataIndex: 'title',
key: 'title', key: 'title',
render: (text: string) => ( render: (text: string) => (
+64 -55
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 { 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 { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -118,54 +118,50 @@ function InitialInfo() {
const isV2 = flowVersion === 'v2'; const isV2 = flowVersion === 'v2';
const baseSteps = isV2 // 使用 useMemo 缓存 baseSteps,避免每次渲染都创建新数组
? [ const baseSteps = useMemo(() => {
{ id: 1, title: '素材与项目信息', description: '固定片段素材和项目描述', childId: 1 }, return isV2
{ id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 }, ? [
{ id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 }, { id: 1, title: '素材与项目信息', description: '固定片段素材和项目描述', childId: 1 },
] { id: 2, title: '生成视频提示词', description: '生成并确认视频提示词', childId: 4 },
: [ { id: 3, title: '生成最终视频', description: '使用当前视频提示词生成视频', childId: 5 },
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 }, ]
{ id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 }, : [
{ id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 }, { id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
{ id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 }, { id: 2, title: '生成提示词', description: '根据素材生成描述词', childId: 2 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 }, { id: 3, title: '生成产品融合图', description: '生成产品与场景融合图', childId: 3 },
]; { id: 4, title: '生成视频提示词', description: '生成视频生成提示词', childId: 4 },
{ id: 5, title: '生成最终视频', description: '合成最终视频', childId: 5 },
];
}, [isV2]);
// 合并基础步骤和API返回的状态 // 使用 useMemo 缓存 steps,避免每次渲染都创建新数组
const steps = baseSteps.map((step, index) => ({ const steps = useMemo(() => {
...step, return baseSteps.map((step, index) => ({
status: apiSteps[index]?.status || '', ...step,
id: apiSteps[index]?.id || index, status: apiSteps[index]?.status || '',
output: apiSteps[index]?.output || '', id: apiSteps[index]?.id || index,
input: apiSteps[index]?.input || {}, output: apiSteps[index]?.output || '',
engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '', input: apiSteps[index]?.input || {},
})); engineId: apiSteps[index]?.input?.payload?.videoConfig?.engineId || '',
errorMessage: apiSteps[index]?.errorMessage || '',
// 当apiSteps更新时,逆向遍历找到第一个已完成或失败的步骤并展开 }));
}, [baseSteps, apiSteps]);
// 当steps更新时,默认只展开生成中的步骤
const lastProcessingKeys = useRef<string>('');
useEffect(() => { useEffect(() => {
const completedFailedKeys = new Set<string>(); const processingKeys = steps
.filter(step => step.status === 'processing')
activeKey.forEach(key => { .map(step => String(step.childId));
const step = steps.find(s => String(s.childId) === key);
if (step && (step.status === 'completed' || step.status === 'failed')) { const processingKeysStr = JSON.stringify(processingKeys);
completedFailedKeys.add(key); 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;
}
} }
}, [steps]);
if (completedFailedKeys.size > 0) {
setActiveKey(Array.from(completedFailedKeys));
} else {
setActiveKey([]);
}
}, [apiSteps]);
@@ -785,13 +781,17 @@ function InitialInfo() {
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} /> <span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span> </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 ? ( {taskDetail?.material?.materialVideoUrl ? (
<video <>
<video
src={buildMediaUrl(taskDetail.material.materialVideoUrl)} src={buildMediaUrl(taskDetail.material.materialVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} 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> <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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
</span> </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 <video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/> />
<div className="video-overlay">
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff', opacity: 0.9 }} />
</div>
</div> </div>
</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)' }}> <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 ? ( {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' ? ( ) : step.status === 'processing' ? (
<div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8 }}> <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' }} /> <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> <span style={{ color: '#6366f1', fontSize: 14 }}>...</span>
</div> </div>
) : step.status === 'failed' ? ( ) : 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> <div style={{ width: '100%', height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}></div>
)} )}