返回 VideoClaw
ScriptStage.tsx
1 'use client';
2
3 import React, { useState, useCallback } from 'react';
4 import { Save, X, Users, MapPin, Film, Sparkles, BookOpen, Lightbulb, Target, User, Crosshair, RefreshCw, Palette, Edit3, Plus, Trash2 } from 'lucide-react';
5 import type { StageViewProps } from './types';
6 import StageActions from './StageActions';
7 import StageProgress from './StageProgress';
8
9 /* ─── 类型 ─── */
10
11 interface LoglineData {
12 logline: string;
13 who: string;
14 goal: string;
15 conflict: string;
16 twist: string;
17 theme: string;
18 }
19
20 interface ScriptCharacter {
21 name: string;
22 character_id?: string;
23 description: string;
24 age?: string;
25 species?: string;
26 occupation?: string;
27 }
28
29 interface ScriptSetting {
30 name: string;
31 description: string;
32 }
33
34 interface ScriptScene {
35 scene_number: number;
36 act?: number;
37 location: string;
38 characters: string[];
39 plot: string;
40 }
41
42 interface ActCompleteData {
43 act: number;
44 act_name: string;
45 characters: ScriptCharacter[];
46 settings: ScriptSetting[];
47 scenes: ScriptScene[];
48 }
49
50 interface ScriptEpisode {
51 act_number: number;
52 episode_number?: number;
53 act_title: string;
54 content: string;
55 }
56
57 interface ScriptData {
58 title?: string;
59 logline?: string;
60 genre?: string[];
61 characters?: ScriptCharacter[];
62 settings?: ScriptSetting[];
63 scenes?: ScriptScene[];
64 episodes?: ScriptEpisode[];
65 overall_style?: string;
66 mood?: string;
67 session_id?: string;
68 [key: string]: any;
69 }
70
71 /* ─── Logline 六要素展示卡 ─── */
72 function LoglineSummaryBar({ logline }: { logline: LoglineData }) {
73 const items = [
74 { icon: Lightbulb, label: 'Logline', value: logline.logline, color: 'text-amber-600' },
75 { icon: User, label: '主角', value: logline.who, color: 'text-blue-600' },
76 { icon: Target, label: '目标', value: logline.goal, color: 'text-green-600' },
77 { icon: Crosshair, label: '障碍', value: logline.conflict, color: 'text-red-500' },
78 { icon: RefreshCw, label: '反转', value: logline.twist, color: 'text-purple-600' },
79 { icon: Palette, label: '主题', value: logline.theme, color: 'text-cyan-600' },
80 ];
81 return (
82 <div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-4">
83 <div className="flex items-center gap-2 mb-3">
84 <Lightbulb className="w-4 h-4 text-amber-500" />
85 <span className="text-xs font-semibold text-amber-700">Logline 核心</span>
86 </div>
87 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-3">
88 {items.map(({ icon: Icon, label, value, color }) => (
89 <div key={label} className="min-w-0">
90 <div className={`flex items-center gap-1 mb-1 ${color}`}>
91 <Icon className="w-3 h-3 flex-shrink-0" />
92 <span className="text-[10px] font-semibold">{label}</span>
93 </div>
94 <p className="text-xs text-gray-600 leading-relaxed break-words">{value}</p>
95 </div>
96 ))}
97 </div>
98 </div>
99 );
100 }
101
102 export default function ScriptStage({ state, sessionId, onConfirm, onIntervene, onRegenerate, onUpdateArtifact, showConfirm, isRunning, hasPendingItems, hasNextStageStarted }: StageViewProps) {
103 const data: ScriptData = state.artifact || {};
104
105 const isLoglinePhase = data.phase === 'logline_selection' || data.phase === 'logline_confirm' || data.phase === 'mode_selection';
106
107 const [showSmartContinueDialog, setShowSmartContinueDialog] = useState(false);
108 const [smartContinueEpisodes, setSmartContinueEpisodes] = useState<number>(1);
109 const [smartContinueIdea, setSmartContinueIdea] = useState<string>('');
110 const [editingEpisodeIndex, setEditingEpisodeIndex] = useState<number | null>(null);
111 const [savingEpisodeIndex, setSavingEpisodeIndex] = useState<number | null>(null);
112 const [episodeDraft, setEpisodeDraft] = useState<{ title: string; content: string }>({ title: '', content: '' });
113 const [editingCharacterIndex, setEditingCharacterIndex] = useState<number | null>(null);
114 const [savingCharacterIndex, setSavingCharacterIndex] = useState<number | null>(null);
115 const [characterDraft, setCharacterDraft] = useState({
116 name: '',
117 species: '',
118 description: '',
119 });
120 const [editingSettingIndex, setEditingSettingIndex] = useState<number | null>(null);
121 const [savingSettingIndex, setSavingSettingIndex] = useState<number | null>(null);
122 const [settingDraft, setSettingDraft] = useState({ name: '', description: '' });
123 const [deleteMode, setDeleteMode] = useState({
124 characters: false,
125 settings: false,
126 episodes: false,
127 });
128
129 const handleSmartContinueConfirm = useCallback(() => {
130 onIntervene({
131 action: 'smart_continue',
132 episodes_to_add: smartContinueEpisodes,
133 sequel_idea: smartContinueIdea
134 });
135 setShowSmartContinueDialog(false);
136 setSmartContinueIdea('');
137 setSmartContinueEpisodes(1);
138 }, [smartContinueEpisodes, smartContinueIdea, onIntervene]);
139
140 const hasContent = Boolean(data.title || data.characters?.length || data.scenes?.length);
141
142 const getEpisodeNumber = (ep: ScriptEpisode, index: number) =>
143 Number(ep.episode_number || ep.act_number || index + 1);
144
145 const patchScriptArtifact = async (patch: Record<string, any>) => {
146 if (!sessionId) throw new Error('缺少会话 ID');
147 const response = await fetch(`/api/project/${sessionId}/artifact/script_generation`, {
148 method: 'PATCH',
149 headers: { 'Content-Type': 'application/json' },
150 body: JSON.stringify(patch),
151 });
152 if (!response.ok) throw new Error('保存剧本修改失败');
153 return response.json();
154 };
155
156 const toggleDeleteMode = (key: keyof typeof deleteMode) => {
157 setDeleteMode(prev => ({ ...prev, [key]: !prev[key] }));
158 };
159
160 const createCharacter = async () => {
161 const nextCharacter: ScriptCharacter = {
162 name: '新角色',
163 species: '',
164 description: '请填写角色描述。',
165 };
166 const nextCharacters = [...(data.characters || []), nextCharacter];
167 try {
168 const result = await patchScriptArtifact({ characters: nextCharacters });
169 const characters = result.artifact?.characters || nextCharacters;
170 onUpdateArtifact?.({ characters });
171 startCharacterEdit(characters[characters.length - 1], characters.length - 1);
172 } catch (error) {
173 console.error('新建角色失败:', error);
174 }
175 };
176
177 const deleteCharacter = async (index: number) => {
178 const target = data.characters?.[index];
179 if (!target || !window.confirm(`确认删除角色「${target.name || index + 1}」吗?`)) return;
180 try {
181 const nextCharacters = (data.characters || []).filter((_, itemIndex) => itemIndex !== index);
182 const result = await patchScriptArtifact({ characters: nextCharacters });
183 onUpdateArtifact?.({ characters: result.artifact?.characters || nextCharacters });
184 if (editingCharacterIndex === index) cancelCharacterEdit();
185 } catch (error) {
186 console.error('删除角色失败:', error);
187 }
188 };
189
190 const createSetting = async () => {
191 const nextSetting: ScriptSetting = {
192 name: '新场景',
193 description: '请填写场景描述。',
194 };
195 const nextSettings = [...(data.settings || []), nextSetting];
196 try {
197 const result = await patchScriptArtifact({ settings: nextSettings });
198 const settings = result.artifact?.settings || nextSettings;
199 onUpdateArtifact?.({ settings });
200 startSettingEdit(settings[settings.length - 1], settings.length - 1);
201 } catch (error) {
202 console.error('新建场景失败:', error);
203 }
204 };
205
206 const deleteSetting = async (index: number) => {
207 const target = data.settings?.[index];
208 if (!target || !window.confirm(`确认删除场景「${target.name || index + 1}」吗?`)) return;
209 try {
210 const nextSettings = (data.settings || []).filter((_, itemIndex) => itemIndex !== index);
211 const result = await patchScriptArtifact({ settings: nextSettings });
212 onUpdateArtifact?.({ settings: result.artifact?.settings || nextSettings });
213 if (editingSettingIndex === index) cancelSettingEdit();
214 } catch (error) {
215 console.error('删除场景失败:', error);
216 }
217 };
218
219 const createEpisode = async () => {
220 const nextNumber = (data.episodes || []).reduce((max, ep, index) => {
221 return Math.max(max, getEpisodeNumber(ep, index));
222 }, 0) + 1;
223 const nextEpisode: ScriptEpisode = {
224 act_number: nextNumber,
225 episode_number: nextNumber,
226 act_title: '新剧集',
227 content: '请填写本集剧情。',
228 };
229 const nextEpisodes = [...(data.episodes || []), nextEpisode];
230 try {
231 const result = await patchScriptArtifact({ episodes: nextEpisodes });
232 const episodes = result.artifact?.episodes || nextEpisodes;
233 onUpdateArtifact?.({ episodes });
234 startEpisodeEdit(episodes[episodes.length - 1], episodes.length - 1);
235 } catch (error) {
236 console.error('新建分集失败:', error);
237 }
238 };
239
240 const deleteEpisode = async (index: number) => {
241 const target = data.episodes?.[index];
242 if (!target || !window.confirm(`确认删除第 ${getEpisodeNumber(target, index)} 集「${target.act_title || ''}」吗?`)) return;
243 try {
244 const nextEpisodes = (data.episodes || []).filter((_, itemIndex) => itemIndex !== index);
245 const result = await patchScriptArtifact({ episodes: nextEpisodes });
246 onUpdateArtifact?.({ episodes: result.artifact?.episodes || nextEpisodes });
247 if (editingEpisodeIndex === index) cancelEpisodeEdit();
248 } catch (error) {
249 console.error('删除分集失败:', error);
250 }
251 };
252
253 const startEpisodeEdit = (ep: ScriptEpisode, index: number) => {
254 setEditingEpisodeIndex(index);
255 setEpisodeDraft({
256 title: ep.act_title || '',
257 content: ep.content || '',
258 });
259 };
260
261 const cancelEpisodeEdit = () => {
262 setEditingEpisodeIndex(null);
263 setEpisodeDraft({ title: '', content: '' });
264 };
265
266 const saveEpisodeEdit = async (ep: ScriptEpisode, index: number) => {
267 if (!sessionId || savingEpisodeIndex !== null) return;
268 setSavingEpisodeIndex(index);
269 try {
270 const nextEpisodes = (data.episodes || []).map((item, itemIndex) => {
271 if (itemIndex !== index) return item;
272 return {
273 ...item,
274 act_title: episodeDraft.title,
275 ...(Object.prototype.hasOwnProperty.call(item, 'title') ? { title: episodeDraft.title } : {}),
276 content: episodeDraft.content,
277 };
278 });
279 const result = await patchScriptArtifact({ episodes: nextEpisodes });
280 if (result.artifact?.episodes) {
281 onUpdateArtifact?.({ episodes: result.artifact.episodes });
282 }
283 cancelEpisodeEdit();
284 } catch (error) {
285 console.error('保存分集剧本失败:', error);
286 } finally {
287 setSavingEpisodeIndex(null);
288 }
289 };
290
291 const startCharacterEdit = (character: ScriptCharacter, index: number) => {
292 setEditingCharacterIndex(index);
293 setEditingSettingIndex(null);
294 setCharacterDraft({
295 name: character.name || '',
296 species: character.species || '',
297 description: character.description || '',
298 });
299 };
300
301 const cancelCharacterEdit = () => {
302 setEditingCharacterIndex(null);
303 setCharacterDraft({
304 name: '',
305 species: '',
306 description: '',
307 });
308 };
309
310 const saveCharacterEdit = async (index: number) => {
311 if (savingCharacterIndex !== null) return;
312 setSavingCharacterIndex(index);
313 try {
314 const nextCharacters = (data.characters || []).map((character, itemIndex) => {
315 if (itemIndex !== index) return character;
316 const restCharacter = { ...(character as ScriptCharacter & Record<string, any>) };
317 delete restCharacter.role;
318 delete restCharacter.personality;
319 delete restCharacter.motivation;
320 delete restCharacter.arc_description;
321 return {
322 ...restCharacter,
323 name: characterDraft.name,
324 species: characterDraft.species,
325 description: characterDraft.description,
326 };
327 });
328 const result = await patchScriptArtifact({ characters: nextCharacters });
329 if (result.artifact?.characters) {
330 onUpdateArtifact?.({ characters: result.artifact.characters });
331 }
332 cancelCharacterEdit();
333 } catch (error) {
334 console.error('保存角色失败:', error);
335 } finally {
336 setSavingCharacterIndex(null);
337 }
338 };
339
340 const startSettingEdit = (setting: ScriptSetting, index: number) => {
341 setEditingSettingIndex(index);
342 setEditingCharacterIndex(null);
343 setSettingDraft({
344 name: setting.name || '',
345 description: setting.description || '',
346 });
347 };
348
349 const cancelSettingEdit = () => {
350 setEditingSettingIndex(null);
351 setSettingDraft({ name: '', description: '' });
352 };
353
354 const saveSettingEdit = async (index: number) => {
355 if (savingSettingIndex !== null) return;
356 setSavingSettingIndex(index);
357 try {
358 const nextSettings = (data.settings || []).map((setting, itemIndex) => (
359 itemIndex === index
360 ? { ...setting, name: settingDraft.name, description: settingDraft.description }
361 : setting
362 ));
363 const result = await patchScriptArtifact({ settings: nextSettings });
364 if (result.artifact?.settings) {
365 onUpdateArtifact?.({ settings: result.artifact.settings });
366 }
367 cancelSettingEdit();
368 } catch (error) {
369 console.error('保存场景失败:', error);
370 } finally {
371 setSavingSettingIndex(null);
372 }
373 };
374
375 return (
376 <div className="flex h-full min-w-0 flex-col">
377 <div className="flex-1 min-w-0 overflow-y-auto p-4 sm:p-6">
378
379 {/* 标题栏 */}
380 <div className="flex flex-wrap items-center justify-between gap-3 mb-1">
381 <h2 className="text-lg font-semibold text-gray-800">剧本生成</h2>
382 </div>
383 <p className="text-sm text-gray-500 mb-6">多轮 LLM 交互,生成结构化剧本数据</p>
384
385 {/* 运行中 - 进度条 & 已选 Logline & 增量生成结果 */}
386 {state.status === 'running' && (
387 <>
388 {data.selected_logline && (
389 <div className="mb-4">
390 <LoglineSummaryBar logline={data.selected_logline as LoglineData} />
391 </div>
392 )}
393
394 {/* 节拍表展示 */}
395 {data.beat_sheet && (
396 <div className="mb-4">
397 <div className="flex items-center gap-2 mb-2">
398 <BookOpen className="w-4 h-4 text-orange-500" />
399 <h3 className="text-sm font-semibold text-gray-700">节拍表 (Beat Sheet)</h3>
400 </div>
401 <div className="bg-white border border-gray-200 rounded-xl p-4">
402 <pre className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap font-sans">{data.beat_sheet as string}</pre>
403 </div>
404 </div>
405 )}
406
407 {/* 逐幕完成的分场结果 */}
408 {data.completed_acts && (data.completed_acts as ActCompleteData[]).length > 0 && (
409 <div className="mb-4 space-y-4">
410 {(data.completed_acts as ActCompleteData[]).map((actData) => (
411 <div key={actData.act}>
412 {/* 幕分隔线 */}
413 <div className="flex items-center gap-3 mb-3">
414 <div className="flex-1 h-px bg-gradient-to-r from-purple-200 to-transparent" />
415 <span className="px-3 py-1 bg-purple-50 text-purple-600 text-xs font-semibold rounded-full whitespace-nowrap">
416 第{actData.act}幕 — {actData.act_name}
417 </span>
418 <div className="flex-1 h-px bg-gradient-to-l from-purple-200 to-transparent" />
419 </div>
420
421 {/* 本幕场景 */}
422 <div className="space-y-2">
423 {actData.scenes.map((sc, i) => (
424 <div key={i} className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
425 <div className="flex items-center gap-3 mb-2">
426 <span className="flex items-center justify-center w-7 h-7 rounded-full bg-purple-100 text-purple-700 text-xs font-bold flex-shrink-0">{sc.scene_number}</span>
427 <span className="px-2 py-0.5 bg-green-50 text-green-600 text-xs rounded-full">{sc.location}</span>
428 <div className="flex flex-wrap gap-1">
429 {(sc.characters || []).map((c: any, ci: number) => (
430 <span key={ci} className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full">{c}</span>
431 ))}
432 </div>
433 </div>
434 <p className="text-sm text-gray-600 leading-relaxed pl-10">{sc.plot}</p>
435 </div>
436 ))}
437 </div>
438 </div>
439 ))}
440 </div>
441 )}
442
443 <StageProgress message={state.progressMessage} fallback="正在生成剧本..." progress={state.progress} color="blue" />
444 </>
445 )}
446
447 {/* 错误 */}
448 {state.error && (
449 <div className="text-sm text-red-600 bg-red-50 border border-red-200 p-4 rounded-xl mb-4">{state.error}</div>
450 )}
451
452 {/* ===== Logline 选择/确认阶段 ===== */}
453 {isLoglinePhase && state.status === 'waiting' && (
454 <div className="space-y-4">
455 {/* 3 个 Logline 选项卡 */}
456 {data.phase === 'logline_selection' && data.logline_options && (
457 <>
458 <div className="flex items-center gap-2 mb-2">
459 <Lightbulb className="w-4 h-4 text-amber-500" />
460 <h3 className="text-sm font-semibold text-gray-700">选择一个 Logline 方案</h3>
461 <span className="text-xs text-gray-400">点击卡片以选择</span>
462 </div>
463 <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
464 {(data.logline_options as LoglineData[]).map((opt, i) => (
465 <button
466 key={i}
467 onClick={() => onIntervene({ selected_logline: opt })}
468 className="text-left p-4 bg-white border border-gray-200 rounded-xl hover:border-blue-400 hover:shadow-md transition-all group cursor-pointer"
469 >
470 <p className="text-sm font-medium text-gray-800 group-hover:text-blue-600 mb-3 leading-relaxed">
471 {opt.logline}
472 </p>
473 <div className="space-y-1.5 text-xs text-gray-500">
474 <p><span className="text-gray-600 font-medium">主角:</span> {opt.who}</p>
475 <p><span className="text-gray-600 font-medium">目标:</span> {opt.goal}</p>
476 <p><span className="text-gray-600 font-medium">障碍:</span> {opt.conflict}</p>
477 <p><span className="text-gray-600 font-medium">反转:</span> {opt.twist}</p>
478 <p><span className="text-gray-600 font-medium">主题:</span> {opt.theme}</p>
479 </div>
480 </button>
481 ))}
482 </div>
483 </>
484 )}
485
486 {/* 单个 Logline 确认 */}
487 {data.phase === 'logline_confirm' && data.logline_summary && (
488 <>
489 <div className="flex items-center gap-2 mb-2">
490 <Lightbulb className="w-4 h-4 text-amber-500" />
491 <h3 className="text-sm font-semibold text-gray-700">Logline 提取结果</h3>
492 </div>
493 <LoglineSummaryBar logline={data.logline_summary as LoglineData} />
494 <div className="flex justify-center pt-2">
495 <button
496 onClick={() => onIntervene({ selected_logline: data.logline_summary })}
497 className="flex items-center gap-2 px-5 py-2.5 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition-colors"
498 >
499 <Sparkles className="w-4 h-4" />
500 确认 Logline 并生成剧本
501 </button>
502 </div>
503 </>
504 )}
505
506 {/* 创作模式选择 */}
507 {data.phase === 'mode_selection' && (
508 <>
509 {data.selected_logline && (
510 <div className="mb-4">
511 <LoglineSummaryBar logline={data.selected_logline as LoglineData} />
512 </div>
513 )}
514 <div className="flex items-center gap-2 mb-3">
515 <Film className="w-4 h-4 text-purple-500" />
516 <h3 className="text-sm font-semibold text-gray-700">选择创作模式</h3>
517 </div>
518 <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
519 <button
520 onClick={() => onIntervene({ selected_mode: 'movie' })}
521 className="text-left p-5 bg-white border-2 border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-lg transition-all group cursor-pointer"
522 >
523 <div className="flex items-center gap-2 mb-3">
524 <span className="flex items-center justify-center w-10 h-10 rounded-xl bg-purple-100 text-purple-600 text-lg">🎬</span>
525 <span className="text-base font-semibold text-gray-800 group-hover:text-purple-600">电影模式</span>
526 </div>
527 <p className="text-sm text-gray-600 leading-relaxed mb-3">
528 按照四幕结构生成完整情节,叙事连贯丰富,有完整的起承转合。
529 </p>
530 <div className="flex flex-wrap gap-1.5">
531 <span className="px-2 py-0.5 bg-purple-50 text-purple-500 text-xs rounded-full">四幕结构</span>
532 <span className="px-2 py-0.5 bg-purple-50 text-purple-500 text-xs rounded-full">叙事完整</span>
533 <span className="px-2 py-0.5 bg-purple-50 text-purple-500 text-xs rounded-full">情节丰富</span>
534 </div>
535 </button>
536 <button
537 onClick={() => onIntervene({ selected_mode: 'micro' })}
538 className="text-left p-5 bg-white border-2 border-gray-200 rounded-xl hover:border-cyan-400 hover:shadow-lg transition-all group cursor-pointer"
539 >
540 <div className="flex items-center gap-2 mb-3">
541 <span className="flex items-center justify-center w-10 h-10 rounded-xl bg-cyan-100 text-cyan-600 text-lg">🎞️</span>
542 <span className="text-base font-semibold text-gray-800 group-hover:text-cyan-600">微电影模式</span>
543 </div>
544 <p className="text-sm text-gray-600 leading-relaxed mb-3">
545 所有内容生成在一幕内,叙事节奏快,情节紧凑,适合短片创作。
546 </p>
547 <div className="flex flex-wrap gap-1.5">
548 <span className="px-2 py-0.5 bg-cyan-50 text-cyan-500 text-xs rounded-full">单幕结构</span>
549 <span className="px-2 py-0.5 bg-cyan-50 text-cyan-500 text-xs rounded-full">节奏紧凑</span>
550 <span className="px-2 py-0.5 bg-cyan-50 text-cyan-500 text-xs rounded-full">3-6场景</span>
551 </div>
552 </button>
553 </div>
554 </>
555 )}
556 </div>
557 )}
558
559 {/* ===== 查看模式 ===== */}
560 {hasContent && (
561 <div className="space-y-8">
562
563 {/* Logline 六要素摘要 */}
564 {data.logline_data && (
565 <LoglineSummaryBar logline={data.logline_data as LoglineData} />
566 )}
567
568 {/* 标题 / Logline / 标签 */}
569 {data.title && (
570 <section className="bg-white border border-gray-200 rounded-xl p-5">
571 <h3 className="text-xl font-bold text-gray-800 mb-2">{data.title}</h3>
572 {data.logline && <p className="text-sm text-gray-500 mb-3">{data.logline}</p>}
573 <div className="flex flex-wrap gap-1.5">
574 {data.genre?.map((g, i) => (
575 <span key={i} className="px-2.5 py-0.5 bg-violet-50 text-violet-600 text-xs rounded-full font-medium">{g}</span>
576 ))}
577 {data.mood && <span className="px-2.5 py-0.5 bg-pink-50 text-pink-600 text-xs rounded-full font-medium">{data.mood}</span>}
578 {data.overall_style && <span className="px-2.5 py-0.5 bg-cyan-50 text-cyan-600 text-xs rounded-full font-medium">{data.overall_style}</span>}
579 </div>
580 </section>
581 )}
582
583 {/* 故事梗概 */}
584 {data.logline && (
585 <section>
586 <div className="flex items-center gap-2 mb-3">
587 <BookOpen className="w-4 h-4 text-orange-500" />
588 <h3 className="text-sm font-semibold text-gray-700">故事梗概</h3>
589 </div>
590 <div className="bg-white border border-gray-200 rounded-xl p-5">
591 <p className="text-sm text-gray-600 leading-relaxed">{data.logline}</p>
592 </div>
593 </section>
594 )}
595
596 {/* 角色 */}
597 {Array.isArray(data.characters) && (
598 <section>
599 <div className="flex items-center gap-2 mb-3">
600 <Users className="w-4 h-4 text-blue-500" />
601 <h3 className="text-sm font-semibold text-gray-700">角色</h3>
602 <button
603 onClick={createCharacter}
604 className="ml-2 inline-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"
605 >
606 <Plus className="w-3 h-3" />
607 新建
608 </button>
609 <button
610 onClick={() => toggleDeleteMode('characters')}
611 className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors ${
612 deleteMode.characters
613 ? 'text-red-600 bg-red-50 hover:bg-red-100'
614 : 'text-gray-500 bg-gray-100 hover:bg-gray-200'
615 }`}
616 >
617 <Trash2 className="w-3 h-3" />
618 删除
619 </button>
620 </div>
621 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
622 {data.characters.map((c, i) => {
623 const isCharacterEditing = editingCharacterIndex === i;
624 const isCharacterSaving = savingCharacterIndex === i;
625 return (
626 <div key={i} className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
627 <div className="flex items-start justify-between gap-2 mb-2">
628 <div className="min-w-0">
629 {isCharacterEditing ? (
630 <input
631 value={characterDraft.name}
632 onChange={e => setCharacterDraft(prev => ({ ...prev, name: e.target.value }))}
633 className="w-full rounded-lg border border-blue-100 px-2 py-1 text-sm font-medium text-gray-800 outline-none focus:ring-2 focus:ring-blue-200"
634 />
635 ) : (
636 <div className="flex flex-wrap items-center gap-2">
637 <span className="font-medium text-gray-800">{c.name}</span>
638 {c.species && c.species !== '人类' && c.species !== 'human' && (
639 <span className="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 text-[10px] rounded">{c.species}</span>
640 )}
641 </div>
642 )}
643 </div>
644 {isCharacterEditing ? (
645 <div className="flex flex-shrink-0 items-center gap-1">
646 <button
647 onClick={cancelCharacterEdit}
648 disabled={isCharacterSaving}
649 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100 disabled:opacity-50"
650 >
651 <X className="w-3 h-3" />取消
652 </button>
653 <button
654 onClick={() => saveCharacterEdit(i)}
655 disabled={isCharacterSaving || !characterDraft.name.trim() || !characterDraft.description.trim()}
656 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 disabled:cursor-not-allowed"
657 >
658 <Save className="w-3 h-3" />{isCharacterSaving ? '保存中' : '保存'}
659 </button>
660 </div>
661 ) : deleteMode.characters ? (
662 <button
663 onClick={() => deleteCharacter(i)}
664 className="flex flex-shrink-0 items-center justify-center w-7 h-7 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 transition-colors"
665 title="删除角色"
666 >
667 <X className="w-3.5 h-3.5" />
668 </button>
669 ) : (
670 <button
671 onClick={() => startCharacterEdit(c, i)}
672 className="flex flex-shrink-0 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"
673 >
674 <Edit3 className="w-3 h-3" />修改
675 </button>
676 )}
677 </div>
678 {isCharacterEditing ? (
679 <div className="space-y-2">
680 <label className="flex flex-col gap-1 text-xs">
681 <span className="text-gray-500 font-medium">物种/类型</span>
682 <input
683 value={characterDraft.species}
684 onChange={e => setCharacterDraft(prev => ({ ...prev, species: e.target.value }))}
685 className="rounded-lg border border-gray-200 px-2 py-1.5 text-sm text-gray-700 outline-none focus:ring-2 focus:ring-blue-200"
686 />
687 </label>
688 <label className="flex flex-col gap-1 text-xs">
689 <span className="text-gray-500 font-medium">描述</span>
690 <textarea
691 value={characterDraft.description}
692 onChange={e => setCharacterDraft(prev => ({ ...prev, description: e.target.value }))}
693 rows={3}
694 className="resize-y rounded-lg border border-gray-200 px-2 py-1.5 text-sm text-gray-700 outline-none focus:ring-2 focus:ring-blue-200"
695 />
696 </label>
697 </div>
698 ) : (
699 <p className="text-sm text-gray-500 leading-relaxed mb-2">{c.description}</p>
700 )}
701 </div>
702 );
703 })}
704 </div>
705 </section>
706 )}
707
708 {/* 场景设置 */}
709 {Array.isArray(data.settings) && (
710 <section>
711 <div className="flex items-center gap-2 mb-3">
712 <MapPin className="w-4 h-4 text-green-500" />
713 <h3 className="text-sm font-semibold text-gray-700">场景</h3>
714 <button
715 onClick={createSetting}
716 className="ml-2 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-green-600 bg-green-50 hover:bg-green-100 transition-colors"
717 >
718 <Plus className="w-3 h-3" />
719 新建
720 </button>
721 <button
722 onClick={() => toggleDeleteMode('settings')}
723 className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors ${
724 deleteMode.settings
725 ? 'text-red-600 bg-red-50 hover:bg-red-100'
726 : 'text-gray-500 bg-gray-100 hover:bg-gray-200'
727 }`}
728 >
729 <Trash2 className="w-3 h-3" />
730 删除
731 </button>
732 </div>
733 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
734 {data.settings.map((s, i) => {
735 const isSettingEditing = editingSettingIndex === i;
736 const isSettingSaving = savingSettingIndex === i;
737 return (
738 <div key={i} className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
739 <div className="flex items-start justify-between gap-2 mb-2">
740 {isSettingEditing ? (
741 <input
742 value={settingDraft.name}
743 onChange={e => setSettingDraft(prev => ({ ...prev, name: e.target.value }))}
744 className="min-w-0 flex-1 rounded-lg border border-green-100 px-2 py-1 text-sm font-medium text-gray-800 outline-none focus:ring-2 focus:ring-green-200"
745 />
746 ) : (
747 <div className="font-medium text-gray-800">{s.name}</div>
748 )}
749 {isSettingEditing ? (
750 <div className="flex flex-shrink-0 items-center gap-1">
751 <button
752 onClick={cancelSettingEdit}
753 disabled={isSettingSaving}
754 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100 disabled:opacity-50"
755 >
756 <X className="w-3 h-3" />取消
757 </button>
758 <button
759 onClick={() => saveSettingEdit(i)}
760 disabled={isSettingSaving || !settingDraft.name.trim() || !settingDraft.description.trim()}
761 className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-white bg-green-500 hover:bg-green-600 disabled:bg-green-300 disabled:cursor-not-allowed"
762 >
763 <Save className="w-3 h-3" />{isSettingSaving ? '保存中' : '保存'}
764 </button>
765 </div>
766 ) : deleteMode.settings ? (
767 <button
768 onClick={() => deleteSetting(i)}
769 className="flex flex-shrink-0 items-center justify-center w-7 h-7 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 transition-colors"
770 title="删除场景"
771 >
772 <X className="w-3.5 h-3.5" />
773 </button>
774 ) : (
775 <button
776 onClick={() => startSettingEdit(s, i)}
777 className="flex flex-shrink-0 items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-green-600 bg-green-50 hover:bg-green-100 transition-colors"
778 >
779 <Edit3 className="w-3 h-3" />修改
780 </button>
781 )}
782 </div>
783 {isSettingEditing ? (
784 <textarea
785 value={settingDraft.description}
786 onChange={e => setSettingDraft(prev => ({ ...prev, description: e.target.value }))}
787 rows={4}
788 className="w-full resize-y rounded-lg border border-gray-200 px-2 py-1.5 text-sm text-gray-700 outline-none focus:ring-2 focus:ring-green-200"
789 />
790 ) : (
791 <p className="text-sm text-gray-500 leading-relaxed">{s.description}</p>
792 )}
793 </div>
794 );
795 })}
796 </div>
797 </section>
798 )}
799
800 {/* 故事线 */}
801 {Array.isArray(data.episodes) ? (
802 <section className="bg-gray-50 p-4 rounded-xl border border-gray-100">
803 <div className="flex items-center justify-between mb-4">
804 <div className="flex items-center gap-2">
805 <Film className="w-5 h-5 text-purple-500" />
806 <h3 className="text-sm font-bold text-gray-800">分集剧本</h3>
807 <button
808 onClick={createEpisode}
809 className="ml-2 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium text-purple-600 bg-purple-50 hover:bg-purple-100 transition-colors"
810 >
811 <Plus className="w-3 h-3" />
812 新建
813 </button>
814 <button
815 onClick={() => toggleDeleteMode('episodes')}
816 className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors ${
817 deleteMode.episodes
818 ? 'text-red-600 bg-red-50 hover:bg-red-100'
819 : 'text-gray-500 bg-gray-100 hover:bg-gray-200'
820 }`}
821 >
822 <Trash2 className="w-3 h-3" />
823 删除
824 </button>
825 </div>
826 </div>
827 <div className="space-y-6">
828 {data.episodes.map((ep, i) => {
829 const episodeNumber = getEpisodeNumber(ep, i);
830 const isEpisodeEditing = editingEpisodeIndex === i;
831 const isSavingEpisode = savingEpisodeIndex === i;
832 return (
833 <div key={i} className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow">
834 <div className="bg-gradient-to-r from-purple-50 to-white px-4 py-3 border-b border-purple-100 flex flex-wrap items-center justify-between gap-3">
835 {isEpisodeEditing ? (
836 <div className="flex min-w-0 flex-1 items-center gap-2">
837 <span className="font-bold text-purple-800 whitespace-nowrap">第 {episodeNumber} 集:</span>
838 <input
839 value={episodeDraft.title}
840 onChange={e => setEpisodeDraft(prev => ({ ...prev, title: e.target.value }))}
841 className="min-w-0 flex-1 rounded-lg border border-purple-100 bg-white px-3 py-1.5 text-sm font-bold text-purple-800 outline-none focus:ring-2 focus:ring-purple-200"
842 />
843 </div>
844 ) : (
845 <h4 className="font-bold text-purple-800">第 {episodeNumber} 集:{ep.act_title}</h4>
846 )}
847 {isEpisodeEditing ? (
848 <div className="flex items-center gap-2">
849 <button
850 onClick={cancelEpisodeEdit}
851 disabled={isSavingEpisode}
852 className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-100 disabled:opacity-50"
853 >
854 <X className="w-3.5 h-3.5" />
855 取消
856 </button>
857 <button
858 onClick={() => saveEpisodeEdit(ep, i)}
859 disabled={isSavingEpisode || !episodeDraft.content.trim()}
860 className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium text-white bg-purple-500 hover:bg-purple-600 disabled:bg-purple-300 disabled:cursor-not-allowed"
861 >
862 <Save className="w-3.5 h-3.5" />
863 {isSavingEpisode ? '保存中' : '保存'}
864 </button>
865 </div>
866 ) : deleteMode.episodes ? (
867 <button
868 onClick={() => deleteEpisode(i)}
869 className="flex items-center justify-center w-8 h-8 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 transition-colors"
870 title="删除分集"
871 >
872 <X className="w-4 h-4" />
873 </button>
874 ) : (
875 <button
876 onClick={() => startEpisodeEdit(ep, i)}
877 className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium text-purple-600 bg-purple-50 hover:bg-purple-100 transition-colors"
878 >
879 <Edit3 className="w-3.5 h-3.5" />
880 修改
881 </button>
882 )}
883 </div>
884 {isEpisodeEditing ? (
885 <div className="p-5">
886 <textarea
887 value={episodeDraft.content}
888 onChange={e => setEpisodeDraft(prev => ({ ...prev, content: e.target.value }))}
889 rows={12}
890 className="w-full resize-y rounded-xl border border-gray-200 bg-gray-50 p-4 text-[15px] leading-relaxed text-gray-700 outline-none focus:bg-white focus:ring-2 focus:ring-purple-200"
891 />
892 </div>
893 ) : (
894 <div className="p-5 text-gray-700 whitespace-pre-wrap leading-relaxed text-[15px]">
895 {ep.content}
896 </div>
897 )}
898 </div>
899 );
900 })}
901 </div>
902 </section>
903 ) : data.scenes && data.scenes.length > 0 && (
904 <section>
905 <div className="flex items-center gap-2 mb-3">
906 <Film className="w-4 h-4 text-purple-500" />
907 <h3 className="text-sm font-semibold text-gray-700">故事线 ({data.scenes.length} 场)</h3>
908 </div>
909 <div className="space-y-3">
910 {data.scenes.map((sc, i) => {
911 // 幕分隔线:当场景有 act 字段,且是第一场或与上一场不同幕时显示
912 const showActSep = sc.act != null && (i === 0 || data.scenes![i - 1].act !== sc.act);
913 const actNames: Record<number, string> = { 1: '激励事件', 2: '进入新世界', 3: '灵魂黑夜', 4: '高潮决战' };
914 return (
915 <React.Fragment key={i}>
916 {showActSep && (
917 <div className="flex items-center gap-3 pt-2">
918 <div className="flex-1 h-px bg-gradient-to-r from-purple-200 to-transparent" />
919 <span className="px-3 py-1 bg-purple-50 text-purple-600 text-xs font-semibold rounded-full whitespace-nowrap">
920 第{sc.act}幕 — {actNames[sc.act!] || ''}
921 </span>
922 <div className="flex-1 h-px bg-gradient-to-l from-purple-200 to-transparent" />
923 </div>
924 )}
925 <div className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
926 <div className="flex items-center gap-3 mb-2">
927 <span className="flex items-center justify-center w-7 h-7 rounded-full bg-purple-100 text-purple-700 text-xs font-bold flex-shrink-0">{sc.scene_number}</span>
928 <span className="px-2 py-0.5 bg-green-50 text-green-600 text-xs rounded-full">{sc.location}</span>
929 <div className="flex flex-wrap gap-1">
930 {(sc.characters || []).map((c: any, ci: number) => (
931 <span key={ci} className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full">{c}</span>
932 ))}
933 </div>
934 </div>
935 <p className="text-sm text-gray-600 leading-relaxed pl-10">{sc.plot}</p>
936 </div>
937 </React.Fragment>
938 );
939 })}
940 </div>
941 </section>
942 )}
943
944 {/* ===== 智能续写 UI ===== */}
945 {!data.new_episodes || data.new_episodes.length === 0 ? (
946 data.episodes && data.episodes.length > 0 && state.status !== 'running' && (
947 <div className="mt-8 flex justify-center">
948 <button
949 onClick={() => setShowSmartContinueDialog(true)}
950 className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-full shadow-md hover:shadow-lg hover:from-purple-600 hover:to-purple-700 transition-all font-medium text-sm"
951 >
952 <Sparkles className="w-4 h-4" />
953 智能续写
954 </button>
955 </div>
956 )
957 ) : (
958 <div className="mt-8 space-y-6">
959 <div className="relative flex py-5 items-center">
960 <div className="flex-grow border-t border-purple-300 border-dashed"></div>
961 <span className="flex-shrink-0 mx-4 text-purple-600 font-bold text-sm bg-purple-50 px-4 py-1 rounded-full shadow-sm">续集</span>
962 <div className="flex-grow border-t border-purple-300 border-dashed"></div>
963 </div>
964
965 {data.new_characters && data.new_characters.length > 0 && (
966 <div className="bg-amber-50 p-4 rounded-xl border border-amber-200 shadow-sm flex flex-col gap-3">
967 <h4 className="text-sm font-bold text-amber-800 flex items-center gap-2">
968 <Users className="w-4 h-4" /> 新增角色
969 </h4>
970 <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
971 {data.new_characters.map((c: any, i: number) => (
972 <div key={i} className="bg-white p-3 rounded-lg border border-amber-100 shadow-sm flex flex-col gap-1">
973 <div className="font-bold text-amber-900 text-sm">{c.name}</div>
974 <p className="text-[11px] text-gray-600 line-clamp-2 md:line-clamp-none">{c.description}</p>
975 </div>
976 ))}
977 </div>
978 </div>
979 )}
980
981 {data.new_settings && data.new_settings.length > 0 && (
982 <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-200 shadow-sm flex flex-col gap-3">
983 <h4 className="text-sm font-bold text-emerald-800 flex items-center gap-2">
984 <MapPin className="w-4 h-4" /> 新增场景
985 </h4>
986 <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
987 {data.new_settings.map((s: any, i: number) => (
988 <div key={i} className="bg-white p-3 rounded-lg border border-emerald-100 shadow-sm flex flex-col gap-1">
989 <div className="font-bold text-emerald-900 text-sm">{s.name}</div>
990 <p className="text-[11px] text-gray-600 line-clamp-2 md:line-clamp-none">{s.description}</p>
991 </div>
992 ))}
993 </div>
994 </div>
995 )}
996
997 <div className="space-y-6">
998 {data.new_episodes.map((ep: any, i: number) => (
999 <div key={`new-${i}`} className="bg-purple-50 border-2 border-purple-300 rounded-xl overflow-hidden shadow-md">
1000 <div className="bg-gradient-to-r from-purple-200 to-purple-100 px-4 py-3 flex items-center justify-between border-b border-purple-200/50">
1001 <h4 className="font-bold text-purple-900">第 {ep.episode_number || (data.episodes ? data.episodes.length + i + 1 : i + 1)} 集:{ep.act_title}</h4>
1002 <span className="text-[10px] font-bold text-purple-700 bg-purple-200/50 px-2 py-0.5 rounded shadow-sm tracking-wider">NEW EPISODE</span>
1003 </div>
1004 <div className="p-5 text-gray-800 whitespace-pre-wrap leading-relaxed text-[15px] bg-white/80">
1005 {ep.content}
1006 </div>
1007 </div>
1008 ))}
1009 </div>
1010
1011 <div className="flex justify-end gap-3 mt-4">
1012 <button
1013 onClick={() => onIntervene({ action: 'delete_continue' })}
1014 className="px-5 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-sm font-medium transition-colors border border-red-200"
1015 >
1016 删除新剧集
1017 </button>
1018 <button
1019 onClick={() => onIntervene({ action: 'confirm_continue' })}
1020 className="flex items-center gap-2 px-5 py-2 bg-gradient-to-r from-purple-600 to-purple-700 text-white rounded-lg text-sm font-medium hover:from-purple-700 hover:to-purple-800 transition-colors shadow-sm"
1021 >
1022 <Save className="w-4 h-4" />
1023 保存新剧集
1024 </button>
1025 </div>
1026 </div>
1027 )}
1028 </div>
1029 )}
1030
1031 {/* 等待状态 */}
1032 {state.status === 'pending' && (
1033 <div className="text-center text-gray-400 text-sm py-20">等待生成...</div>
1034 )}
1035 </div>
1036
1037 {!isLoglinePhase && (
1038 <StageActions
1039 status={state.status}
1040 onConfirm={onConfirm}
1041 showConfirm={showConfirm}
1042 onRegenerate={onRegenerate}
1043 stageId="script_generation"
1044 hasPendingItems={hasPendingItems}
1045 hasNextStageStarted={hasNextStageStarted}
1046 isRunning={isRunning}
1047 />
1048 )}
1049
1050 {/* ===== 智能续写弹窗 ===== */}
1051 {showSmartContinueDialog && (
1052 <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm shadow-2xl transition-opacity duration-200">
1053 <div className="bg-white rounded-2xl w-[min(480px,calc(100vw-2rem))] p-6 shadow-2xl animate-in zoom-in-95 duration-200">
1054 <h3 className="text-lg font-bold text-gray-800 mb-5 flex items-center gap-2 border-b border-gray-100 pb-3">
1055 <Sparkles className="w-5 h-5 text-purple-500" />
1056 智能续写设置
1057 </h3>
1058
1059 <div className="space-y-5">
1060 <div>
1061 <label className="block text-[13px] font-bold text-gray-600 mb-2 uppercase tracking-wide">一次续写剧集数</label>
1062 <div className="flex gap-3">
1063 {[1, 2, 3].map((num) => (
1064 <button
1065 key={num}
1066 onClick={() => setSmartContinueEpisodes(num)}
1067 className={`flex-1 py-2.5 px-4 rounded-xl font-bold text-sm transition-all border outline-none ${
1068 smartContinueEpisodes === num
1069 ? 'bg-purple-50 border-purple-500 text-purple-700 shadow-[0_0_0_2px_rgba(168,85,247,0.1)]'
1070 : 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50 hover:border-gray-300'
1071 }`}
1072 >
1073 {num} 集
1074 </button>
1075 ))}
1076 </div>
1077 </div>
1078
1079 <div>
1080 <label className="block text-[13px] font-bold text-gray-600 mb-2 flex items-center gap-2">
1081 后续剧情想法主线 <span className="text-[10px] font-medium text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">(可选)</span>
1082 </label>
1083 <textarea
1084 value={smartContinueIdea}
1085 onChange={(e) => setSmartContinueIdea(e.target.value)}
1086 placeholder="如果留空,AI 将自动为您生成后续的续写灵感主线。"
1087 className="w-full h-32 border border-gray-200 rounded-xl p-3 text-[14px] text-gray-700 focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 resize-none transition-all placeholder:text-gray-400 bg-gray-50/50 focus:bg-white"
1088 ></textarea>
1089 </div>
1090 </div>
1091
1092 <div className="flex justify-end gap-3 mt-8">
1093 <button
1094 onClick={() => setShowSmartContinueDialog(false)}
1095 className="px-5 py-2.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl text-sm font-bold transition-colors outline-none"
1096 >
1097 取消
1098 </button>
1099 <button
1100 onClick={handleSmartContinueConfirm}
1101 className="px-6 py-2.5 bg-gradient-to-r from-purple-500 to-purple-600 text-white rounded-xl text-sm font-bold hover:from-purple-600 hover:to-purple-700 transition-all shadow-md group flex items-center justify-center gap-2 outline-none hover:shadow-lg"
1102 >
1103 <Sparkles className="w-4 h-4 opacity-70" />
1104 确认生成
1105 </button>
1106 </div>
1107 </div>
1108 </div>
1109 )}
1110 </div>
1111 );
1112 }
1113
1113 lines Plain Text