返回 VideoClaw
ReferenceStage.tsx
根目录 / video-claw / video-claw / frontend / components / stages / ReferenceStage.tsx
1 'use client';
2
3 import React, { useState, useRef, useEffect } from 'react';
4 import { Image as ImageIcon, RefreshCw, ChevronLeft, ChevronRight, Loader, AlertCircle, ZoomIn, ImagePlus, Edit2, Save, X, Upload } from 'lucide-react';
5 import type { StageViewProps } from './types';
6 import { assetUrl, assetVersionLabel } from './utils';
7 import { uploadArtifactImage } from '@/lib/workflowApi';
8 import StageActions from './StageActions';
9 import StageProgress from './StageProgress';
10 import ImageLightbox from './ImageLightbox';
11 import RewriteResultBadge, { type RewriteResult } from './RewriteResultBadge';
12
13 /* ─── 类型 ─── */
14 interface SceneItem {
15 id: string; // shot_001_01, shot_001_02, ...
16 name: string; // 场景1-镜头1
17 index?: number; // 全局编号
18 description: string; // 视觉提示词
19 selected: string; // 当前选中的文件路径
20 versions: string[]; // 所有历史版本路径
21 status?: 'pending' | 'done' | 'failed' | 'running';
22 rewrite_result?: RewriteResult;
23 }
24
25 /* ─── 水平滚动图片画廊 ─── */
26 function ImageGallery({
27 versions,
28 selected,
29 onSelect,
30 showPlaceholder,
31 }: {
32 versions: string[];
33 selected: string;
34 onSelect: (path: string) => void;
35 showPlaceholder?: boolean;
36 }) {
37 const scrollRef = useRef<HTMLDivElement>(null);
38 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
39
40 const scroll = (dir: 'left' | 'right') => {
41 if (!scrollRef.current) return;
42 scrollRef.current.scrollBy({ left: dir === 'left' ? -260 : 260, behavior: 'smooth' });
43 };
44
45 if (!versions.length) {
46 return (
47 <div className="flex items-center justify-center h-full text-gray-400 text-xs">
48 暂无图片
49 </div>
50 );
51 }
52
53 return (
54 <div className="relative group">
55 {versions.length > 1 && (
56 <>
57 <button
58 onClick={() => scroll('left')}
59 className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-white/90 shadow border border-gray-200 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
60 >
61 <ChevronLeft className="w-4 h-4 text-gray-600" />
62 </button>
63 <button
64 onClick={() => scroll('right')}
65 className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-white/90 shadow border border-gray-200 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
66 >
67 <ChevronRight className="w-4 h-4 text-gray-600" />
68 </button>
69 </>
70 )}
71 <div
72 ref={scrollRef}
73 className="flex gap-3 overflow-x-auto scrollbar-hide py-1 px-1"
74 style={{ scrollbarWidth: 'none' }}
75 >
76 {versions.map((path, i) => {
77 const isSelected = path === selected;
78 return (
79 <div
80 key={path}
81 onClick={() => onSelect(path)}
82 className={`flex-shrink-0 cursor-pointer rounded-lg overflow-hidden transition-all ${
83 isSelected
84 ? 'ring-3 ring-emerald-500 shadow-lg shadow-emerald-200'
85 : 'ring-1 ring-gray-200 hover:ring-gray-300 hover:shadow-md'
86 }`}
87 >
88 <div className="relative group/img">
89 <img
90 src={assetUrl(path)}
91 alt={`v${i + 1}`}
92 className="h-32 w-auto object-cover"
93 onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
94 />
95 <button
96 onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }}
97 className="absolute top-1 right-1 w-6 h-6 rounded-full bg-black/40 flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity hover:bg-black/60"
98 title="放大查看"
99 >
100 <ZoomIn className="w-3 h-3 text-white" />
101 </button>
102 </div>
103 <div className={`text-center text-[10px] py-0.5 ${
104 isSelected ? 'bg-emerald-500 text-white font-medium' : 'bg-gray-50 text-gray-400'
105 }`}>
106 {assetVersionLabel(path, i)}
107 </div>
108 </div>
109 );
110 })}
111 {showPlaceholder && (
112 <div className="flex-shrink-0 flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200">
113 <div className="flex items-center gap-2 text-gray-400 text-xs">
114 <Loader className="w-4 h-4 animate-spin" />
115 <span>生成中...</span>
116 </div>
117 </div>
118 )}
119 </div>
120 {lightboxIndex !== null && (
121 <ImageLightbox
122 images={versions}
123 initialIndex={lightboxIndex}
124 onClose={() => setLightboxIndex(null)}
125 />
126 )}
127 </div>
128 );
129 }
130
131 /* ─── 场景行 ─── */
132 function SceneRow({
133 scene,
134 canEdit,
135 editDesc,
136 onDescChange,
137 onRegenerate,
138 onSelectVersion,
139 onSavePrompt,
140 isStageRunning,
141 isRegenerating,
142 isEditing,
143 onToggleEdit,
144 getSelected,
145 allowMissingGenerate,
146 onCancelEdit,
147 onUploadImage,
148 isUploading,
149 }: {
150 scene: SceneItem;
151 canEdit: boolean;
152 editDesc: string;
153 onDescChange: (val: string) => void;
154 onRegenerate: () => void;
155 onSelectVersion: (path: string) => void;
156 onSavePrompt: () => void;
157 isStageRunning?: boolean;
158 isRegenerating?: boolean;
159 isEditing?: boolean;
160 onToggleEdit?: () => void;
161 getSelected: (scene: SceneItem) => string;
162 allowMissingGenerate?: boolean;
163 onCancelEdit?: () => void;
164 onUploadImage: (file: File) => void;
165 isUploading?: boolean;
166 }) {
167 const isRunning = scene.status === 'running' || isRegenerating;
168 const isPending = scene.status === 'pending';
169 const isFailed = scene.status === 'failed' && !isRegenerating;
170 const hasImage = Boolean(getSelected(scene)) || scene.versions.length > 0;
171 const canGenerateMissing = Boolean(allowMissingGenerate) && !hasImage && !isRunning && !isRegenerating;
172 const canShowRegenerate = !isRunning && !isPending && (hasImage || isFailed || canGenerateMissing);
173 const hasChanges = editDesc !== scene.description;
174
175 return (
176 <div className={`flex flex-col xl:flex-row border rounded-xl overflow-hidden bg-white ${
177 isFailed ? 'border-red-200' : 'border-gray-200'
178 }`}>
179 {/* 左侧: 提示词 */}
180 <div className="w-full xl:w-80 xl:flex-shrink-0 p-4 border-b xl:border-b-0 xl:border-r border-gray-100 flex flex-col">
181 <div className="flex items-center gap-2 mb-2">
182 <RewriteResultBadge rewriteResult={scene.rewrite_result} />
183 <span className="flex items-center justify-center h-6 px-1.5 rounded-full bg-emerald-100 text-emerald-700 text-[10px] font-bold flex-shrink-0 whitespace-nowrap">
184 {scene.index ?? scene.id.replace('Scene_', '')}
185 </span>
186 <span className="text-sm font-semibold text-gray-800 truncate">{scene.name || (scene as any).title || `片段 ${scene.index}`}</span>
187 {isPending && (
188 <span className="text-[10px] bg-gray-50 text-gray-500 px-1.5 py-0.5 rounded">等待中</span>
189 )}
190 {isRunning && (
191 <span className="inline-flex items-center gap-1 text-[10px] bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded">
192 <Loader className="w-2.5 h-2.5 animate-spin" />生成中
193 </span>
194 )}
195 {isFailed && (
196 <span className="text-[10px] bg-red-50 text-red-500 px-1.5 py-0.5 rounded">失败</span>
197 )}
198 {/* 编辑/保存按钮 */}
199 {canEdit && !isStageRunning && (
200 isEditing ? (
201 <div className="ml-auto flex gap-1">
202 <button
203 onClick={onCancelEdit}
204 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100"
205 >
206 <X className="w-3 h-3" />取消
207 </button>
208 <button
209 onClick={onSavePrompt}
210 disabled={!hasChanges}
211 className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors ${
212 hasChanges
213 ? 'text-white bg-emerald-500 hover:bg-emerald-600'
214 : 'text-gray-400 bg-gray-100 cursor-not-allowed'
215 }`}
216 >
217 <Save className="w-3 h-3" />
218 保存
219 </button>
220 </div>
221 ) : (
222 <button
223 onClick={onToggleEdit}
224 className="ml-auto flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors"
225 >
226 <Edit2 className="w-3 h-3" />
227 编辑
228 </button>
229 )
230 )}
231 </div>
232 {isEditing ? (
233 <textarea
234 value={editDesc}
235 onChange={e => onDescChange(e.target.value)}
236 rows={5}
237 className="h-[120px] text-xs text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-2 resize-none focus:outline-none focus:ring-1 focus:ring-emerald-300"
238 />
239 ) : (
240 <div className="h-[120px] overflow-y-auto pr-1 custom-scrollbar">
241 <p className="text-xs text-gray-600 leading-relaxed whitespace-pre-wrap">{scene.description}</p>
242 </div>
243 )}
244 <div className="mt-3 flex flex-wrap items-center gap-2">
245 {canShowRegenerate && (
246 <button
247 onClick={onRegenerate}
248 className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
249 isFailed
250 ? 'text-red-600 bg-red-50 hover:bg-red-100'
251 : 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100'
252 }`}
253 >
254 <RefreshCw className="w-3 h-3" />
255 {isFailed ? '点击重试' : hasImage ? '重新生成' : '生成'}
256 </button>
257 )}
258 <label className={`ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
259 isUploading
260 ? 'text-gray-400 bg-gray-100 cursor-wait'
261 : 'text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer'
262 }`}>
263 {isUploading ? <Loader className="w-3 h-3 animate-spin" /> : <Upload className="w-3 h-3" />}
264 {isUploading ? '上传中...' : '上传照片'}
265 <input
266 type="file"
267 accept="image/*"
268 className="hidden"
269 disabled={isUploading}
270 onChange={e => {
271 const file = e.target.files?.[0];
272 e.currentTarget.value = '';
273 if (file) onUploadImage(file);
274 }}
275 />
276 </label>
277 </div>
278 </div>
279
280 {/* 右侧: 图片画廊 / 占位 */}
281 <div className="flex-1 min-w-0 p-3 flex items-center">
282 {isRunning && !hasImage ? (
283 <div className="flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200">
284 <div className="flex items-center gap-2 text-gray-400 text-xs">
285 <Loader className="w-4 h-4 animate-spin" />
286 <span>正在生成...</span>
287 </div>
288 </div>
289 ) : !hasImage ? (
290 <div className="flex items-center justify-center h-32 aspect-video bg-gray-50/60 rounded-lg border border-dashed border-gray-200">
291 <div className="flex flex-col items-center gap-1 text-gray-400 text-xs">
292 {isFailed ? (
293 <>
294 <AlertCircle className="w-4 h-4" />
295 <span>生成失败</span>
296 {!isRunning && (
297 <button
298 onClick={onRegenerate}
299 disabled={isRegenerating}
300 className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-red-600 bg-red-50 hover:bg-red-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed"
301 >
302 <RefreshCw className="w-2.5 h-2.5" />
303 点击重试
304 </button>
305 )}
306 </>
307 ) : (
308 <>
309 <ImagePlus className="w-4 h-4" />
310 <span>{isPending ? '等待生成...' : '暂无图片'}</span>
311 {canGenerateMissing && (
312 <button
313 onClick={onRegenerate}
314 disabled={isRegenerating}
315 className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-emerald-600 bg-emerald-50 hover:bg-emerald-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed"
316 >
317 <RefreshCw className="w-2.5 h-2.5" />
318 生成
319 </button>
320 )}
321 </>
322 )}
323 </div>
324 </div>
325 ) : (
326 <div className="relative w-full">
327 <ImageGallery
328 versions={scene.versions}
329 selected={getSelected(scene)}
330 onSelect={onSelectVersion}
331 showPlaceholder={isRunning}
332 />
333 {isFailed && (
334 <button
335 onClick={onRegenerate}
336 className="absolute top-1 right-1 z-10 flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-white bg-red-500/80 hover:bg-red-600 shadow transition-colors"
337 >
338 <RefreshCw className="w-2.5 h-2.5" />
339 重试
340 </button>
341 )}
342 </div>
343 )}
344 </div>
345 </div>
346 );
347 }
348
349 /* ─── 主组件 ─── */
350 export default function ReferenceStage({ state, sessionId, onConfirm, onIntervene, onRegenerate, onUpdateArtifact, onSaveSelections, showConfirm, isRunning, hasPendingItems, hasNextStageStarted, scriptArtifact }: StageViewProps) {
351 const [editDescs, setEditDescs] = useState<Record<string, string>>({});
352 const [selectedVersions, setSelectedVersions] = useState<Record<string, string>>({});
353 const [regeneratingIds, setRegeneratingIds] = useState<Set<string>>(new Set());
354 const regenerationStartCounts = useRef<Record<string, number>>({});
355 const [editingIds, setEditingIds] = useState<Set<string>>(new Set());
356 const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
357 const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
358
359 // 提取剧集标题映射
360 const episodeTitleMap = React.useMemo(() => {
361 const map: Record<number, string> = {};
362 if (scriptArtifact?.episodes) {
363 scriptArtifact.episodes.forEach((ep: any) => {
364 // 关键修复:这里的字段名应该是 episode_number 和 act_title
365 const epNum = ep.episode_number || ep.episode;
366 const epTitle = ep.act_title || ep.title;
367 if (epNum) {
368 map[Number(epNum)] = epTitle || '';
369 }
370 });
371 }
372 return map;
373 }, [scriptArtifact]);
374
375 const getSelected = (scene: SceneItem) => selectedVersions[scene.id] || scene.selected;
376
377 // 兼容旧格式: scene_images: {Scene_1: "path"} → scenes: [{id, ...}]
378 const scenes: SceneItem[] = (() => {
379 if (state.artifact?.scenes?.length) return state.artifact.scenes;
380 if (state.artifact?.scene_images) {
381 const si = state.artifact.scene_images as Record<string, string>;
382 return Object.entries(si)
383 .sort(([a], [b]) => {
384 const na = parseInt(a.replace(/\D/g, '')) || 0;
385 const nb = parseInt(b.replace(/\D/g, '')) || 0;
386 return na - nb;
387 })
388 .map(([id, path]) => ({
389 id,
390 name: `场景 ${id.replace('Scene_', '')}`,
391 description: '',
392 selected: path,
393 versions: [path],
394 status: 'done' as const,
395 }));
396 }
397 return [];
398 })();
399
400 const canEdit = state.status === 'waiting' || state.status === 'completed';
401
402 // 当场景数据变化时,初始化编辑描述
403 useEffect(() => {
404 if (scenes.length > 0) {
405 setEditDescs(prev => {
406 const next: Record<string, string> = {};
407 scenes.forEach(s => { next[s.id] = prev[s.id] ?? s.description; });
408 return next;
409 });
410 }
411 }, [scenes]);
412
413 // 当对应片段新增版本或失败时,仅清除该片段的重新生成状态,支持多个任务并行。
414 useEffect(() => {
415 if (regeneratingIds.size === 0) return;
416 setRegeneratingIds(prev => {
417 let changed = false;
418 const next = new Set(prev);
419 for (const id of prev) {
420 const scene = scenes.find(item => item.id === id);
421 if (!scene) continue;
422 const startCount = regenerationStartCounts.current[id] ?? 0;
423 if ((scene.versions?.length || 0) > startCount || scene.status === 'failed') {
424 next.delete(id);
425 delete regenerationStartCounts.current[id];
426 changed = true;
427 }
428 }
429 return changed ? next : prev;
430 });
431 }, [scenes, regeneratingIds.size]);
432
433 const hasScenes = scenes.length > 0;
434
435 // 保存单个提示词到后端 JSON
436 const handleSavePrompt = async (sceneId: string) => {
437 const newPrompt = editDescs[sceneId];
438 if (!newPrompt) return;
439
440 setSavingIds(prev => new Set(prev).add(sceneId));
441 try {
442 // 调用后端 API 保存提示词
443 const response = await fetch(`/api/project/${sessionId}/artifact/reference_generation`, {
444 method: 'PATCH',
445 headers: { 'Content-Type': 'application/json' },
446 body: JSON.stringify({
447 segments: scenes.map(s => ({
448 segment_id: s.id,
449 visual_prompt: s.id === sceneId ? newPrompt : s.description
450 }))
451 })
452 });
453 if (response.ok) {
454 // 保存成功后关闭编辑模式
455 setEditingIds(prev => {
456 const next = new Set(prev);
457 next.delete(sceneId);
458 return next;
459 });
460 // 更新本地状态,使用保存后的值
461 setEditDescs(prev => ({ ...prev, [sceneId]: newPrompt }));
462 // 同步更新 artifact 以便后续阶段能获取最新的提示词
463 if (onUpdateArtifact && state.artifact?.scenes) {
464 const updatedScenes = state.artifact.scenes.map((s: SceneItem) =>
465 s.id === sceneId ? { ...s, description: newPrompt } : s
466 );
467 onUpdateArtifact({ scenes: updatedScenes });
468 }
469 }
470 } catch (error) {
471 console.error('保存提示词失败:', error);
472 } finally {
473 setSavingIds(prev => {
474 const next = new Set(prev);
475 next.delete(sceneId);
476 return next;
477 });
478 }
479 };
480
481 // 切换编辑模式
482 const handleToggleEdit = (sceneId: string) => {
483 setEditingIds(prev => {
484 const next = new Set(prev);
485 if (next.has(sceneId)) {
486 next.delete(sceneId);
487 } else {
488 next.add(sceneId);
489 }
490 return next;
491 });
492 };
493
494 const handleCancelEdit = (sceneId: string) => {
495 const scene = scenes.find(item => item.id === sceneId);
496 setEditDescs(prev => ({ ...prev, [sceneId]: scene?.description || '' }));
497 setEditingIds(prev => {
498 const next = new Set(prev);
499 next.delete(sceneId);
500 return next;
501 });
502 };
503
504 const handleUploadImage = async (sceneId: string, file: File) => {
505 setUploadingIds(prev => new Set(prev).add(sceneId));
506 try {
507 const result = await uploadArtifactImage(sessionId, 'reference_generation', 'scenes', sceneId, file);
508 if (result.artifact?.scenes) {
509 onUpdateArtifact?.({ scenes: result.artifact.scenes });
510 }
511 setSelectedVersions(prev => ({ ...prev, [sceneId]: result.path }));
512 } catch (error) {
513 console.error('上传图片失败:', error);
514 } finally {
515 setUploadingIds(prev => {
516 const next = new Set(prev);
517 next.delete(sceneId);
518 return next;
519 });
520 }
521 };
522
523 const handleRegenerate = (sceneId: string) => {
524 const scene = scenes.find(item => item.id === sceneId);
525 regenerationStartCounts.current[sceneId] = scene?.versions?.length || 0;
526 setRegeneratingIds(prev => {
527 const next = new Set(prev);
528 next.add(sceneId);
529 return next;
530 });
531 onIntervene({ regenerate_scenes: [sceneId] });
532 };
533
534 const handleSelectVersion = async (sceneId: string, path: string) => {
535 setSelectedVersions(prev => ({ ...prev, [sceneId]: path }));
536 // 同步更新 artifact 以便确认时能传递正确的选中图片给阶段5
537 if (onUpdateArtifact && state.artifact?.scenes) {
538 const updatedScenes = state.artifact.scenes.map((s: SceneItem) =>
539 s.id === sceneId ? { ...s, selected: path } : s
540 );
541 onUpdateArtifact({ scenes: updatedScenes });
542 }
543 // 自动保存选择
544 const selections: Record<string, string> = {};
545 scenes.forEach(s => { selections[s.id] = selectedVersions[s.id] || s.selected; });
546 selections[sceneId] = path;
547 if (onSaveSelections) {
548 await onSaveSelections(selections);
549 }
550 };
551
552 return (
553 <div className="flex flex-col h-full">
554 <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6">
555 {/* 标题栏 */}
556 <div className="flex items-center justify-between mb-1">
557 <h2 className="text-lg font-semibold text-gray-800">参考图生成</h2>
558 </div>
559 <p className="text-sm text-gray-500 mb-6">
560 基于角色/场景素材 + 分镜视觉描述,使用图生图生成场景参考图
561 </p>
562
563 {/* 运行中 */}
564 {state.status === 'running' && (
565 <StageProgress message={state.progressMessage} fallback="正在生成参考图..." progress={state.progress} color="emerald" />
566 )}
567
568 {state.error && (
569 <div className="text-sm text-red-600 bg-red-50 border border-red-200 p-4 rounded-xl mb-4">{state.error}</div>
570 )}
571
572 {/* ═══ 场景列表 ═══ */}
573 {hasScenes && (
574 <div className="space-y-10">
575 {(() => {
576 // 按剧集分组
577 const episodes: Record<number, SceneItem[]> = {};
578 scenes.forEach(s => {
579 const ep = (s as any).episode || 1;
580 if (!episodes[ep]) episodes[ep] = [];
581 episodes[ep].push(s);
582 });
583
584 return Object.keys(episodes).sort((a, b) => Number(a) - Number(b)).map(epNum => {
585 const epScenes = episodes[Number(epNum)];
586 const fallbackTitle = (epScenes[0] as any).episode_title || `第 ${epNum} 集`;
587 const scriptTitle = episodeTitleMap[Number(epNum)];
588 const episodeTitle = scriptTitle ? `第 ${epNum} 集:${scriptTitle}` : fallbackTitle;
589
590 return (
591 <div key={epNum} className="space-y-4">
592 <div className="flex items-center justify-between py-2 px-1 border-b border-gray-100">
593 <div className="flex items-center gap-3">
594 <div className="w-1.5 h-6 bg-emerald-500 rounded-full" />
595 <h3 className="text-base font-bold text-gray-800">{episodeTitle}</h3>
596 </div>
597 <span className="text-[11px] text-emerald-600 font-medium bg-emerald-50 px-2.5 py-1 rounded-full border border-emerald-100 italic">
598 {epScenes.length} 个片段
599 </span>
600 </div>
601
602 <div className="space-y-4">
603 {epScenes.map(scene => (
604 <div key={scene.id} className="relative">
605 <SceneRow
606 scene={scene}
607 editDesc={editDescs[scene.id] ?? scene.description}
608 onDescChange={(val) => setEditDescs(prev => ({ ...prev, [scene.id]: val }))}
609 onSavePrompt={() => handleSavePrompt(scene.id)}
610 onRegenerate={() => handleRegenerate(scene.id)}
611 onSelectVersion={(path) => handleSelectVersion(scene.id, path)}
612 isStageRunning={state.status === 'running'}
613 isRegenerating={regeneratingIds.has(scene.id)}
614 isEditing={editingIds.has(scene.id)}
615 onToggleEdit={() => handleToggleEdit(scene.id)}
616 onCancelEdit={() => handleCancelEdit(scene.id)}
617 canEdit={canEdit}
618 getSelected={getSelected}
619 allowMissingGenerate={state.status !== 'pending'}
620 onUploadImage={file => handleUploadImage(scene.id, file)}
621 isUploading={uploadingIds.has(scene.id)}
622 />
623 </div>
624 ))}
625 </div>
626 </div>
627 );
628 });
629 })()}
630 </div>
631 )}
632
633 {/* 如果有 artifact 数据(即使 status 是 pending),也显示内容 */}
634 {!hasScenes && state.status === 'pending' && (
635 <div className="text-center text-gray-400 text-sm py-20">等待上一阶段完成...</div>
636 )}
637 </div>
638
639 {/* 底部操作栏 */}
640 <StageActions
641 status={state.status}
642 onConfirm={onConfirm}
643 showConfirm={showConfirm}
644 onRegenerate={onRegenerate}
645 stageId="reference_generation"
646 hasPendingItems={hasPendingItems}
647 hasNextStageStarted={hasNextStageStarted}
648 isRunning={isRunning}
649 />
650 </div>
651 );
652 }
653
653 lines Plain Text