import { useCallback, useEffect, 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 { Button, Divider, InputNumber, Layout, Menu, notification, Space, theme, Tooltip } from "antd"; import { countPendingTasks, listPendingTasks, markTaskDone, type Task, } from "./lib/tasks"; import { EVT_RECORDING_FAILED, EVT_RECORDING_FINISHED, EVT_RECORD_SHORTCUT, EVT_SCREENSHOT_FAILED, EVT_SCREENSHOT_FINISHED, EVT_TASKS_IMPORTED, type RecordingFailedPayload, type RecordingFinishedPayload, type RecordShortcutPayload, type RecordState, type ScreenshotFailedPayload, type ScreenshotFinishedPayload, type TaskAssets, } from "./types/ipc"; import { capturePage } from "./lib/capture"; import { cancelRecording, getRecordState, startRecording, stopRecording, subscribeRecordState, } from "./lib/recorder"; import "./App.css"; import { BankOutlined, CheckCircleOutlined, CloseOutlined, FileImageOutlined, FolderOpenOutlined, LinkOutlined, LoadingOutlined, MinusOutlined, OrderedListOutlined, PictureOutlined, PlayCircleFilled, PlusOutlined, StopOutlined, VideoCameraOutlined, } from "@ant-design/icons"; const { Header, Content, Sider } = Layout; /** * 与 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: "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("navigate_webview", { taskId, url }); } catch (e) { console.error("webview 加载失败:", e); } } /** 点条目右侧图标:调系统浏览器打开 */ // async function openInBrowser(url: string) { // try { // await openUrl(url); // } catch (e) { // console.error("打开系统浏览器失败:", e); // } // } function App() { // 当前激活的任务 id(仅用于左栏视觉高亮) const [activeId, setActiveId] = useState(null); // 待处理任务(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); // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖 // 导致 listen 频繁重订) const activeIdRef = useRef(activeId); useEffect(() => { activeIdRef.current = activeId; }, [activeId]); // antd 6 notification 必须用 hook + contextHolder 才能正确取到主题 const [notifyApi, notifyContext] = notification.useNotification(); /** * 重新拉取 sqlite 中 status=0 的任务列表。 * 调用场景: * - 首次挂载 * - 监听到 EVT_TASKS_IMPORTED(导入窗口写入完) * - "完成" 按钮把当前任务标记为已完成后 */ const reloadTasks = useCallback(async (): Promise => { try { const list = await listPendingTasks(); setTasks(list); return list; } catch (e) { console.error("加载任务列表失败:", e); notifyApi.error({ message: "加载任务失败", description: String(e), duration: 6, }); return []; } }, [notifyApi]); /** 启动「批量导入 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); notifyApi.error({ message: "无法打开导入窗口", description: String(e), duration: 6, }); } }, [notifyApi]); function handleSelectTask({ key }: { key: string }) { const t = tasks.find((v) => v.id == key); if (!t) { return; } setActiveId(t.id); void loadInWebview(t.id, t.url); } /** 重新查询指定任务的产物文件信息(截图 + 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) { setTimeout(() => openImportWindow(), 500); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 监听导入窗口发出的 tasks-imported 事件,刷新主列表 useEffect(() => { let unlisten: UnlistenFn | null = null; let disposed = false; (async () => { const u = await listen<{ count: number }>(EVT_TASKS_IMPORTED, (e) => { notifyApi.success({ message: "导入完成", description: `已写入 ${e.payload.count} 条任务`, duration: 3, }); void reloadTasks(); }); if (disposed) u(); else unlisten = u; })(); return () => { disposed = true; unlisten?.(); }; }, [notifyApi, reloadTasks]); /** 工具栏 "完成" 按钮:把当前激活任务标记为已完成(status=1)→ 刷新 → 清空选中 */ const handleCompleteTask = useCallback(async () => { if (!activeId) return; try { await markTaskDone(activeId); notifyApi.success({ message: "任务已完成", description: `任务 ${activeId} 已从列表移除`, duration: 3, }); setActiveId(null); const list = await reloadTasks(); // 若已无任务,自动再拉起一次导入窗口(用户可继续粘贴) if (list.length === 0) { await openImportWindow(); } } catch (e) { notifyApi.error({ message: "标记完成失败", description: String(e), duration: 6, }); } }, [activeId, reloadTasks, openImportWindow, notifyApi]); /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */ const handleManualCapture = useCallback(async () => { if (!activeId) return; try { await capturePage(activeId); } catch (e) { // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底 console.error("手动截图失败:", e); } }, [activeId]); // 订阅 Rust 端的截图事件,统一用 notification 提示 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; notifyApi.success({ message: auto ? "自动截图完成" : "截图完成", description: `任务 ${taskId} → ${path}`, duration: 4, }); // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点 if (activeIdRef.current === taskId) { void refreshAssets(taskId); } }); const u2 = await listen(EVT_SCREENSHOT_FAILED, (e) => { const { taskId, auto, error } = e.payload; notifyApi.error({ message: auto ? "自动截图失败" : "截图失败", description: `任务 ${taskId}:${error}`, duration: 6, }); }); if (disposed) { u1(); u2(); } else { unlistenFinished = u1; unlistenFailed = u2; } })(); return () => { disposed = true; unlistenFinished?.(); unlistenFailed?.(); }; }, [notifyApi, refreshAssets]); // 订阅录制状态机变化,驱动按钮 UI 切换 useEffect(() => { return subscribeRecordState(setRecordState); }, []); // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一 notification 提示。 // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。 useEffect(() => { let unlistenFinished: UnlistenFn | null = null; let unlistenFailed: UnlistenFn | null = null; let disposed = false; (async () => { const u1 = await listen(EVT_RECORDING_FINISHED, (e) => { const { taskId, path } = e.payload; notifyApi.success({ message: "录制完成", description: `任务 ${taskId} → ${path}`, duration: 5, }); if (activeIdRef.current === taskId) { void refreshAssets(taskId); } }); const u2 = await listen(EVT_RECORDING_FAILED, (e) => { const { taskId, error } = e.payload; notifyApi.error({ message: "录制失败", description: `任务 ${taskId}:${error}`, duration: 6, }); }); if (disposed) { u1(); u2(); } else { unlistenFinished = u1; unlistenFailed = u2; } })(); return () => { disposed = true; unlistenFinished?.(); unlistenFailed?.(); }; }, [notifyApi, refreshAssets]); // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream useEffect(() => { return () => { void cancelRecording(); }; }, []); /** 开始按钮:仅在 idle + 已选任务 时可点;Rust 端 spawn ffmpeg 子进程录屏 */ const handleStartRecord = useCallback(async () => { if (recordState !== "idle" || !activeId) return; try { await startRecording(activeId); } catch (e) { notifyApi.error({ message: "开始录制失败", description: String(e), duration: 6, }); } }, [recordState, activeId, notifyApi]); /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */ const handleStopRecord = useCallback(async () => { if (recordState !== "recording") return; try { await stopRecording(); // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹 } catch (e) { // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理 notifyApi.error({ message: "停止录制失败", description: String(e), duration: 6, }); } }, [recordState, notifyApi]); /** 全局快捷键派发:F9 = 开始,F11 = 停止 */ const handleShortcut = useCallback( async (action: "start" | "stop") => { try { if (action === "start") { if (recordState !== "idle") return; if (!activeId) { notifyApi.warning({ message: "无法开始录制", description: "请先在左栏选择一个任务", duration: 4, }); return; } await startRecording(activeId); } else if (action === "stop") { if (recordState === "recording") { await stopRecording(); } } } catch (e) { notifyApi.error({ message: "快捷键操作失败", description: String(e), duration: 6, }); } }, [recordState, activeId, notifyApi], ); // 订阅 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) { notifyApi.error({ message: "打开文件夹失败", description: String(e), duration: 6, }); } }, [assets, notifyApi]); /** 在新 WebviewWindow 中预览截图 */ const handlePreviewImage = useCallback(async () => { if (!assets?.screenshot_exists) return; try { await invoke("open_preview_window", { path: assets.screenshot_path, kind: "image", }); } catch (e) { notifyApi.error({ message: "图片预览失败", description: String(e), duration: 6, }); } }, [assets, notifyApi]); /** 在新 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) { notifyApi.error({ message: "视频预览失败", description: String(e), duration: 6, }); } }, [assets, notifyApi]); // 开始按钮的图标 / tooltip const startConfig = (() => { if (recordState === "stopping") { return { icon: , tip: "正在停止…" }; } if (recordState === "recording") { // 视觉上保持 Play 图标,但 disabled return { icon: , tip: "录制中…(F11 停止)" }; } return { icon: , tip: activeId ? "开始录制(F9)" : "请先选择任务", }; })(); // 开始按钮仅在 idle 且已选任务时可点;其它状态 disabled const startDisabled = recordState !== "idle" || !activeId; // 停止按钮仅在 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 [statusColor] = useState("#888"); const [statusText] = useState("请选择任务"); // 当前窗口句柄,用于自定义标题栏的最小化 / 关闭 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]); return ( {/* data-tauri-drag-region:Tauri 2 原生支持,webview 层会自动 hook mousedown * 触发系统级拖窗,比手写 startDragging 更稳定(Windows 尤其如此)。 */}
))} {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} )}
{statusText}
{notifyContext}
); } export default App;