| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015 |
- 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<StatusKind, string> = {
- success: "#52c41a",
- error: "#ff4d4f",
- warning: "#faad14",
- };
- const STATUS_DIALOG_KIND: Record<StatusKind, "info" | "warning" | "error"> = {
- 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<string | null>(null);
- const activeIdRef = useRef(activeId);
- activeIdRef.current = activeId;
- const activeTaskRef = useRef<Task | undefined>(undefined);
- useLayoutEffect(() => {
- activeTaskRef.current = activeId ? tasks.find(({ id }) => id == activeId) : undefined;
- }, [activeId]);
- // 待处理任务(status=0)的真实列表,来自 sqlite
- const [tasks, setTasks] = useState<Task[]>([]);
- 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<RecordState>(getRecordState());
- // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
- const [assets, setAssets] = useState<TaskAssets | null>(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<Task[]> => {
- 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<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]);
- // 首次挂载:从 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<TaskTagsExtractedPayload>(
- 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<TaskSiteUrlFoundPayload>(
- 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<TaskPageReadyPayload>(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<TaskPageTimeoutPayload>(
- 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<ScreenshotFinishedPayload>(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<ScreenshotFailedPayload>(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<RecordingFinishedPayload>(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<RecordingFailedPayload>(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<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) {
- 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: <LoadingOutlined />, tip: "正在停止…" };
- }
- if (recordState === "recording") {
- // 视觉上保持 Play 图标,但 disabled
- return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
- }
- if (!activeId) {
- return { icon: <PlayCircleFilled />, tip: "请先选择任务" };
- }
- if (!pageReady) {
- return { icon: <LoadingOutlined />, tip: "等待最终 URL 加载完成…" };
- }
- return { icon: <PlayCircleFilled />, 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 (
- <Layout className="m-0 p-0 h-full overflow-hidden">
- {/* data-tauri-drag-region:Tauri 2 原生支持,webview 层会自动 hook mousedown
- * 触发系统级拖窗,比手写 startDragging 更稳定(Windows 尤其如此)。 */}
- <Header
- className="app-title h-8 select-none"
- style={{ paddingLeft: 8, paddingRight: 8 }}
- data-tauri-drag-region
- >
- <div className="flex h-full flex-row items-center" data-tauri-drag-region>
- <Space size={4}>
- <Button
- className="app-close"
- icon={<CloseOutlined />}
- // size="large"
- type="text"
- data-tauri-drag-region="no-drag"
- onClick={handleAppClose}
- />
- <Button
- className="app-minimize"
- icon={<MinusOutlined />}
- // size="large"
- type="text"
- data-tauri-drag-region="no-drag"
- onClick={handleAppMinimize}
- />
- </Space>
- <div className="w-8" />
- <Button
- icon={<LeftOutlined />}
- data-tauri-drag-region="no-drag"
- onClick={handleBack}
- />
- <Button
- icon={<RedoOutlined />}
- data-tauri-drag-region="no-drag"
- onClick={handleRefresh}
- />
- <Divider orientation="vertical" />
- <Tooltip
- title={
- !activeId
- ? "请先选择任务"
- : !pageReady
- ? "等待最终 URL 加载完成…"
- : "截图整页(覆盖之前的自动截图)"
- }
- placement="right"
- >
- <Button
- icon={<PictureOutlined />}
- disabled={!activeId || !pageReady}
- onClick={handleManualCapture}
- />
- </Tooltip>
- <Divider orientation="vertical" />
- {/* 开始按钮:仅 idle + 已选任务时可点 */}
- <Tooltip title={startConfig.tip} placement="right">
- <Button
- icon={startConfig.icon}
- disabled={startDisabled}
- onClick={() => void handleStartRecord()}
- />
- </Tooltip>
- {/* 停止按钮:仅 recording 时可用 */}
- <Tooltip
- title={
- stopDisabled
- ? "无进行中的录制"
- : "停止录制并落盘 mp4(F11)"
- }
- placement="right"
- >
- <Button
- icon={<StopOutlined />}
- disabled={stopDisabled}
- onClick={() => void handleStopRecord()}
- />
- </Tooltip>
- <Divider orientation="vertical" />
- {/* 完成按钮:把当前任务在 sqlite 标记为 status=1,从主列表移除 */}
- <Tooltip
- title={activeId ? "标记当前任务为已完成" : "请先选择任务"}
- placement="right"
- >
- <Button
- type="primary"
- icon={<CheckCircleOutlined />}
- disabled={!activeId || !assets?.recording_exists || !assets.screenshot_exists}
- onClick={() => void handleCompleteTask()}
- >完成</Button>
- </Tooltip>
- {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
- <Tooltip
- title={
- !activeId
- ? "请先选择任务"
- : assets?.recording_exists
- ? "在文件管理器中显示录制视频"
- : assets?.screenshot_exists
- ? "在文件管理器中显示截图"
- : "打开截图目录"
- }
- placement="right"
- >
- <Button
- icon={<FolderOpenOutlined />}
- disabled={!assets}
- onClick={() => void handleOpenFolder()}
- />
- </Tooltip>
- {/* 预览截图:仅当 png 存在 */}
- <Tooltip
- title={
- !assets?.screenshot_exists
- ? "暂无截图可预览"
- : "在新窗口预览截图"
- }
- placement="right"
- >
- <Button
- icon={<FileImageOutlined />}
- disabled={!assets?.screenshot_exists}
- onClick={() => void handlePreviewImage()}
- />
- </Tooltip>
- {/* 预览视频:仅当 mp4 存在 */}
- <Tooltip
- title={
- !assets?.recording_exists
- ? "暂无录制视频可预览"
- : "在新窗口预览录制视频"
- }
- placement="right"
- >
- <Button
- icon={<VideoCameraOutlined />}
- disabled={!assets?.recording_exists}
- onClick={() => void handlePreviewVideo()}
- />
- </Tooltip>
- {isWorking && <Tooltip title="停止当前任务" placement="right">
- <Button icon={<CloseOutlined color="red" style={{ color: 'red' }} />} color="red" onClick={handleStopTask} />
- </Tooltip>}
- <div className="flex-1">
- </div>
- {!customMode ? (
- <>
- {SIZE_PRESETS.map((p) => (
- <Button
- key={p.label}
- type={contentSize.w == p.w && contentSize.h == p.h ? "primary" : undefined}
- onClick={contentSize.w != p.w || contentSize.h != p.h ? () => applyWorkAreaSize(p.w, p.h) as any : undefined}
- >
- {p.label}
- </Button>
- ))}
- {isCustomSize() ?
- <Button type="primary" onClick={() => setCustomMode(true)}>
- 自定义({contentSize.w}x{contentSize.h})
- </Button> : <Button onClick={() => setCustomMode(true)}>
- 自定义
- </Button>}
- </>
- ) : (
- <>
- <InputNumber
- 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
- min={200}
- max={2160}
- value={contentSize.h}
- onChange={(v) => setContentSize({ w: contentSize.w, h: v || 0 })}
- style={{ width: 80 }}
- />
- <Button
- type="primary"
- onClick={() => {
- setCustomMode(false);
- applyWorkAreaSize();
- }}
- >
- 应用
- </Button>
- <Button onClick={() => {
- setContentSize(contentSizeRef.current)
- setCustomMode(false);
- }}>
- 取消
- </Button>
- </>
- )}
- </div>
- </Header>
- <Layout>
- <Sider className="select-none" collapsible collapsed={collapsed} onCollapse={(c) => handleCollaspsed(c)} width={180} style={{ background: colorBgContainer }}>
- <div className="h-full w-full flex-row">
- <div className="flex justify-between items-center text-md pl-3 font-bold text-white"><OrderedListOutlined />{collapsed ? '' : '所有任务'}<Button icon={<PlusOutlined />} onClick={openImportWindow} /> </div>
- <div className="flex-1 overflow-y-auto">
- <Menu
- mode="vertical"
- defaultSelectedKeys={[activeId || '']}
- selectedKeys={[activeId || '']}
- style={{ borderInlineEnd: 0, height: '100%' }}
- onClick={isWorking ? undefined : handleSelectTask}
- >
- {tasks.map((t) => <Menu.Item icon={isWorking && t.id == activeId ? <LoadingOutlined /> : <LinkOutlined />} key={t.id}>
- {t.id}
- </Menu.Item>
- )}
- </Menu>
- </div>
- </div>
- </Sider>
- <Layout className="flex flex-row">
- <Content className="w-full m-0 p-1 flex-1" />
- <div className="w-full h-6 flex items-center px-2 text-gray-3">
- <div className="flex-1">任务:{activeId} {isLoading && <LoadingOutlined />}</div>
- <div className="flex-1 truncate">{assets?.site_url || activeTaskRef.current?.url}</div>
- <div className="flex-1 truncate">{assets?.tags}</div>
- <div className="flex-1 text-right">{statusText}</div>
- <div className="bg-gray-6 rounded-full w-3 h-3 m-2" style={{ backgroundColor: isWorking ? '#ff0' : statusColor }} />
- </div>
- </Layout>
- </Layout>
- </Layout >
- );
- }
- export default App;
|