返回 VideoClaw
AppShell.tsx
1 'use client';
2
3 import Link from 'next/link';
4 import { usePathname, useRouter } from 'next/navigation';
5 import { CheckCircle2, ChevronLeft, ChevronRight, Clapperboard, Clock, Hexagon, Home, Loader2, PanelLeftOpen, Repeat2, Settings, Trash2, UserRound } from 'lucide-react';
6 import clsx from 'clsx';
7 import { useEffect, useState, type CSSProperties } from 'react';
8 import { clearTempCache, fetchPipelineTasks, fetchSandboxTasks, fetchSessions, type PipelineTask, type SandboxTask } from '@/lib/workflowApi';
9
10 const NAV_ITEMS = [
11 { href: '/', label: 'Video-Claw', icon: Home },
12 { href: '/sandbox', label: '临时工作台', icon: Hexagon },
13 { href: '/pipelines/standard', label: '文艺短视频', icon: Clapperboard },
14 { href: '/pipelines/action-transfer', label: '动作迁移', icon: Repeat2 },
15 { href: '/pipelines/digital-human', label: '数字人口播', icon: UserRound },
16 ];
17
18 const SETTINGS_ITEM = { href: '/settings', label: '设置', icon: Settings };
19
20 const PIPELINE_ROUTES: Record<string, { href: string; label: string }> = {
21 standard: { href: '/pipelines/standard', label: '文艺短视频' },
22 action_transfer: { href: '/pipelines/action-transfer', label: '动作迁移' },
23 digital_human: { href: '/pipelines/digital-human', label: '数字人口播' },
24 };
25
26 const TASK_STATUS_STYLE: Record<string, string> = {
27 pending: 'bg-gray-100 text-gray-500',
28 running: 'bg-blue-50 text-blue-600',
29 waiting: 'bg-amber-50 text-amber-600',
30 completed: 'bg-green-50 text-green-600',
31 failed: 'bg-red-50 text-red-600',
32 };
33
34 function statusText(status?: string) {
35 if (status === 'pending') return '等待中';
36 if (status === 'running') return '生成中';
37 if (status === 'waiting') return '待确认';
38 if (status === 'completed') return '已完成';
39 if (status === 'failed') return '失败';
40 return status || '未知';
41 }
42
43 function taskTitle(task: PipelineTask) {
44 const input = task.input || {};
45 const output = task.output || {};
46 return output.title || input.title || input.goods_title || input.text || input.prompt_text || input.goods_text || task.task_id;
47 }
48
49 type RunningTaskItem = {
50 id: string;
51 href: string;
52 title: string;
53 scope: string;
54 status: string;
55 progress: number;
56 };
57
58 const WORKFLOW_STAGE_COUNT = 7;
59 const COMPLETED_TASK_DISMISS_KEY = 'video-claw.dismissed-completed-tasks';
60 const SIDEBAR_OPEN_KEY = 'video-claw.sidebar-open';
61
62 function projectTaskFromSession(session: any): RunningTaskItem | null {
63 const statusMap = session.status || {};
64 const values = Object.values(statusMap);
65 if (!values.includes('running')) return null;
66 const completed = values.filter(value => ['completed', 'session_completed'].includes(String(value))).length;
67 const runningStage = Object.keys(statusMap).find(key => statusMap[key] === 'running');
68 return {
69 id: `project-${session.id}`,
70 href: `/?session=${encodeURIComponent(session.id)}`,
71 title: session.idea || session.id,
72 scope: runningStage ? `主流程 · ${runningStage}` : '主流程',
73 status: 'running',
74 progress: Math.round((completed / WORKFLOW_STAGE_COUNT) * 100),
75 };
76 }
77
78 function projectReviewTaskFromSession(session: any): RunningTaskItem | null {
79 const statusMap = session.status || {};
80 const waitingStage = Object.keys(statusMap).find(key => statusMap[key] === 'waiting');
81 const completed = Object.values(statusMap).filter(value => ['completed', 'session_completed'].includes(String(value))).length;
82 const allDone = completed >= WORKFLOW_STAGE_COUNT || statusMap.completed === 'completed';
83 if (!waitingStage && !allDone) return null;
84 const targetStage = waitingStage || Object.keys(statusMap).reverse().find(key => ['completed', 'session_completed'].includes(String(statusMap[key]))) || '';
85 return {
86 id: `project-review-${session.id}-${waitingStage || 'completed'}`,
87 href: `/?session=${encodeURIComponent(session.id)}${targetStage ? `&stage=${encodeURIComponent(targetStage)}` : ''}`,
88 title: session.idea || session.title || session.id,
89 scope: waitingStage ? `主流程 · ${waitingStage}` : '主流程',
90 status: waitingStage ? 'waiting' : 'completed',
91 progress: waitingStage ? Math.round((completed / WORKFLOW_STAGE_COUNT) * 100) : 100,
92 };
93 }
94
95 function pipelineTaskItem(task: PipelineTask): RunningTaskItem | null {
96 const route = PIPELINE_ROUTES[task.pipeline];
97 if (!route || !['pending', 'running'].includes(task.status)) return null;
98 return {
99 id: `pipeline-${task.task_id}`,
100 href: `${route.href}?task=${encodeURIComponent(task.task_id)}`,
101 title: String(taskTitle(task)),
102 scope: route.label,
103 status: task.status,
104 progress: task.progress || 0,
105 };
106 }
107
108 function pipelineCompletedTaskItem(task: PipelineTask): RunningTaskItem | null {
109 const route = PIPELINE_ROUTES[task.pipeline];
110 if (!route || task.status !== 'completed') return null;
111 return {
112 id: `pipeline-completed-${task.task_id}`,
113 href: `${route.href}?task=${encodeURIComponent(task.task_id)}`,
114 title: String(taskTitle(task)),
115 scope: route.label,
116 status: 'completed',
117 progress: 100,
118 };
119 }
120
121 const SANDBOX_TOOL_LABELS: Record<string, string> = {
122 llm: 'LLM',
123 vlm: 'VLM',
124 t2i: '文生图',
125 i2i: '图生图',
126 video: '视频生成',
127 };
128
129 function sandboxTaskItem(task: SandboxTask): RunningTaskItem {
130 const input = task.input || {};
131 return {
132 id: `sandbox-${task.id}`,
133 href: `/sandbox?task=${encodeURIComponent(task.id)}`,
134 title: input.prompt || input.reference_image || task.id,
135 scope: `临时工作台 · ${SANDBOX_TOOL_LABELS[task.tool] || task.tool}`,
136 status: task.status || 'running',
137 progress: task.progress || 1,
138 };
139 }
140
141 function loadDismissedCompletedTasks(): Set<string> {
142 if (typeof window === 'undefined') return new Set();
143 try {
144 const raw = window.localStorage.getItem(COMPLETED_TASK_DISMISS_KEY);
145 const parsed = raw ? JSON.parse(raw) : [];
146 return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []);
147 } catch {
148 return new Set();
149 }
150 }
151
152 function saveDismissedCompletedTasks(ids: Set<string>) {
153 if (typeof window === 'undefined') return;
154 window.localStorage.setItem(COMPLETED_TASK_DISMISS_KEY, JSON.stringify(Array.from(ids)));
155 }
156
157 function loadSidebarOpen(): boolean {
158 if (typeof window === 'undefined') return false;
159 return window.localStorage.getItem(SIDEBAR_OPEN_KEY) === 'true';
160 }
161
162 function saveSidebarOpen(open: boolean) {
163 if (typeof window === 'undefined') return;
164 window.localStorage.setItem(SIDEBAR_OPEN_KEY, open ? 'true' : 'false');
165 }
166
167 function TaskPanel({
168 title,
169 icon,
170 loading,
171 tasks,
172 currentPath,
173 emptyText,
174 onTaskClick,
175 }: {
176 title: string;
177 icon: React.ReactNode;
178 loading?: boolean;
179 tasks: RunningTaskItem[];
180 currentPath: string;
181 emptyText: string;
182 onTaskClick: (task: RunningTaskItem) => void;
183 }) {
184 return (
185 <section className="h-[20vh] min-h-32 border-t border-gray-100 p-3">
186 <div className="mb-2 flex items-center gap-2 px-1">
187 {icon}
188 <span className="text-xs font-medium text-gray-500">{title}</span>
189 {loading && <Loader2 className="ml-auto h-3 w-3 animate-spin text-gray-300" />}
190 </div>
191 <div className="h-[calc(100%-26px)] overflow-y-auto pr-1">
192 {tasks.length ? (
193 <div className="space-y-1.5">
194 {tasks.map(task => {
195 const active = currentPath === task.href.split('?')[0];
196 return (
197 <button
198 key={task.id}
199 type="button"
200 onClick={() => onTaskClick(task)}
201 className={clsx(
202 'w-full rounded-lg border px-2.5 py-2 text-left transition-colors',
203 active ? 'border-blue-100 bg-blue-50/60' : 'border-gray-100 bg-white hover:border-blue-200 hover:bg-blue-50/40'
204 )}
205 >
206 <div className="flex items-center gap-2">
207 <span className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700">
208 {task.title.slice(0, 36)}
209 </span>
210 <span className={clsx('flex-shrink-0 rounded px-1.5 py-0.5 text-[10px]', TASK_STATUS_STYLE[task.status] || TASK_STATUS_STYLE.pending)}>
211 {statusText(task.status)}
212 </span>
213 </div>
214 <div className="mt-1 flex items-center gap-2">
215 <span className="truncate text-[10px] text-gray-400">{task.scope}</span>
216 <div className="h-1 min-w-10 flex-1 overflow-hidden rounded-full bg-gray-100">
217 <div className="h-full rounded-full bg-blue-500" style={{ width: `${task.progress || 0}%` }} />
218 </div>
219 </div>
220 </button>
221 );
222 })}
223 </div>
224 ) : (
225 <div className="flex h-full items-center justify-center rounded-lg border border-dashed border-gray-100 px-2 text-center text-xs text-gray-300">
226 {emptyText}
227 </div>
228 )}
229 </div>
230 </section>
231 );
232 }
233
234 function SidebarTaskPanels({ currentPath }: { currentPath: string }) {
235 const router = useRouter();
236 const [runningTasks, setRunningTasks] = useState<RunningTaskItem[]>([]);
237 const [completedTasks, setCompletedTasks] = useState<RunningTaskItem[]>([]);
238 const [dismissedCompleted, setDismissedCompleted] = useState<Set<string>>(() => loadDismissedCompletedTasks());
239 const [loading, setLoading] = useState(false);
240
241 const load = async () => {
242 setLoading(true);
243 try {
244 const [pipelineRecords, sessions, sandboxRecords] = await Promise.all([
245 fetchPipelineTasks(100).catch(() => []),
246 fetchSessions().catch(() => []),
247 fetchSandboxTasks().catch(() => []),
248 ]);
249 setRunningTasks([
250 ...sandboxRecords.map(sandboxTaskItem),
251 ...pipelineRecords.map(pipelineTaskItem).filter((task): task is RunningTaskItem => Boolean(task)),
252 ...sessions.map(projectTaskFromSession).filter((task): task is RunningTaskItem => Boolean(task)),
253 ]);
254 setCompletedTasks([
255 ...pipelineRecords.map(pipelineCompletedTaskItem).filter((task): task is RunningTaskItem => Boolean(task)),
256 ...sessions.map(projectReviewTaskFromSession).filter((task): task is RunningTaskItem => Boolean(task)),
257 ].filter(task => !dismissedCompleted.has(task.id)));
258 } finally {
259 setLoading(false);
260 }
261 };
262
263 useEffect(() => {
264 load().catch(() => {});
265 const timer = window.setInterval(() => load().catch(() => {}), 3000);
266 return () => window.clearInterval(timer);
267 }, [dismissedCompleted]);
268
269 const handleRunningClick = (task: RunningTaskItem) => {
270 router.push(task.href);
271 };
272
273 const handleCompletedClick = (task: RunningTaskItem) => {
274 const next = new Set(dismissedCompleted);
275 next.add(task.id);
276 saveDismissedCompletedTasks(next);
277 setDismissedCompleted(next);
278 setCompletedTasks(current => current.filter(item => item.id !== task.id));
279 router.push(task.href);
280 };
281
282 return (
283 <>
284 <TaskPanel
285 title="进行中任务"
286 icon={<Clock className="h-3.5 w-3.5 text-gray-400" />}
287 loading={loading}
288 tasks={runningTasks}
289 currentPath={currentPath}
290 emptyText="暂无进行中任务"
291 onTaskClick={handleRunningClick}
292 />
293 <TaskPanel
294 title="已完成/等待确认"
295 icon={<CheckCircle2 className="h-3.5 w-3.5 text-gray-400" />}
296 tasks={completedTasks}
297 currentPath={currentPath}
298 emptyText="暂无已完成或待确认任务"
299 onTaskClick={handleCompletedClick}
300 />
301 </>
302 );
303 }
304
305 export default function AppShell({ children }: { children: React.ReactNode }) {
306 const pathname = usePathname();
307 const router = useRouter();
308 const [open, setOpen] = useState(false);
309 const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
310 const [clearingCache, setClearingCache] = useState(false);
311
312 useEffect(() => {
313 setOpen(loadSidebarOpen());
314 }, []);
315
316 const setSidebarOpen = (nextOpen: boolean) => {
317 setOpen(nextOpen);
318 saveSidebarOpen(nextOpen);
319 };
320
321 const handleClearCache = async () => {
322 setClearingCache(true);
323 try {
324 const result = await clearTempCache();
325 window.alert(`缓存已清空,删除 ${result.deleted} 项,释放 ${Number(result.freed_mb || 0).toFixed(2)} MB。`);
326 setSettingsMenuOpen(false);
327 } catch (error: any) {
328 window.alert(error?.message || '清空缓存失败');
329 } finally {
330 setClearingCache(false);
331 }
332 };
333
334 return (
335 <div
336 className="min-h-screen bg-gray-50 text-gray-800"
337 style={{ '--app-sidebar-width': open ? '15rem' : '0px' } as CSSProperties}
338 >
339 <aside
340 className={clsx(
341 'fixed inset-y-0 left-0 z-40 border-r border-gray-200 bg-white shadow-sm transition-all duration-300',
342 open ? 'w-60' : 'w-0 border-r-0'
343 )}
344 >
345 <div className={clsx('flex h-full flex-col overflow-hidden transition-opacity duration-200', open ? 'opacity-100' : 'opacity-0')}>
346 <div className="flex h-16 items-center px-4 border-b border-gray-100">
347 <div className="flex items-center gap-2 min-w-0">
348 <PanelLeftOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
349 <span className="text-sm font-semibold text-gray-800 truncate">Video-Claw</span>
350 </div>
351 </div>
352 <nav className="min-h-0 flex-1 overflow-y-auto p-3 space-y-1">
353 {NAV_ITEMS.map(item => {
354 const Icon = item.icon;
355 const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href);
356 return (
357 <Link
358 key={item.href}
359 href={item.href}
360 className={clsx(
361 'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
362 active
363 ? 'bg-blue-50 text-blue-600'
364 : 'text-gray-500 hover:bg-gray-50 hover:text-gray-800'
365 )}
366 >
367 <Icon className="w-4 h-4 flex-shrink-0" />
368 <span className="truncate">{item.label}</span>
369 </Link>
370 );
371 })}
372 </nav>
373 <SidebarTaskPanels currentPath={pathname} />
374 <div className="relative border-t border-gray-100 p-3">
375 {(() => {
376 const Icon = SETTINGS_ITEM.icon;
377 const active = pathname.startsWith(SETTINGS_ITEM.href);
378 return (
379 <>
380 {settingsMenuOpen && (
381 <div className="absolute bottom-[62px] left-3 right-3 rounded-xl border border-gray-200 bg-white p-2 shadow-lg">
382 <button
383 type="button"
384 onClick={() => {
385 setSettingsMenuOpen(false);
386 router.push(SETTINGS_ITEM.href);
387 }}
388 className="flex h-10 w-full items-center gap-2 rounded-lg px-3 text-left text-sm font-medium text-gray-600 hover:bg-blue-50 hover:text-blue-600"
389 >
390 <Settings className="h-4 w-4" />
391 修改配置
392 </button>
393 <button
394 type="button"
395 onClick={() => handleClearCache()}
396 disabled={clearingCache}
397 className="mt-1 flex h-10 w-full items-center gap-2 rounded-lg px-3 text-left text-sm font-medium text-gray-600 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-60"
398 >
399 {clearingCache ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
400 清空缓存
401 </button>
402 </div>
403 )}
404 <button
405 type="button"
406 onClick={() => setSettingsMenuOpen(value => !value)}
407 className={clsx(
408 'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
409 active || settingsMenuOpen
410 ? 'bg-blue-50 text-blue-600'
411 : 'text-gray-500 hover:bg-gray-50 hover:text-gray-800'
412 )}
413 >
414 <Icon className="w-4 h-4 flex-shrink-0" />
415 <span className="truncate">{SETTINGS_ITEM.label}</span>
416 </button>
417 </>
418 );
419 })()}
420 </div>
421 </div>
422 </aside>
423
424 {open && (
425 <button
426 onClick={() => setSidebarOpen(false)}
427 className="fixed left-60 top-1/2 z-50 h-14 w-7 -translate-y-1/2 rounded-r-xl border border-l-0 border-gray-200 bg-white text-gray-400 shadow-sm hover:w-9 hover:text-blue-600 hover:border-blue-200 hover:bg-blue-50 flex items-center justify-center transition-all"
428 title="收起侧边栏"
429 >
430 <ChevronLeft className="w-4 h-4" />
431 </button>
432 )}
433
434 {!open && (
435 <button
436 onClick={() => setSidebarOpen(true)}
437 className="fixed left-0 top-1/2 z-50 h-14 w-7 -translate-y-1/2 rounded-r-xl border border-l-0 border-gray-200 bg-white text-gray-400 shadow-sm hover:w-9 hover:text-blue-600 hover:border-blue-200 hover:bg-blue-50 flex items-center justify-center transition-all"
438 title="打开侧边栏"
439 >
440 <ChevronRight className="w-4 h-4" />
441 </button>
442 )}
443
444 <main className={clsx('min-h-screen min-w-0 overflow-x-hidden transition-[margin] duration-300', open ? 'ml-60' : 'ml-0')}>
445 {children}
446 </main>
447 </div>
448 );
449 }
450
450 lines Plain Text