媒体生成百分比显示

This commit is contained in:
sjy
2026-07-16 17:52:49 +08:00
parent 5036efe9aa
commit f0ff573650
8 changed files with 533 additions and 215 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DUztRbKf.js"></script> <script type="module" crossorigin src="/assets/index-DWsZ8Hba.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css"> <link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css">
</head> </head>
<body> <body>
@@ -551,8 +551,13 @@ const AppLayout: React.FC = () => {
if (data.alipay) setPaymentMethod('alipay'); if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat'); else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { }); }).catch(() => { });
loadUnreadCount(); }, []);
}, [user]);
useEffect(() => {
if (user) {
loadUnreadCount();
}
}, [user?.id, user?.credits]);
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname; const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = SIDEBAR_W; const sidebarW = SIDEBAR_W;
+47 -40
View File
@@ -31,6 +31,8 @@ interface UploadSelectorProps {
maxAudioDuration?: number; maxAudioDuration?: number;
usedAudioDuration?: number; usedAudioDuration?: number;
hideLimitHint?: boolean; hideLimitHint?: boolean;
/** 创作类型:'image' 时隐藏真人素材库/虚拟素材库选项 */
mediaType?: string;
} }
const UploadSelector: React.FC<UploadSelectorProps> = ({ const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -54,6 +56,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
maxAudioDuration, maxAudioDuration,
usedAudioDuration, usedAudioDuration,
hideLimitHint, hideLimitHint,
mediaType,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false); const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
@@ -146,46 +149,50 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} /> <DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span> <span style={{ fontSize: 14, color: '#334155' }}></span>
</div> </div>
<div {mediaType !== 'image' && (
onClick={() => openPortraitPicker('real_person')} <div
style={{ onClick={() => openPortraitPicker('real_person')}
display: 'flex', style={{
alignItems: 'center', display: 'flex',
gap: 12, alignItems: 'center',
padding: '8px 16px', gap: 12,
cursor: 'pointer', padding: '8px 16px',
transition: 'background 0.15s ease', cursor: 'pointer',
}} transition: 'background 0.15s ease',
onMouseEnter={(e) => { }}
e.currentTarget.style.background = '#f1f5f9'; onMouseEnter={(e) => {
}} e.currentTarget.style.background = '#f1f5f9';
onMouseLeave={(e) => { }}
e.currentTarget.style.background = 'transparent'; onMouseLeave={(e) => {
}} e.currentTarget.style.background = 'transparent';
> }}
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} /> >
<span style={{ fontSize: 14, color: '#334155' }}></span> <UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
</div> <span style={{ fontSize: 14, color: '#334155' }}></span>
<div </div>
onClick={() => openPortraitPicker('aigc_virtual')} )}
style={{ {mediaType !== 'image' && (
display: 'flex', <div
alignItems: 'center', onClick={() => openPortraitPicker('aigc_virtual')}
gap: 12, style={{
padding: '8px 16px', display: 'flex',
cursor: 'pointer', alignItems: 'center',
transition: 'background 0.15s ease', gap: 12,
}} padding: '8px 16px',
onMouseEnter={(e) => { cursor: 'pointer',
e.currentTarget.style.background = '#f1f5f9'; transition: 'background 0.15s ease',
}} }}
onMouseLeave={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'transparent'; e.currentTarget.style.background = '#f1f5f9';
}} }}
> onMouseLeave={(e) => {
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} /> e.currentTarget.style.background = 'transparent';
<span style={{ fontSize: 14, color: '#334155' }}></span> }}
</div> >
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
)}
</div> </div>
); );
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons'; import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
export interface GenerationTaskResourceItem { export interface GenerationTaskResourceItem {
@@ -12,6 +12,7 @@ export interface GenerationTaskResourceItem {
videoCoverUrl?: string | null; videoCoverUrl?: string | null;
errorMessage?: string | null; errorMessage?: string | null;
generationIndex?: number | null; generationIndex?: number | null;
createdAt?: string;
} }
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem { export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
@@ -49,6 +50,125 @@ const isPending = (item: GenerationTaskResourceItem): boolean => {
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status); return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
}; };
const MAX_DURATION_SECONDS = 180;
interface ProgressItemProps {
item: GenerationTaskResourceItem;
isPending: boolean;
isCompleted: boolean;
onAnimationComplete?: () => void;
onProgressChange?: (progress: number) => void;
}
const PROGRESS_RATE = 0.6;
const calculateProgressValue = (createdAt?: string): number => {
if (!createdAt) return 0;
const createdTime = new Date(createdAt).getTime();
const now = Date.now();
const elapsedSeconds = (now - createdTime) / 1000;
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
return 99;
}
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
};
const ProgressItem: React.FC<ProgressItemProps> = ({ item, isPending: isPendingProp, isCompleted, onAnimationComplete, onProgressChange }) => {
const [progress, setProgress] = useState<number>(0);
const [displayProgress, setDisplayProgress] = useState<number>(0);
const [isFinishing, setIsFinishing] = useState(false);
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (onProgressChange) {
onProgressChange(displayProgress);
}
}, [displayProgress, onProgressChange]);
useEffect(() => {
const newProgress = calculateProgressValue(item.createdAt);
setProgress(newProgress);
setDisplayProgress(newProgress);
}, [item]);
useEffect(() => {
if (isCompleted && !isFinishing) {
setIsFinishing(true);
return;
}
if (!isPendingProp && !isFinishing) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
const updateProgress = () => {
const newProgress = calculateProgressValue(item.createdAt);
setProgress(newProgress);
setDisplayProgress(newProgress);
};
intervalRef.current = window.setInterval(updateProgress, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [item.createdAt, isPendingProp, isCompleted, isFinishing, item.id]);
useEffect(() => {
if (isFinishing) {
const targetProgress = 100;
const currentProgress = displayProgress;
const duration = 800;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
setDisplayProgress(newProgress);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setTimeout(() => {
onAnimationComplete?.();
}, 100);
}
};
requestAnimationFrame(animate);
}
}, [isFinishing, displayProgress, onAnimationComplete]);
if (!isPendingProp && !isFinishing) return null;
return (
<div style={{
marginTop: 4,
}}>
<div style={{
textAlign: 'center',
fontSize: 13,
color: '#8b5cf6',
fontWeight: 700,
textShadow: '0 0 8px rgba(139, 92, 246, 0.3)',
transition: 'all 0.3s ease-out',
}}>
{Math.round(displayProgress)}%
</div>
</div>
);
};
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => { const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => {
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1))); const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0)); const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
@@ -62,8 +182,49 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
pipelineStage: task.pipelineStage, pipelineStage: task.pipelineStage,
generationIndex: index + 1, generationIndex: index + 1,
errorMessage: task.errorMessage, errorMessage: task.errorMessage,
createdAt: task.createdAt,
})) : [task]); })) : [task]);
const [finishingItems, setFinishingItems] = useState<Set<string>>(new Set());
const [fullProgressItems, setFullProgressItems] = useState<Set<string>>(new Set());
const processedItems = useRef<Set<string>>(new Set());
const handleProgressChange = (itemId: string, progress: number) => {
if (progress >= 100) {
setFullProgressItems(prev => {
if (prev.has(itemId)) return prev;
const next = new Set(prev);
next.add(itemId);
return next;
});
}
};
useEffect(() => {
items.slice(0, 5).forEach((item, index) => {
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
const pending = isPending(item);
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
const itemId = item.id || `${index}`;
if (completed && !processedItems.current.has(itemId) && pending === false) {
processedItems.current.add(itemId);
setFinishingItems(prev => {
const next = new Set(prev);
next.add(itemId);
return next;
});
}
});
}, [items]);
const handleFinishAnimation = (itemId: string) => {
setFinishingItems(prev => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
};
return ( return (
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}> <div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
{items.slice(0, 5).map((item, index) => { {items.slice(0, 5).map((item, index) => {
@@ -73,6 +234,14 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`); const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
const isVideo = (item.genType || task.genType) === 'video'; const isVideo = (item.genType || task.genType) === 'video';
const hasResource = isVideo ? !!videoUrl : !!imageUrl; const hasResource = isVideo ? !!videoUrl : !!imageUrl;
const pending = isPending(item);
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
const itemId = item.id || `${index}`;
const isFinishing = finishingItems.has(itemId);
const isFullProgress = fullProgressItems.has(itemId);
const shouldShowContent = completed && !isFinishing;
const statusLabel = isFinishing ? '加载中' : statusText(item);
return ( return (
<div <div
key={item.id || `${index}`} key={item.id || `${index}`}
@@ -87,7 +256,7 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
border: '1px solid #E7EAF0', border: '1px solid #E7EAF0',
}} }}
> >
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? ( {hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && shouldShowContent ? (
<button <button
type="button" type="button"
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')} onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
@@ -103,9 +272,10 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
</button> </button>
) : ( ) : (
<div style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}> <div style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}>
{isPending(item) ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />} {pending || isFinishing ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: isPending(item) ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusText(item)}</span> <span style={{ fontSize: items.length > 2 ? 10 : 12, color: pending || isFinishing ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusLabel}</span>
{!isPending(item) && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null} <ProgressItem item={item} isPending={pending || isFinishing} isCompleted={completed} onAnimationComplete={() => handleFinishAnimation(itemId)} onProgressChange={(progress) => handleProgressChange(itemId, progress)} />
{!pending && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
</div> </div>
)} )}
{items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null} {items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null}
+74 -51
View File
@@ -29,7 +29,7 @@ import GenerationTaskResourceGrid from '../components/generation/GenerationTaskR
import { import {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio, getParameters, createGenerationTask, getgen_list, getEngine, uploadImage, uploadAudio,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api'; } from '../api';
@@ -285,10 +285,10 @@ const AIChatPage: React.FC = () => {
if (previewVisible && previewType === 'video') { if (previewVisible && previewType === 'video') {
const playVideo = () => { const playVideo = () => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.play().catch(() => {}); videoRef.current.play().catch(() => { });
} }
}; };
if (videoRef.current) { if (videoRef.current) {
if (videoRef.current.readyState >= 2) { if (videoRef.current.readyState >= 2) {
playVideo(); playVideo();
@@ -296,9 +296,9 @@ const AIChatPage: React.FC = () => {
videoRef.current.addEventListener('loadedmetadata', playVideo); videoRef.current.addEventListener('loadedmetadata', playVideo);
} }
} }
const timer = setTimeout(playVideo, 300); const timer = setTimeout(playVideo, 300);
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
if (videoRef.current) { if (videoRef.current) {
@@ -376,7 +376,7 @@ const AIChatPage: React.FC = () => {
playPromise.catch(() => { playPromise.catch(() => {
// 自动播放被阻止时静音重试 // 自动播放被阻止时静音重试
v.muted = true; v.muted = true;
v.play().catch(() => {}); v.play().catch(() => { });
}); });
} }
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) { } else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
@@ -517,7 +517,7 @@ const AIChatPage: React.FC = () => {
useEffect(() => { useEffect(() => {
const state = location.state as any; const state = location.state as any;
if (state) { if (state) {
if (state.generationPrompt) { if (state.generationPrompt) {
setInputValue(state.generationPrompt); setInputValue(state.generationPrompt);
@@ -754,10 +754,8 @@ const AIChatPage: React.FC = () => {
setCreditCalculationData(data); setCreditCalculationData(data);
}) })
getgen_list(Pagebreak).then((data: any) => { getgen_list(Pagebreak).then((data: any) => {
// API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。
const mess_list = data.items
const total = data.total const total = data.total
setGen_list(mess_list) setGen_list(data.items)
setTotalnumber(total) setTotalnumber(total)
}) })
// getParameters() // getParameters()
@@ -809,16 +807,12 @@ const AIChatPage: React.FC = () => {
// 设置5秒轮询 // 设置5秒轮询
pollingRef.current = window.setInterval(() => { pollingRef.current = window.setInterval(() => {
getgen_list(Pagebreak).then((data: any) => { getgen_list(Pagebreak).then((data: any) => {
// 只更新正在生成的任务,不影响其他已加载的数据
setGen_list((prevList: any[]) => { setGen_list((prevList: any[]) => {
return prevList.map((prevItem: any) => { return prevList.map((prevItem: any) => {
// 只更新正在生成的任务
if (prevItem.status === 'generating') { if (prevItem.status === 'generating') {
const newItem = data.items.find((item: any) => item.id === prevItem.id); const newItem = data.items.find((item: any) => item.id === prevItem.id);
// 如果找到了对应的新数据,使用新数据;否则保持旧数据
return newItem || prevItem; return newItem || prevItem;
} }
// 非生成中的任务保持不变
return prevItem; return prevItem;
}); });
}); });
@@ -898,7 +892,7 @@ const AIChatPage: React.FC = () => {
} }
const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : []; const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : [];
const newRatioOptions = supportedRatios.map((res: any) => ({ const newRatioOptions = supportedRatios.map((res: any) => ({
value: res, value: res,
label: res, label: res,
@@ -1020,14 +1014,23 @@ const AIChatPage: React.FC = () => {
} }
} }
const toSnakeCase = (str: string) => str.replace(/([A-Z])/g, '_$1').toLowerCase();
const convertToSnake = (obj: any) => {
if (!obj || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(convertToSnake);
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [toSnakeCase(k), convertToSnake(v)])
);
};
let mediaReferences: MediaReference[] | undefined; let mediaReferences: MediaReference[] | undefined;
if (isFirstLastFrameMode) { if (isFirstLastFrameMode) {
mediaReferences = []; mediaReferences = [];
if (firstFrame) { if (firstFrame) {
mediaReferences.push({ ...firstFrame, role: 'first_frame' }); mediaReferences.push(convertToSnake({ ...firstFrame, role: 'first_frame' }));
} }
if (lastFrame) { if (lastFrame) {
mediaReferences.push({ ...lastFrame, role: 'last_frame' }); mediaReferences.push(convertToSnake({ ...lastFrame, role: 'last_frame' }));
} }
if (mediaReferences.length === 0) { if (mediaReferences.length === 0) {
mediaReferences = undefined; mediaReferences = undefined;
@@ -1037,9 +1040,9 @@ const AIChatPage: React.FC = () => {
mediaReferences = currentMedia.map((m) => { mediaReferences = currentMedia.map((m) => {
if (m.type === 'image') { if (m.type === 'image') {
const { duration, ...rest } = m; const { duration, ...rest } = m;
return rest; return convertToSnake(rest);
} }
return m; return convertToSnake(m);
}); });
} else { } else {
mediaReferences = undefined; mediaReferences = undefined;
@@ -1075,6 +1078,11 @@ const AIChatPage: React.FC = () => {
console.log(newMessage);
@@ -1094,9 +1102,21 @@ const AIChatPage: React.FC = () => {
setPagebreak(newPagebreak); setPagebreak(newPagebreak);
getgen_list(newPagebreak).then((data: any) => { getgen_list(newPagebreak).then((data: any) => {
// 将data.items的最后一个元素添加到gen_list末尾 const convertedItems = data.items.map((item: any) => {
const newestItem = Array.isArray(data.items) && data.items.length > 0 if (item.media_references && Array.isArray(item.media_references)) {
? data.items[data.items.length - 1] item.mediaReferences = item.media_references.map((ref: any) => ({
...ref,
display_url: ref.display_url || ref.displayUrl,
preview_url: ref.preview_url || ref.previewUrl,
private_asset_id: ref.private_asset_id || ref.privateAssetId,
provider_url: ref.provider_url || ref.providerUrl,
remote_asset_id: ref.remote_asset_id || ref.remoteAssetId,
}));
}
return item;
});
const newestItem = Array.isArray(convertedItems) && convertedItems.length > 0
? convertedItems[convertedItems.length - 1]
: null; : null;
if (newestItem) { if (newestItem) {
setGen_list((prev: any[]) => [...prev, newestItem]); setGen_list((prev: any[]) => [...prev, newestItem]);
@@ -1452,7 +1472,7 @@ const AIChatPage: React.FC = () => {
} }
if (isAudio) { if (isAudio) {
try { try {
@@ -1498,7 +1518,7 @@ const AIChatPage: React.FC = () => {
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] }))); setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
} catch (error) { } catch (error) {
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败'; const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
antdMessage.error(errorMsg); antdMessage.error(errorMsg);
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -1634,7 +1654,7 @@ const AIChatPage: React.FC = () => {
}; };
} catch (error) { } catch (error) {
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败'; const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
antdMessage.error(errorMsg); antdMessage.error(errorMsg);
return false; return false;
} }
}; };
@@ -1642,12 +1662,12 @@ const AIChatPage: React.FC = () => {
const handleBatchUpload = async (files: File[]) => { const handleBatchUpload = async (files: File[]) => {
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
setUploading(true); setUploading(true);
for (const file of files) { for (const file of files) {
const result = await doUpload(file); const result = await doUpload(file);
if (result) { if (result) {
const latestMedia = useAppStore.getState().currentMedia; const latestMedia = useAppStore.getState().currentMedia;
const newList = [...latestMedia, result]; const newList = [...latestMedia, result];
@@ -1658,9 +1678,9 @@ const AIChatPage: React.FC = () => {
failCount++; failCount++;
} }
} }
setUploading(false); setUploading(false);
if (successCount > 0) { if (successCount > 0) {
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `${failCount}个文件上传失败` : ''}`); antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `${failCount}个文件上传失败` : ''}`);
} }
@@ -1924,7 +1944,7 @@ const AIChatPage: React.FC = () => {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = downloadUrl; link.href = downloadUrl;
console.log(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);
link.click(); link.click();
@@ -2038,7 +2058,7 @@ const AIChatPage: React.FC = () => {
{!collapsed && ( {!collapsed && (
<Button <Button
block block
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={handleNewChat} onClick={handleNewChat}
@@ -2047,7 +2067,7 @@ const AIChatPage: React.FC = () => {
</Button> </Button>
)} )}
{!collapsed && conversations.length > 0 && ( {!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}> <div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
@@ -2349,6 +2369,7 @@ const AIChatPage: React.FC = () => {
setReferenceMode('first_last_frame'); setReferenceMode('first_last_frame');
} else { } else {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({ setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
...ref,
name: ref.name, name: ref.name,
type: ref.type, type: ref.type,
url: ref.url, url: ref.url,
@@ -2817,7 +2838,7 @@ const AIChatPage: React.FC = () => {
{/* 上方输入布局 */} {/* 上方输入布局 */}
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}> <div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
{/* 左侧附件区域 */} {/* 左侧附件区域 */}
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}> <div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
@@ -2966,22 +2987,22 @@ const AIChatPage: React.FC = () => {
} }
}} }}
onPortraitSelect={(assets) => { onPortraitSelect={(assets) => {
if (assets.length > 0) { if (assets.length > 0) {
const asset = assets[0]; const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || ''; const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = { const mediaRef: MediaReference = {
name: asset.name || '尾帧图片', name: asset.name || '尾帧图片',
type: 'image', type: 'image',
url: previewUrl, url: previewUrl,
role: 'last_frame', role: 'last_frame',
label: '', label: '',
source: 'private_portrait_asset', source: 'private_portrait_asset',
private_asset_id: asset.id, private_asset_id: asset.id,
}; };
setLastFrame(mediaRef); setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧'); antdMessage.success('成功添加尾帧');
} }
}} }}
uploading={uploading} uploading={uploading}
> >
<div <div
@@ -3045,6 +3066,7 @@ const AIChatPage: React.FC = () => {
maxAudioDuration={15} maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)} usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'} hideLimitHint={mediaType === 'image'}
mediaType={mediaType}
> >
<div <div
style={{ style={{
@@ -3227,6 +3249,7 @@ const AIChatPage: React.FC = () => {
maxAudioDuration={15} maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)} usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'} hideLimitHint={mediaType === 'image'}
mediaType={mediaType}
> >
<div <div
style={{ style={{
@@ -3939,7 +3962,7 @@ const AIChatPage: React.FC = () => {
fontWeight: 500, fontWeight: 500,
color: '#2f3440', color: '#2f3440',
}}> }}>
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height} {selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution === '1K' ? '1K标清' : selectedResolution === '2K' ? '2K高清' : '4K超清'} · {width}×{height}
</Text> </Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} /> <CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button> </button>
+189 -69
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import { copyToClipboard } from "../utils/clipboard"; import { copyToClipboard } from "../utils/clipboard";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
@@ -199,6 +199,101 @@ const GeneratePage: React.FC = () => {
const [recordStates, setRecordStates] = useState< const [recordStates, setRecordStates] = useState<
Record<string, "idle" | "generating" | "done" | "failed"> Record<string, "idle" | "generating" | "done" | "failed">
>({}); >({});
const [generationProgress, setGenerationProgress] = useState<Record<string, number>>({});
const [generationDisplayProgress, setGenerationDisplayProgress] = useState<Record<string, number>>({});
const [generationFinishing, setGenerationFinishing] = useState<Record<string, boolean>>({});
const generationIntervalRef = useRef<number | null>(null);
const MAX_DURATION_SECONDS = 180;
const PROGRESS_RATE = 0.6;
const calculateProgressValue = useCallback((createdAt?: string): number => {
if (!createdAt) return 0;
const createdTime = new Date(createdAt).getTime();
const now = Date.now();
const elapsedSeconds = (now - createdTime) / 1000;
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
return 99;
}
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
}, []);
useEffect(() => {
const generatingIds = Object.entries(recordStates)
.filter(([, state]) => state === "generating")
.map(([id]) => id);
if (generatingIds.length === 0) {
if (generationIntervalRef.current) {
clearInterval(generationIntervalRef.current);
generationIntervalRef.current = null;
}
return;
}
const updateProgress = () => {
generatingIds.forEach((id) => {
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
if (record?.createdAt) {
const newProgress = calculateProgressValue(record.createdAt);
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
}
});
};
updateProgress();
generationIntervalRef.current = window.setInterval(updateProgress, 1000);
return () => {
if (generationIntervalRef.current) {
clearInterval(generationIntervalRef.current);
generationIntervalRef.current = null;
}
};
}, [recordStates, calculateProgressValue]);
useEffect(() => {
Object.entries(recordStates).forEach(([id, state]) => {
if (state === "done" && !generationFinishing[id]) {
setGenerationFinishing((prev) => ({ ...prev, [id]: true }));
const currentProgress = generationDisplayProgress[id] || 0;
const targetProgress = 100;
const duration = 800;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
});
}, [recordStates]);
useEffect(() => {
Object.entries(recordStates).forEach(([id, state]) => {
if (state === "generating") {
const record = useAppStore.getState().records.items.find((r: any) => r.id === id);
if (record?.createdAt) {
const newProgress = calculateProgressValue(record.createdAt);
setGenerationProgress((prev) => ({ ...prev, [id]: newProgress }));
setGenerationDisplayProgress((prev) => ({ ...prev, [id]: newProgress }));
}
}
});
}, [currentRecord]);
const [editablePrompts, setEditablePrompts] = useState< const [editablePrompts, setEditablePrompts] = useState<
Record<string, string> Record<string, string>
>({}); >({});
@@ -452,30 +547,33 @@ const GeneratePage: React.FC = () => {
return 0; return 0;
}; };
// 根据比例计算尺寸
const calculateSizeFromRatione = (ratio: string) => { const calculateSizeFromRatione = (ratio: string) => {
setBlindex(ratio); setBlindex(ratio);
const size = supportedSizes[selectedResolution]?.[ratio];
// const resolution = selectedResolution === '4k' ? 4096 : 2048; if (size) {
// const ratioParts = ratio.split(':'); const [w, h] = size.split(/x/i).map(Number);
// if (ratio === 'auto') { setWidth(w);
// // 智能模式保持当前尺寸 setHeight(h);
// return; }
// }
// const w = parseInt(ratioParts[0]);
// const h = parseInt(ratioParts[1]);
// if (w >= h) {
// // 横向或正方形
// setWidth(resolution);
// setHeight(Math.round(resolution * h / w));
// } else {
// // 纵向
// setHeight(resolution);
// setWidth(Math.round(resolution * w / h));
// }
}; };
const calculateSizeFromRatiotwo = (ratio: string) => { const calculateSizeFromRatiotwo = (resolution: string) => {
setFBlindex(parseInt(ratio)); setFBlindex(resolution);
const resolutionRatios = Object.keys(supportedSizes[resolution] || {});
const ratioOpts = resolutionRatios.map((key) => ({
value: key,
label: key,
}));
setRatioOptions(ratioOpts);
const newRatio = resolutionRatios.includes(selectedRatio) ? selectedRatio : resolutionRatios[0] || "1:1";
if (newRatio !== selectedRatio) {
setSelectedRatio(newRatio);
}
const size = supportedSizes[resolution]?.[newRatio];
if (size) {
const [w, h] = size.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
}; };
// 图片比例选项 // 图片比例选项
@@ -486,6 +584,7 @@ const GeneratePage: React.FC = () => {
const [widthandheight, setWidthandHeight] = useState([]); const [widthandheight, setWidthandHeight] = useState([]);
const [blindex, setBlindex] = useState<any>(0); const [blindex, setBlindex] = useState<any>(0);
const [fblindex, setFBlindex] = useState<any>(0); const [fblindex, setFBlindex] = useState<any>(0);
const [supportedSizes, setSupportedSizes] = useState<Record<string, Record<string, string>>>({});
// 监听 blindex 状态变化(解决异步问题) // 监听 blindex 状态变化(解决异步问题)
useEffect(() => { useEffect(() => {
@@ -556,40 +655,34 @@ const GeneratePage: React.FC = () => {
getParameters() getParameters()
.then((data) => { .then((data) => {
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {}; const sizes = (data as any).items?.[0]?.supportedSizes || {};
let supportedResolutions = []; setSupportedSizes(sizes);
let twokwidth = [];
let fourkwidth = [];
for (let key in supportedSizes["2K"]) { const resolutionKeys = Object.keys(sizes);
supportedResolutions.push(key); const resolutionOpts = resolutionKeys.map((key) => ({
twokwidth.push(supportedSizes["2K"][key]); value: key,
label: key,
}));
setResolutionOptions(resolutionOpts);
const initResolution = resolutionKeys.includes(selectedResolution) ? selectedResolution : resolutionKeys[0] || "2K";
const ratioKeys = Object.keys(sizes[initResolution] || {});
const ratioOpts = ratioKeys.map((key) => ({
value: key,
label: key,
}));
setRatioOptions(ratioOpts);
const initRatio = ratioKeys.includes(selectedRatio) ? selectedRatio : ratioKeys[0] || "1:1";
if (initRatio !== selectedRatio) {
setSelectedRatio(initRatio);
} }
const initSize = sizes[initResolution]?.[initRatio];
supportedResolutions.forEach((res, index) => { if (initSize) {
setRatioOptions((prev) => [ const [w, h] = initSize.split(/x/i).map(Number);
...prev, setWidth(w);
{ value: res, label: String(index) }, setHeight(h);
]);
});
for (let key in supportedSizes["4K"]) {
fourkwidth.push(supportedSizes["4K"][key]);
} }
const newWidthandHeight = [twokwidth, fourkwidth];
setWidthandHeight(newWidthandHeight);
let supportedRatios = [];
for (let key in supportedSizes) {
supportedRatios.push(key);
}
supportedRatios.forEach((ratio, index) => {
setResolutionOptions((prev) => [
...prev,
{ value: ratio, label: String(index) },
]);
});
}) })
.catch(() => { }); .catch(() => { });
getVideoEngines() getVideoEngines()
@@ -980,9 +1073,14 @@ const GeneratePage: React.FC = () => {
if (currentPollingIds.includes(item.id)) { if (currentPollingIds.includes(item.id)) {
const latest = allRecords.items.find((r: any) => r.id === item.id); const latest = allRecords.items.find((r: any) => r.id === item.id);
if (latest) { if (latest) {
// 如果状态不再是generating,标记为完成 const frontendState = recordStates[item.id];
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
// 不管前端当前是什么状态,都要处理完成的任务
if (latest.status !== "generating") { if (latest.status !== "generating") {
completedIds.push(item.id); completedIds.push(item.id);
} else if (frontendState !== "generating") {
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
setRecordStates((p) => ({ ...p, [item.id]: "generating" }));
} }
return latest; return latest;
} }
@@ -1052,6 +1150,14 @@ const GeneratePage: React.FC = () => {
}; };
}, []); }, []);
useEffect(() => {
const generatingRecords = recordItems.filter((r) => r.status === "generating");
generatingRecords.forEach((record) => {
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
startPolling(record.id);
});
}, [recordItems]);
const handleGenerate = async (recordId: string) => { const handleGenerate = async (recordId: string) => {
if (!canAffordVideo) { if (!canAffordVideo) {
message.error("积分不足,请先充值"); message.error("积分不足,请先充值");
@@ -1114,6 +1220,9 @@ const GeneratePage: React.FC = () => {
if (!record) return; if (!record) return;
setGenerating((p) => ({ ...p, [recordId]: true })); setGenerating((p) => ({ ...p, [recordId]: true }));
setRecordStates((p) => ({ ...p, [recordId]: "generating" })); setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
setGenerationProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationDisplayProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationFinishing((p) => ({ ...p, [recordId]: false }));
message.loading({ message.loading({
content: `${projectName}」正在重新提交...`, content: `${projectName}」正在重新提交...`,
duration: 0, duration: 0,
@@ -2245,9 +2354,8 @@ const GeneratePage: React.FC = () => {
<Typography.Text <Typography.Text
style={{ fontSize: 12, color: "#9ca3af" }} style={{ fontSize: 12, color: "#9ca3af" }}
> >
{selectedResolution == "2K" ? "清 2K" : "4K"} {selectedResolution === "1K" ? "1K" : selectedResolution === "2K" ? "2K" : `${selectedResolution}分辨率`}
| {width}×{height} | {width}×{height}
{/* {selectedResolution === '2k' ? '高清 2K' : '超清 4K'} | {width}×{height} */}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>
@@ -2676,8 +2784,22 @@ const GeneratePage: React.FC = () => {
: "#6366f1", : "#6366f1",
}} }}
> >
{recordStates[currentRecord.id] === "generating" && {recordStates[currentRecord.id] === "generating" && (
`${mediaType === "image" ? "图片" : "视频"}生成中,请稍候...`} <span>
{mediaType === "image" ? "图片" : "视频"}...
<span
style={{
marginLeft: 8,
fontSize: 14,
fontWeight: 700,
color: "#6366f1",
textShadow: "0 0 8px rgba(99, 102, 241, 0.3)",
}}
>
{Math.round(generationDisplayProgress[currentRecord.id] || 0)}%
</span>
</span>
)}
{recordStates[currentRecord.id] === "done" && {recordStates[currentRecord.id] === "done" &&
`${mediaType === "image" ? "图片" : "视频"}生成成功`} `${mediaType === "image" ? "图片" : "视频"}生成成功`}
{recordStates[currentRecord.id] === "failed" && {recordStates[currentRecord.id] === "failed" &&
@@ -3585,7 +3707,7 @@ const GeneratePage: React.FC = () => {
className="record-actions" className="record-actions"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Space> {/* <Space>
{status === "failed" && ( {status === "failed" && (
<Button <Button
type="primary" type="primary"
@@ -3599,7 +3721,7 @@ const GeneratePage: React.FC = () => {
重试 重试
</Button> </Button>
)} )}
</Space> </Space> */}
</div> </div>
</div> </div>
@@ -4324,22 +4446,20 @@ const GeneratePage: React.FC = () => {
aspectRatio: params?.aspectRatio, aspectRatio: params?.aspectRatio,
resolution: params?.resolution, resolution: params?.resolution,
}); });
setRecordStates((p) => ({ startPolling(record.id);
...p,
[record.id]: "done",
}));
message.success({ message.success({
content: `${projectName}${mediaText}生成成功!`, content: `${projectName}${mediaText}已提交,正在生成中...`,
key: record.id, key: record.id,
duration: 3, duration: 3,
}); });
} catch { } catch (error: any) {
setRecordStates((p) => ({ setRecordStates((p) => ({
...p, ...p,
[record.id]: "failed", [record.id]: "failed",
})); }));
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
message.error({ message.error({
content: `${projectName}${mediaText}生成失败`, content: `${projectName}${errorMsg}`,
key: record.id, key: record.id,
duration: 3, duration: 3,
}); });
@@ -4401,7 +4521,7 @@ const GeneratePage: React.FC = () => {
right: 20, right: 20,
}} }}
> >
<Button {/* <Button
type="primary" type="primary"
danger danger
size="large" size="large"
@@ -4416,7 +4536,7 @@ const GeneratePage: React.FC = () => {
}} }}
> >
重新生成{type === "video" ? "视频" : "图片"} 重新生成{type === "video" ? "视频" : "图片"}
</Button> </Button> */}
</div> </div>
)} )}
+1 -8
View File
@@ -98,14 +98,7 @@ const ProjectsPage: React.FC = () => {
return ( return (
<div <div
style={ className="content_box" >
{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
padding:'24px'
}
}>
{/* Header banner */} {/* Header banner */}
<div className="animate-fadeInUp" style={{ <div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',