返回 VideoClaw
HomePage.tsx
1 'use client';
2
3 import React, { useState, useRef, useEffect } from 'react';
4 import { Sparkles, Play, Settings2, Clock, ArrowRight, Zap, CheckCircle, Trash2, X, Lock, Globe, ListOrdered, Upload, Loader2 } from 'lucide-react';
5 import clsx from 'clsx';
6 import { PROMPT_EXAMPLES } from '@/config/examples';
7 import {
8 STYLES,
9 VIDEO_RATIOS,
10 VIDEO_RESOLUTIONS,
11 VIDEO_GENERATION_MODES,
12 type ProviderGroup,
13 type VideoGenerationMode,
14 } from '@/config/models';
15 import { STAGES } from './TopBar';
16 import { fetchModelGroupsByType, fetchVideoModelGroupsByAbility } from '@/lib/modelRegistry';
17
18 export interface ProjectParams {
19 idea: string;
20 file_path?: string; // 上传的文件路径 (由后端返回的文件名)
21 style: string;
22 video_ratio: string;
23 video_resolution: string;
24 llm_model: string;
25 vlm_model: string;
26 image_t2i_model: string;
27 image_it2i_model: string;
28 video_model: string;
29 video_first_frame_model: string;
30 video_start_end_model: string;
31 video_reference_model: string;
32 video_generation_mode: VideoGenerationMode;
33 expand_idea?: boolean;
34 enable_concurrency?: boolean;
35 web_search?: boolean;
36 episodes?: number;
37 }
38
39 interface HistoryItem {
40 id: string;
41 idea: string;
42 style?: string;
43 date: string;
44 status: string;
45 stages?: Record<string, string>;
46 }
47
48 interface HomePageProps {
49 onStartProject: (params: ProjectParams, autoMode?: boolean) => void;
50 onResumeProject: (sessionId: string) => void;
51 onDeleteSession: (sessionId: string) => Promise<void>;
52 history: HistoryItem[];
53 }
54
55 /* 根据 status 映射生成进度文本 */
56 function stageProgressLabel(statusMap?: Record<string, string>): { text: string; color: string } {
57 const map = statusMap || {};
58 const completed = Object.keys(map).filter(k => ["completed", "session_completed"].includes(map[k]));
59 if (completed.length === 0) return { text: '未开始', color: 'text-gray-400' };
60 if (completed.length >= STAGES.length) return { text: '已完成', color: 'text-green-600' };
61
62 // 对比 STAGES 获取最后一个已完成的
63 const lastStageId = STAGES.filter(s => completed.includes(s.id)).pop()?.id || completed[completed.length - 1];
64 const stageDef = STAGES.find(s => s.id === lastStageId);
65 const name = stageDef?.shortName || lastStageId;
66 return { text: `已完成: ${name} (${completed.length}/${STAGES.length})`, color: 'text-blue-600' };
67 }
68
69 export default function HomePage({ onStartProject, onResumeProject, onDeleteSession, history }: HomePageProps) {
70 const [idea, setIdea] = useState('');
71 const [showSettings, setShowSettings] = useState(false);
72 const [selectedStyle, setSelectedStyle] = useState('realistic');
73 const [selectedLLM, setSelectedLLM] = useState('');
74 const [selectedVLM, setSelectedVLM] = useState('');
75 const [selectedT2I, setSelectedT2I] = useState('');
76 const [selectedI2I, setSelectedI2I] = useState('');
77 const [selectedFirstFrameVideo, setSelectedFirstFrameVideo] = useState('');
78 const [selectedStartEndVideo, setSelectedStartEndVideo] = useState('');
79 const [selectedReferenceVideo, setSelectedReferenceVideo] = useState('');
80 const [selectedVideoMode, setSelectedVideoMode] = useState<VideoGenerationMode>('first_frame');
81 const [selectedRatio, setSelectedRatio] = useState('');
82 const [selectedResolution, setSelectedResolution] = useState('720P');
83 const [configLoading, setConfigLoading] = useState(true);
84 const [configError, setConfigError] = useState('');
85 const [enableConcurrency, setEnableConcurrency] = useState(true);
86 const [webSearch, setWebSearch] = useState(false);
87 const [episodes, setEpisodes] = useState(4);
88 const [showEpisodesPanel, setShowEpisodesPanel] = useState(false);
89
90 // 上传相关状态
91 const fileInputRef = useRef<HTMLInputElement>(null);
92 const [uploading, setUploading] = useState(false);
93 const [uploadedFile, setUploadedFile] = useState<{name: string, path: string} | null>(null);
94
95 // 管理模式状态
96 const [manageMode, setManageMode] = useState(false);
97 const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
98 const [deleteError, setDeleteError] = useState('');
99 const [deleting, setDeleting] = useState(false);
100 const [llmProviders, setLlmProviders] = useState<ProviderGroup[]>([]);
101 const [vlmProviders, setVlmProviders] = useState<ProviderGroup[]>([]);
102 const [t2iProviders, setT2iProviders] = useState<ProviderGroup[]>([]);
103 const [i2iProviders, setI2iProviders] = useState<ProviderGroup[]>([]);
104 const [firstFrameVideoProviders, setFirstFrameVideoProviders] = useState<ProviderGroup[]>([]);
105 const [startEndVideoProviders, setStartEndVideoProviders] = useState<ProviderGroup[]>([]);
106 const [referenceVideoProviders, setReferenceVideoProviders] = useState<ProviderGroup[]>([]);
107 const activeVideoModel =
108 selectedVideoMode === 'start_end_frame'
109 ? selectedStartEndVideo
110 : selectedVideoMode === 'reference'
111 ? selectedReferenceVideo
112 : selectedFirstFrameVideo;
113 const modelConfigReady = Boolean(selectedLLM && selectedVLM && selectedT2I && selectedI2I && activeVideoModel && selectedRatio && selectedResolution);
114 const canStart = Boolean((idea.trim() || uploadedFile) && modelConfigReady && !configLoading);
115
116 useEffect(() => {
117 let cancelled = false;
118 fetchModelGroupsByType('llm')
119 .then(groups => { if (!cancelled) setLlmProviders(groups); })
120 .catch(() => {});
121 fetchModelGroupsByType('vlm')
122 .then(groups => { if (!cancelled) setVlmProviders(groups); })
123 .catch(() => {});
124 fetchModelGroupsByType('t2i')
125 .then(groups => { if (!cancelled) setT2iProviders(groups); })
126 .catch(() => {});
127 fetchModelGroupsByType('i2i')
128 .then(groups => { if (!cancelled) setI2iProviders(groups); })
129 .catch(() => {});
130 fetchVideoModelGroupsByAbility('first_frame_i2v')
131 .then(groups => { if (!cancelled) setFirstFrameVideoProviders(groups); })
132 .catch(() => {});
133 fetchVideoModelGroupsByAbility('start_end_frame_i2v')
134 .then(groups => { if (!cancelled) setStartEndVideoProviders(groups); })
135 .catch(() => {});
136 fetchVideoModelGroupsByAbility('reference_to_video')
137 .then(groups => { if (!cancelled) setReferenceVideoProviders(groups); })
138 .catch(() => {});
139 return () => { cancelled = true; };
140 }, []);
141
142 useEffect(() => {
143 let cancelled = false;
144 const loadDefaultConfig = async () => {
145 setConfigLoading(true);
146 setConfigError('');
147 try {
148 const resp = await fetch('/api/config');
149 if (!resp.ok) throw new Error('读取默认模型配置失败');
150 const data = await resp.json();
151 const models = data.config?.models || {};
152 const generation = data.config?.generation || {};
153 // Legacy config compatibility: older config.yaml only has models.video, so treat it as first-frame video.
154 const firstFrameModel = models.video_first_frame || models.video;
155 const startEndModel = models.video_start_end || 'wan2.7-i2v';
156 const referenceModel = models.video_reference || 'wan2.7-r2v';
157 const videoMode = (generation.video_generation_mode || 'first_frame') as VideoGenerationMode;
158 const selectedModel = videoMode === 'start_end_frame' ? startEndModel : videoMode === 'reference' ? referenceModel : firstFrameModel;
159 if (!models.llm || !models.vlm || !models.image_t2i || !models.image_it2i || !selectedModel) {
160 throw new Error('backend/config.yaml 缺少主流程默认模型');
161 }
162 if (cancelled) return;
163 setSelectedStyle(generation.style || 'realistic');
164 setSelectedLLM(models.llm);
165 setSelectedVLM(models.vlm);
166 setSelectedT2I(models.image_t2i);
167 setSelectedI2I(models.image_it2i);
168 setSelectedVideoMode(videoMode);
169 setSelectedFirstFrameVideo(firstFrameModel);
170 setSelectedStartEndVideo(startEndModel);
171 setSelectedReferenceVideo(referenceModel);
172 setSelectedRatio(generation.video_ratio || '16:9');
173 setSelectedResolution(generation.video_resolution || '720P');
174 } catch (e: any) {
175 if (!cancelled) setConfigError(e.message || '读取默认模型配置失败');
176 } finally {
177 if (!cancelled) setConfigLoading(false);
178 }
179 };
180 loadDefaultConfig();
181 return () => { cancelled = true; };
182 }, []);
183
184 const handleDelete = async () => {
185 if (!deleteTarget) return;
186 setDeleting(true);
187 setDeleteError('');
188 try {
189 await onDeleteSession(deleteTarget);
190 setDeleteTarget(null);
191 } catch (e: any) {
192 setDeleteError(e.message || '删除失败');
193 } finally {
194 setDeleting(false);
195 }
196 };
197
198 const activeVideoProviders =
199 selectedVideoMode === 'start_end_frame'
200 ? startEndVideoProviders
201 : selectedVideoMode === 'reference'
202 ? referenceVideoProviders
203 : firstFrameVideoProviders;
204
205 const setActiveVideoModel = (value: string) => {
206 if (selectedVideoMode === 'start_end_frame') {
207 setSelectedStartEndVideo(value);
208 } else if (selectedVideoMode === 'reference') {
209 setSelectedReferenceVideo(value);
210 } else {
211 setSelectedFirstFrameVideo(value);
212 }
213 };
214
215 const selectedVideoModeLabel = VIDEO_GENERATION_MODES.find(item => item.id === selectedVideoMode)?.label || '首帧生视频';
216
217 const handleStart = (auto?: boolean) => {
218 if (!canStart) return;
219 onStartProject({
220 idea,
221 file_path: uploadedFile?.path, // 如果上传了文件,传给后端
222 style: selectedStyle,
223 video_ratio: selectedRatio,
224 video_resolution: selectedResolution,
225 llm_model: selectedLLM,
226 vlm_model: selectedVLM,
227 image_t2i_model: selectedT2I,
228 image_it2i_model: selectedI2I,
229 video_generation_mode: selectedVideoMode,
230 video_first_frame_model: selectedFirstFrameVideo,
231 video_start_end_model: selectedStartEndVideo,
232 video_reference_model: selectedReferenceVideo,
233 video_model: activeVideoModel,
234 enable_concurrency: enableConcurrency,
235 web_search: webSearch,
236 episodes,
237 }, auto);
238 };
239
240 const handleExampleClick = (text: string) => {
241 setIdea(text);
242 };
243
244 const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
245 const file = e.target.files?.[0];
246 if (!file) return;
247
248 const allowedExtensions = ['.doc', '.docx', '.txt', '.md', '.pdf'];
249 const extension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
250
251 if (!allowedExtensions.includes(extension)) {
252 alert(`仅支持 ${allowedExtensions.join(', ')} 格式的文件`);
253 return;
254 }
255
256 setUploading(true);
257 const formData = new FormData();
258 formData.append('file', file);
259
260 try {
261 const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'}/api/upload_file`, {
262 method: 'POST',
263 body: formData,
264 });
265
266 if (!response.ok) {
267 throw new Error('文件上传失败');
268 }
269
270 const data = await response.json();
271 if (data.file_path) {
272 // 记录已上传的文件信息,不修改输入框
273 setUploadedFile({
274 name: file.name,
275 path: data.file_path
276 });
277 }
278 } catch (error) {
279 console.error('上传错误:', error);
280 alert('上传提取内容失败,请重试');
281 } finally {
282 setUploading(false);
283 // 清空 input 方便下次选择同一文件
284 if (fileInputRef.current) fileInputRef.current.value = '';
285 }
286 };
287
288 return (
289 <div className="h-full flex flex-col items-center overflow-y-auto bg-gray-50/50">
290 {/* 主区域 - 居中 */}
291 <div className="w-full max-w-6xl px-6 pt-16 pb-8 flex-shrink-0">
292 {/* 标题 */}
293 <div className="text-center mb-10">
294 <div className="inline-flex items-center gap-2 mb-3">
295 <Sparkles className="w-7 h-7 text-blue-500" />
296 <h1 className="text-2xl font-bold text-gray-800">Video-Claw</h1>
297 </div>
298 <p className="text-sm text-gray-500">
299 输入你的创意,AI 将为你分步生成完整短片
300 </p>
301 </div>
302
303 {/* 输入区域 */}
304 <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 mb-6">
305 <textarea
306 value={idea}
307 onChange={e => setIdea(e.target.value)}
308 placeholder="描述你的视频创意... 例如:一只叫Luna的猫意外进入太空站,遇到一个孤独的宇航员"
309 className="w-full bg-transparent text-sm text-gray-800 placeholder-gray-400 resize-none outline-none min-h-[100px]"
310 onKeyDown={e => {
311 if (e.key === 'Enter' && !e.shiftKey && (idea.trim() || uploadedFile)) {
312 e.preventDefault();
313 handleStart(false);
314 }
315 }}
316 />
317
318 <div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
319 <div className="flex items-center gap-3">
320 <div className="relative">
321 <button
322 onClick={() => setShowEpisodesPanel(!showEpisodesPanel)}
323 className={clsx(
324 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
325 showEpisodesPanel
326 ? 'bg-blue-50 text-blue-600'
327 : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
328 )}
329 >
330 <ListOrdered className="w-3.5 h-3.5" />
331 剧集: {episodes}集
332 </button>
333
334 {showEpisodesPanel && (
335 <>
336 <div
337 className="fixed inset-0 z-10"
338 onClick={() => setShowEpisodesPanel(false)}
339 />
340 <div className="absolute bottom-full left-0 mb-2 w-48 bg-white rounded-xl shadow-xl border border-gray-100 p-4 z-20 animate-in fade-in slide-in-from-bottom-2">
341 <div className="flex flex-col gap-3">
342 <div className="flex items-center justify-between">
343 <span className="text-xs font-semibold text-gray-700">设置总集数</span>
344 <span className="text-[10px] text-blue-500 font-bold bg-blue-50 px-1.5 py-0.5 rounded-full">
345 {episodes} 集
346 </span>
347 </div>
348
349 <div className="flex items-center gap-2">
350 <button
351 onClick={(e) => { e.stopPropagation(); setEpisodes(Math.max(1, episodes - 1)); }}
352 className="w-7 h-7 flex items-center justify-center rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 active:scale-95 transition-all text-sm font-bold"
353 >
354 -
355 </button>
356 <input
357 type="range"
358 min={1}
359 max={10}
360 value={episodes}
361 onChange={(e) => setEpisodes(parseInt(e.target.value))}
362 className="flex-1 h-1.5 bg-gray-100 rounded-lg appearance-none cursor-pointer accent-blue-500"
363 />
364 <button
365 onClick={(e) => { e.stopPropagation(); setEpisodes(Math.min(10, episodes + 1)); }}
366 className="w-7 h-7 flex items-center justify-center rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 active:scale-95 transition-all text-sm font-bold"
367 >
368 +
369 </button>
370 </div>
371
372 <div className="space-y-1 border-t border-gray-50 pt-2">
373 <p className="text-[10px] text-gray-400 leading-tight">
374 • 每集预估时长约 1-2 分钟
375 </p>
376 <p className="text-[10px] text-blue-400/80 leading-tight">
377 • 推荐设置 4-6 集
378 </p>
379 </div>
380 </div>
381 </div>
382 </>
383 )}
384 </div>
385
386 <button
387 onClick={() => setShowSettings(!showSettings)}
388 className={clsx(
389 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
390 showSettings
391 ? 'bg-blue-50 text-blue-600'
392 : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
393 )}
394 >
395 <Settings2 className="w-3.5 h-3.5" />
396 生成配置
397 </button>
398 <button
399 onClick={() => setWebSearch(!webSearch)}
400 className={clsx(
401 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
402 webSearch
403 ? 'bg-blue-50 text-blue-600'
404 : 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
405 )}
406 >
407 <Globe className="w-3.5 h-3.5" />
408 联网搜索
409 </button>
410 </div>
411 <div className="flex items-center gap-2">
412 {/* 隐藏的文件输入框 */}
413 <input
414 type="file"
415 ref={fileInputRef}
416 onChange={handleFileUpload}
417 accept=".doc,.docx,.txt,.md,.pdf"
418 className="hidden"
419 />
420 <button
421 onClick={() => {
422 if (uploadedFile) {
423 setUploadedFile(null); // 已有文件则点击取消
424 } else {
425 fileInputRef.current?.click();
426 }
427 }}
428 disabled={uploading}
429 className={clsx(
430 'flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium transition-colors border shadow-sm relative',
431 uploading
432 ? 'bg-gray-50 text-gray-400 cursor-not-allowed border-gray-100'
433 : uploadedFile
434 ? 'bg-blue-50 text-blue-600 border-blue-100 hover:bg-blue-100'
435 : 'bg-white text-gray-600 hover:bg-gray-50 border-gray-200'
436 )}
437 title={uploadedFile ? `已选择: ${uploadedFile.name} (点击取消)` : "上传文档 (Word/TXT/MD)"}
438 >
439 {uploading ? (
440 <Loader2 className="w-4 h-4 animate-spin" />
441 ) : uploadedFile ? (
442 <CheckCircle className="w-4 h-4" />
443 ) : (
444 <Upload className="w-4 h-4" />
445 )}
446 {uploading ? '上传中...' : uploadedFile ? `已选: ${uploadedFile.name.length > 8 ? uploadedFile.name.substring(0, 8) + '...' : uploadedFile.name}` : '上传文件'}
447 {uploadedFile && (
448 <div className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5">
449 <X className="w-2.5 h-2.5" />
450 </div>
451 )}
452 </button>
453 <button
454 onClick={() => handleStart(false)}
455 disabled={!canStart}
456 className={clsx(
457 'flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium transition-colors',
458 canStart
459 ? 'bg-blue-500 text-white hover:bg-blue-600 shadow-sm'
460 : 'bg-gray-100 text-gray-400 cursor-not-allowed'
461 )}
462 >
463 <Play className="w-4 h-4" />
464 逐步创作
465 </button>
466 <button
467 onClick={() => handleStart(true)}
468 disabled={!canStart}
469 className={clsx(
470 'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors',
471 canStart
472 ? 'bg-amber-500 text-white hover:bg-amber-600 shadow-sm'
473 : 'bg-gray-100 text-gray-400 cursor-not-allowed'
474 )}
475 title="自动执行全部六个阶段,无需手动确认"
476 >
477 <Zap className="w-4 h-4" />
478 一键生成
479 </button>
480 </div>
481 </div>
482 {(configLoading || configError) && (
483 <div className={clsx('mt-3 text-xs', configError ? 'text-red-500' : 'text-gray-400')}>
484 {configError || '正在读取 backend/config.yaml 中的默认模型...'}
485 </div>
486 )}
487
488 {/* 模型设置折叠面板 */}
489 {showSettings && (
490 <div className="mt-4 p-4 bg-gray-50 rounded-xl space-y-4 text-xs">
491 <div className="grid grid-cols-1 gap-3">
492 <label className="flex flex-col gap-1">
493 <span className="text-gray-500 font-medium">风格</span>
494 <select
495 value={selectedStyle}
496 onChange={e => setSelectedStyle(e.target.value)}
497 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
498 >
499 {STYLES.map(s => (
500 <option key={s.id} value={s.id}>{s.label}</option>
501 ))}
502 </select>
503 </label>
504 </div>
505
506 <div className="grid grid-cols-2 gap-3">
507 <label className="flex flex-col gap-1">
508 <span className="text-gray-500 font-medium">视频分辨率</span>
509 <select
510 value={selectedResolution}
511 onChange={e => setSelectedResolution(e.target.value)}
512 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none min-h-[40px]"
513 >
514 {VIDEO_RESOLUTIONS.map(item => (
515 <option key={item.id} value={item.id}>{item.label}</option>
516 ))}
517 </select>
518 </label>
519 <label className="flex flex-col gap-1">
520 <span className="text-gray-500 font-medium">视频长宽比</span>
521 <div className="flex gap-1">
522 {VIDEO_RATIOS.map(r => (
523 <button
524 key={r.id}
525 onClick={() => setSelectedRatio(r.id)}
526 className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
527 selectedRatio === r.id
528 ? 'border-indigo-500 bg-indigo-50'
529 : 'border-gray-200 hover:border-gray-300'
530 }`}
531 title={r.label}
532 >
533 <div
534 className="bg-gray-700 rounded-sm"
535 style={{
536 width: r.ratio === '16:9' ? '32px' :
537 r.ratio === '9:16' ? '18px' :
538 r.ratio === '1:1' ? '24px' :
539 r.ratio === '4:3' ? '28px' :
540 r.ratio === '3:4' ? '20px' :
541 '36px',
542 height: r.ratio === '16:9' ? '18px' :
543 r.ratio === '9:16' ? '32px' :
544 r.ratio === '1:1' ? '24px' :
545 r.ratio === '4:3' ? '21px' :
546 r.ratio === '3:4' ? '28px' :
547 '15px',
548 }}
549 />
550 <span className="text-[10px] text-gray-500">{r.label}</span>
551 </button>
552 ))}
553 </div>
554 </label>
555 </div>
556
557 <div className="space-y-3 border-t border-gray-200/70 pt-4">
558 <div className="flex items-center justify-between">
559 <span className="text-gray-500 font-semibold">模型配置</span>
560 <span className="text-[10px] text-gray-400">用于主流程各阶段调用</span>
561 </div>
562 <div className="grid grid-cols-2 gap-3">
563 <label className="flex flex-col gap-1">
564 <span className="text-gray-500 font-medium">LLM 模型</span>
565 <select
566 value={selectedLLM}
567 onChange={e => setSelectedLLM(e.target.value)}
568 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
569 >
570 {llmProviders.map(pg => (
571 <optgroup key={pg.provider} label={pg.label}>
572 {pg.models.map(m => (
573 <option key={m.id} value={m.id}>{m.label}</option>
574 ))}
575 </optgroup>
576 ))}
577 </select>
578 </label>
579 <label className="flex flex-col gap-1">
580 <span className="text-gray-500 font-medium">VLM 评估模型</span>
581 <select
582 value={selectedVLM}
583 onChange={e => setSelectedVLM(e.target.value)}
584 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
585 >
586 {vlmProviders.map(pg => (
587 <optgroup key={pg.provider} label={pg.label}>
588 {pg.models.map(m => (
589 <option key={m.id} value={m.id}>{m.label}</option>
590 ))}
591 </optgroup>
592 ))}
593 </select>
594 </label>
595 <label className="flex flex-col gap-1">
596 <span className="text-gray-500 font-medium">文生图</span>
597 <select
598 value={selectedT2I}
599 onChange={e => setSelectedT2I(e.target.value)}
600 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
601 >
602 {t2iProviders.map(pg => (
603 <optgroup key={pg.provider} label={pg.label}>
604 {pg.models.map(m => (
605 <option key={m.id} value={m.id}>{m.label}</option>
606 ))}
607 </optgroup>
608 ))}
609 </select>
610 </label>
611 <label className="flex flex-col gap-1">
612 <span className="text-gray-500 font-medium">图生图</span>
613 <select
614 value={selectedI2I}
615 onChange={e => setSelectedI2I(e.target.value)}
616 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
617 >
618 {i2iProviders.map(pg => (
619 <optgroup key={pg.provider} label={pg.label}>
620 {pg.models.map(m => (
621 <option key={m.id} value={m.id}>{m.label}</option>
622 ))}
623 </optgroup>
624 ))}
625 </select>
626 </label>
627 <label className="flex flex-col gap-1 col-span-2">
628 <span className="text-gray-500 font-medium">视频生成方式</span>
629 <select
630 value={selectedVideoMode}
631 onChange={e => setSelectedVideoMode(e.target.value as VideoGenerationMode)}
632 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
633 >
634 {VIDEO_GENERATION_MODES.map(item => (
635 <option key={item.id} value={item.id}>{item.label}</option>
636 ))}
637 </select>
638 </label>
639 <label className="flex flex-col gap-1 col-span-2">
640 <span className="text-gray-500 font-medium">{selectedVideoModeLabel}模型</span>
641 <select
642 value={activeVideoModel}
643 onChange={e => setActiveVideoModel(e.target.value)}
644 className="bg-white border border-gray-200 rounded-lg px-2.5 py-2 text-gray-700 outline-none"
645 >
646 {activeVideoProviders.map(pg => (
647 <optgroup key={pg.provider} label={pg.label}>
648 {pg.models.map(m => (
649 <option key={m.id} value={m.id}>{m.label}</option>
650 ))}
651 </optgroup>
652 ))}
653 </select>
654 </label>
655 <label className="flex items-center gap-2 text-sm cursor-pointer select-none">
656 <input
657 type="checkbox"
658 checked={enableConcurrency}
659 onChange={e => setEnableConcurrency(e.target.checked)}
660 className="w-4 h-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500/30"
661 />
662 <span className="text-gray-600">并发生成</span>
663 </label>
664 </div>
665 </div>
666 </div>
667 )}
668 </div>
669
670 {/* 示例卡片 */}
671 <div className="mb-10">
672 <h3 className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">
673 灵感示例
674 </h3>
675 <div className="grid grid-cols-2 md:grid-cols-3 gap-2.5">
676 {PROMPT_EXAMPLES.map((ex, idx) => (
677 <button
678 key={idx}
679 onClick={() => handleExampleClick(ex.text)}
680 className="text-left p-3.5 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all group"
681 >
682 <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600 transition-colors mb-1">
683 {ex.title}
684 </div>
685 <div className="text-xs text-gray-400 line-clamp-2">
686 {ex.description}
687 </div>
688 </button>
689 ))}
690 </div>
691 </div>
692 </div>
693
694 {/* 历史记录区域 */}
695 {history.length > 0 && (
696 <div className="w-full max-w-6xl px-6 pb-12 flex-shrink-0">
697 <div className="flex items-center gap-2 mb-4">
698 <Clock className="w-4 h-4 text-gray-400" />
699 <h3 className="text-sm font-medium text-gray-600">历史记录</h3>
700 <button
701 onClick={() => setManageMode(m => !m)}
702 className={`ml-auto text-xs px-2 py-0.5 rounded transition-colors ${
703 manageMode ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
704 }`}
705 >
706 {manageMode ? '完成' : '管理'}
707 </button>
708 </div>
709 <div className="max-h-[60vh] overflow-y-auto pr-1">
710 <div className="grid grid-cols-2 gap-3">
711 {history.map(item => {
712 const progress = stageProgressLabel(item.stages);
713 return (
714 <div key={item.id} className="relative group">
715 <div
716 onClick={() => !manageMode && onResumeProject(item.id)}
717 className={`w-full text-left p-4 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all ${!manageMode ? 'cursor-pointer' : ''}`}
718 >
719 <div className="flex items-start justify-between gap-2">
720 <div className="flex-1 min-w-0">
721 <div className="text-sm font-medium text-gray-700 group-hover:text-blue-600 transition-colors truncate">
722 {item.idea}
723 </div>
724 <div className="flex items-center gap-2 mt-1.5 flex-wrap">
725 {item.style && (
726 <span className="text-[10px] bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded">
727 {item.style}
728 </span>
729 )}
730 <span className="text-[10px] text-gray-400">{item.date}</span>
731 </div>
732 <div className={`flex items-center gap-1 mt-1.5 text-[10px] font-medium ${progress.color}`}>
733 {item.stages && Object.keys(item.stages).filter(k => ["completed", "session_completed"].includes(item.stages![k])).length >= STAGES.length ? (
734 <CheckCircle className="w-3 h-3" />
735 ) : (
736 <span className="w-1.5 h-1.5 rounded-full bg-current flex-shrink-0" />
737 )}
738 <span>{progress.text}</span>
739 </div>
740 </div>
741 {manageMode ? (
742 <button
743 onClick={(e) => { e.stopPropagation(); setDeleteTarget(item.id); setDeleteError(''); }}
744 className="w-6 h-6 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors flex-shrink-0 mt-0.5"
745 title="删除"
746 >
747 <Trash2 className="w-3 h-3" />
748 </button>
749 ) : (
750 <ArrowRight className="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors flex-shrink-0 mt-0.5" />
751 )}
752 </div>
753 </div>
754 </div>
755 );
756 })}
757 </div>
758 </div>
759 </div>
760 )}
761
762 {/* 删除确认弹窗 */}
763 {deleteTarget && (
764 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
765 <div className="bg-white rounded-2xl shadow-xl w-80 p-6 relative">
766 <button
767 onClick={() => { setDeleteTarget(null); setDeleteError(''); }}
768 className="absolute top-3 right-3 text-gray-400 hover:text-gray-600"
769 >
770 <X className="w-4 h-4" />
771 </button>
772 <div className="flex items-center gap-2 mb-4">
773 <Trash2 className="w-4 h-4 text-red-500" />
774 <h4 className="text-sm font-semibold text-gray-700">确认删除</h4>
775 </div>
776 <p className="text-xs text-gray-500 mb-6">删除后不可恢复,确定要删除此项目吗?</p>
777 {deleteError && <p className="text-xs text-red-500 mb-2">{deleteError}</p>}
778 <div className="flex gap-2">
779 <button
780 onClick={() => { setDeleteTarget(null); setDeleteError(''); }}
781 className="flex-1 text-sm py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50"
782 >
783 取消
784 </button>
785 <button
786 onClick={handleDelete}
787 disabled={deleting}
788 className="flex-1 text-sm py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50"
789 >
790 {deleting ? '删除中…' : '确认删除'}
791 </button>
792 </div>
793 </div>
794 </div>
795 )}
796 </div>
797 );
798 }
799
799 lines Plain Text