AI创作批量生成任务 main V1 init

This commit is contained in:
2026-07-15 13:03:13 +08:00
parent 5b937f652b
commit 51f9deecde
59 changed files with 8091 additions and 413 deletions
@@ -0,0 +1,119 @@
import React from 'react';
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
export interface GenerationTaskResourceItem {
id?: string;
genType?: string;
status?: string;
displayStatus?: string | null;
pipelineStage?: string | null;
imageUrl?: string | null;
videoUrl?: string | null;
videoCoverUrl?: string | null;
errorMessage?: string | null;
generationIndex?: number | null;
}
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
generationCount?: number | null;
childItems?: GenerationTaskResourceItem[] | null;
}
interface Props {
task: GenerationTaskResourceGroup;
onPreview: (url: string, type: 'image' | 'video') => void;
resolveUrl?: (url?: string | null) => string;
}
const spanByCount = (count: number, index: number): number => {
if (count <= 1) return 6;
if (count === 2 || count === 4) return 3;
if (count === 3) return index < 2 ? 3 : 6;
return index < 3 ? 2 : 3;
};
const statusText = (item: GenerationTaskResourceItem): string => {
const status = item.displayStatus || item.pipelineStage || item.status || 'generating';
const labels: Record<string, string> = {
pending: '待处理', queued: '排队中', preparing: '准备中', generating: '生成中',
creating_provider_task: '创建任务中', waiting_remote: '等待生成', polling: '轮询中',
result_ready: '结果就绪', download_queued: '等待下载', downloading: '下载中',
retry_waiting: '等待重试', completed: '已完成', failed: '生成失败',
download_failed: '下载失败', deleted: '已删除',
};
return labels[status] || status;
};
const isPending = (item: GenerationTaskResourceItem): boolean => {
const status = item.displayStatus || item.pipelineStage || item.status;
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
};
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));
const items: GenerationTaskResourceItem[] = children.length > 0
? children
: (count > 1 ? Array.from({ length: count }, (_, index) => ({
id: `${task.id || 'task'}-placeholder-${index + 1}`,
genType: task.genType,
status: task.status,
displayStatus: task.displayStatus,
pipelineStage: task.pipelineStage,
generationIndex: index + 1,
errorMessage: task.errorMessage,
})) : [task]);
return (
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 4 : 0 }}>
{items.slice(0, 5).map((item, index) => {
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
const imageUrl = resolveUrl(item.imageUrl);
const videoUrl = resolveUrl(item.videoUrl);
const coverUrl = resolveUrl(item.videoCoverUrl);
const isVideo = (item.genType || task.genType) === 'video';
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
return (
<div
key={item.id || `${index}`}
style={{
gridColumn: `span ${spanByCount(items.length, index)}`,
minWidth: 0,
minHeight: 0,
position: 'relative',
overflow: 'hidden',
borderRadius: items.length === 1 ? 12 : 8,
background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)',
border: items.length === 1 ? 'none' : '1px solid #E7EAF0',
}}
>
{hasResource && displayStatus !== 'deleted' ? (
<button
type="button"
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
style={{ width: '100%', height: '100%', border: 0, padding: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}
>
{isVideo ? (
coverUrl ? <img src={coverUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
: <video src={videoUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
<img src={imageUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
)}
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', fontSize: items.length > 2 ? 28 : 46, color: 'rgba(255,255,255,.92)', filter: 'drop-shadow(0 4px 10px rgba(0,0,0,.28))' }} /> : null}
</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}
</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}
</div>
);
})}
</div>
);
};
export default GenerationTaskResourceGrid;
+91 -53
View File
@@ -24,6 +24,7 @@ import bg3 from '../assets/bg3.png';
import text from '../assets/testb.png';
import UploadSelector from '../components/UploadSelector';
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
@@ -68,6 +69,17 @@ const { Option } = Select;
// 解构Typography组件
const { Text } = Typography;
const GENERATION_RESOURCE_BASE = (import.meta.env.VITE_API_BASE || 'http://localhost:8000')
.replace(/\/api\/?$/i, '')
.replace(/\/$/, '');
const resolveGenerationResourceUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${GENERATION_RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
};
interface MediaReference {
name: string;
@@ -97,6 +109,7 @@ interface Message {
resolution?: string;
timestamp?: string;
engine_id: string;
generation_count: number;
}
@@ -138,6 +151,7 @@ const AIChatPage: React.FC = () => {
const {
mediaType,
countType,
generationCount,
selectedRatio,
selectedResolution,
width,
@@ -150,6 +164,7 @@ const AIChatPage: React.FC = () => {
inputValue,
setMediaType,
setCountType,
setGenerationCount,
setSelectedRatio,
setSelectedResolution,
setWidth,
@@ -170,6 +185,24 @@ const AIChatPage: React.FC = () => {
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImageCount = currentEngine?.maxImageCount ?? 4;
const maxVideoCount = currentEngine?.maxVideoCount ?? 1;
const multiGenerationEnabled = Boolean(currentEngine?.multiGenerationEnabled);
const configuredMaxGenerationCount = multiGenerationEnabled
? Math.max(1, Math.min(5, Number(currentEngine?.maxGenerationCount || 1)))
: 1;
const referenceImageCount = mediaType === 'image'
? currentMedia.filter((item) => item.type === 'image').length
: 0;
const imageProviderRemainingCount = mediaType === 'image'
? Math.max(1, Number(currentEngine?.multiImageMaxImages || 15) - referenceImageCount)
: 5;
const effectiveMaxGenerationCount = Math.max(
1,
Math.min(
5,
configuredMaxGenerationCount,
mediaType === 'image' ? imageProviderRemainingCount : 5,
),
);
const [uploading, setUploading] = useState<boolean>(false);
@@ -444,7 +477,7 @@ const AIChatPage: React.FC = () => {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
return Number((total * generationCount).toFixed(2));
} else {
// 图片:baseCredits × ratio
let total = config.baseCredits * config.ratio;
@@ -456,7 +489,7 @@ const AIChatPage: React.FC = () => {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
return Number((total * generationCount).toFixed(2));
}
};
@@ -558,6 +591,22 @@ const AIChatPage: React.FC = () => {
}
}, [mediaType, enginesele, enginesLoaded]);
// 切换媒体类型或引擎时,默认回到最安全的单份生成。
useEffect(() => {
if (!enginesLoaded) return;
setGenerationCount(1);
}, [mediaType, countType, enginesLoaded, setGenerationCount]);
// 图片参考图数量变化后动态收敛本次可选数量;后端仍会再次校验。
useEffect(() => {
if (generationCount > effectiveMaxGenerationCount) {
setGenerationCount(effectiveMaxGenerationCount);
if (mediaType === 'image') {
antdMessage.info(`受当前引擎或参考图数量限制,本次最多生成 ${effectiveMaxGenerationCount}`);
}
}
}, [generationCount, effectiveMaxGenerationCount, mediaType, setGenerationCount, antdMessage]);
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -703,8 +752,9 @@ const AIChatPage: React.FC = () => {
setCreditCalculationData(data);
})
getgen_list(Pagebreak).then((data: any) => {
let mess_list = data.items
let total = data.total
// API 按创建时间倒序返回;对话区按时间正序展示,最新消息保持在底部。
const mess_list = [...(data.items || [])].reverse()
const total = data.total
setGen_list(mess_list)
setTotalnumber(total)
})
@@ -1005,6 +1055,7 @@ const AIChatPage: React.FC = () => {
engine_id: countType,
idempotency_key: new Date().toLocaleString('zh-CN'),
generation_count: generationCount,
media_references: mediaReferences,
// 图片参数(仅图片模式时添加)
...(mediaType === 'image' && {
@@ -1034,6 +1085,7 @@ const AIChatPage: React.FC = () => {
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
setGenerationCount(1);
// 创建任务成功后,重置页数为1,获取最新列表
const newPagebreak = { ...Pagebreak, page: 1 };
@@ -1041,7 +1093,12 @@ const AIChatPage: React.FC = () => {
getgen_list(newPagebreak).then((data: any) => {
// 将data.items的最后一个元素添加到gen_list末尾
setGen_list((prev: any[]) => [...prev, data.items[data.items.length - 1]]);
const newestItem = Array.isArray(data.items) && data.items.length > 0
? data.items[data.items.length - 1]
: null;
if (newestItem) {
setGen_list((prev: any[]) => [...prev, newestItem]);
}
setTotalnumber(data.total);
// 发送消息后滚动到底部
setTimeout(() => {
@@ -1121,13 +1178,13 @@ const AIChatPage: React.FC = () => {
setGen_list((prev: any[]) => {
const existingIds = new Set(prev.map((item: any) => item.id));
// 只添加不存在的新数据,保持新数据的原有顺序
const newItems = data.items.filter((item: any) => {
const newItems = (data.items || []).filter((item: any) => {
if (!item.id) return false;
if (existingIds.has(item.id)) return false;
existingIds.add(item.id);
return true;
});
// 新数据在前,旧数据在后
}).reverse();
// 加载的是更早一页,按时间正序放到现有消息前面。
return [...newItems, ...prev];
});
@@ -2453,50 +2510,16 @@ const AIChatPage: React.FC = () => {
display: 'flex', gap: 16, marginBottom: 16, marginTop: 16, width: '100%', alignItems: 'flex-start',
}}>
<div style={{ width: '50%', overflow: 'hidden', borderRadius: 12, position: 'relative', height: 220, border: '1px solid #E7EAF0', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)' }}>
{msg.status === 'generating' ? (
<>
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', alignItems: 'center', gap: 8, zIndex: 2 }}>
{/* <div style={{ width: 20, height: 20, border: '2px solid #ddd6fe', borderTopColor: '#8b5cf6', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> */}
</div>
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.6) 50%, transparent 100%)', animation: 'shimmer 2s infinite' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'rgba(139, 92, 246, 0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(139, 92, 246, 0.2)' }}>
<div style={{ width: 48, height: 48, border: '3px solid #ddd6fe', borderTopColor: '#8b5cf6', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>...</span>
{/* <div style={{ display: 'flex', gap: 4 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#a8a1b6', animation: 'pulse 1.5s ease-in-out 0.2s infinite' }} />
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#ddd6fe', animation: 'pulse 1.5s ease-in-out 0.4s infinite' }} />
</div> */}
</div>
</>
) : msg.status === 'failed' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<div style={{ width: 48, height: 48, borderRadius: '50%', background: 'rgba(168, 90, 106, 0.10)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<WarningOutlined style={{ color: '#A45B5B', fontSize: 22 }} />
</div>
<span style={{ fontSize: 14, color: '#A45B5B', fontWeight: 620 }}>退</span>
{msg.errorMessage && (
<span style={{ fontSize: 13, color: '#A45B5B', textAlign: 'center', padding: '0 8px', lineHeight: 1.5 }}>{msg.errorMessage}</span>
)}
</div>
) : (
<>
{msg.genType === 'image' ? (
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.imageUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.imageUrl); setPreviewType('image'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
) : (
<>
<img src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${msg.videoCoverUrl}&w=300&p=50`} alt={msg.name} style={{ width: '100%', height: '100%', borderRadius: 12, objectFit: 'contain', cursor: 'pointer', transition: 'transform 0.3s ease' }} onClick={() => { setPreviewUrl(msg.videoUrl); setPreviewType('video'); setPreviewVisible(true); }} onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: 56, height: 56, background: 'rgba(47, 52, 64, 0.72)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none', boxShadow: '0 10px 24px rgba(47, 52, 64, 0.22)' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z" /></svg>
</div>
</>
)}
</>
)}
<div style={{ width: '50%', overflow: 'hidden', borderRadius: 12, position: 'relative', height: 220, border: '1px solid #E7EAF0', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.08)', background: 'linear-gradient(135deg, #ffffff 0%, #FAFBFC 100%)' }}>
<GenerationTaskResourceGrid
task={msg}
resolveUrl={resolveGenerationResourceUrl}
onPreview={(url, type) => {
setPreviewUrl(url);
setPreviewType(type);
setPreviewVisible(true);
}}
/>
</div>
<div style={{ width: '50%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ background: '#FFFFFF', borderRadius: 12, padding: 12, border: '1px solid #E7EAF0', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -2506,7 +2529,7 @@ const AIChatPage: React.FC = () => {
<span style={{
// background: 'rgba(139, 92, 246, 0.08)',
borderRadius: 16, color: '#8b5cf6', fontWeight: 500
}}>{msg.engineSnapshot.name}</span>
}}>{msg.engineSnapshot?.name || '未知引擎'}</span>
<span style={{
// background: 'rgba(139, 92, 246, 0.08)',
borderRadius: 16, color: '#667085'
@@ -4418,6 +4441,21 @@ const AIChatPage: React.FC = () => {
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', height: 34, padding: '0 8px 0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}></Text>
<Select
value={generationCount}
onChange={(value) => setGenerationCount(Number(value || 1))}
disabled={effectiveMaxGenerationCount <= 1}
size="small"
variant="borderless"
style={{ width: 68 }}
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
value: index + 1,
label: `${index + 1}`,
}))}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#667085', whiteSpace: 'nowrap', height: 34, padding: '0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}></Text>
<Text style={{ fontSize: 13, color: '#2f3440', fontWeight: 800 }}>{getEstimatedCredits()}</Text>
+5
View File
@@ -18,6 +18,7 @@ interface AppState {
// 生成配置状态 - 页面跳转时保留,刷新时重置
mediaType: string;
countType: string;
generationCount: number;
selectedRatio: string;
selectedResolution: string;
width: number;
@@ -46,6 +47,7 @@ interface AppState {
// 生成配置状态更新方法
setMediaType: (mediaType: string) => void;
setCountType: (countType: string) => void;
setGenerationCount: (generationCount: number) => void;
setImageSettings: (ratio: string, resolution: string, width: number, height: number) => void;
setVideoSettings: (duration: number, aspectRatio: string, resolution: string) => void;
setEngineOptions: (options: { ratios: string[]; resolutions: string[]; durations: number[] }) => void;
@@ -71,6 +73,7 @@ export const useAppStore = create<AppState>((set, get) => ({
// 生成配置状态初始值
mediaType: 'video',
countType: '请选择',
generationCount: 1,
selectedRatio: '1:1',
selectedResolution: '2K',
width: 2048,
@@ -160,6 +163,7 @@ export const useAppStore = create<AppState>((set, get) => ({
// 生成配置状态更新方法
setMediaType: (mediaType) => set({ mediaType }),
setCountType: (countType) => set({ countType }),
setGenerationCount: (generationCount) => set({ generationCount }),
setImageSettings: (ratio, resolution, width, height) =>
set({ selectedRatio: ratio, selectedResolution: resolution, width, height }),
setVideoSettings: (duration, aspectRatio, resolution) =>
@@ -179,6 +183,7 @@ export const useAppStore = create<AppState>((set, get) => ({
resetGenerationConfig: () => set({
mediaType: 'image',
countType: '请选择',
generationCount: 1,
selectedRatio: '1:1',
selectedResolution: '2K',
width: 2048,