真人人像修改
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Upload,
|
||||
message,
|
||||
Space,
|
||||
Typography,
|
||||
@@ -320,6 +319,28 @@ const AIChatPage: React.FC = () => {
|
||||
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
|
||||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||||
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// 弹窗打开时自动播放视频,关闭时停止
|
||||
useEffect(() => {
|
||||
if (attachmentPreviewVisible && attachmentPreviewType === 'video' && attachmentPreviewVideoRef.current) {
|
||||
const v = attachmentPreviewVideoRef.current;
|
||||
v.currentTime = 0;
|
||||
v.muted = false;
|
||||
const playPromise = v.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {
|
||||
// 自动播放被阻止时静音重试
|
||||
v.muted = true;
|
||||
v.play().catch(() => {});
|
||||
});
|
||||
}
|
||||
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
|
||||
const v = attachmentPreviewVideoRef.current;
|
||||
v.pause();
|
||||
v.muted = true;
|
||||
v.currentTime = 0;
|
||||
}
|
||||
}, [attachmentPreviewVisible, attachmentPreviewType, attachmentPreviewUrl]);
|
||||
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
|
||||
@@ -387,24 +408,22 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据配置计算积分
|
||||
// 根据配置计算积分(保留两位小数,不做取整)
|
||||
if (mediaType === 'video') {
|
||||
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
||||
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||||
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
|
||||
// 传入视频积分
|
||||
const inputVideoDuration = currentMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (inputVideoDuration > 0) {
|
||||
const inputVideoCost = Math.round(
|
||||
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
|
||||
);
|
||||
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
return total;
|
||||
return Number(total.toFixed(2));
|
||||
} else {
|
||||
// 图片:baseCredits × ratio
|
||||
return config.baseCredits * config.ratio;
|
||||
return Number((config.baseCredits * config.ratio).toFixed(2));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -412,6 +431,8 @@ const AIChatPage: React.FC = () => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pollingRef = useRef<number | null>(null);
|
||||
const isFirstLoadRef = useRef<boolean>(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
const lastAutoScrollTime = useRef(0);
|
||||
|
||||
const currentConversation = conversations.find((c) => c.id === currentConversationId);
|
||||
|
||||
@@ -424,7 +445,6 @@ const AIChatPage: React.FC = () => {
|
||||
});
|
||||
const [Totalnumber, setTotalnumber] = useState<any>(0);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
// ==================== 副作用 ====================
|
||||
|
||||
// 滚动到底部的辅助函数
|
||||
@@ -440,6 +460,8 @@ const AIChatPage: React.FC = () => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
lastAutoScrollTime.current = Date.now();
|
||||
|
||||
const doScroll = () => {
|
||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
|
||||
};
|
||||
@@ -507,9 +529,8 @@ const AIChatPage: React.FC = () => {
|
||||
const supportsUR = engine.supportsUniversalReference ?? true;
|
||||
|
||||
setReferenceMode((prevMode) => {
|
||||
if (prevMode === 'first_last_frame' && !supportsFLF) {
|
||||
if (supportsUR) return 'universal';
|
||||
} else if (prevMode === 'universal' && !supportsUR) {
|
||||
if (supportsUR) return 'universal';
|
||||
if (prevMode === 'universal' && !supportsUR) {
|
||||
if (supportsFLF) return 'first_last_frame';
|
||||
}
|
||||
return prevMode;
|
||||
@@ -582,26 +603,9 @@ const AIChatPage: React.FC = () => {
|
||||
{ value: '2K', label: '高清 2K' },
|
||||
{ value: '4K', label: '超清 4K' },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
});
|
||||
// getCreditRatios()
|
||||
// .then((data: any) => {
|
||||
// // console.log('积分', data);
|
||||
|
||||
// setCreditRatios(data.video || []);
|
||||
// setCimage(data.image || []);
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// });
|
||||
calculateCredits().then((data: any) => {
|
||||
// console.log('积分计算', data);
|
||||
// 保存积分计算数据
|
||||
@@ -765,6 +769,8 @@ const AIChatPage: React.FC = () => {
|
||||
if (mode === 'universal' && !supportsUniversalReference) return;
|
||||
setReferenceMode(mode);
|
||||
setReferenceModeDropdownVisible(false);
|
||||
setCurrentMedia([]);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleSwapFrames = () => {
|
||||
@@ -1704,7 +1710,7 @@ const AIChatPage: React.FC = () => {
|
||||
const composerPlaceholder = mediaType === 'image'
|
||||
? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。'
|
||||
: isFirstLastFrameComposer
|
||||
? '描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。'
|
||||
? '首帧尾帧可以不传,如需传入建议描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。'
|
||||
: '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。';
|
||||
const composerHelperText = mediaType === 'image'
|
||||
? '图片参考 · 适合海报、产品图、场景图与风格图生成'
|
||||
@@ -1906,27 +1912,21 @@ const AIChatPage: React.FC = () => {
|
||||
background: '#fff',
|
||||
// borderRadius: 22,
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const now = Date.now();
|
||||
const isAutoScroll = now - lastAutoScrollTime.current < 500;
|
||||
if (!isAutoScroll && isInitialLoadDone.current && target.scrollTop <= 50 && !isLoadingMore && gen_list.length < Totalnumber) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 加载更多按钮 - 在列表顶部 */}
|
||||
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
|
||||
{gen_list.length >= Totalnumber ? (
|
||||
<span style={{ fontSize: 12, color: '#98a2b3' }}>消息全部加载</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#8b5cf6',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isLoadingMore ? '加载中...' : '加载更多'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 加载更多提示 */}
|
||||
{isLoadingMore && (
|
||||
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 12, color: '#8b5cf6' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 遍历消息列表 */}
|
||||
{(() => {
|
||||
const msgApi = message;
|
||||
@@ -2226,7 +2226,7 @@ const AIChatPage: React.FC = () => {
|
||||
<span style={{
|
||||
// background: 'rgba(139, 92, 246, 0.08)',
|
||||
borderRadius: 16, color: '#667085'
|
||||
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
||||
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}秒 · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
||||
<span style={{
|
||||
// background: 'rgba(164, 91, 91, 0.08)',
|
||||
fontSize: 14,
|
||||
@@ -2497,7 +2497,7 @@ const AIChatPage: React.FC = () => {
|
||||
{/* 首帧 */}
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>首帧</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>首帧(可选)</span>
|
||||
</div>
|
||||
{firstFrame ? (
|
||||
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
|
||||
@@ -2520,7 +2520,45 @@ const AIChatPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'first')}>
|
||||
<UploadSelector
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
onLocalSelect={(files) => {
|
||||
if (files.length > 0) {
|
||||
handleUpload(files[0], 'first');
|
||||
}
|
||||
}}
|
||||
onHistorySelect={(items) => {
|
||||
if (items.length > 0) {
|
||||
const item = items[0];
|
||||
const mediaRef: MediaReference = {
|
||||
name: item.name,
|
||||
type: 'image',
|
||||
url: item.url,
|
||||
role: 'first_frame',
|
||||
label: '',
|
||||
};
|
||||
setFirstFrame(mediaRef);
|
||||
message.success('成功添加首帧');
|
||||
}
|
||||
}}
|
||||
onPortraitSelect={(assets) => {
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||
const mediaRef: MediaReference = {
|
||||
name: asset.name || '首帧图片',
|
||||
type: 'image',
|
||||
url: previewUrl,
|
||||
role: 'first_frame',
|
||||
label: '',
|
||||
};
|
||||
setFirstFrame(mediaRef);
|
||||
message.success('成功添加首帧');
|
||||
}
|
||||
}}
|
||||
uploading={uploading}
|
||||
>
|
||||
<div
|
||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
||||
@@ -2529,7 +2567,7 @@ const AIChatPage: React.FC = () => {
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
||||
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传首帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2572,7 +2610,45 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
firstFrame ? (
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'last')}>
|
||||
<UploadSelector
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
onLocalSelect={(files) => {
|
||||
if (files.length > 0) {
|
||||
handleUpload(files[0], 'last');
|
||||
}
|
||||
}}
|
||||
onHistorySelect={(items) => {
|
||||
if (items.length > 0) {
|
||||
const item = items[0];
|
||||
const mediaRef: MediaReference = {
|
||||
name: item.name,
|
||||
type: 'image',
|
||||
url: item.url,
|
||||
role: 'last_frame',
|
||||
label: '',
|
||||
};
|
||||
setLastFrame(mediaRef);
|
||||
message.success('成功添加尾帧');
|
||||
}
|
||||
}}
|
||||
onPortraitSelect={(assets) => {
|
||||
if (assets.length > 0) {
|
||||
const asset = assets[0];
|
||||
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||
const mediaRef: MediaReference = {
|
||||
name: asset.name || '尾帧图片',
|
||||
type: 'image',
|
||||
url: previewUrl,
|
||||
role: 'last_frame',
|
||||
label: '',
|
||||
};
|
||||
setLastFrame(mediaRef);
|
||||
message.success('成功添加尾帧');
|
||||
}
|
||||
}}
|
||||
uploading={uploading}
|
||||
>
|
||||
<div
|
||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
||||
@@ -2581,7 +2657,7 @@ const AIChatPage: React.FC = () => {
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
||||
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
|
||||
</div>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
) : (
|
||||
<div
|
||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }}
|
||||
@@ -2644,23 +2720,23 @@ const AIChatPage: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: '#ffffff',
|
||||
background: '#f4f4f4',
|
||||
flexDirection: 'column',
|
||||
gap: 5,
|
||||
transform: 'rotate(-7deg)',
|
||||
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||
// boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#D7DDE7';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
||||
e.currentTarget.style.background = '#f4f4f4';
|
||||
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
||||
// e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
||||
e.currentTarget.style.background = '#f4f4f4';
|
||||
e.currentTarget.style.transform = 'rotate(-7deg)';
|
||||
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||
// e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
@@ -3099,6 +3175,8 @@ const AIChatPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setMediaType(item.value);
|
||||
setShowMediaTypeModal(false);
|
||||
setCurrentMedia([]);
|
||||
setInputValue('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -3221,14 +3299,12 @@ const AIChatPage: React.FC = () => {
|
||||
key={engine.id}
|
||||
onClick={() => {
|
||||
setCountType(engine.id);
|
||||
// 根据选中的引擎更新视频参数选项
|
||||
if (mediaType === 'video') {
|
||||
setEngineOptions({
|
||||
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
|
||||
});
|
||||
// 更新默认选中值,确保在新引擎支持的范围内
|
||||
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
|
||||
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
|
||||
}
|
||||
@@ -3240,6 +3316,8 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
setShowEngineModal(false);
|
||||
setCurrentMedia([]);
|
||||
setInputValue('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -3798,7 +3876,7 @@ const AIChatPage: React.FC = () => {
|
||||
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
|
||||
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}s</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}秒</span>
|
||||
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
|
||||
</button>
|
||||
|
||||
@@ -4187,13 +4265,12 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
}
|
||||
onCancel={() => {
|
||||
// 关闭前强制停止视频播放,避免关闭后仍有声音
|
||||
// 关闭前停止视频播放
|
||||
if (attachmentPreviewVideoRef.current) {
|
||||
const v = attachmentPreviewVideoRef.current;
|
||||
v.pause();
|
||||
v.muted = true;
|
||||
v.removeAttribute('src');
|
||||
v.load();
|
||||
v.currentTime = 0;
|
||||
}
|
||||
setAttachmentPreviewVisible(false);
|
||||
}}
|
||||
@@ -4250,6 +4327,7 @@ const AIChatPage: React.FC = () => {
|
||||
ref={attachmentPreviewVideoRef}
|
||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||
controls
|
||||
playsInline
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
@@ -4262,6 +4340,13 @@ const AIChatPage: React.FC = () => {
|
||||
onSelect={handlePrivatePortraitAssetsSelected}
|
||||
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
||||
maxCount={Math.max(1, maxImage + maxVideo)}
|
||||
maxImageCount={maxImageCount}
|
||||
maxVideoCount={maxVideoCount}
|
||||
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
|
||||
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
|
||||
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||
maxVideoDuration={15}
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||
/>
|
||||
|
||||
</Layout>
|
||||
|
||||
@@ -281,14 +281,42 @@ const GeneratePage: React.FC = () => {
|
||||
const videoCount = references.filter(
|
||||
(r) => r.type === "video",
|
||||
).length;
|
||||
if (isImage && imageCount >= 10) {
|
||||
message.error("最多上传10张图片");
|
||||
const videoDuration = references
|
||||
.filter((r) => r.type === "video")
|
||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
|
||||
const MAX_IMAGES = 5;
|
||||
const MAX_VIDEOS = 2;
|
||||
const MAX_VIDEO_DURATION = 15;
|
||||
|
||||
if (isImage && imageCount >= MAX_IMAGES) {
|
||||
message.error(`最多上传${MAX_IMAGES}张图片`);
|
||||
return false;
|
||||
}
|
||||
if (isVideo && videoCount >= 3) {
|
||||
message.error("最多上传3个视频");
|
||||
if (isVideo && videoCount >= MAX_VIDEOS) {
|
||||
message.error(`最多上传${MAX_VIDEOS}个视频`);
|
||||
return false;
|
||||
}
|
||||
let fileDuration = 0;
|
||||
if (isVideo) {
|
||||
fileDuration = await new Promise<number>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
resolve(video.duration || 0);
|
||||
video.remove();
|
||||
};
|
||||
video.onerror = () => {
|
||||
resolve(0);
|
||||
video.remove();
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
|
||||
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setUploading(true);
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
try {
|
||||
@@ -303,6 +331,7 @@ const GeneratePage: React.FC = () => {
|
||||
url: res.url,
|
||||
type: isImage ? "image" : "video",
|
||||
name: `${typeLabel}${typeCount}`,
|
||||
duration: isVideo ? fileDuration : undefined,
|
||||
},
|
||||
]);
|
||||
message.success(`${typeLabel}上传成功`);
|
||||
@@ -1842,17 +1871,24 @@ const GeneratePage: React.FC = () => {
|
||||
items.forEach((item: any) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: item.previewUrl || '',
|
||||
type: 'image',
|
||||
type: item.assetType === 'Video' ? 'video' : 'image',
|
||||
name: item.name || '真人素材',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: item.id,
|
||||
label: '',
|
||||
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
|
||||
}]);
|
||||
});
|
||||
message.success(`已添加 ${items.length} 个真人素材参考`);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={`参考内容(${references.length}/10)`}
|
||||
tooltipTitle={`图片${references.filter((r) => r.type === 'image').length}/5,视频${references.filter((r) => r.type === 'video').length}/2`}
|
||||
maxImageCount={5}
|
||||
maxVideoCount={2}
|
||||
usedImageCount={references.filter((r) => r.type === 'image').length}
|
||||
usedVideoCount={references.filter((r) => r.type === 'video').length}
|
||||
usedVideoDuration={references.filter((r) => r.type === 'video').reduce((sum, r) => sum + (r.duration || 0), 0)}
|
||||
maxVideoDuration={15}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -916,7 +916,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
}));
|
||||
}, [filterType, filterMedia]);
|
||||
return (
|
||||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
|
||||
<div className="content_box" >
|
||||
{/* 操作栏:筛选 + 推送按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -67,7 +67,7 @@ const HomePage: React.FC = () => {
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
|
||||
getmedit(5).then((res) => {
|
||||
getmedit(10).then((res) => {
|
||||
for (let item in res) {
|
||||
res[item].forEach(element => {
|
||||
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
|
||||
@@ -479,10 +479,9 @@ const HomePage: React.FC = () => {
|
||||
onClick={() => navigate(entry.path)}
|
||||
className="project-card"
|
||||
style={{
|
||||
flex: '1',
|
||||
minWidth: 260,
|
||||
height: 120,
|
||||
padding: '16px',
|
||||
flex: '1 1 280px',
|
||||
minWidth: 280,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
@@ -492,6 +491,7 @@ const HomePage: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
minHeight: 96,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = accent.color;
|
||||
@@ -529,18 +529,27 @@ const HomePage: React.FC = () => {
|
||||
>
|
||||
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: accent.color,
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: accent.light, fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}>{accent.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', }}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{entry.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Table,
|
||||
Space,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
||||
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { TextArea } = Input;
|
||||
@@ -1206,7 +1207,7 @@ const GenerateConver: React.FC = () => {
|
||||
dataIndex: 'targetProjectName',
|
||||
key: 'targetProjectName',
|
||||
align: 'center',
|
||||
width: 200,
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
|
||||
{text || '-'}
|
||||
@@ -1329,24 +1330,61 @@ const GenerateConver: React.FC = () => {
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<button
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||
style={{
|
||||
color: '#6366f1',
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<Space>
|
||||
<button
|
||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||
style={{
|
||||
color: '#6366f1',
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.08)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
|
||||
<Popconfirm
|
||||
title="确认删除这个爆款开头复刻任务吗?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteHotOpeningReplicationTask(record.id);
|
||||
message.success('删除成功');
|
||||
fetchList(1, pageSize, false, searchKeyword);
|
||||
} catch (err) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
color: '#ef4444',
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
border: 'none',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.12)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.08)';
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
|
||||
import { createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
|
||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@@ -131,6 +131,51 @@ function RemoveInfo() {
|
||||
}
|
||||
}, [creatID]);
|
||||
|
||||
const handleReanalyze = useCallback(async () => {
|
||||
if (!creatID) return;
|
||||
try {
|
||||
await reanalyzeShotReplication(creatID);
|
||||
message.success('重新分析已提交');
|
||||
fetchTaskDetail();
|
||||
if (!analysisPollingRef.current) {
|
||||
analysisPollingRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await getShotReplicationDetail(creatID);
|
||||
setTaskDetail(res);
|
||||
if (res.analysisStatus === 'completed' || res.analysisStatus === 'failed') {
|
||||
if (analysisPollingRef.current) {
|
||||
clearInterval(analysisPollingRef.current);
|
||||
analysisPollingRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '重新分析失败');
|
||||
}
|
||||
}, [creatID, fetchTaskDetail]);
|
||||
|
||||
const handleSegmentReanalyze = useCallback(async (segmentId: string) => {
|
||||
try {
|
||||
await reanalyzeSegment(segmentId);
|
||||
message.success('重新分析已提交');
|
||||
fetchSegments();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '重新分析失败');
|
||||
}
|
||||
}, [fetchSegments]);
|
||||
|
||||
const handleDeleteSegment = useCallback(async (segmentId: string) => {
|
||||
try {
|
||||
await deleteSegment(segmentId);
|
||||
message.success('删除成功');
|
||||
fetchSegments();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
}, [fetchSegments]);
|
||||
|
||||
const refreshPageData = useCallback(async () => {
|
||||
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
||||
}, [fetchTaskDetail, fetchSegments]);
|
||||
@@ -154,7 +199,7 @@ function RemoveInfo() {
|
||||
if (!taskDetail) return;
|
||||
|
||||
if (taskDetail.analysisStatus === 'processing') {
|
||||
|
||||
|
||||
analysisPollingRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await getShotReplicationDetail(creatID);
|
||||
@@ -227,13 +272,13 @@ function RemoveInfo() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const params = {
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: buildAssetUrl(productImage),
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: buildAssetUrl(productImage),
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
|
||||
|
||||
setLoading(true);
|
||||
@@ -242,23 +287,23 @@ function RemoveInfo() {
|
||||
message.success('视频生成任务创建成功');
|
||||
handleCloseDrawer();
|
||||
Removelist(creatID).then((res: any) => {
|
||||
|
||||
const targetItem = res.items.find((item: any) => item.id === currentSegment);
|
||||
message.loading('创建中...', 3);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
const targetItem = res.items.find((item: any) => item.id === currentSegment);
|
||||
message.loading('创建中...', 3);
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/removelens/${targetItem.moduleProjectId}/removefenbu`);
|
||||
}, 3000);
|
||||
});
|
||||
// fetchSegments().then((res: any) => {
|
||||
// console.log('123123123123',res);
|
||||
|
||||
|
||||
// // const targetItem = res.items.find((item: any) => item.id === currentSegment);
|
||||
// // console.log(targetItem);
|
||||
// });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// if (targetItem?.moduleProjectId) {
|
||||
// message.loading('跳转中...', 1.5);
|
||||
//
|
||||
@@ -380,13 +425,13 @@ function RemoveInfo() {
|
||||
width: 500,
|
||||
align: 'left' as const,
|
||||
render: (_: any, record: any) => {
|
||||
|
||||
|
||||
|
||||
|
||||
// AI建议的片段直接显示内容,不经过分析状态判断
|
||||
if (record.source_mode === 'ai_suggestion' || record.sourceMode === 'ai_suggestion') {
|
||||
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{record.segmentContent || record.lastError || '-'}</div>;
|
||||
}
|
||||
|
||||
|
||||
const analysisStatus = record.analysis_status || record.analysisStatus;
|
||||
const statusMap: Record<string, string> = {
|
||||
'not_required': '无需单独分析',
|
||||
@@ -394,7 +439,7 @@ function RemoveInfo() {
|
||||
'processing': '分析中',
|
||||
'failed': '分析失败',
|
||||
};
|
||||
|
||||
|
||||
if (analysisStatus === 'processing') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -403,11 +448,11 @@ function RemoveInfo() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (analysisStatus === 'failed') {
|
||||
return <div style={{ fontSize: 14, color: '#ef4444', lineHeight: 1.6 }}>分析失败</div>;
|
||||
}
|
||||
|
||||
|
||||
const displayText = analysisStatus && statusMap[analysisStatus] ? statusMap[analysisStatus] : (record.segmentContent || record.lastError || '-');
|
||||
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{displayText}</div>;
|
||||
},
|
||||
@@ -427,100 +472,100 @@ function RemoveInfo() {
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
dataIndex: 'moduleProjectStatus',
|
||||
render: (text: string, record: any) => {
|
||||
const status = text;
|
||||
const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode;
|
||||
|
||||
const getStatusText = () => {
|
||||
render: (text: string, record: any) => {
|
||||
const status = text;
|
||||
const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode;
|
||||
|
||||
const getStatusText = () => {
|
||||
if (currentStepCode === 'image_prompt_optimize') {
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待融合图生成', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '图片提示词生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '图片提示词生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '图片提示词生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待融合图生成', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '图片提示词生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '图片提示词生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '图片提示词生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
} else if (currentStepCode === 'image_generate') {
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待生成视频提示词', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '融合图生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '融合图生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '融合图生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return status || '-';
|
||||
}
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待生成视频提示词', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '融合图生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '融合图生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '融合图生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return status || '-';
|
||||
}
|
||||
} else if (currentStepCode === 'video_prompt_optimize') {
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待最终视频生成', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '视频提示词生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '视频提示词生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '视频提示词生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待最终视频生成', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '视频提示词生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '视频提示词生成成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '视频提示词生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
} else if (currentStepCode === 'video_generate') {
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '', color: '#666' };
|
||||
case 'processing':
|
||||
return { text: '最终视频生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '任务完成', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '最终视频生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '', color: '#666' };
|
||||
case 'processing':
|
||||
return { text: '最终视频生成中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '任务完成', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '最终视频生成失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
} else if (currentStepCode === 'material_input') {
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待生成图片提示词', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '素材处理中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '素材上传成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '素材上传失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
switch (status) {
|
||||
case 'waiting_user':
|
||||
return { text: '等待生成图片提示词', color: '#f59e0b' };
|
||||
case 'processing':
|
||||
return { text: '素材处理中', color: '#f59e0b' };
|
||||
case 'completed':
|
||||
return { text: '素材上传成功', color: '#10b981' };
|
||||
case 'failed':
|
||||
return { text: '素材上传失败', color: '#ef4444' };
|
||||
default:
|
||||
return { text: status || '-', color: '#666' };
|
||||
}
|
||||
} else {
|
||||
if (!record.moduleProjectId) {
|
||||
return { text: '待生成任务', color: '#94a3b8' };
|
||||
}
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
'pending': { text: '子任务待处理', color: '#f59e0b' },
|
||||
'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' },
|
||||
'processing': { text: '子任务处理中', color: '#f59e0b' },
|
||||
'completed': { text: '子任务完成', color: '#10b981' },
|
||||
'failed': { text: '子任务失败', color: '#ef4444' },
|
||||
'cancelled': { text: '子任务取消', color: '#94a3b8' },
|
||||
};
|
||||
const result = statusMap[status] || { text: status || '-', color: '#666' };
|
||||
return result;
|
||||
if (!record.moduleProjectId) {
|
||||
return { text: '待生成任务', color: '#94a3b8' };
|
||||
}
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
'pending': { text: '子任务待处理', color: '#f59e0b' },
|
||||
'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' },
|
||||
'processing': { text: '子任务处理中', color: '#f59e0b' },
|
||||
'completed': { text: '子任务完成', color: '#10b981' },
|
||||
'failed': { text: '子任务失败', color: '#ef4444' },
|
||||
'cancelled': { text: '子任务取消', color: '#94a3b8' },
|
||||
};
|
||||
const result = statusMap[status] || { text: status || '-', color: '#666' };
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const result = getStatusText() as { text: string; color: string };
|
||||
if (typeof result === 'string') {
|
||||
};
|
||||
|
||||
const result = getStatusText() as { text: string; color: string };
|
||||
if (typeof result === 'string') {
|
||||
return <span style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>{result}</span>;
|
||||
}
|
||||
return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>;
|
||||
},
|
||||
|
||||
}
|
||||
return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>;
|
||||
},
|
||||
|
||||
|
||||
// render: (_: any, record: any) => {
|
||||
// const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -559,6 +604,28 @@ function RemoveInfo() {
|
||||
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
||||
</Button>
|
||||
)}
|
||||
{record.analysisStatus === 'failed' && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => handleSegmentReanalyze(String(record.id))}
|
||||
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
重新分析
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此片段?"
|
||||
onConfirm={() => handleDeleteSegment(String(record.id))}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -613,35 +680,88 @@ function RemoveInfo() {
|
||||
</div>
|
||||
|
||||
{taskDetail ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', padding: '0 32px 32px' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', }}>
|
||||
{/* 视频总结卡片 */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)',
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 30,
|
||||
padding: 24,
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
||||
// boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 卡片装饰 */}
|
||||
<div style={{ position: 'absolute', top: -50, right: -50, width: 200, height: 200, background: 'radial-gradient(circle, rgba(99,102,241,0.05) 0%, transparent 70%)', borderRadius: '50%' }} />
|
||||
|
||||
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)' }}>
|
||||
<video
|
||||
controls
|
||||
src={videoUrl}
|
||||
style={{ width: '100%', height: '100%'}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setPreviewVideoUrl(videoUrl);
|
||||
setPreviewModalVisible(true);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
|
||||
if (overlay) overlay.style.opacity = '1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
|
||||
if (overlay) overlay.style.opacity = '0';
|
||||
}}
|
||||
>
|
||||
<video
|
||||
|
||||
src={videoUrl}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, transition: 'opacity 0.2s' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{taskDetail.analysisStatus === 'failed' && (
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<Button
|
||||
onClick={handleReanalyze}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: '6px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
border: '1px solid #ef4444',
|
||||
color: '#ef4444',
|
||||
background: 'rgba(239, 68, 68, 0.05)',
|
||||
cursor: taskDetail.analysisStatus === 'processing' ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (taskDetail.analysisStatus !== 'processing') {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.05)';
|
||||
}}
|
||||
>
|
||||
{taskDetail.analysisStatus === 'processing' ? '分析中...' : '重新分析'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -669,6 +789,8 @@ function RemoveInfo() {
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : taskDetail.analysisStatus === 'failed' ? (
|
||||
<span style={{ color: '#ef4444', fontSize: 14 }}>分析失败</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
@@ -679,7 +801,9 @@ function RemoveInfo() {
|
||||
<span style={{ fontSize: 14, color: '#f59e0b' }}>分析中</span>
|
||||
</div>
|
||||
) : taskDetail.analysisStatus === 'completed' ? (
|
||||
<span style={{ color: '#475569', lineHeight: 1.6 , height: 100,overflowY: 'auto'}}>{taskDetail.originalVideoContent}</span>
|
||||
<span style={{ color: '#475569', lineHeight: 1.6, height: 100, overflowY: 'auto' }}>{taskDetail.originalVideoContent}</span>
|
||||
) : taskDetail.analysisStatus === 'failed' ? (
|
||||
<span style={{ color: '#ef4444', fontSize: 14 }}>分析失败</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -748,55 +872,55 @@ function RemoveInfo() {
|
||||
>
|
||||
<span style={{ position: 'relative', zIndex: 1 }}>{autoSplitButtonText}</span>
|
||||
{/* 按钮光效 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
transition: 'left 0.5s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.left = '100%';
|
||||
}} />
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.left = '100%';
|
||||
}} />
|
||||
{taskDetail?.aiSuggestions && taskDetail.aiSuggestions.length > 0 && (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div>
|
||||
{(() => {
|
||||
const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || [];
|
||||
return suggestions.map((item: any) => (
|
||||
<div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}>
|
||||
<span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span>
|
||||
<span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span>
|
||||
<br />
|
||||
<span style={{ color: '#666' }}>{item.content}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
trigger="hover"
|
||||
overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
|
||||
>
|
||||
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI 镜头拆分方案)</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div>
|
||||
{(() => {
|
||||
const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || [];
|
||||
return suggestions.map((item: any) => (
|
||||
<div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}>
|
||||
<span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span>
|
||||
<span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span>
|
||||
<br />
|
||||
<span style={{ color: '#666' }}>{item.content}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
trigger="hover"
|
||||
overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
|
||||
>
|
||||
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI 镜头拆分方案)</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
|
||||
@@ -817,43 +941,43 @@ function RemoveInfo() {
|
||||
components={{
|
||||
body: {
|
||||
row: ({ className, style, ...rest }) => (
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
<tr
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
transition: 'all 0.2s ease',
|
||||
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
|
||||
height: 80,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ className, style, ...rest }) => (
|
||||
<td
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
<td
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
padding: '16px 24px',
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
header: {
|
||||
cell: ({ className, style, ...rest }) => (
|
||||
<th
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
<th
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
background: 'rgba(255, 255, 255, 1)',
|
||||
color: '#64748b',
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
padding: '16px 24px',
|
||||
borderBottom: 'none',
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -1011,17 +1135,17 @@ function RemoveInfo() {
|
||||
centered
|
||||
styles={{
|
||||
body: { padding: 24, },
|
||||
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
|
||||
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', }}>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', }}>
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
controls
|
||||
src={previewVideoUrl || ''}
|
||||
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
|
||||
playsInline
|
||||
webkit-playsinline
|
||||
webkit-playsinline="true"
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm, Tag, Space, message } from 'antd';
|
||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
|
||||
import { uploadVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
|
||||
import bg1 from '../assets/bg1.png';
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
pending_analysis: { label: '等待分析', color: 'default' },
|
||||
analyzing: { label: '分析中', color: 'processing' },
|
||||
analysis_completed: { label: '分析完成', color: 'blue' },
|
||||
analysis_failed: { label: '分析失败', color: 'red' },
|
||||
splitting: { label: '拆镜中', color: 'processing' },
|
||||
split_completed: { label: '拆镜完成', color: 'green' },
|
||||
partial_failed: { label: '部分失败', color: 'orange' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
deleted: { label: '已软删', color: 'default' },
|
||||
};
|
||||
|
||||
const renderStatus = (status: string) => {
|
||||
const config = statusConfig[status] || { label: status, color: 'default' };
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
};
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -542,6 +559,12 @@ export default function VideoFrameExtractor() {
|
||||
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => renderStatus(status),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
@@ -563,25 +586,59 @@ export default function VideoFrameExtractor() {
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
||||
style={{
|
||||
color: '#6366f1',
|
||||
fontSize: 13,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
||||
style={{
|
||||
color: '#6366f1',
|
||||
fontSize: 13,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除这个拆镜项目吗?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteShotReplicationProject(record.id);
|
||||
message.success('删除成功');
|
||||
fetchList(currentPage, pageSize, searchKeyword);
|
||||
} catch (err) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
style={{
|
||||
color: '#ef4444',
|
||||
fontSize: 13,
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user