| 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 |