import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { ask, message } from "@tauri-apps/plugin-dialog"; import { Button, Divider, InputNumber, Layout, Menu, Space, theme, Tooltip } from "antd"; import { countPendingTasks, listPendingTasks, markTaskDone, updateTaskSiteUrl, updateTaskTags, type Task, } from "./lib/tasks"; import { EVT_RECORDING_FAILED, EVT_RECORDING_FINISHED, EVT_RECORD_SHORTCUT, EVT_SCREENSHOT_FAILED, EVT_SCREENSHOT_FINISHED, EVT_TASK_PAGE_READY, EVT_TASK_PAGE_TIMEOUT, EVT_TASK_SITE_URL_FOUND, EVT_TASK_TAGS_EXTRACTED, EVT_TASKS_IMPORTED, type RecordingFailedPayload, type RecordingFinishedPayload, type RecordShortcutPayload, type RecordState, type ScreenshotFailedPayload, type ScreenshotFinishedPayload, type TaskAssets, type TaskPageReadyPayload, type TaskPageTimeoutPayload, type TaskSiteUrlFoundPayload, type TaskTagsExtractedPayload, } from "./types/ipc"; import { capturePage } from "./lib/capture"; import { cancelRecording, getRecordState, startRecording, stopRecording, subscribeRecordState, } from "./lib/recorder"; import "./App.css"; import { CheckCircleOutlined, CloseOutlined, FileImageOutlined, FolderOpenOutlined, LeftOutlined, LinkOutlined, LoadingOutlined, MinusOutlined, OrderedListOutlined, PictureOutlined, PlayCircleFilled, PlusOutlined, RedoOutlined, StopOutlined, VideoCameraOutlined, } from "@ant-design/icons"; import { asyncSleep } from "./lib/utils"; const { Header, Content, Sider } = Layout; /** showStatus 支持的三类语义,对应底部状态栏色块颜色 + 原生对话框 kind */ type StatusKind = "success" | "error" | "warning"; const STATUS_COLOR: Record = { success: "#52c41a", error: "#ff4d4f", warning: "#faad14", }; const STATUS_DIALOG_KIND: Record = { success: "info", error: "error", warning: "warning", }; /** * 与 Rust 端常量保持一致(src-tauri/src/lib.rs): * LEFT_PANEL_WIDTH = 180 * TOOLBAR_HEIGHT = 48 * * 导出以避免 noUnusedLocals 误报;后续如有组件需要可直接 import。 */ /** 工具栏右侧的尺寸预设(统一横屏:宽 × 高) */ const SIZE_PRESETS = [ { label: "1280×720", w: 1280, h: 720 }, { label: "720x1280", w: 720, h: 1280 }, { label: "1920×1080", w: 1920, h: 1080 }, { label: "1024×768", w: 1024, h: 768 } ]; /** 点条目:把 url 加载到子 webview,并把 task_id 一并下发(用于截图命名 + 自动截图回调) */ async function loadInWebview(taskId: string, url: string) { try { await invoke("clean_webview"); await asyncSleep(200); } catch (e) { } try { await invoke("navigate_webview", { taskId, url }); } catch (e) { console.error("webview 加载失败:", e); } } function App() { // 当前激活的任务 id(仅用于左栏视觉高亮) const [activeId, setActiveId] = useState(null); const activeIdRef = useRef(activeId); activeIdRef.current = activeId; const activeTaskRef = useRef(undefined); useLayoutEffect(() => { activeTaskRef.current = activeId ? tasks.find(({ id }) => id == activeId) : undefined; }, [activeId]); // 待处理任务(status=0)的真实列表,来自 sqlite const [tasks, setTasks] = useState([]); const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 }); const contentSizeRef = useRef(contentSize); const [customMode, setCustomMode] = useState(false); const isCustomSize = useCallback(() => { return !SIZE_PRESETS.find(({ w, h }) => w == contentSize.w && h == contentSize.h); }, [contentSize]) // 录制状态机(由 lib/recorder.ts 内部单例维护,这里订阅以驱动按钮) const [recordState, setRecordState] = useState(getRecordState()); // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled const [assets, setAssets] = useState(null); /** * landing 流转门控:true 表示已加载到最终 (Site) URL,可截图 / 录制。 * - handleSelectTask 触发 navigate 时强制 false * - 监听 EVT_TASK_PAGE_READY 后变 true * - 监听 EVT_TASK_PAGE_TIMEOUT 时保持 false,弹窗让用户「重试 / 取消」 */ const [pageReady, setPageReady] = useState(false); const [isWorking, setIsWorking] = useState(false); const [isLoading, setIsLoading] = useState(false); // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖 // 导致 listen 频繁重订) // 底部状态栏文案与左侧色块;showStatus 同时弹原生对话框 + 写状态栏 const [statusColor, setStatusColor] = useState("#888"); const [statusText, setStatusText] = useState("请选择任务"); /** * 统一的状态提示通道: * 1) 调 @tauri-apps/plugin-dialog 的 message() 弹一个原生对话框 * 2) 同步把 statusText / statusColor 更新到底部状态栏 * 对话框为非阻塞触发(fire-and-forget),失败仅打日志兜底 */ const showStatus = useCallback( (kind: StatusKind, title: string, description?: string, dlg?: boolean) => { setStatusColor(STATUS_COLOR[kind]); setStatusText(title); dlg !== false && void message(description ? `${title}\n${description}` : title, { title: "提示", kind: STATUS_DIALOG_KIND[kind], }).catch((e) => console.error("显示对话框失败:", e)); }, [], ); /** * 重新拉取 sqlite 中 status=0 的任务列表。 * 调用场景: * - 首次挂载 * - 监听到 EVT_TASKS_IMPORTED(导入窗口写入完) * - "完成" 按钮把当前任务标记为已完成后 */ const reloadTasks = useCallback(async (): Promise => { try { const list = await listPendingTasks(); setTasks(list); if (!activeId && list.length > 0) { await asyncSleep(500); handleSelectTask({ key: list[0].id }); } return list; } catch (e) { console.error("加载任务列表失败:", e); showStatus("error", "加载任务失败", String(e)); return []; } }, [showStatus]); /** 启动「批量导入 URL」窗口(独立 WebviewWindow,label = "import") */ const openImportWindow = useCallback(async () => { try { // 若已存在直接 setFocus,避免重复创建 const existing = await WebviewWindow.getByLabel("import"); if (existing) { await existing.setFocus(); return; } const w = new WebviewWindow("import", { url: "import.html", title: "导入 URL", width: 560, height: 480, resizable: true, // 主窗口是 decorations: false,这里给原生标题栏方便用户拖动/关闭 decorations: true, }); w.once("tauri://error", (e) => { console.error("导入窗口创建失败:", e); }); } catch (e) { console.error("openImportWindow 失败:", e); showStatus("error", "无法打开导入窗口", String(e)); } }, [showStatus]); const handleSelectTask = useCallback(({ key }: { key: string }) => { const t = tasks.find((v) => v.id == key); if (!t) { return; } showStatus("success", "任务开始", "任务开始", false); setIsWorking(true); setActiveId(t.id); // 新一轮 landing 流转开始:先把按钮门控锁住,等 Rust 端发 task-page-ready 再放开 setPageReady(false); void loadInWebview(t.id, t.url); }, [tasks, showStatus, setIsWorking, setActiveId, setPageReady]); const handleStopTask = useCallback(() => { setActiveId(null); setIsLoading(false); setIsWorking(false); invoke("clean_webview"); }, []); /** 重新查询指定任务的产物文件信息(截图 + mp4) */ const refreshAssets = useCallback(async (taskId: string | null) => { if (!taskId) { setAssets(null); return; } try { const a = await invoke("query_task_assets", { taskId }); setAssets(a); } catch (e) { console.error("query_task_assets 失败:", e); setAssets(null); } }, []); // activeId 变化时刷新 assets(切换任务后右侧按钮要按新任务的文件存在性变 enabled/disabled) useEffect(() => { void refreshAssets(activeId); }, [activeId, refreshAssets]); // 首次挂载:从 sqlite 加载待处理任务;若为空则拉起「批量导入 URL」窗口。 // 依赖项故意只放 reloadTasks / openImportWindow(都用 useCallback 稳定引用), // 避免 tasks 变化导致再次拉起导入窗口。 useEffect(() => { (async () => { const pending = await countPendingTasks().catch(() => 0); const list = await reloadTasks(); if (pending === 0 && list.length === 0) { await asyncSleep(500); openImportWindow(); } })(); }, []); // 监听导入窗口发出的 tasks-imported 事件,刷新主列表 useEffect(() => { let unlisten: UnlistenFn | null = null; let disposed = false; (async () => { const u = await listen<{ count: number }>(EVT_TASKS_IMPORTED, (e) => { showStatus("success", "导入完成", `已写入 ${e.payload.count} 条任务`); void reloadTasks(); }); if (disposed) u(); else unlisten = u; })(); return () => { disposed = true; unlisten?.(); }; }, [showStatus, reloadTasks]); /** * 监听 landing 三个事件: * - task-tags-extracted:中间页抓到标签 → 写库 + reload * - task-page-ready:跳到最终 URL 完成 → 解锁按钮 + 状态栏提示 * - task-page-timeout:未找到 Visit Site → ask() 弹「重试 / 取消」 * 全部按 activeIdRef 过滤,避免给已切走的旧任务弹无效提示。 */ useEffect(() => { let unlistenTags: UnlistenFn | null = null; let unlistenSite: UnlistenFn | null = null; let unlistenReady: UnlistenFn | null = null; let unlistenTimeout: UnlistenFn | null = null; let unlistenPageStarted: UnlistenFn | null = null; let unlistenPageLoaded: UnlistenFn | null = null; let disposed = false; (async () => { const u1 = await listen( EVT_TASK_TAGS_EXTRACTED, async (e) => { const { taskId, tags } = e.payload; setAssets((assets) => { if (assets?.task_id !== taskId) { return assets; } return { ...assets, tags }; }); try { await updateTaskTags(taskId, tags); // await reloadTasks(); showStatus("success", "已提取标签", tags || "(空)", false); } catch (err) { console.error("写入 tags 失败:", err); showStatus("error", "写入标签失败", String(err)); } }, ); const u2 = await listen( EVT_TASK_SITE_URL_FOUND, async (e) => { const { taskId, url } = e.payload; if (!url) return; setAssets((assets) => { if (assets?.task_id !== taskId) { return assets; } return { ...assets, site_url: url }; }); try { await updateTaskSiteUrl(taskId, url); // await reloadTasks(); } catch (err) { console.error("写入 site_url 失败:", err); } }, ); const u3 = await listen(EVT_TASK_PAGE_READY, (e) => { const { taskId } = e.payload; // 只对当前激活任务生效,避免切走后还把按钮放开 if (activeIdRef.current !== taskId) return; setPageReady(true); setIsWorking(false); showStatus("success", "页面就绪", "已加载到最终 URL,可截图 / 录制", false); }); const u4 = await listen( EVT_TASK_PAGE_TIMEOUT, async (e) => { const { taskId, url, reason } = e.payload; if (activeIdRef.current !== taskId) return; // 弹原生 ask 对话框:确认 = 重试;取消 = 保持禁用 const retry = await ask(`${reason}\n\n是否刷新重试?`, { title: "未找到 网站 链接", kind: "warning", okLabel: "重试", cancelLabel: "取消", }).catch(() => false); if (retry) { // 重新 navigate:Rust 端会重置 page_stage=Initial 并重新轮询 setPageReady(false); await loadInWebview(taskId, url); } else { setActiveId(null); setIsLoading(false); setIsWorking(false); // 用户取消:保持按钮禁用,写状态栏告知 showStatus("warning", "已取消任务", "重新点击任务可继续尝试"); } }, ); const u5 = await listen("page_started", () => { setIsLoading(true); }) const u6 = await listen("page_loaded", () => { setIsLoading(false); }); if (disposed) { u1(); u2(); u3(); u4(); u5(); u6(); } else { unlistenTags = u1; unlistenSite = u2; unlistenReady = u3; unlistenTimeout = u4; unlistenPageStarted = u5; unlistenPageLoaded = u6; } })(); return () => { disposed = true; unlistenTags?.(); unlistenSite?.(); unlistenReady?.(); unlistenTimeout?.(); unlistenPageStarted?.(); unlistenPageLoaded?.(); }; }, [showStatus, setAssets]); /** 工具栏 "完成" 按钮:把当前激活任务标记为已完成(status=1)→ 刷新 → 清空选中 */ const handleCompleteTask = useCallback(async () => { if (!activeId) return; try { await markTaskDone(activeId); showStatus("success", "任务已完成", `任务 ${activeId} 已从列表移除`, false); setActiveId(null); setIsLoading(false); setIsWorking(false); await asyncSleep(500); const list = await reloadTasks(); // 若已无任务,自动再拉起一次导入窗口(用户可继续粘贴) if (list.length === 0) { await openImportWindow(); } } catch (e) { showStatus("error", "标记完成失败", String(e)); } }, [activeId, reloadTasks, openImportWindow, showStatus]); /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */ const handleManualCapture = useCallback(async () => { // 必须已加载到最终页才能截图,否则截到的是中间页 if (!activeId || !pageReady) return; try { await capturePage(activeId); } catch (e) { // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底 console.error("手动截图失败:", e); } }, [activeId, pageReady]); // 订阅 Rust 端的截图事件,统一走 showStatus(原生 dialog + 状态栏) useEffect(() => { let unlistenFinished: UnlistenFn | null = null; let unlistenFailed: UnlistenFn | null = null; let disposed = false; (async () => { const u1 = await listen(EVT_SCREENSHOT_FINISHED, (e) => { const { taskId, path, auto } = e.payload; showStatus( "success", auto ? "自动截图完成" : "截图完成", `任务 ${taskId} → ${path}`, false ); // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点 if (activeIdRef.current === taskId) { void refreshAssets(taskId); } }); const u2 = await listen(EVT_SCREENSHOT_FAILED, (e) => { const { taskId, auto, error } = e.payload; showStatus( "error", auto ? "自动截图失败" : "截图失败", `任务 ${taskId}:${error}`, ); }); if (disposed) { u1(); u2(); } else { unlistenFinished = u1; unlistenFailed = u2; } })(); return () => { disposed = true; unlistenFinished?.(); unlistenFailed?.(); }; }, [showStatus, refreshAssets]); // 订阅录制状态机变化,驱动按钮 UI 切换 useEffect(() => { return subscribeRecordState(setRecordState); }, []); // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一走 showStatus。 // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。 useEffect(() => { let unlistenFinished: UnlistenFn | null = null; let unlistenFailed: UnlistenFn | null = null; let disposed = false; (async () => { const u1 = await listen(EVT_RECORDING_FINISHED, async (e) => { const { taskId, path } = e.payload; showStatus("success", "录制完成", `任务 ${taskId} → ${path}`, false); if (activeIdRef.current === taskId) { await refreshAssets(taskId); await asyncSleep(100); handleCompleteTask(); } }); const u2 = await listen(EVT_RECORDING_FAILED, (e) => { const { taskId, error } = e.payload; showStatus("error", "录制失败", `任务 ${taskId}:${error}`); }); if (disposed) { u1(); u2(); } else { unlistenFinished = u1; unlistenFailed = u2; } })(); return () => { disposed = true; unlistenFinished?.(); unlistenFailed?.(); }; }, [showStatus, refreshAssets]); // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream useEffect(() => { return () => { void cancelRecording(); }; }, []); /** 开始按钮:仅在 idle + 已选任务 + 页面已就绪 时可点;Rust 端 spawn ffmpeg 子进程录屏 */ const handleStartRecord = useCallback(async () => { if (recordState !== "idle" || !activeId || !pageReady) return; try { await startRecording(activeId, contentSize.w, contentSize.h); } catch (e) { showStatus("error", "开始录制失败", String(e)); } }, [recordState, activeId, pageReady, contentSize, showStatus]); /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */ const handleStopRecord = useCallback(async () => { if (recordState !== "recording") return; try { await stopRecording(); // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹 } catch (e) { // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理 showStatus("error", "停止录制失败", String(e)); } }, [recordState, showStatus]); /** 全局快捷键派发:F9 = 开始,F11 = 停止 */ const handleShortcut = useCallback( async (action: "start" | "stop") => { try { if (action === "start") { if (recordState !== "idle") return; if (!activeId) { showStatus("warning", "无法开始录制", "请先在左栏选择一个任务"); return; } if (!pageReady) { showStatus("warning", "无法开始录制", "等待最终页加载完成后再试"); return; } await startRecording(activeId, contentSize.w, contentSize.h); } else if (action === "stop") { if (recordState === "recording") { await stopRecording(); } } } catch (e) { showStatus("error", "快捷键操作失败", String(e)); } }, [recordState, activeId, pageReady, contentSize, showStatus], ); // 订阅 Rust 端的全局快捷键事件 (F9 / F11) useEffect(() => { let unlisten: UnlistenFn | null = null; let disposed = false; (async () => { const u = await listen(EVT_RECORD_SHORTCUT, (e) => { void handleShortcut(e.payload.action); }); if (disposed) u(); else unlisten = u; })(); return () => { disposed = true; unlisten?.(); }; }, [handleShortcut]); /** 打开当前任务产物所在的文件夹:优先 reveal mp4 → png → 兜底打开 screenshots 目录 */ const handleOpenFolder = useCallback(async () => { if (!assets) return; const target = assets.recording_exists ? assets.recording_path : assets.screenshot_exists ? assets.screenshot_path : assets.screenshots_dir; try { await revealItemInDir(target); } catch (e) { showStatus("error", "打开文件夹失败", String(e)); } }, [assets, showStatus]); /** 在新 WebviewWindow 中预览截图 */ const handlePreviewImage = useCallback(async () => { if (!assets?.screenshot_exists) return; try { await invoke("open_preview_window", { path: assets.screenshot_path, kind: "image", }); } catch (e) { showStatus("error", "图片预览失败", String(e)); } }, [assets, showStatus]); /** 在新 WebviewWindow 中预览录制 mp4 */ const handlePreviewVideo = useCallback(async () => { if (!assets?.recording_exists) return; try { await invoke("open_preview_window", { path: assets.recording_path, kind: "video", }); } catch (e) { showStatus("error", "视频预览失败", String(e)); } }, [assets, showStatus]); // 开始按钮的图标 / tooltip const startConfig = (() => { if (recordState === "stopping") { return { icon: , tip: "正在停止…" }; } if (recordState === "recording") { // 视觉上保持 Play 图标,但 disabled return { icon: , tip: "录制中…(F11 停止)" }; } if (!activeId) { return { icon: , tip: "请先选择任务" }; } if (!pageReady) { return { icon: , tip: "等待最终 URL 加载完成…" }; } return { icon: , tip: "开始录制(F9)" }; })(); // 开始按钮仅在 idle + 已选任务 + 最终页已就绪时可点 const startDisabled = recordState !== "idle" || !activeId || !pageReady; // 停止按钮仅在 recording 时可点 const stopDisabled = recordState !== "recording"; const [collapsed, setCollapsed] = useState(false); const applyWorkAreaSize = useCallback(async (w?: number, h?: number) => { if (w && h) { setContentSize({ w, h }); contentSizeRef.current = { w, h }; } else { w = contentSize.w; h = contentSize.h; } try { // 先调主窗口,避免子 webview 越界 await invoke("set_window_size", { width: w + (collapsed ? 30 : 180) + 4, height: h + 48 + 24 + 4, contentX: (collapsed ? 30 : 180) + 2, contentY: 50, contentWidth: w, contentHeight: h }); } catch (e) { console.error("调整尺寸失败 ", e); } }, [contentSize, setContentSize]); useEffect(() => { applyWorkAreaSize(); }, []); // 录制状态机(RecordState 类型已抽到 src/types/ipc.ts),录制功能下一阶段接入 const handleCollaspsed = useCallback(async (c: boolean) => { setCollapsed(c); try { // 先调主窗口,避免子 webview 越界 await invoke("set_window_size", { width: contentSize.w + (collapsed ? 30 : 180), height: contentSize.h + 60, contentWidth: contentSize.w, contentHeight: contentSize.h }); } catch (e) { console.error("调整尺寸失败:", e); } }, [setCollapsed]); const { token: { colorBgContainer }, } = theme.useToken(); // 当前窗口句柄,用于自定义标题栏的最小化 / 关闭 const appWindow = useMemo(() => getCurrentWindow(), []); const handleAppClose = useCallback(async () => { try { await appWindow.close(); } catch (e) { console.error("appWindow.close 失败:", e); } }, [appWindow]); const handleAppMinimize = useCallback(async () => { try { await appWindow.minimize(); } catch (e) { console.error("appWindow.minimize 失败:", e); } }, [appWindow]); const handleBack = useCallback(() => { invoke("webview_history_action", { action: "back" }); }, []); const handleRefresh = useCallback(() => { invoke("webview_history_action", { action: "reload" }); }, []); return ( {/* data-tauri-drag-region:Tauri 2 原生支持,webview 层会自动 hook mousedown * 触发系统级拖窗,比手写 startDragging 更稳定(Windows 尤其如此)。 */}
{/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */} ))} {isCustomSize() ? : } ) : ( <> setContentSize({ w: v || 0, h: contentSize.h })} style={{ width: 80 }} /> × setContentSize({ w: contentSize.w, h: v || 0 })} style={{ width: 80 }} /> )}
handleCollaspsed(c)} width={180} style={{ background: colorBgContainer }}>
{collapsed ? '' : '所有任务'}
{tasks.map((t) => : } key={t.id}> {t.id} )}
任务:{activeId} {isLoading && }
{assets?.site_url || activeTaskRef.current?.url}
{assets?.tags}
{statusText}
); } export default App;