This commit is contained in:
2026-07-17 09:14:36 +08:00
10 changed files with 1770 additions and 264 deletions
@@ -551,8 +551,13 @@ const AppLayout: React.FC = () => {
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { });
loadUnreadCount();
}, [user]);
}, []);
useEffect(() => {
if (user) {
loadUnreadCount();
}
}, [user?.id, user?.credits]);
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = SIDEBAR_W;
+47 -40
View File
@@ -31,6 +31,8 @@ interface UploadSelectorProps {
maxAudioDuration?: number;
usedAudioDuration?: number;
hideLimitHint?: boolean;
/** 创作类型:'image' 时隐藏真人素材库/虚拟素材库选项 */
mediaType?: string;
}
const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -54,6 +56,7 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
maxAudioDuration,
usedAudioDuration,
hideLimitHint,
mediaType,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
@@ -146,46 +149,50 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('real_person')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('aigc_virtual')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
{mediaType !== 'image' && (
<div
onClick={() => openPortraitPicker('real_person')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
)}
{mediaType !== 'image' && (
<div
onClick={() => openPortraitPicker('aigc_virtual')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</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';
export interface GenerationTaskResourceItem {
@@ -12,6 +12,7 @@ export interface GenerationTaskResourceItem {
videoCoverUrl?: string | null;
errorMessage?: string | null;
generationIndex?: number | null;
createdAt?: string;
}
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);
};
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 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));
@@ -62,8 +182,49 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
pipelineStage: task.pipelineStage,
generationIndex: index + 1,
errorMessage: task.errorMessage,
createdAt: task.createdAt,
})) : [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 (
<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) => {
@@ -73,6 +234,14 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
const isVideo = (item.genType || task.genType) === 'video';
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 (
<div
key={item.id || `${index}`}
@@ -87,7 +256,7 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
border: '1px solid #E7EAF0',
}}
>
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && !isPending(item) ? (
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && shouldShowContent ? (
<button
type="button"
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
@@ -103,9 +272,10 @@ const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveU
</button>
) : (
<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 }} />}
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: isPending(item) ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusText(item)}</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}
{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: pending || isFinishing ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusLabel}</span>
<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>
)}
{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 {
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage, uploadAudio,
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
} from '../api';
@@ -285,10 +285,10 @@ const AIChatPage: React.FC = () => {
if (previewVisible && previewType === 'video') {
const playVideo = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {});
videoRef.current.play().catch(() => { });
}
};
if (videoRef.current) {
if (videoRef.current.readyState >= 2) {
playVideo();
@@ -296,9 +296,9 @@ const AIChatPage: React.FC = () => {
videoRef.current.addEventListener('loadedmetadata', playVideo);
}
}
const timer = setTimeout(playVideo, 300);
return () => {
clearTimeout(timer);
if (videoRef.current) {
@@ -376,7 +376,7 @@ const AIChatPage: React.FC = () => {
playPromise.catch(() => {
// 自动播放被阻止时静音重试
v.muted = true;
v.play().catch(() => {});
v.play().catch(() => { });
});
}
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
@@ -517,7 +517,7 @@ const AIChatPage: React.FC = () => {
useEffect(() => {
const state = location.state as any;
if (state) {
if (state.generationPrompt) {
setInputValue(state.generationPrompt);
@@ -754,10 +754,8 @@ const AIChatPage: React.FC = () => {
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
// API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。
const mess_list = data.items
const total = data.total
setGen_list(mess_list)
setGen_list(data.items)
setTotalnumber(total)
})
// getParameters()
@@ -809,16 +807,12 @@ const AIChatPage: React.FC = () => {
// 设置5秒轮询
pollingRef.current = window.setInterval(() => {
getgen_list(Pagebreak).then((data: any) => {
// 只更新正在生成的任务,不影响其他已加载的数据
setGen_list((prevList: any[]) => {
return prevList.map((prevItem: any) => {
// 只更新正在生成的任务
if (prevItem.status === 'generating') {
const newItem = data.items.find((item: any) => item.id === prevItem.id);
// 如果找到了对应的新数据,使用新数据;否则保持旧数据
return newItem || prevItem;
}
// 非生成中的任务保持不变
return prevItem;
});
});
@@ -898,7 +892,7 @@ const AIChatPage: React.FC = () => {
}
const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : [];
const newRatioOptions = supportedRatios.map((res: any) => ({
value: 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;
if (isFirstLastFrameMode) {
mediaReferences = [];
if (firstFrame) {
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
mediaReferences.push(convertToSnake({ ...firstFrame, role: 'first_frame' }));
}
if (lastFrame) {
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
mediaReferences.push(convertToSnake({ ...lastFrame, role: 'last_frame' }));
}
if (mediaReferences.length === 0) {
mediaReferences = undefined;
@@ -1037,9 +1040,9 @@ const AIChatPage: React.FC = () => {
mediaReferences = currentMedia.map((m) => {
if (m.type === 'image') {
const { duration, ...rest } = m;
return rest;
return convertToSnake(rest);
}
return m;
return convertToSnake(m);
});
} else {
mediaReferences = undefined;
@@ -1075,6 +1078,11 @@ const AIChatPage: React.FC = () => {
console.log(newMessage);
@@ -1094,9 +1102,21 @@ const AIChatPage: React.FC = () => {
setPagebreak(newPagebreak);
getgen_list(newPagebreak).then((data: any) => {
// 将data.items的最后一个元素添加到gen_list末尾
const newestItem = Array.isArray(data.items) && data.items.length > 0
? data.items[data.items.length - 1]
const convertedItems = data.items.map((item: any) => {
if (item.media_references && Array.isArray(item.media_references)) {
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;
if (newestItem) {
setGen_list((prev: any[]) => [...prev, newestItem]);
@@ -1452,7 +1472,7 @@ const AIChatPage: React.FC = () => {
}
if (isAudio) {
try {
@@ -1498,7 +1518,7 @@ const AIChatPage: React.FC = () => {
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
} catch (error) {
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
antdMessage.error(errorMsg);
antdMessage.error(errorMsg);
} finally {
setUploading(false);
}
@@ -1634,7 +1654,7 @@ const AIChatPage: React.FC = () => {
};
} catch (error) {
const errorMsg = error?.response?.data?.message || error?.response?.data?.detail || error?.message || '上传失败';
antdMessage.error(errorMsg);
antdMessage.error(errorMsg);
return false;
}
};
@@ -1642,12 +1662,12 @@ const AIChatPage: React.FC = () => {
const handleBatchUpload = async (files: File[]) => {
let successCount = 0;
let failCount = 0;
setUploading(true);
for (const file of files) {
const result = await doUpload(file);
if (result) {
const latestMedia = useAppStore.getState().currentMedia;
const newList = [...latestMedia, result];
@@ -1658,9 +1678,9 @@ const AIChatPage: React.FC = () => {
failCount++;
}
}
setUploading(false);
if (successCount > 0) {
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `${failCount}个文件上传失败` : ''}`);
}
@@ -1924,7 +1944,7 @@ const AIChatPage: React.FC = () => {
const link = document.createElement('a');
link.href = downloadUrl;
console.log(downloadUrl);
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
document.body.appendChild(link);
link.click();
@@ -2038,7 +2058,7 @@ const AIChatPage: React.FC = () => {
{!collapsed && (
<Button
block
block
type="primary"
icon={<PlusOutlined />}
onClick={handleNewChat}
@@ -2047,7 +2067,7 @@ const AIChatPage: React.FC = () => {
</Button>
)}
{!collapsed && conversations.length > 0 && (
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
@@ -2349,6 +2369,7 @@ const AIChatPage: React.FC = () => {
setReferenceMode('first_last_frame');
} else {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
...ref,
name: ref.name,
type: ref.type,
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={{ 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) => {
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: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
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: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
uploading={uploading}
>
<div
@@ -3045,6 +3066,7 @@ const AIChatPage: React.FC = () => {
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
mediaType={mediaType}
>
<div
style={{
@@ -3227,6 +3249,7 @@ const AIChatPage: React.FC = () => {
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
hideLimitHint={mediaType === 'image'}
mediaType={mediaType}
>
<div
style={{
@@ -3939,7 +3962,7 @@ const AIChatPage: React.FC = () => {
fontWeight: 500,
color: '#2f3440',
}}>
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution == '2K' ? '2K高清' : '4K超清'} · {width}×{height}
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution === '1K' ? '1K标清' : selectedResolution === '2K' ? '2K高清' : '4K超清'} · {width}×{height}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</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 { createPortal } from "react-dom";
import {
@@ -199,6 +199,101 @@ const GeneratePage: React.FC = () => {
const [recordStates, setRecordStates] = useState<
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<
Record<string, string>
>({});
@@ -452,30 +547,33 @@ const GeneratePage: React.FC = () => {
return 0;
};
// 根据比例计算尺寸
const calculateSizeFromRatione = (ratio: string) => {
setBlindex(ratio);
// const resolution = selectedResolution === '4k' ? 4096 : 2048;
// const ratioParts = ratio.split(':');
// if (ratio === 'auto') {
// // 智能模式保持当前尺寸
// 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 size = supportedSizes[selectedResolution]?.[ratio];
if (size) {
const [w, h] = size.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
};
const calculateSizeFromRatiotwo = (ratio: string) => {
setFBlindex(parseInt(ratio));
const calculateSizeFromRatiotwo = (resolution: string) => {
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 [blindex, setBlindex] = useState<any>(0);
const [fblindex, setFBlindex] = useState<any>(0);
const [supportedSizes, setSupportedSizes] = useState<Record<string, Record<string, string>>>({});
// 监听 blindex 状态变化(解决异步问题)
useEffect(() => {
@@ -556,40 +655,34 @@ const GeneratePage: React.FC = () => {
getParameters()
.then((data) => {
let supportedSizes = (data as any).items?.[0]?.supportedSizes || {};
let supportedResolutions = [];
let twokwidth = [];
let fourkwidth = [];
const sizes = (data as any).items?.[0]?.supportedSizes || {};
setSupportedSizes(sizes);
for (let key in supportedSizes["2K"]) {
supportedResolutions.push(key);
twokwidth.push(supportedSizes["2K"][key]);
const resolutionKeys = Object.keys(sizes);
const resolutionOpts = resolutionKeys.map((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);
}
supportedResolutions.forEach((res, index) => {
setRatioOptions((prev) => [
...prev,
{ value: res, label: String(index) },
]);
});
for (let key in supportedSizes["4K"]) {
fourkwidth.push(supportedSizes["4K"][key]);
const initSize = sizes[initResolution]?.[initRatio];
if (initSize) {
const [w, h] = initSize.split(/x/i).map(Number);
setWidth(w);
setHeight(h);
}
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(() => { });
getVideoEngines()
@@ -980,9 +1073,14 @@ const GeneratePage: React.FC = () => {
if (currentPollingIds.includes(item.id)) {
const latest = allRecords.items.find((r: any) => r.id === item.id);
if (latest) {
// 如果状态不再是generating,标记为完成
const frontendState = recordStates[item.id];
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
// 不管前端当前是什么状态,都要处理完成的任务
if (latest.status !== "generating") {
completedIds.push(item.id);
} else if (frontendState !== "generating") {
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
setRecordStates((p) => ({ ...p, [item.id]: "generating" }));
}
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) => {
if (!canAffordVideo) {
message.error("积分不足,请先充值");
@@ -1114,6 +1220,9 @@ const GeneratePage: React.FC = () => {
if (!record) return;
setGenerating((p) => ({ ...p, [recordId]: true }));
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
setGenerationProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationDisplayProgress((p) => ({ ...p, [recordId]: 0 }));
setGenerationFinishing((p) => ({ ...p, [recordId]: false }));
message.loading({
content: `${projectName}」正在重新提交...`,
duration: 0,
@@ -2245,9 +2354,8 @@ const GeneratePage: React.FC = () => {
<Typography.Text
style={{ fontSize: 12, color: "#9ca3af" }}
>
{selectedResolution == "2K" ? "清 2K" : "4K"}
{selectedResolution === "1K" ? "1K" : selectedResolution === "2K" ? "2K" : `${selectedResolution}分辨率`}
| {width}×{height}
{/* {selectedResolution === '2k' ? '高清 2K' : '超清 4K'} | {width}×{height} */}
</Typography.Text>
</div>
</div>
@@ -2676,8 +2784,22 @@ const GeneratePage: React.FC = () => {
: "#6366f1",
}}
>
{recordStates[currentRecord.id] === "generating" &&
`${mediaType === "image" ? "图片" : "视频"}生成中,请稍候...`}
{recordStates[currentRecord.id] === "generating" && (
<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" &&
`${mediaType === "image" ? "图片" : "视频"}生成成功`}
{recordStates[currentRecord.id] === "failed" &&
@@ -3585,7 +3707,7 @@ const GeneratePage: React.FC = () => {
className="record-actions"
onClick={(e) => e.stopPropagation()}
>
<Space>
{/* <Space>
{status === "failed" && (
<Button
type="primary"
@@ -3599,7 +3721,7 @@ const GeneratePage: React.FC = () => {
重试
</Button>
)}
</Space>
</Space> */}
</div>
</div>
@@ -4324,22 +4446,20 @@ const GeneratePage: React.FC = () => {
aspectRatio: params?.aspectRatio,
resolution: params?.resolution,
});
setRecordStates((p) => ({
...p,
[record.id]: "done",
}));
startPolling(record.id);
message.success({
content: `${projectName}${mediaText}生成成功!`,
content: `${projectName}${mediaText}已提交,正在生成中...`,
key: record.id,
duration: 3,
});
} catch {
} catch (error: any) {
setRecordStates((p) => ({
...p,
[record.id]: "failed",
}));
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
message.error({
content: `${projectName}${mediaText}生成失败`,
content: `${projectName}${errorMsg}`,
key: record.id,
duration: 3,
});
@@ -4401,7 +4521,7 @@ const GeneratePage: React.FC = () => {
right: 20,
}}
>
<Button
{/* <Button
type="primary"
danger
size="large"
@@ -4416,7 +4536,7 @@ const GeneratePage: React.FC = () => {
}}
>
重新生成{type === "video" ? "视频" : "图片"}
</Button>
</Button> */}
</div>
)}
+1 -8
View File
@@ -98,14 +98,7 @@ const ProjectsPage: React.FC = () => {
return (
<div
style={
{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
padding:'24px'
}
}>
className="content_box" >
{/* Header banner */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',