返回 VideoClaw
TopBar.tsx
1 'use client';
2
3 import React, { useState, useRef, useEffect } from 'react';
4 import { CheckCircle, Circle, Loader, Edit3, AlertCircle, Square, Zap, Settings2, ChevronDown } from 'lucide-react';
5 import clsx from 'clsx';
6 import {
7 VIDEO_RATIOS,
8 VIDEO_RESOLUTIONS,
9 VIDEO_GENERATION_MODES,
10 type ProviderGroup,
11 type VideoGenerationMode,
12 } from '@/config/models';
13 import { fetchModelGroupsByType, fetchVideoModelGroupsByAbility } from '@/lib/modelRegistry';
14
15 export type StageStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'error' | 'stopped';
16
17 export const STAGES = [
18 { id: 'script_generation', name: '剧本生成', shortName: '剧本' },
19 { id: 'character_design', name: '角色设计', shortName: '角色' },
20 { id: 'storyboard', name: '分镜设计', shortName: '分镜' },
21 { id: 'reference_generation', name: '参考图', shortName: '参考图' },
22 { id: 'video_generation', name: '视频生成', shortName: '视频' },
23 { id: 'post_production', name: '后期剪辑', shortName: '后期' },
24 ] as const;
25
26 export type StageId = typeof STAGES[number]['id'];
27
28 export interface ModelConfig {
29 llm_model: string;
30 vlm_model: string;
31 image_t2i_model: string;
32 image_it2i_model: string;
33 video_model: string;
34 video_first_frame_model: string;
35 video_start_end_model: string;
36 video_reference_model: string;
37 video_generation_mode: VideoGenerationMode;
38 video_ratio: string;
39 video_resolution: string;
40 enable_concurrency: boolean;
41 }
42
43 interface TopBarProps {
44 /** null = 首页 */
45 activeStage: string | null;
46 stageStatuses: Record<string, StageStatus>;
47 onStageClick: (stageId: string) => void;
48 onHomeClick: () => void;
49 /** 是否处于工作流中(有 sessionId) */
50 hasSession: boolean;
51 /** 是否正在执行 */
52 isRunning: boolean;
53 /** 停止执行 */
54 onStop: () => void;
55 /** 代理模式(自动执行全流程) */
56 autoMode: boolean;
57 onAutoModeChange: (auto: boolean) => void;
58 /** 当前模型配置 */
59 modelConfig?: ModelConfig;
60 /** 模型配置变更 */
61 onModelConfigChange?: (config: ModelConfig) => void;
62 /** 项目状态(如 running, waiting, completed, stopped, idle, error 等) */
63 projectStatus?: string;
64 }
65
66 /* ─── 带 Provider 分组的 <select> ─── */
67 function ProviderSelect({
68 value,
69 providers,
70 onChange,
71 }: {
72 value: string;
73 providers: ProviderGroup[];
74 onChange: (val: string) => void;
75 }) {
76 return (
77 <select
78 value={value}
79 onChange={e => onChange(e.target.value)}
80 className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full"
81 >
82 {providers.map(pg => (
83 <optgroup key={pg.provider} label={pg.label}>
84 {pg.models.map(m => (
85 <option key={m.id} value={m.id}>{m.label}</option>
86 ))}
87 </optgroup>
88 ))}
89 </select>
90 );
91 }
92
93 /* ─── 模型选择下拉面板 ─── */
94 function ModelSelector({
95 config,
96 onChange,
97 }: {
98 config: ModelConfig;
99 onChange: (config: ModelConfig) => void;
100 }) {
101 const [open, setOpen] = useState(false);
102 const ref = useRef<HTMLDivElement>(null);
103 const [llmProviders, setLlmProviders] = useState<ProviderGroup[]>([]);
104 const [vlmProviders, setVlmProviders] = useState<ProviderGroup[]>([]);
105 const [t2iProviders, setT2iProviders] = useState<ProviderGroup[]>([]);
106 const [i2iProviders, setI2iProviders] = useState<ProviderGroup[]>([]);
107 const [firstFrameVideoProviders, setFirstFrameVideoProviders] = useState<ProviderGroup[]>([]);
108 const [startEndVideoProviders, setStartEndVideoProviders] = useState<ProviderGroup[]>([]);
109 const [referenceVideoProviders, setReferenceVideoProviders] = useState<ProviderGroup[]>([]);
110
111 useEffect(() => {
112 const handler = (e: MouseEvent) => {
113 if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
114 };
115 if (open) document.addEventListener('mousedown', handler);
116 return () => document.removeEventListener('mousedown', handler);
117 }, [open]);
118
119 useEffect(() => {
120 let cancelled = false;
121 fetchModelGroupsByType('llm')
122 .then(groups => { if (!cancelled) setLlmProviders(groups); })
123 .catch(() => {});
124 fetchModelGroupsByType('vlm')
125 .then(groups => { if (!cancelled) setVlmProviders(groups); })
126 .catch(() => {});
127 fetchModelGroupsByType('t2i')
128 .then(groups => { if (!cancelled) setT2iProviders(groups); })
129 .catch(() => {});
130 fetchModelGroupsByType('i2i')
131 .then(groups => { if (!cancelled) setI2iProviders(groups); })
132 .catch(() => {});
133 fetchVideoModelGroupsByAbility('first_frame_i2v')
134 .then(groups => { if (!cancelled) setFirstFrameVideoProviders(groups); })
135 .catch(() => {});
136 fetchVideoModelGroupsByAbility('start_end_frame_i2v')
137 .then(groups => { if (!cancelled) setStartEndVideoProviders(groups); })
138 .catch(() => {});
139 fetchVideoModelGroupsByAbility('reference_to_video')
140 .then(groups => { if (!cancelled) setReferenceVideoProviders(groups); })
141 .catch(() => {});
142 return () => { cancelled = true; };
143 }, []);
144
145 const update = (key: keyof ModelConfig, val: string | boolean) => {
146 onChange({ ...config, [key]: val });
147 };
148
149 const activeVideoModel =
150 config.video_generation_mode === 'start_end_frame'
151 ? config.video_start_end_model
152 : config.video_generation_mode === 'reference'
153 ? config.video_reference_model
154 : config.video_first_frame_model;
155 const activeVideoProviders =
156 config.video_generation_mode === 'start_end_frame'
157 ? startEndVideoProviders
158 : config.video_generation_mode === 'reference'
159 ? referenceVideoProviders
160 : firstFrameVideoProviders;
161 const activeVideoLabel = VIDEO_GENERATION_MODES.find(item => item.id === config.video_generation_mode)?.label || '首帧生视频';
162
163 const updateVideoMode = (mode: VideoGenerationMode) => {
164 const nextModel =
165 mode === 'start_end_frame'
166 ? config.video_start_end_model
167 : mode === 'reference'
168 ? config.video_reference_model
169 : config.video_first_frame_model;
170 onChange({ ...config, video_generation_mode: mode, video_model: nextModel });
171 };
172
173 const updateActiveVideoModel = (model: string) => {
174 if (config.video_generation_mode === 'start_end_frame') {
175 onChange({ ...config, video_start_end_model: model, video_model: model });
176 } else if (config.video_generation_mode === 'reference') {
177 onChange({ ...config, video_reference_model: model, video_model: model });
178 } else {
179 onChange({ ...config, video_first_frame_model: model, video_model: model });
180 }
181 };
182
183 return (
184 <div ref={ref} className="relative">
185 <button
186 onClick={() => setOpen(!open)}
187 className={clsx(
188 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all',
189 open
190 ? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
191 : 'text-gray-500 hover:bg-gray-50'
192 )}
193 title="生成配置"
194 >
195 <Settings2 className="w-3.5 h-3.5" />
196 <span>生成配置</span>
197 <ChevronDown className={clsx('w-3 h-3 transition-transform', open && 'rotate-180')} />
198 </button>
199
200 {open && (
201 <div className="absolute right-0 top-full mt-1 w-72 bg-white rounded-xl shadow-lg border border-gray-200 p-3 z-50 space-y-2.5">
202 <label className="flex flex-col gap-1">
203 <span className="text-[10px] text-gray-400 font-medium">LLM 模型</span>
204 <ProviderSelect value={config.llm_model} providers={llmProviders} onChange={v => update('llm_model', v)} />
205 </label>
206 <label className="flex flex-col gap-1">
207 <span className="text-[10px] text-gray-400 font-medium">VLM 评估模型</span>
208 <ProviderSelect value={config.vlm_model} providers={vlmProviders} onChange={v => update('vlm_model', v)} />
209 </label>
210 <label className="flex flex-col gap-1">
211 <span className="text-[10px] text-gray-400 font-medium">文生图</span>
212 <ProviderSelect value={config.image_t2i_model} providers={t2iProviders} onChange={v => update('image_t2i_model', v)} />
213 </label>
214 <label className="flex flex-col gap-1">
215 <span className="text-[10px] text-gray-400 font-medium">图生图</span>
216 <ProviderSelect value={config.image_it2i_model} providers={i2iProviders} onChange={v => update('image_it2i_model', v)} />
217 </label>
218 <label className="flex flex-col gap-1">
219 <span className="text-[10px] text-gray-400 font-medium">视频生成方式</span>
220 <select
221 value={config.video_generation_mode}
222 onChange={e => updateVideoMode(e.target.value as VideoGenerationMode)}
223 className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full"
224 >
225 {VIDEO_GENERATION_MODES.map(item => (
226 <option key={item.id} value={item.id}>{item.label}</option>
227 ))}
228 </select>
229 </label>
230 <label className="flex flex-col gap-1">
231 <span className="text-[10px] text-gray-400 font-medium">{activeVideoLabel}模型</span>
232 <ProviderSelect value={activeVideoModel} providers={activeVideoProviders} onChange={updateActiveVideoModel} />
233 </label>
234 <label className="flex flex-col gap-1">
235 <span className="text-[10px] text-gray-400 font-medium">视频比例</span>
236 <div className="flex gap-0.5">
237 {VIDEO_RATIOS.map(r => (
238 <button
239 key={r.id}
240 onClick={() => update('video_ratio', r.id)}
241 className={`flex flex-col items-center gap-0.5 p-1 rounded border transition-all ${
242 config.video_ratio === r.id
243 ? 'border-indigo-500 bg-indigo-50'
244 : 'border-gray-200 hover:border-gray-300'
245 }`}
246 title={r.label}
247 >
248 <div
249 className="bg-gray-600 rounded-sm"
250 style={{
251 width: r.ratio === '16:9' ? '16px' :
252 r.ratio === '9:16' ? '9px' :
253 r.ratio === '1:1' ? '12px' :
254 r.ratio === '4:3' ? '14px' :
255 r.ratio === '3:4' ? '10px' :
256 '18px',
257 height: r.ratio === '16:9' ? '9px' :
258 r.ratio === '9:16' ? '16px' :
259 r.ratio === '1:1' ? '12px' :
260 r.ratio === '4:3' ? '10px' :
261 r.ratio === '3:4' ? '14px' :
262 '7px',
263 }}
264 />
265 <span className="text-[8px] text-gray-500">{r.label}</span>
266 </button>
267 ))}
268 </div>
269 </label>
270 <label className="flex flex-col gap-1">
271 <span className="text-[10px] text-gray-400 font-medium">视频分辨率</span>
272 <select
273 value={config.video_resolution}
274 onChange={e => update('video_resolution', e.target.value)}
275 className="bg-gray-50 border border-gray-200 rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none w-full"
276 >
277 {VIDEO_RESOLUTIONS.map(item => (
278 <option key={item.id} value={item.id}>{item.label}</option>
279 ))}
280 </select>
281 </label>
282 <label className="flex items-center gap-2 text-xs cursor-pointer select-none">
283 <input
284 type="checkbox"
285 checked={!!config.enable_concurrency}
286 onChange={e => update('enable_concurrency', e.target.checked)}
287 className="w-3.5 h-3.5 rounded border-gray-300 text-blue-500 focus:ring-blue-500/30"
288 />
289 <span className="text-gray-500">并发生成</span>
290 </label>
291 </div>
292 )}
293 </div>
294 );
295 }
296
297 export default function TopBar({
298 activeStage,
299 stageStatuses,
300 onStageClick,
301 onHomeClick,
302 hasSession,
303 isRunning,
304 onStop,
305 autoMode,
306 onAutoModeChange,
307 modelConfig,
308 onModelConfigChange,
309 projectStatus,
310 }: TopBarProps) {
311 const getStageIcon = (status: StageStatus, isActive: boolean) => {
312 switch (status) {
313 case 'completed':
314 return <CheckCircle className="w-4 h-4 text-green-500" />;
315 case 'running':
316 return <Loader className="w-4 h-4 text-blue-500 animate-spin" />;
317 case 'waiting':
318 return <Edit3 className="w-4 h-4 text-amber-500" />;
319 case 'error':
320 return <AlertCircle className="w-4 h-4 text-red-500" />;
321 default:
322 return (
323 <Circle
324 className={clsx('w-4 h-4', isActive ? 'text-blue-400' : 'text-gray-300')}
325 />
326 );
327 }
328 };
329
330 return (
331 <>
332 <header className="fixed top-0 right-0 left-[var(--app-sidebar-width)] z-30 h-14 bg-white border-b border-gray-200 flex items-center px-4 min-w-0 transition-[left] duration-300">
333 {/* Logo & 名称 */}
334 <button
335 onClick={onHomeClick}
336 className="flex items-center gap-2 mr-6 hover:opacity-80 transition-opacity flex-shrink-0"
337 >
338 <img
339 src="/logo.jpg"
340 alt="Logo"
341 className="w-8 h-8 rounded-lg object-contain"
342 />
343 <div className="flex flex-col leading-tight">
344 <span className="font-bold text-sm text-gray-800 tracking-tight">
345 Video-Claw
346 </span>
347 </div>
348 </button>
349
350 {/* 分隔线 */}
351 {hasSession && <div className="w-px h-6 bg-gray-200 mr-4 flex-shrink-0" />}
352
353 {/* 阶段进度条 */}
354 {hasSession && (
355 <nav className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0">
356 {STAGES.map((stage, idx) => {
357 const status = stageStatuses[stage.id] || 'pending';
358 const isActive = activeStage === stage.id;
359
360 return (
361 <React.Fragment key={stage.id}>
362 {idx > 0 && (
363 <div
364 className={clsx(
365 'w-6 h-px flex-shrink-0',
366 stageStatuses[STAGES[idx - 1].id] === 'completed'
367 ? 'bg-green-300'
368 : 'bg-gray-200'
369 )}
370 />
371 )}
372 <button
373 onClick={() => onStageClick(stage.id)}
374 className={clsx(
375 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap flex-shrink-0',
376 isActive
377 ? 'bg-blue-50 text-blue-700 ring-1 ring-blue-200'
378 : status === 'completed'
379 ? 'text-green-700 hover:bg-green-50'
380 : status === 'error'
381 ? 'text-red-600 hover:bg-red-50'
382 : 'text-gray-500 hover:bg-gray-50'
383 )}
384 >
385 {getStageIcon(status, isActive)}
386 <span>{stage.shortName}</span>
387 </button>
388 </React.Fragment>
389 );
390 })}
391 </nav>
392 )}
393
394 {/* 右侧控制区 */}
395 <div className="ml-auto flex min-w-0 items-center justify-end gap-2 flex-shrink-0">
396 {/* 模型选择 */}
397 {hasSession && modelConfig && onModelConfigChange && (
398 <ModelSelector config={modelConfig} onChange={onModelConfigChange} />
399 )}
400
401 {/* 代理模式切换 */}
402 {hasSession && (
403 <button
404 onClick={() => onAutoModeChange(!autoMode)}
405 className={clsx(
406 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all',
407 autoMode
408 ? 'bg-amber-50 text-amber-700 ring-1 ring-amber-200'
409 : 'text-gray-500 hover:bg-gray-50'
410 )}
411 title={autoMode ? '代理模式:自动执行全流程' : '手动模式:每阶段需确认'}
412 >
413 <Zap className="w-3.5 h-3.5" />
414 <span>{autoMode ? '自动' : '手动'}</span>
415 </button>
416 )}
417
418 {/* 停止按钮 */}
419 {isRunning && (
420 <button
421 onClick={onStop}
422 className="flex items-center gap-1.5 px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-lg text-xs font-medium transition-colors ring-1 ring-red-200"
423 title="停止执行"
424 >
425 <Square className="w-3.5 h-3.5 fill-current" />
426 <span>停止</span>
427 </button>
428 )}
429
430 {/* 项目状态 */}
431 {hasSession && projectStatus && (
432 <div
433 className={clsx(
434 'px-2 py-1 rounded-lg text-xs font-medium flex items-center gap-1',
435 projectStatus === 'running' && 'bg-blue-50 text-blue-700',
436 projectStatus === 'waiting' && 'bg-amber-50 text-amber-700',
437 projectStatus === 'completed' && 'bg-green-50 text-green-700',
438 projectStatus === 'pending' && 'bg-gray-50 text-gray-600',
439 projectStatus === 'error' && 'bg-red-50 text-red-700',
440 projectStatus === 'stopped' && 'bg-orange-50 text-orange-700'
441 )}
442 title="项目状态"
443 >
444 {projectStatus === 'running' && <Loader className="w-3 h-3 animate-spin" />}
445 {projectStatus === 'waiting' && <Edit3 className="w-3 h-3" />}
446 {projectStatus === 'error' && <AlertCircle className="w-3 h-3" />}
447 <span>
448 {projectStatus === 'running' ? '执行中' :
449 projectStatus === 'waiting' ? '等待确认' :
450 projectStatus === 'completed' ? '已完成' :
451 projectStatus === 'pending' ? '空闲' :
452 projectStatus === 'stopped' ? '已停止' :
453 projectStatus === 'error' ? '出错' : projectStatus}
454 </span>
455 </div>
456 )}
457
458 </div>
459 </header>
460 <div className="h-14 flex-shrink-0" />
461 </>
462 );
463 }
464
464 lines Plain Text