| 1 | 'use client'; |
| 2 | |
| 3 | import React, { useState, useRef, useCallback, useEffect } from 'react'; |
| 4 | import { Users, MapPin, RefreshCw, Save, X, ChevronLeft, ChevronRight, Loader, AlertCircle, ZoomIn, ImagePlus, Edit2, 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 AssetVersion { |
| 15 | id: string; // 唯一标识 (character_id / setting_id) |
| 16 | name: string; |
| 17 | description: string; |
| 18 | selected: string; // 当前选中的文件路径 |
| 19 | versions: string[]; // 所有历史版本路径 |
| 20 | status?: 'pending' | 'done' | 'failed' | 'running'; // 生成状态 |
| 21 | rewrite_result?: RewriteResult; |
| 22 | } |
| 23 | |
| 24 | /* ─── 水平滚动图片画廊 ─── */ |
| 25 | function ImageGallery({ |
| 26 | versions, |
| 27 | selected, |
| 28 | onSelect, |
| 29 | showPlaceholder, |
| 30 | }: { |
| 31 | versions: string[]; |
| 32 | selected: string; |
| 33 | onSelect: (path: string) => void; |
| 34 | showPlaceholder?: boolean; |
| 35 | }) { |
| 36 | const scrollRef = useRef<HTMLDivElement>(null); |
| 37 | const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); |
| 38 | |
| 39 | const scroll = (dir: 'left' | 'right') => { |
| 40 | if (!scrollRef.current) return; |
| 41 | const amount = 260; |
| 42 | scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, 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-violet-500 shadow-lg shadow-violet-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-violet-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 AssetRow({ |
| 133 | asset, |
| 134 | type, |
| 135 | isEditing, |
| 136 | editDesc, |
| 137 | onDescChange, |
| 138 | onRegenerate, |
| 139 | onSelectVersion, |
| 140 | onSaveEdit, |
| 141 | onCancelEdit, |
| 142 | onToggleEdit, |
| 143 | onUploadImage, |
| 144 | isStageRunning, |
| 145 | isRegenerating, |
| 146 | isUploading, |
| 147 | }: { |
| 148 | asset: AssetVersion; |
| 149 | type: 'character' | 'setting'; |
| 150 | isEditing: boolean; |
| 151 | editDesc: string; |
| 152 | onDescChange: (val: string) => void; |
| 153 | onRegenerate: () => void; |
| 154 | onSelectVersion: (path: string) => void; |
| 155 | onSaveEdit: () => void; |
| 156 | onCancelEdit: () => void; |
| 157 | onToggleEdit: () => void; |
| 158 | onUploadImage: (file: File) => void; |
| 159 | isStageRunning?: boolean; |
| 160 | isRegenerating?: boolean; |
| 161 | isUploading?: boolean; |
| 162 | }) { |
| 163 | const isRunning = asset.status === 'running' || isRegenerating; |
| 164 | const isPending = asset.status === 'pending'; |
| 165 | const isFailed = asset.status === 'failed' && !isRegenerating; |
| 166 | const hasImage = Boolean(asset.selected) || asset.versions.length > 0; |
| 167 | const canGenerateMissing = !hasImage && !isPending && !isRunning; |
| 168 | const canShowRegenerate = !isRunning && !isPending && (hasImage || isFailed || canGenerateMissing); |
| 169 | |
| 170 | return ( |
| 171 | <div className={`flex flex-col xl:flex-row border rounded-xl overflow-hidden bg-white ${ |
| 172 | isFailed ? 'border-red-200' : 'border-gray-200' |
| 173 | }`}> |
| 174 | {/* 左侧: 描述 */} |
| 175 | <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"> |
| 176 | <div className="flex items-center gap-2 mb-2"> |
| 177 | <RewriteResultBadge rewriteResult={asset.rewrite_result} /> |
| 178 | {type === 'character' |
| 179 | ? <Users className="w-3.5 h-3.5 text-violet-500" /> |
| 180 | : <MapPin className="w-3.5 h-3.5 text-emerald-500" /> |
| 181 | } |
| 182 | <span className="text-sm font-semibold text-gray-800 truncate">{asset.name}</span> |
| 183 | {(isPending || isRunning) && ( |
| 184 | <span className="inline-flex items-center gap-1 text-[10px] bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded"> |
| 185 | {isRunning && <Loader className="w-2.5 h-2.5 animate-spin" />} |
| 186 | {isRunning ? '生成中' : '等待中'} |
| 187 | </span> |
| 188 | )} |
| 189 | {isFailed && ( |
| 190 | <span className="text-[10px] bg-red-50 text-red-500 px-1.5 py-0.5 rounded">失败</span> |
| 191 | )} |
| 192 | {!isStageRunning && ( |
| 193 | isEditing ? ( |
| 194 | <div className="ml-auto flex gap-1"> |
| 195 | <button |
| 196 | onClick={onCancelEdit} |
| 197 | className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100" |
| 198 | > |
| 199 | <X className="w-3 h-3" />取消 |
| 200 | </button> |
| 201 | <button |
| 202 | onClick={onSaveEdit} |
| 203 | className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-white bg-violet-500 hover:bg-violet-600" |
| 204 | > |
| 205 | <Save className="w-3 h-3" />保存 |
| 206 | </button> |
| 207 | </div> |
| 208 | ) : ( |
| 209 | <button |
| 210 | onClick={onToggleEdit} |
| 211 | 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" |
| 212 | > |
| 213 | <Edit2 className="w-3 h-3" />修改 |
| 214 | </button> |
| 215 | ) |
| 216 | )} |
| 217 | </div> |
| 218 | {isEditing ? ( |
| 219 | <textarea |
| 220 | value={editDesc} |
| 221 | onChange={e => onDescChange(e.target.value)} |
| 222 | rows={5} |
| 223 | 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-violet-300" |
| 224 | /> |
| 225 | ) : ( |
| 226 | <div className="h-[120px] overflow-y-auto pr-1 custom-scrollbar"> |
| 227 | <p className="text-xs text-gray-600 leading-relaxed">{asset.description}</p> |
| 228 | </div> |
| 229 | )} |
| 230 | <div className="mt-3 flex flex-wrap items-center gap-2"> |
| 231 | {canShowRegenerate && ( |
| 232 | <button |
| 233 | onClick={onRegenerate} |
| 234 | className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ |
| 235 | isFailed |
| 236 | ? 'text-red-600 bg-red-50 hover:bg-red-100' |
| 237 | : 'text-violet-600 bg-violet-50 hover:bg-violet-100' |
| 238 | }`} |
| 239 | > |
| 240 | <RefreshCw className="w-3 h-3" /> |
| 241 | {isFailed ? '点击重试' : hasImage ? '重新生成' : '生成'} |
| 242 | </button> |
| 243 | )} |
| 244 | <label className={`ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ |
| 245 | isUploading |
| 246 | ? 'text-gray-400 bg-gray-100 cursor-wait' |
| 247 | : 'text-gray-600 bg-gray-100 hover:bg-gray-200 cursor-pointer' |
| 248 | }`}> |
| 249 | {isUploading ? <Loader className="w-3 h-3 animate-spin" /> : <Upload className="w-3 h-3" />} |
| 250 | {isUploading ? '上传中...' : '上传照片'} |
| 251 | <input |
| 252 | type="file" |
| 253 | accept="image/*" |
| 254 | className="hidden" |
| 255 | disabled={isUploading} |
| 256 | onChange={e => { |
| 257 | const file = e.target.files?.[0]; |
| 258 | e.currentTarget.value = ''; |
| 259 | if (file) onUploadImage(file); |
| 260 | }} |
| 261 | /> |
| 262 | </label> |
| 263 | </div> |
| 264 | </div> |
| 265 | |
| 266 | {/* 右侧: 图片画廊 / 占位 */} |
| 267 | <div className="flex-1 min-w-0 p-3 flex items-center"> |
| 268 | {isPending && !hasImage ? ( |
| 269 | <div className="flex items-center justify-center h-32 aspect-video bg-gray-50 rounded-lg border border-dashed border-gray-200"> |
| 270 | <div className="flex items-center gap-2 text-gray-400 text-xs"> |
| 271 | <Loader className="w-4 h-4 animate-spin" /> |
| 272 | <span>正在生成...</span> |
| 273 | </div> |
| 274 | </div> |
| 275 | ) : !hasImage ? ( |
| 276 | <div className="flex items-center justify-center h-32 aspect-video bg-gray-50/60 rounded-lg border border-dashed border-gray-200"> |
| 277 | <div className="flex flex-col items-center gap-1 text-gray-400 text-xs"> |
| 278 | {isFailed ? ( |
| 279 | <> |
| 280 | <AlertCircle className="w-4 h-4" /> |
| 281 | <span>生成失败</span> |
| 282 | {!isRunning && ( |
| 283 | <button |
| 284 | onClick={onRegenerate} |
| 285 | disabled={isRegenerating} |
| 286 | 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" |
| 287 | > |
| 288 | <RefreshCw className="w-2.5 h-2.5" /> |
| 289 | 点击重试 |
| 290 | </button> |
| 291 | )} |
| 292 | </> |
| 293 | ) : ( |
| 294 | <> |
| 295 | <ImagePlus className="w-4 h-4" /> |
| 296 | <span>暂无图片</span> |
| 297 | {canGenerateMissing && ( |
| 298 | <button |
| 299 | onClick={onRegenerate} |
| 300 | disabled={isRegenerating} |
| 301 | className="mt-1 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-violet-600 bg-violet-50 hover:bg-violet-100 transition-colors disabled:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" |
| 302 | > |
| 303 | <RefreshCw className="w-2.5 h-2.5" /> |
| 304 | 生成 |
| 305 | </button> |
| 306 | )} |
| 307 | </> |
| 308 | )} |
| 309 | </div> |
| 310 | </div> |
| 311 | ) : ( |
| 312 | <div className="relative w-full"> |
| 313 | <ImageGallery |
| 314 | versions={asset.versions} |
| 315 | selected={asset.selected} |
| 316 | onSelect={onSelectVersion} |
| 317 | showPlaceholder={isRunning} |
| 318 | /> |
| 319 | {isFailed && ( |
| 320 | <button |
| 321 | onClick={onRegenerate} |
| 322 | 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" |
| 323 | > |
| 324 | <RefreshCw className="w-2.5 h-2.5" /> |
| 325 | 重试 |
| 326 | </button> |
| 327 | )} |
| 328 | </div> |
| 329 | )} |
| 330 | </div> |
| 331 | </div> |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | /* ─── 主组件 ─── */ |
| 336 | export default function CharacterStage({ state, sessionId, onConfirm, onIntervene, onRegenerate, onUpdateArtifact, onSaveSelections, showConfirm, isRunning, hasPendingItems, hasNextStageStarted }: StageViewProps) { |
| 337 | const characters: AssetVersion[] = state.artifact?.characters || []; |
| 338 | const settingsData: AssetVersion[] = state.artifact?.settings || []; |
| 339 | |
| 340 | const [editChars, setEditChars] = useState<Record<string, string>>({}); |
| 341 | const [editSets, setEditSets] = useState<Record<string, string>>({}); |
| 342 | // 跟踪前端选择的版本(覆盖后端返回的 selected) |
| 343 | const [selectedChars, setSelectedChars] = useState<Record<string, string>>({}); |
| 344 | const [selectedSets, setSelectedSets] = useState<Record<string, string>>({}); |
| 345 | const [regeneratingIds, setRegeneratingIds] = useState<Set<string>>(new Set()); |
| 346 | const regenerationStartCounts = useRef<Record<string, number>>({}); |
| 347 | const [editingIds, setEditingIds] = useState<Set<string>>(new Set()); |
| 348 | const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set()); |
| 349 | |
| 350 | const allAssets = React.useMemo(() => [...characters, ...settingsData], [characters, settingsData]); |
| 351 | |
| 352 | // 当对应素材新增版本或失败时,仅清除该素材的重新生成状态,支持多个任务并行。 |
| 353 | useEffect(() => { |
| 354 | if (regeneratingIds.size === 0) return; |
| 355 | setRegeneratingIds(prev => { |
| 356 | let changed = false; |
| 357 | const next = new Set(prev); |
| 358 | for (const id of prev) { |
| 359 | const asset = allAssets.find(item => item.id === id); |
| 360 | if (!asset) continue; |
| 361 | const startCount = regenerationStartCounts.current[id] ?? 0; |
| 362 | if ((asset.versions?.length || 0) > startCount || asset.status === 'failed') { |
| 363 | next.delete(id); |
| 364 | delete regenerationStartCounts.current[id]; |
| 365 | changed = true; |
| 366 | } |
| 367 | } |
| 368 | return changed ? next : prev; |
| 369 | }); |
| 370 | }, [allAssets, regeneratingIds.size]); |
| 371 | |
| 372 | const hasChars = characters.length > 0; |
| 373 | const hasSets = settingsData.length > 0; |
| 374 | |
| 375 | const startEdit = useCallback((id: string) => { |
| 376 | const cd: Record<string, string> = {}; |
| 377 | characters.forEach(c => { cd[c.id] = c.description; }); |
| 378 | setEditChars(cd); |
| 379 | const sd: Record<string, string> = {}; |
| 380 | settingsData.forEach(s => { sd[s.id] = s.description; }); |
| 381 | setEditSets(sd); |
| 382 | setEditingIds(prev => new Set(prev).add(id)); |
| 383 | }, [characters, settingsData]); |
| 384 | |
| 385 | const cancelEdit = (id: string) => { |
| 386 | setEditingIds(prev => { |
| 387 | const next = new Set(prev); |
| 388 | next.delete(id); |
| 389 | return next; |
| 390 | }); |
| 391 | }; |
| 392 | |
| 393 | const saveEdit = (id: string) => { |
| 394 | const selections: Record<string, any> = {}; |
| 395 | characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; }); |
| 396 | settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; }); |
| 397 | selections._editDescs = { |
| 398 | characters: id in editChars ? { [id]: editChars[id] } : {}, |
| 399 | settings: id in editSets ? { [id]: editSets[id] } : {}, |
| 400 | }; |
| 401 | onSaveSelections?.(selections); |
| 402 | cancelEdit(id); |
| 403 | }; |
| 404 | |
| 405 | const handleUploadImage = async (type: 'characters' | 'settings', id: string, file: File) => { |
| 406 | setUploadingIds(prev => new Set(prev).add(id)); |
| 407 | try { |
| 408 | const result = await uploadArtifactImage(sessionId, 'character_design', type, id, file); |
| 409 | const artifact = result.artifact; |
| 410 | if (artifact) { |
| 411 | onUpdateArtifact?.({ |
| 412 | characters: artifact.characters || [], |
| 413 | settings: artifact.settings || [], |
| 414 | }); |
| 415 | } |
| 416 | if (artifact?.characters) { |
| 417 | const char = artifact.characters.find((item: AssetVersion) => item.id === id); |
| 418 | if (char?.selected) setSelectedChars(prev => ({ ...prev, [id]: char.selected })); |
| 419 | } |
| 420 | if (artifact?.settings) { |
| 421 | const setting = artifact.settings.find((item: AssetVersion) => item.id === id); |
| 422 | if (setting?.selected) setSelectedSets(prev => ({ ...prev, [id]: setting.selected })); |
| 423 | } |
| 424 | } catch (error) { |
| 425 | console.error('上传图片失败:', error); |
| 426 | } finally { |
| 427 | setUploadingIds(prev => { |
| 428 | const next = new Set(prev); |
| 429 | next.delete(id); |
| 430 | return next; |
| 431 | }); |
| 432 | } |
| 433 | }; |
| 434 | |
| 435 | const handleRegenerate = (type: 'characters' | 'settings', id: string) => { |
| 436 | const source = type === 'characters' ? characters : settingsData; |
| 437 | const asset = source.find(item => item.id === id); |
| 438 | regenerationStartCounts.current[id] = asset?.versions?.length || 0; |
| 439 | setRegeneratingIds(prev => new Set(prev).add(id)); |
| 440 | if (type === 'characters') { |
| 441 | onIntervene({ regenerate_characters: [id] }); |
| 442 | } else { |
| 443 | onIntervene({ regenerate_settings: [id] }); |
| 444 | } |
| 445 | }; |
| 446 | |
| 447 | const handleSelectCharVersion = async (id: string, path: string) => { |
| 448 | setSelectedChars(prev => ({ ...prev, [id]: path })); |
| 449 | // 自动保存选择 |
| 450 | const selections: Record<string, string> = {}; |
| 451 | characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; }); |
| 452 | selections[id] = path; |
| 453 | settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; }); |
| 454 | if (onSaveSelections) { |
| 455 | await onSaveSelections(selections); |
| 456 | } |
| 457 | }; |
| 458 | |
| 459 | const handleSelectSetVersion = async (id: string, path: string) => { |
| 460 | setSelectedSets(prev => ({ ...prev, [id]: path })); |
| 461 | // 自动保存选择 |
| 462 | const selections: Record<string, string> = {}; |
| 463 | characters.forEach(c => { selections[c.id] = selectedChars[c.id] || c.selected; }); |
| 464 | settingsData.forEach(s => { selections[s.id] = selectedSets[s.id] || s.selected; }); |
| 465 | selections[id] = path; |
| 466 | if (onSaveSelections) { |
| 467 | await onSaveSelections(selections); |
| 468 | } |
| 469 | }; |
| 470 | |
| 471 | const getCharSelected = (asset: AssetVersion) => selectedChars[asset.id] || asset.selected; |
| 472 | const getSetSelected = (asset: AssetVersion) => selectedSets[asset.id] || asset.selected; |
| 473 | |
| 474 | return ( |
| 475 | <div className="flex flex-col h-full"> |
| 476 | <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6"> |
| 477 | {/* 标题栏 */} |
| 478 | <div className="flex items-center justify-between mb-1"> |
| 479 | <h2 className="text-lg font-semibold text-gray-800">角色 / 场景设计</h2> |
| 480 | </div> |
| 481 | <p className="text-sm text-gray-500 mb-6"> |
| 482 | 生成角色4视图 (正面特写·正面全身·侧面全身·背面全身) 和场景全景图 |
| 483 | </p> |
| 484 | |
| 485 | {/* 运行中 */} |
| 486 | {state.status === 'running' && ( |
| 487 | <StageProgress message={state.progressMessage} fallback="正在生成角色与场景..." progress={state.progress} color="violet" /> |
| 488 | )} |
| 489 | |
| 490 | {state.error && ( |
| 491 | <div className="text-sm text-red-600 bg-red-50 border border-red-200 p-4 rounded-xl mb-4">{state.error}</div> |
| 492 | )} |
| 493 | |
| 494 | {/* ═══ 角色列表 ═══ */} |
| 495 | {hasChars && ( |
| 496 | <section className="mb-8"> |
| 497 | <div className="flex items-center gap-2 mb-3"> |
| 498 | <Users className="w-4 h-4 text-violet-500" /> |
| 499 | <h3 className="text-sm font-semibold text-gray-700">角色 ({characters.length})</h3> |
| 500 | </div> |
| 501 | <div className="space-y-3"> |
| 502 | {characters.map(asset => ( |
| 503 | <AssetRow |
| 504 | key={asset.id} |
| 505 | asset={{ ...asset, selected: getCharSelected(asset) }} |
| 506 | type="character" |
| 507 | isEditing={editingIds.has(asset.id)} |
| 508 | editDesc={editChars[asset.id] || asset.description} |
| 509 | onDescChange={val => setEditChars(prev => ({ ...prev, [asset.id]: val }))} |
| 510 | onRegenerate={() => handleRegenerate('characters', asset.id)} |
| 511 | onSelectVersion={path => handleSelectCharVersion(asset.id, path)} |
| 512 | onToggleEdit={() => startEdit(asset.id)} |
| 513 | onSaveEdit={() => saveEdit(asset.id)} |
| 514 | onCancelEdit={() => cancelEdit(asset.id)} |
| 515 | onUploadImage={file => handleUploadImage('characters', asset.id, file)} |
| 516 | isStageRunning={state.status === 'running'} |
| 517 | isRegenerating={regeneratingIds.has(asset.id)} |
| 518 | isUploading={uploadingIds.has(asset.id)} |
| 519 | /> |
| 520 | ))} |
| 521 | </div> |
| 522 | </section> |
| 523 | )} |
| 524 | |
| 525 | {/* ═══ 场景列表 ═══ */} |
| 526 | {hasSets && ( |
| 527 | <section> |
| 528 | <div className="flex items-center gap-2 mb-3"> |
| 529 | <MapPin className="w-4 h-4 text-emerald-500" /> |
| 530 | <h3 className="text-sm font-semibold text-gray-700">场景 ({settingsData.length})</h3> |
| 531 | </div> |
| 532 | <div className="space-y-3"> |
| 533 | {settingsData.map(asset => ( |
| 534 | <AssetRow |
| 535 | key={asset.id} |
| 536 | asset={{ ...asset, selected: getSetSelected(asset) }} |
| 537 | type="setting" |
| 538 | isEditing={editingIds.has(asset.id)} |
| 539 | editDesc={editSets[asset.id] || asset.description} |
| 540 | onDescChange={val => setEditSets(prev => ({ ...prev, [asset.id]: val }))} |
| 541 | onRegenerate={() => handleRegenerate('settings', asset.id)} |
| 542 | onSelectVersion={path => handleSelectSetVersion(asset.id, path)} |
| 543 | onToggleEdit={() => startEdit(asset.id)} |
| 544 | onSaveEdit={() => saveEdit(asset.id)} |
| 545 | onCancelEdit={() => cancelEdit(asset.id)} |
| 546 | onUploadImage={file => handleUploadImage('settings', asset.id, file)} |
| 547 | isStageRunning={state.status === 'running'} |
| 548 | isRegenerating={regeneratingIds.has(asset.id)} |
| 549 | isUploading={uploadingIds.has(asset.id)} |
| 550 | /> |
| 551 | ))} |
| 552 | </div> |
| 553 | </section> |
| 554 | )} |
| 555 | |
| 556 | {state.status === 'pending' && ( |
| 557 | <div className="text-center text-gray-400 text-sm py-20">等待上一阶段完成...</div> |
| 558 | )} |
| 559 | </div> |
| 560 | |
| 561 | {/* 底部操作栏 */} |
| 562 | <StageActions |
| 563 | status={state.status} |
| 564 | onConfirm={onConfirm} |
| 565 | showConfirm={showConfirm} |
| 566 | onRegenerate={onRegenerate} |
| 567 | stageId="character_design" |
| 568 | hasPendingItems={hasPendingItems} |
| 569 | hasNextStageStarted={hasNextStageStarted} |
| 570 | isRunning={isRunning} |
| 571 | /> |
| 572 | </div> |
| 573 | ); |
| 574 | } |
| 575 |