import { useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener"; import { Button, Divider, InputNumber, notification, Tooltip } from "antd"; import { mockTasks, type Task } from "./mocks/tasks"; import { EVT_RECORDING_FAILED, EVT_RECORDING_FINISHED, EVT_RECORD_SHORTCUT, EVT_SCREENSHOT_FAILED, EVT_SCREENSHOT_FINISHED, 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 { FileImageOutlined, FolderOpenOutlined, LoadingOutlined, PictureOutlined, PlayCircleFilled, StopOutlined, VideoCameraOutlined, } from "@ant-design/icons"; /** * 与 Rust 端常量保持一致(src-tauri/src/lib.rs): * LEFT_PANEL_WIDTH = 180 * TOOLBAR_HEIGHT = 48 * * 导出以避免 noUnusedLocals 误报;后续如有组件需要可直接 import。 */ export const LEFT_PANEL_WIDTH = 180; export const TOOLBAR_HEIGHT = 48; /** 工具栏右侧的尺寸预设(统一横屏:宽 × 高) */ const SIZE_PRESETS = [ { label: "1920×1080", w: 1920, h: 1080 }, { label: "1024×768", w: 1024, h: 768 }, { label: "1280×720", w: 1280, h: 720 }, ]; /** 点条目:把 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); const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 }); const [customMode, setCustomMode] = useState(false); // 录制状态机(由 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(); function handleSelectTask(t: Task) { 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]); /** 手动截图:交给 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 applyWorkAreaSize = useCallback(async (w: number, h: number) => { try { // 先调主窗口,避免子 webview 越界 await invoke("set_window_size", { width: w + 180, height: h + 46, contentWidth: w, contentHeight: h }); setContentSize({ w, h }) } catch (e) { console.error("调整尺寸失败:", e); } }, [setContentSize]); // 首次挂载时按默认 contentSize 调整窗口;依赖项故意为空(仅初始化用)。 useEffect(() => { applyWorkAreaSize(contentSize.w, contentSize.h); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 录制状态机(RecordState 类型已抽到 src/types/ipc.ts),录制功能下一阶段接入 return (
{/* antd notification 的渲染容器 —— 必须挂在树中 */} {notifyContext} {/* ===== 左栏:任务列表(180px) ===== */} {/* ===== 右侧主区 ===== */}
{/* 工具栏(48px) */}
{/* 左侧:操作按钮区 */}
{/* 右侧:尺寸预设 / 自定义 */}
{!customMode ? ( <> {SIZE_PRESETS.map((p) => ( ))} ) : ( <> setContentSize({ w: v || 0, h: contentSize.h })} style={{ width: 80 }} /> × setContentSize({ w: contentSize.w, h: v || 0 })} style={{ width: 80 }} /> )}
{/* 工作区占位:实际由 Rust child webview 覆盖在此区域之上 */}
); } export default App;