App.tsx 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015
  1. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
  2. import { invoke } from "@tauri-apps/api/core";
  3. import { listen, type UnlistenFn } from "@tauri-apps/api/event";
  4. import { getCurrentWindow } from "@tauri-apps/api/window";
  5. import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
  6. import { revealItemInDir } from "@tauri-apps/plugin-opener";
  7. import { ask, message } from "@tauri-apps/plugin-dialog";
  8. import { Button, Divider, InputNumber, Layout, Menu, Space, theme, Tooltip } from "antd";
  9. import {
  10. countPendingTasks,
  11. listPendingTasks,
  12. markTaskDone,
  13. updateTaskSiteUrl,
  14. updateTaskTags,
  15. type Task,
  16. } from "./lib/tasks";
  17. import {
  18. EVT_RECORDING_FAILED,
  19. EVT_RECORDING_FINISHED,
  20. EVT_RECORD_SHORTCUT,
  21. EVT_SCREENSHOT_FAILED,
  22. EVT_SCREENSHOT_FINISHED,
  23. EVT_TASK_PAGE_READY,
  24. EVT_TASK_PAGE_TIMEOUT,
  25. EVT_TASK_SITE_URL_FOUND,
  26. EVT_TASK_TAGS_EXTRACTED,
  27. EVT_TASKS_IMPORTED,
  28. type RecordingFailedPayload,
  29. type RecordingFinishedPayload,
  30. type RecordShortcutPayload,
  31. type RecordState,
  32. type ScreenshotFailedPayload,
  33. type ScreenshotFinishedPayload,
  34. type TaskAssets,
  35. type TaskPageReadyPayload,
  36. type TaskPageTimeoutPayload,
  37. type TaskSiteUrlFoundPayload,
  38. type TaskTagsExtractedPayload,
  39. } from "./types/ipc";
  40. import { capturePage } from "./lib/capture";
  41. import {
  42. cancelRecording,
  43. getRecordState,
  44. startRecording,
  45. stopRecording,
  46. subscribeRecordState,
  47. } from "./lib/recorder";
  48. import "./App.css";
  49. import {
  50. CheckCircleOutlined,
  51. CloseOutlined,
  52. FileImageOutlined,
  53. FolderOpenOutlined,
  54. LeftOutlined,
  55. LinkOutlined,
  56. LoadingOutlined,
  57. MinusOutlined,
  58. OrderedListOutlined,
  59. PictureOutlined,
  60. PlayCircleFilled,
  61. PlusOutlined,
  62. RedoOutlined,
  63. StopOutlined,
  64. VideoCameraOutlined,
  65. } from "@ant-design/icons";
  66. import { asyncSleep } from "./lib/utils";
  67. const { Header, Content, Sider } = Layout;
  68. /** showStatus 支持的三类语义,对应底部状态栏色块颜色 + 原生对话框 kind */
  69. type StatusKind = "success" | "error" | "warning";
  70. const STATUS_COLOR: Record<StatusKind, string> = {
  71. success: "#52c41a",
  72. error: "#ff4d4f",
  73. warning: "#faad14",
  74. };
  75. const STATUS_DIALOG_KIND: Record<StatusKind, "info" | "warning" | "error"> = {
  76. success: "info",
  77. error: "error",
  78. warning: "warning",
  79. };
  80. /**
  81. * 与 Rust 端常量保持一致(src-tauri/src/lib.rs):
  82. * LEFT_PANEL_WIDTH = 180
  83. * TOOLBAR_HEIGHT = 48
  84. *
  85. * 导出以避免 noUnusedLocals 误报;后续如有组件需要可直接 import。
  86. */
  87. /** 工具栏右侧的尺寸预设(统一横屏:宽 × 高) */
  88. const SIZE_PRESETS = [
  89. { label: "1280×720", w: 1280, h: 720 },
  90. { label: "720x1280", w: 720, h: 1280 },
  91. { label: "1920×1080", w: 1920, h: 1080 },
  92. { label: "1024×768", w: 1024, h: 768 }
  93. ];
  94. /** 点条目:把 url 加载到子 webview,并把 task_id 一并下发(用于截图命名 + 自动截图回调) */
  95. async function loadInWebview(taskId: string, url: string) {
  96. try {
  97. await invoke("clean_webview");
  98. await asyncSleep(200);
  99. } catch (e) {
  100. }
  101. try {
  102. await invoke("navigate_webview", { taskId, url });
  103. } catch (e) {
  104. console.error("webview 加载失败:", e);
  105. }
  106. }
  107. function App() {
  108. // 当前激活的任务 id(仅用于左栏视觉高亮)
  109. const [activeId, setActiveId] = useState<string | null>(null);
  110. const activeIdRef = useRef(activeId);
  111. activeIdRef.current = activeId;
  112. const activeTaskRef = useRef<Task | undefined>(undefined);
  113. useLayoutEffect(() => {
  114. activeTaskRef.current = activeId ? tasks.find(({ id }) => id == activeId) : undefined;
  115. }, [activeId]);
  116. // 待处理任务(status=0)的真实列表,来自 sqlite
  117. const [tasks, setTasks] = useState<Task[]>([]);
  118. const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 });
  119. const contentSizeRef = useRef(contentSize);
  120. const [customMode, setCustomMode] = useState(false);
  121. const isCustomSize = useCallback(() => {
  122. return !SIZE_PRESETS.find(({ w, h }) => w == contentSize.w && h == contentSize.h);
  123. }, [contentSize])
  124. // 录制状态机(由 lib/recorder.ts 内部单例维护,这里订阅以驱动按钮)
  125. const [recordState, setRecordState] = useState<RecordState>(getRecordState());
  126. // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
  127. const [assets, setAssets] = useState<TaskAssets | null>(null);
  128. /**
  129. * landing 流转门控:true 表示已加载到最终 (Site) URL,可截图 / 录制。
  130. * - handleSelectTask 触发 navigate 时强制 false
  131. * - 监听 EVT_TASK_PAGE_READY 后变 true
  132. * - 监听 EVT_TASK_PAGE_TIMEOUT 时保持 false,弹窗让用户「重试 / 取消」
  133. */
  134. const [pageReady, setPageReady] = useState(false);
  135. const [isWorking, setIsWorking] = useState(false);
  136. const [isLoading, setIsLoading] = useState(false);
  137. // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
  138. // 导致 listen 频繁重订)
  139. // 底部状态栏文案与左侧色块;showStatus 同时弹原生对话框 + 写状态栏
  140. const [statusColor, setStatusColor] = useState("#888");
  141. const [statusText, setStatusText] = useState("请选择任务");
  142. /**
  143. * 统一的状态提示通道:
  144. * 1) 调 @tauri-apps/plugin-dialog 的 message() 弹一个原生对话框
  145. * 2) 同步把 statusText / statusColor 更新到底部状态栏
  146. * 对话框为非阻塞触发(fire-and-forget),失败仅打日志兜底
  147. */
  148. const showStatus = useCallback(
  149. (kind: StatusKind, title: string, description?: string, dlg?: boolean) => {
  150. setStatusColor(STATUS_COLOR[kind]);
  151. setStatusText(title);
  152. dlg !== false && void message(description ? `${title}\n${description}` : title, {
  153. title: "提示",
  154. kind: STATUS_DIALOG_KIND[kind],
  155. }).catch((e) => console.error("显示对话框失败:", e));
  156. },
  157. [],
  158. );
  159. /**
  160. * 重新拉取 sqlite 中 status=0 的任务列表。
  161. * 调用场景:
  162. * - 首次挂载
  163. * - 监听到 EVT_TASKS_IMPORTED(导入窗口写入完)
  164. * - "完成" 按钮把当前任务标记为已完成后
  165. */
  166. const reloadTasks = useCallback(async (): Promise<Task[]> => {
  167. try {
  168. const list = await listPendingTasks();
  169. setTasks(list);
  170. if (!activeId && list.length > 0) {
  171. await asyncSleep(500);
  172. handleSelectTask({ key: list[0].id });
  173. }
  174. return list;
  175. } catch (e) {
  176. console.error("加载任务列表失败:", e);
  177. showStatus("error", "加载任务失败", String(e));
  178. return [];
  179. }
  180. }, [showStatus]);
  181. /** 启动「批量导入 URL」窗口(独立 WebviewWindow,label = "import") */
  182. const openImportWindow = useCallback(async () => {
  183. try {
  184. // 若已存在直接 setFocus,避免重复创建
  185. const existing = await WebviewWindow.getByLabel("import");
  186. if (existing) {
  187. await existing.setFocus();
  188. return;
  189. }
  190. const w = new WebviewWindow("import", {
  191. url: "import.html",
  192. title: "导入 URL",
  193. width: 560,
  194. height: 480,
  195. resizable: true,
  196. // 主窗口是 decorations: false,这里给原生标题栏方便用户拖动/关闭
  197. decorations: true,
  198. });
  199. w.once("tauri://error", (e) => {
  200. console.error("导入窗口创建失败:", e);
  201. });
  202. } catch (e) {
  203. console.error("openImportWindow 失败:", e);
  204. showStatus("error", "无法打开导入窗口", String(e));
  205. }
  206. }, [showStatus]);
  207. const handleSelectTask = useCallback(({ key }: { key: string }) => {
  208. const t = tasks.find((v) => v.id == key);
  209. if (!t) {
  210. return;
  211. }
  212. showStatus("success", "任务开始", "任务开始", false);
  213. setIsWorking(true);
  214. setActiveId(t.id);
  215. // 新一轮 landing 流转开始:先把按钮门控锁住,等 Rust 端发 task-page-ready 再放开
  216. setPageReady(false);
  217. void loadInWebview(t.id, t.url);
  218. }, [tasks, showStatus, setIsWorking, setActiveId, setPageReady]);
  219. const handleStopTask = useCallback(() => {
  220. setActiveId(null);
  221. setIsLoading(false);
  222. setIsWorking(false);
  223. invoke("clean_webview");
  224. }, []);
  225. /** 重新查询指定任务的产物文件信息(截图 + mp4) */
  226. const refreshAssets = useCallback(async (taskId: string | null) => {
  227. if (!taskId) {
  228. setAssets(null);
  229. return;
  230. }
  231. try {
  232. const a = await invoke<TaskAssets>("query_task_assets", { taskId });
  233. setAssets(a);
  234. } catch (e) {
  235. console.error("query_task_assets 失败:", e);
  236. setAssets(null);
  237. }
  238. }, []);
  239. // activeId 变化时刷新 assets(切换任务后右侧按钮要按新任务的文件存在性变 enabled/disabled)
  240. useEffect(() => {
  241. void refreshAssets(activeId);
  242. }, [activeId, refreshAssets]);
  243. // 首次挂载:从 sqlite 加载待处理任务;若为空则拉起「批量导入 URL」窗口。
  244. // 依赖项故意只放 reloadTasks / openImportWindow(都用 useCallback 稳定引用),
  245. // 避免 tasks 变化导致再次拉起导入窗口。
  246. useEffect(() => {
  247. (async () => {
  248. const pending = await countPendingTasks().catch(() => 0);
  249. const list = await reloadTasks();
  250. if (pending === 0 && list.length === 0) {
  251. await asyncSleep(500);
  252. openImportWindow();
  253. }
  254. })();
  255. }, []);
  256. // 监听导入窗口发出的 tasks-imported 事件,刷新主列表
  257. useEffect(() => {
  258. let unlisten: UnlistenFn | null = null;
  259. let disposed = false;
  260. (async () => {
  261. const u = await listen<{ count: number }>(EVT_TASKS_IMPORTED, (e) => {
  262. showStatus("success", "导入完成", `已写入 ${e.payload.count} 条任务`);
  263. void reloadTasks();
  264. });
  265. if (disposed) u();
  266. else unlisten = u;
  267. })();
  268. return () => {
  269. disposed = true;
  270. unlisten?.();
  271. };
  272. }, [showStatus, reloadTasks]);
  273. /**
  274. * 监听 landing 三个事件:
  275. * - task-tags-extracted:中间页抓到标签 → 写库 + reload
  276. * - task-page-ready:跳到最终 URL 完成 → 解锁按钮 + 状态栏提示
  277. * - task-page-timeout:未找到 Visit Site → ask() 弹「重试 / 取消」
  278. * 全部按 activeIdRef 过滤,避免给已切走的旧任务弹无效提示。
  279. */
  280. useEffect(() => {
  281. let unlistenTags: UnlistenFn | null = null;
  282. let unlistenSite: UnlistenFn | null = null;
  283. let unlistenReady: UnlistenFn | null = null;
  284. let unlistenTimeout: UnlistenFn | null = null;
  285. let unlistenPageStarted: UnlistenFn | null = null;
  286. let unlistenPageLoaded: UnlistenFn | null = null;
  287. let disposed = false;
  288. (async () => {
  289. const u1 = await listen<TaskTagsExtractedPayload>(
  290. EVT_TASK_TAGS_EXTRACTED,
  291. async (e) => {
  292. const { taskId, tags } = e.payload;
  293. setAssets((assets) => {
  294. if (assets?.task_id !== taskId) {
  295. return assets;
  296. }
  297. return { ...assets, tags };
  298. });
  299. try {
  300. await updateTaskTags(taskId, tags);
  301. // await reloadTasks();
  302. showStatus("success", "已提取标签", tags || "(空)", false);
  303. } catch (err) {
  304. console.error("写入 tags 失败:", err);
  305. showStatus("error", "写入标签失败", String(err));
  306. }
  307. },
  308. );
  309. const u2 = await listen<TaskSiteUrlFoundPayload>(
  310. EVT_TASK_SITE_URL_FOUND,
  311. async (e) => {
  312. const { taskId, url } = e.payload;
  313. if (!url) return;
  314. setAssets((assets) => {
  315. if (assets?.task_id !== taskId) {
  316. return assets;
  317. }
  318. return { ...assets, site_url: url };
  319. });
  320. try {
  321. await updateTaskSiteUrl(taskId, url);
  322. // await reloadTasks();
  323. } catch (err) {
  324. console.error("写入 site_url 失败:", err);
  325. }
  326. },
  327. );
  328. const u3 = await listen<TaskPageReadyPayload>(EVT_TASK_PAGE_READY, (e) => {
  329. const { taskId } = e.payload;
  330. // 只对当前激活任务生效,避免切走后还把按钮放开
  331. if (activeIdRef.current !== taskId) return;
  332. setPageReady(true);
  333. setIsWorking(false);
  334. showStatus("success", "页面就绪", "已加载到最终 URL,可截图 / 录制", false);
  335. });
  336. const u4 = await listen<TaskPageTimeoutPayload>(
  337. EVT_TASK_PAGE_TIMEOUT,
  338. async (e) => {
  339. const { taskId, url, reason } = e.payload;
  340. if (activeIdRef.current !== taskId) return;
  341. // 弹原生 ask 对话框:确认 = 重试;取消 = 保持禁用
  342. const retry = await ask(`${reason}\n\n是否刷新重试?`, {
  343. title: "未找到 网站 链接",
  344. kind: "warning",
  345. okLabel: "重试",
  346. cancelLabel: "取消",
  347. }).catch(() => false);
  348. if (retry) {
  349. // 重新 navigate:Rust 端会重置 page_stage=Initial 并重新轮询
  350. setPageReady(false);
  351. await loadInWebview(taskId, url);
  352. } else {
  353. setActiveId(null);
  354. setIsLoading(false);
  355. setIsWorking(false);
  356. // 用户取消:保持按钮禁用,写状态栏告知
  357. showStatus("warning", "已取消任务", "重新点击任务可继续尝试");
  358. }
  359. },
  360. );
  361. const u5 = await listen("page_started", () => {
  362. setIsLoading(true);
  363. })
  364. const u6 = await listen("page_loaded", () => {
  365. setIsLoading(false);
  366. });
  367. if (disposed) {
  368. u1();
  369. u2();
  370. u3();
  371. u4();
  372. u5();
  373. u6();
  374. } else {
  375. unlistenTags = u1;
  376. unlistenSite = u2;
  377. unlistenReady = u3;
  378. unlistenTimeout = u4;
  379. unlistenPageStarted = u5;
  380. unlistenPageLoaded = u6;
  381. }
  382. })();
  383. return () => {
  384. disposed = true;
  385. unlistenTags?.();
  386. unlistenSite?.();
  387. unlistenReady?.();
  388. unlistenTimeout?.();
  389. unlistenPageStarted?.();
  390. unlistenPageLoaded?.();
  391. };
  392. }, [showStatus, setAssets]);
  393. /** 工具栏 "完成" 按钮:把当前激活任务标记为已完成(status=1)→ 刷新 → 清空选中 */
  394. const handleCompleteTask = useCallback(async () => {
  395. if (!activeId) return;
  396. try {
  397. await markTaskDone(activeId);
  398. showStatus("success", "任务已完成", `任务 ${activeId} 已从列表移除`, false);
  399. setActiveId(null);
  400. setIsLoading(false);
  401. setIsWorking(false);
  402. await asyncSleep(500);
  403. const list = await reloadTasks();
  404. // 若已无任务,自动再拉起一次导入窗口(用户可继续粘贴)
  405. if (list.length === 0) {
  406. await openImportWindow();
  407. }
  408. } catch (e) {
  409. showStatus("error", "标记完成失败", String(e));
  410. }
  411. }, [activeId, reloadTasks, openImportWindow, showStatus]);
  412. /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */
  413. const handleManualCapture = useCallback(async () => {
  414. // 必须已加载到最终页才能截图,否则截到的是中间页
  415. if (!activeId || !pageReady) return;
  416. try {
  417. await capturePage(activeId);
  418. } catch (e) {
  419. // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底
  420. console.error("手动截图失败:", e);
  421. }
  422. }, [activeId, pageReady]);
  423. // 订阅 Rust 端的截图事件,统一走 showStatus(原生 dialog + 状态栏)
  424. useEffect(() => {
  425. let unlistenFinished: UnlistenFn | null = null;
  426. let unlistenFailed: UnlistenFn | null = null;
  427. let disposed = false;
  428. (async () => {
  429. const u1 = await listen<ScreenshotFinishedPayload>(EVT_SCREENSHOT_FINISHED, (e) => {
  430. const { taskId, path, auto } = e.payload;
  431. showStatus(
  432. "success",
  433. auto ? "自动截图完成" : "截图完成",
  434. `任务 ${taskId} → ${path}`, false
  435. );
  436. // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
  437. if (activeIdRef.current === taskId) {
  438. void refreshAssets(taskId);
  439. }
  440. });
  441. const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
  442. const { taskId, auto, error } = e.payload;
  443. showStatus(
  444. "error",
  445. auto ? "自动截图失败" : "截图失败",
  446. `任务 ${taskId}:${error}`,
  447. );
  448. });
  449. if (disposed) {
  450. u1();
  451. u2();
  452. } else {
  453. unlistenFinished = u1;
  454. unlistenFailed = u2;
  455. }
  456. })();
  457. return () => {
  458. disposed = true;
  459. unlistenFinished?.();
  460. unlistenFailed?.();
  461. };
  462. }, [showStatus, refreshAssets]);
  463. // 订阅录制状态机变化,驱动按钮 UI 切换
  464. useEffect(() => {
  465. return subscribeRecordState(setRecordState);
  466. }, []);
  467. // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一走 showStatus。
  468. // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。
  469. useEffect(() => {
  470. let unlistenFinished: UnlistenFn | null = null;
  471. let unlistenFailed: UnlistenFn | null = null;
  472. let disposed = false;
  473. (async () => {
  474. const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, async (e) => {
  475. const { taskId, path } = e.payload;
  476. showStatus("success", "录制完成", `任务 ${taskId} → ${path}`, false);
  477. if (activeIdRef.current === taskId) {
  478. await refreshAssets(taskId);
  479. await asyncSleep(100);
  480. handleCompleteTask();
  481. }
  482. });
  483. const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
  484. const { taskId, error } = e.payload;
  485. showStatus("error", "录制失败", `任务 ${taskId}:${error}`);
  486. });
  487. if (disposed) {
  488. u1();
  489. u2();
  490. } else {
  491. unlistenFinished = u1;
  492. unlistenFailed = u2;
  493. }
  494. })();
  495. return () => {
  496. disposed = true;
  497. unlistenFinished?.();
  498. unlistenFailed?.();
  499. };
  500. }, [showStatus, refreshAssets]);
  501. // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream
  502. useEffect(() => {
  503. return () => {
  504. void cancelRecording();
  505. };
  506. }, []);
  507. /** 开始按钮:仅在 idle + 已选任务 + 页面已就绪 时可点;Rust 端 spawn ffmpeg 子进程录屏 */
  508. const handleStartRecord = useCallback(async () => {
  509. if (recordState !== "idle" || !activeId || !pageReady) return;
  510. try {
  511. await startRecording(activeId, contentSize.w, contentSize.h);
  512. } catch (e) {
  513. showStatus("error", "开始录制失败", String(e));
  514. }
  515. }, [recordState, activeId, pageReady, contentSize, showStatus]);
  516. /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
  517. const handleStopRecord = useCallback(async () => {
  518. if (recordState !== "recording") return;
  519. try {
  520. await stopRecording();
  521. // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹
  522. } catch (e) {
  523. // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理
  524. showStatus("error", "停止录制失败", String(e));
  525. }
  526. }, [recordState, showStatus]);
  527. /** 全局快捷键派发:F9 = 开始,F11 = 停止 */
  528. const handleShortcut = useCallback(
  529. async (action: "start" | "stop") => {
  530. try {
  531. if (action === "start") {
  532. if (recordState !== "idle") return;
  533. if (!activeId) {
  534. showStatus("warning", "无法开始录制", "请先在左栏选择一个任务");
  535. return;
  536. }
  537. if (!pageReady) {
  538. showStatus("warning", "无法开始录制", "等待最终页加载完成后再试");
  539. return;
  540. }
  541. await startRecording(activeId, contentSize.w, contentSize.h);
  542. } else if (action === "stop") {
  543. if (recordState === "recording") {
  544. await stopRecording();
  545. }
  546. }
  547. } catch (e) {
  548. showStatus("error", "快捷键操作失败", String(e));
  549. }
  550. },
  551. [recordState, activeId, pageReady, contentSize, showStatus],
  552. );
  553. // 订阅 Rust 端的全局快捷键事件 (F9 / F11)
  554. useEffect(() => {
  555. let unlisten: UnlistenFn | null = null;
  556. let disposed = false;
  557. (async () => {
  558. const u = await listen<RecordShortcutPayload>(EVT_RECORD_SHORTCUT, (e) => {
  559. void handleShortcut(e.payload.action);
  560. });
  561. if (disposed) u();
  562. else unlisten = u;
  563. })();
  564. return () => {
  565. disposed = true;
  566. unlisten?.();
  567. };
  568. }, [handleShortcut]);
  569. /** 打开当前任务产物所在的文件夹:优先 reveal mp4 → png → 兜底打开 screenshots 目录 */
  570. const handleOpenFolder = useCallback(async () => {
  571. if (!assets) return;
  572. const target = assets.recording_exists
  573. ? assets.recording_path
  574. : assets.screenshot_exists
  575. ? assets.screenshot_path
  576. : assets.screenshots_dir;
  577. try {
  578. await revealItemInDir(target);
  579. } catch (e) {
  580. showStatus("error", "打开文件夹失败", String(e));
  581. }
  582. }, [assets, showStatus]);
  583. /** 在新 WebviewWindow 中预览截图 */
  584. const handlePreviewImage = useCallback(async () => {
  585. if (!assets?.screenshot_exists) return;
  586. try {
  587. await invoke("open_preview_window", {
  588. path: assets.screenshot_path,
  589. kind: "image",
  590. });
  591. } catch (e) {
  592. showStatus("error", "图片预览失败", String(e));
  593. }
  594. }, [assets, showStatus]);
  595. /** 在新 WebviewWindow 中预览录制 mp4 */
  596. const handlePreviewVideo = useCallback(async () => {
  597. if (!assets?.recording_exists) return;
  598. try {
  599. await invoke("open_preview_window", {
  600. path: assets.recording_path,
  601. kind: "video",
  602. });
  603. } catch (e) {
  604. showStatus("error", "视频预览失败", String(e));
  605. }
  606. }, [assets, showStatus]);
  607. // 开始按钮的图标 / tooltip
  608. const startConfig = (() => {
  609. if (recordState === "stopping") {
  610. return { icon: <LoadingOutlined />, tip: "正在停止…" };
  611. }
  612. if (recordState === "recording") {
  613. // 视觉上保持 Play 图标,但 disabled
  614. return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
  615. }
  616. if (!activeId) {
  617. return { icon: <PlayCircleFilled />, tip: "请先选择任务" };
  618. }
  619. if (!pageReady) {
  620. return { icon: <LoadingOutlined />, tip: "等待最终 URL 加载完成…" };
  621. }
  622. return { icon: <PlayCircleFilled />, tip: "开始录制(F9)" };
  623. })();
  624. // 开始按钮仅在 idle + 已选任务 + 最终页已就绪时可点
  625. const startDisabled = recordState !== "idle" || !activeId || !pageReady;
  626. // 停止按钮仅在 recording 时可点
  627. const stopDisabled = recordState !== "recording";
  628. const [collapsed, setCollapsed] = useState(false);
  629. const applyWorkAreaSize = useCallback(async (w?: number, h?: number) => {
  630. if (w && h) {
  631. setContentSize({ w, h });
  632. contentSizeRef.current = { w, h };
  633. } else {
  634. w = contentSize.w;
  635. h = contentSize.h;
  636. }
  637. try {
  638. // 先调主窗口,避免子 webview 越界
  639. await invoke("set_window_size", {
  640. width: w + (collapsed ? 30 : 180) + 4,
  641. height: h + 48 + 24 + 4,
  642. contentX: (collapsed ? 30 : 180) + 2,
  643. contentY: 50,
  644. contentWidth: w,
  645. contentHeight: h
  646. });
  647. } catch (e) {
  648. console.error("调整尺寸失败 ", e);
  649. }
  650. }, [contentSize, setContentSize]);
  651. useEffect(() => {
  652. applyWorkAreaSize();
  653. }, []);
  654. // 录制状态机(RecordState 类型已抽到 src/types/ipc.ts),录制功能下一阶段接入
  655. const handleCollaspsed = useCallback(async (c: boolean) => {
  656. setCollapsed(c);
  657. try {
  658. // 先调主窗口,避免子 webview 越界
  659. await invoke("set_window_size", {
  660. width: contentSize.w + (collapsed ? 30 : 180),
  661. height: contentSize.h + 60,
  662. contentWidth: contentSize.w,
  663. contentHeight: contentSize.h
  664. });
  665. } catch (e) {
  666. console.error("调整尺寸失败:", e);
  667. }
  668. }, [setCollapsed]);
  669. const {
  670. token: { colorBgContainer },
  671. } = theme.useToken();
  672. // 当前窗口句柄,用于自定义标题栏的最小化 / 关闭
  673. const appWindow = useMemo(() => getCurrentWindow(), []);
  674. const handleAppClose = useCallback(async () => {
  675. try {
  676. await appWindow.close();
  677. } catch (e) {
  678. console.error("appWindow.close 失败:", e);
  679. }
  680. }, [appWindow]);
  681. const handleAppMinimize = useCallback(async () => {
  682. try {
  683. await appWindow.minimize();
  684. } catch (e) {
  685. console.error("appWindow.minimize 失败:", e);
  686. }
  687. }, [appWindow]);
  688. const handleBack = useCallback(() => {
  689. invoke("webview_history_action", { action: "back" });
  690. }, []);
  691. const handleRefresh = useCallback(() => {
  692. invoke("webview_history_action", { action: "reload" });
  693. }, []);
  694. return (
  695. <Layout className="m-0 p-0 h-full overflow-hidden">
  696. {/* data-tauri-drag-region:Tauri 2 原生支持,webview 层会自动 hook mousedown
  697. * 触发系统级拖窗,比手写 startDragging 更稳定(Windows 尤其如此)。 */}
  698. <Header
  699. className="app-title h-8 select-none"
  700. style={{ paddingLeft: 8, paddingRight: 8 }}
  701. data-tauri-drag-region
  702. >
  703. <div className="flex h-full flex-row items-center" data-tauri-drag-region>
  704. <Space size={4}>
  705. <Button
  706. className="app-close"
  707. icon={<CloseOutlined />}
  708. // size="large"
  709. type="text"
  710. data-tauri-drag-region="no-drag"
  711. onClick={handleAppClose}
  712. />
  713. <Button
  714. className="app-minimize"
  715. icon={<MinusOutlined />}
  716. // size="large"
  717. type="text"
  718. data-tauri-drag-region="no-drag"
  719. onClick={handleAppMinimize}
  720. />
  721. </Space>
  722. <div className="w-8" />
  723. <Button
  724. icon={<LeftOutlined />}
  725. data-tauri-drag-region="no-drag"
  726. onClick={handleBack}
  727. />
  728. <Button
  729. icon={<RedoOutlined />}
  730. data-tauri-drag-region="no-drag"
  731. onClick={handleRefresh}
  732. />
  733. <Divider orientation="vertical" />
  734. <Tooltip
  735. title={
  736. !activeId
  737. ? "请先选择任务"
  738. : !pageReady
  739. ? "等待最终 URL 加载完成…"
  740. : "截图整页(覆盖之前的自动截图)"
  741. }
  742. placement="right"
  743. >
  744. <Button
  745. icon={<PictureOutlined />}
  746. disabled={!activeId || !pageReady}
  747. onClick={handleManualCapture}
  748. />
  749. </Tooltip>
  750. <Divider orientation="vertical" />
  751. {/* 开始按钮:仅 idle + 已选任务时可点 */}
  752. <Tooltip title={startConfig.tip} placement="right">
  753. <Button
  754. icon={startConfig.icon}
  755. disabled={startDisabled}
  756. onClick={() => void handleStartRecord()}
  757. />
  758. </Tooltip>
  759. {/* 停止按钮:仅 recording 时可用 */}
  760. <Tooltip
  761. title={
  762. stopDisabled
  763. ? "无进行中的录制"
  764. : "停止录制并落盘 mp4(F11)"
  765. }
  766. placement="right"
  767. >
  768. <Button
  769. icon={<StopOutlined />}
  770. disabled={stopDisabled}
  771. onClick={() => void handleStopRecord()}
  772. />
  773. </Tooltip>
  774. <Divider orientation="vertical" />
  775. {/* 完成按钮:把当前任务在 sqlite 标记为 status=1,从主列表移除 */}
  776. <Tooltip
  777. title={activeId ? "标记当前任务为已完成" : "请先选择任务"}
  778. placement="right"
  779. >
  780. <Button
  781. type="primary"
  782. icon={<CheckCircleOutlined />}
  783. disabled={!activeId || !assets?.recording_exists || !assets.screenshot_exists}
  784. onClick={() => void handleCompleteTask()}
  785. >完成</Button>
  786. </Tooltip>
  787. {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
  788. <Tooltip
  789. title={
  790. !activeId
  791. ? "请先选择任务"
  792. : assets?.recording_exists
  793. ? "在文件管理器中显示录制视频"
  794. : assets?.screenshot_exists
  795. ? "在文件管理器中显示截图"
  796. : "打开截图目录"
  797. }
  798. placement="right"
  799. >
  800. <Button
  801. icon={<FolderOpenOutlined />}
  802. disabled={!assets}
  803. onClick={() => void handleOpenFolder()}
  804. />
  805. </Tooltip>
  806. {/* 预览截图:仅当 png 存在 */}
  807. <Tooltip
  808. title={
  809. !assets?.screenshot_exists
  810. ? "暂无截图可预览"
  811. : "在新窗口预览截图"
  812. }
  813. placement="right"
  814. >
  815. <Button
  816. icon={<FileImageOutlined />}
  817. disabled={!assets?.screenshot_exists}
  818. onClick={() => void handlePreviewImage()}
  819. />
  820. </Tooltip>
  821. {/* 预览视频:仅当 mp4 存在 */}
  822. <Tooltip
  823. title={
  824. !assets?.recording_exists
  825. ? "暂无录制视频可预览"
  826. : "在新窗口预览录制视频"
  827. }
  828. placement="right"
  829. >
  830. <Button
  831. icon={<VideoCameraOutlined />}
  832. disabled={!assets?.recording_exists}
  833. onClick={() => void handlePreviewVideo()}
  834. />
  835. </Tooltip>
  836. {isWorking && <Tooltip title="停止当前任务" placement="right">
  837. <Button icon={<CloseOutlined color="red" style={{ color: 'red' }} />} color="red" onClick={handleStopTask} />
  838. </Tooltip>}
  839. <div className="flex-1">
  840. </div>
  841. {!customMode ? (
  842. <>
  843. {SIZE_PRESETS.map((p) => (
  844. <Button
  845. key={p.label}
  846. type={contentSize.w == p.w && contentSize.h == p.h ? "primary" : undefined}
  847. onClick={contentSize.w != p.w || contentSize.h != p.h ? () => applyWorkAreaSize(p.w, p.h) as any : undefined}
  848. >
  849. {p.label}
  850. </Button>
  851. ))}
  852. {isCustomSize() ?
  853. <Button type="primary" onClick={() => setCustomMode(true)}>
  854. 自定义({contentSize.w}x{contentSize.h})
  855. </Button> : <Button onClick={() => setCustomMode(true)}>
  856. 自定义
  857. </Button>}
  858. </>
  859. ) : (
  860. <>
  861. <InputNumber
  862. min={200}
  863. max={3840}
  864. value={contentSize.w}
  865. onChange={(v) => setContentSize({ w: v || 0, h: contentSize.h })}
  866. style={{ width: 80 }}
  867. />
  868. <span className="text-gray-7 select-none">×</span>
  869. <InputNumber
  870. min={200}
  871. max={2160}
  872. value={contentSize.h}
  873. onChange={(v) => setContentSize({ w: contentSize.w, h: v || 0 })}
  874. style={{ width: 80 }}
  875. />
  876. <Button
  877. type="primary"
  878. onClick={() => {
  879. setCustomMode(false);
  880. applyWorkAreaSize();
  881. }}
  882. >
  883. 应用
  884. </Button>
  885. <Button onClick={() => {
  886. setContentSize(contentSizeRef.current)
  887. setCustomMode(false);
  888. }}>
  889. 取消
  890. </Button>
  891. </>
  892. )}
  893. </div>
  894. </Header>
  895. <Layout>
  896. <Sider className="select-none" collapsible collapsed={collapsed} onCollapse={(c) => handleCollaspsed(c)} width={180} style={{ background: colorBgContainer }}>
  897. <div className="h-full w-full flex-row">
  898. <div className="flex justify-between items-center text-md pl-3 font-bold text-white"><OrderedListOutlined />{collapsed ? '' : '所有任务'}<Button icon={<PlusOutlined />} onClick={openImportWindow} /> </div>
  899. <div className="flex-1 overflow-y-auto">
  900. <Menu
  901. mode="vertical"
  902. defaultSelectedKeys={[activeId || '']}
  903. selectedKeys={[activeId || '']}
  904. style={{ borderInlineEnd: 0, height: '100%' }}
  905. onClick={isWorking ? undefined : handleSelectTask}
  906. >
  907. {tasks.map((t) => <Menu.Item icon={isWorking && t.id == activeId ? <LoadingOutlined /> : <LinkOutlined />} key={t.id}>
  908. {t.id}
  909. </Menu.Item>
  910. )}
  911. </Menu>
  912. </div>
  913. </div>
  914. </Sider>
  915. <Layout className="flex flex-row">
  916. <Content className="w-full m-0 p-1 flex-1" />
  917. <div className="w-full h-6 flex items-center px-2 text-gray-3">
  918. <div className="flex-1">任务:{activeId} {isLoading && <LoadingOutlined />}</div>
  919. <div className="flex-1 truncate">{assets?.site_url || activeTaskRef.current?.url}</div>
  920. <div className="flex-1 truncate">{assets?.tags}</div>
  921. <div className="flex-1 text-right">{statusText}</div>
  922. <div className="bg-gray-6 rounded-full w-3 h-3 m-2" style={{ backgroundColor: isWorking ? '#ff0' : statusColor }} />
  923. </div>
  924. </Layout>
  925. </Layout>
  926. </Layout >
  927. );
  928. }
  929. export default App;