| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621 |
- 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<string | null>(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<RecordState>(getRecordState());
- // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
- const [assets, setAssets] = useState<TaskAssets | null>(null);
- // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
- // 导致 listen 频繁重订)
- const activeIdRef = useRef<string | null>(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<TaskAssets>("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<ScreenshotFinishedPayload>(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<ScreenshotFailedPayload>(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<RecordingFinishedPayload>(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<RecordingFailedPayload>(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<RecordShortcutPayload>(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: <LoadingOutlined />, tip: "正在停止…" };
- }
- if (recordState === "recording") {
- // 视觉上保持 Play 图标,但 disabled
- return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
- }
- return {
- icon: <PlayCircleFilled />,
- 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 (
- <div className="flex h-screen w-screen overflow-hidden select-none">
- {/* antd notification 的渲染容器 —— 必须挂在树中 */}
- {notifyContext}
- {/* ===== 左栏:任务列表(180px) ===== */}
- <aside className="w-[180px] shrink-0 border-r border-gray-4 bg-gray-2 h-full flex flex-col">
- <div className="px-3 py-2 text-xs text-gray-7 border-b border-gray-4 select-none">
- 任务列表
- </div>
- <div className="flex-1 overflow-y-auto">
- <ul>
- {mockTasks.map((t) => {
- const active = activeId === t.id;
- return (
- <li
- key={t.id}
- className={
- "flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors " +
- (active
- ? "bg-primary-1 text-primary-7"
- : "hover:bg-gray-3 text-gray-10")
- }
- onClick={() => handleSelectTask(t)}
- >
- <span className="truncate">任务 {t.id}</span>
- <button
- type="button"
- title="在系统浏览器打开"
- className="ml-2 inline-flex items-center justify-center w-6 h-6 rounded-sm hover:bg-gray-4 text-gray-7 hover:text-primary-6"
- onClick={(e) => {
- e.stopPropagation();
- void openInBrowser(t.url);
- }}
- >
- {/* 外链图标(lucide external-link 同款 path,不引入新依赖) */}
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
- <polyline points="15 3 21 3 21 9" />
- <line x1="10" y1="14" x2="21" y2="3" />
- </svg>
- </button>
- </li>
- );
- })}
- </ul>
- </div>
- </aside>
- {/* ===== 右侧主区 ===== */}
- <main className="flex-1 flex flex-col min-w-0">
- {/* 工具栏(48px) */}
- <header className="h-12 shrink-0 flex items-center justify-between gap-2 px-3 border-b border-gray-4 bg-gray-1">
- {/* 左侧:操作按钮区 */}
- <div className="flex items-center gap-2">
- <Tooltip title={activeId ? "截图整页(覆盖之前的自动截图)" : "请先选择任务"} placement="top">
- <Button
- size="small"
- icon={<PictureOutlined />}
- disabled={!activeId}
- onClick={() => void handleManualCapture()}
- />
- </Tooltip>
- <Divider type="vertical" />
- {/* 开始按钮:仅 idle + 已选任务时可点 */}
- <Tooltip title={startConfig.tip} placement="top">
- <Button
- size="small"
- icon={startConfig.icon}
- disabled={startDisabled}
- onClick={() => void handleStartRecord()}
- />
- </Tooltip>
- {/* 停止按钮:仅 recording 时可用 */}
- <Tooltip
- title={
- stopDisabled
- ? "无进行中的录制"
- : "停止录制并落盘 mp4(F11)"
- }
- placement="top"
- >
- <Button
- size="small"
- icon={<StopOutlined />}
- disabled={stopDisabled}
- onClick={() => void handleStopRecord()}
- />
- </Tooltip>
- <Divider type="vertical" />
- {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
- <Tooltip
- title={
- !activeId
- ? "请先选择任务"
- : assets?.recording_exists
- ? "在文件管理器中显示录制视频"
- : assets?.screenshot_exists
- ? "在文件管理器中显示截图"
- : "打开截图目录"
- }
- placement="top"
- >
- <Button
- size="small"
- icon={<FolderOpenOutlined />}
- disabled={!assets}
- onClick={() => void handleOpenFolder()}
- />
- </Tooltip>
- {/* 预览截图:仅当 png 存在 */}
- <Tooltip
- title={
- !assets?.screenshot_exists
- ? "暂无截图可预览"
- : "在新窗口预览截图"
- }
- placement="top"
- >
- <Button
- size="small"
- icon={<FileImageOutlined />}
- disabled={!assets?.screenshot_exists}
- onClick={() => void handlePreviewImage()}
- />
- </Tooltip>
- {/* 预览视频:仅当 mp4 存在 */}
- <Tooltip
- title={
- !assets?.recording_exists
- ? "暂无录制视频可预览"
- : "在新窗口预览录制视频"
- }
- placement="top"
- >
- <Button
- size="small"
- icon={<VideoCameraOutlined />}
- disabled={!assets?.recording_exists}
- onClick={() => void handlePreviewVideo()}
- />
- </Tooltip>
- </div>
- {/* 右侧:尺寸预设 / 自定义 */}
- <div className="flex items-center gap-2">
- {!customMode ? (
- <>
- {SIZE_PRESETS.map((p) => (
- <Button
- key={p.label}
- size="small"
- disabled={contentSize.w == p.w && contentSize.h == p.h}
- onClick={() => applyWorkAreaSize(p.w, p.h)}
- >
- {p.label}
- </Button>
- ))}
- <Button size="small" onClick={() => setCustomMode(true)}>
- 自定义
- </Button>
- </>
- ) : (
- <>
- <InputNumber
- size="small"
- min={200}
- max={3840}
- value={contentSize.w}
- onChange={(v) => setContentSize({ w: v || 0, h: contentSize.h })}
- style={{ width: 80 }}
- />
- <span className="text-gray-7 select-none">×</span>
- <InputNumber
- size="small"
- min={200}
- max={2160}
- value={contentSize.h}
- onChange={(v) => setContentSize({ w: contentSize.w, h: v || 0 })}
- style={{ width: 80 }}
- />
- <Button
- size="small"
- type="primary"
- onClick={() => applyWorkAreaSize(contentSize.w, contentSize.h)}
- >
- 应用
- </Button>
- <Button size="small" onClick={() => setCustomMode(false)}>
- 取消
- </Button>
- </>
- )}
- </div>
- </header>
- {/* 工作区占位:实际由 Rust child webview 覆盖在此区域之上 */}
- <section className="flex-1 bg-gray-7" />
- <section className="h-10 bg-gray-5 broder border-gray-7" />
- </main>
- </div>
- );
- }
- export default App;
|