App.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. import { useCallback, useEffect, useRef, useState } from "react";
  2. import { invoke } from "@tauri-apps/api/core";
  3. import { listen, type UnlistenFn } from "@tauri-apps/api/event";
  4. import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
  5. import { Button, Divider, InputNumber, notification, Tooltip } from "antd";
  6. import { mockTasks, type Task } from "./mocks/tasks";
  7. import {
  8. EVT_RECORDING_FAILED,
  9. EVT_RECORDING_FINISHED,
  10. EVT_RECORD_SHORTCUT,
  11. EVT_SCREENSHOT_FAILED,
  12. EVT_SCREENSHOT_FINISHED,
  13. type RecordingFailedPayload,
  14. type RecordingFinishedPayload,
  15. type RecordShortcutPayload,
  16. type RecordState,
  17. type ScreenshotFailedPayload,
  18. type ScreenshotFinishedPayload,
  19. type TaskAssets,
  20. } from "./types/ipc";
  21. import { capturePage } from "./lib/capture";
  22. import {
  23. cancelRecording,
  24. getRecordState,
  25. startRecording,
  26. stopRecording,
  27. subscribeRecordState,
  28. } from "./lib/recorder";
  29. import "./App.css";
  30. import {
  31. FileImageOutlined,
  32. FolderOpenOutlined,
  33. LoadingOutlined,
  34. PictureOutlined,
  35. PlayCircleFilled,
  36. StopOutlined,
  37. VideoCameraOutlined,
  38. } from "@ant-design/icons";
  39. /**
  40. * 与 Rust 端常量保持一致(src-tauri/src/lib.rs):
  41. * LEFT_PANEL_WIDTH = 180
  42. * TOOLBAR_HEIGHT = 48
  43. *
  44. * 导出以避免 noUnusedLocals 误报;后续如有组件需要可直接 import。
  45. */
  46. export const LEFT_PANEL_WIDTH = 180;
  47. export const TOOLBAR_HEIGHT = 48;
  48. /** 工具栏右侧的尺寸预设(统一横屏:宽 × 高) */
  49. const SIZE_PRESETS = [
  50. { label: "1920×1080", w: 1920, h: 1080 },
  51. { label: "1024×768", w: 1024, h: 768 },
  52. { label: "1280×720", w: 1280, h: 720 },
  53. ];
  54. /** 点条目:把 url 加载到子 webview,并把 task_id 一并下发(用于截图命名 + 自动截图回调) */
  55. async function loadInWebview(taskId: string, url: string) {
  56. try {
  57. await invoke("navigate_webview", { taskId, url });
  58. } catch (e) {
  59. console.error("webview 加载失败:", e);
  60. }
  61. }
  62. /** 点条目右侧图标:调系统浏览器打开 */
  63. async function openInBrowser(url: string) {
  64. try {
  65. await openUrl(url);
  66. } catch (e) {
  67. console.error("打开系统浏览器失败:", e);
  68. }
  69. }
  70. function App() {
  71. // 当前激活的任务 id(仅用于左栏视觉高亮)
  72. const [activeId, setActiveId] = useState<string | null>(null);
  73. const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 });
  74. const [customMode, setCustomMode] = useState(false);
  75. // 录制状态机(由 lib/recorder.ts 内部单例维护,这里订阅以驱动按钮)
  76. const [recordState, setRecordState] = useState<RecordState>(getRecordState());
  77. // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
  78. const [assets, setAssets] = useState<TaskAssets | null>(null);
  79. // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
  80. // 导致 listen 频繁重订)
  81. const activeIdRef = useRef<string | null>(activeId);
  82. useEffect(() => {
  83. activeIdRef.current = activeId;
  84. }, [activeId]);
  85. // antd 6 notification 必须用 hook + contextHolder 才能正确取到主题
  86. const [notifyApi, notifyContext] = notification.useNotification();
  87. function handleSelectTask(t: Task) {
  88. setActiveId(t.id);
  89. void loadInWebview(t.id, t.url);
  90. }
  91. /** 重新查询指定任务的产物文件信息(截图 + mp4) */
  92. const refreshAssets = useCallback(async (taskId: string | null) => {
  93. if (!taskId) {
  94. setAssets(null);
  95. return;
  96. }
  97. try {
  98. const a = await invoke<TaskAssets>("query_task_assets", { taskId });
  99. setAssets(a);
  100. } catch (e) {
  101. console.error("query_task_assets 失败:", e);
  102. setAssets(null);
  103. }
  104. }, []);
  105. // activeId 变化时刷新 assets(切换任务后右侧按钮要按新任务的文件存在性变 enabled/disabled)
  106. useEffect(() => {
  107. void refreshAssets(activeId);
  108. }, [activeId, refreshAssets]);
  109. /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */
  110. const handleManualCapture = useCallback(async () => {
  111. if (!activeId) return;
  112. try {
  113. await capturePage(activeId);
  114. } catch (e) {
  115. // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底
  116. console.error("手动截图失败:", e);
  117. }
  118. }, [activeId]);
  119. // 订阅 Rust 端的截图事件,统一用 notification 提示
  120. useEffect(() => {
  121. let unlistenFinished: UnlistenFn | null = null;
  122. let unlistenFailed: UnlistenFn | null = null;
  123. let disposed = false;
  124. (async () => {
  125. const u1 = await listen<ScreenshotFinishedPayload>(EVT_SCREENSHOT_FINISHED, (e) => {
  126. const { taskId, path, auto } = e.payload;
  127. notifyApi.success({
  128. message: auto ? "自动截图完成" : "截图完成",
  129. description: `任务 ${taskId} → ${path}`,
  130. duration: 4,
  131. });
  132. // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
  133. if (activeIdRef.current === taskId) {
  134. void refreshAssets(taskId);
  135. }
  136. });
  137. const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
  138. const { taskId, auto, error } = e.payload;
  139. notifyApi.error({
  140. message: auto ? "自动截图失败" : "截图失败",
  141. description: `任务 ${taskId}:${error}`,
  142. duration: 6,
  143. });
  144. });
  145. if (disposed) {
  146. u1();
  147. u2();
  148. } else {
  149. unlistenFinished = u1;
  150. unlistenFailed = u2;
  151. }
  152. })();
  153. return () => {
  154. disposed = true;
  155. unlistenFinished?.();
  156. unlistenFailed?.();
  157. };
  158. }, [notifyApi, refreshAssets]);
  159. // 订阅录制状态机变化,驱动按钮 UI 切换
  160. useEffect(() => {
  161. return subscribeRecordState(setRecordState);
  162. }, []);
  163. // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一 notification 提示。
  164. // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。
  165. useEffect(() => {
  166. let unlistenFinished: UnlistenFn | null = null;
  167. let unlistenFailed: UnlistenFn | null = null;
  168. let disposed = false;
  169. (async () => {
  170. const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, (e) => {
  171. const { taskId, path } = e.payload;
  172. notifyApi.success({
  173. message: "录制完成",
  174. description: `任务 ${taskId} → ${path}`,
  175. duration: 5,
  176. });
  177. if (activeIdRef.current === taskId) {
  178. void refreshAssets(taskId);
  179. }
  180. });
  181. const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
  182. const { taskId, error } = e.payload;
  183. notifyApi.error({
  184. message: "录制失败",
  185. description: `任务 ${taskId}:${error}`,
  186. duration: 6,
  187. });
  188. });
  189. if (disposed) {
  190. u1();
  191. u2();
  192. } else {
  193. unlistenFinished = u1;
  194. unlistenFailed = u2;
  195. }
  196. })();
  197. return () => {
  198. disposed = true;
  199. unlistenFinished?.();
  200. unlistenFailed?.();
  201. };
  202. }, [notifyApi, refreshAssets]);
  203. // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream
  204. useEffect(() => {
  205. return () => {
  206. void cancelRecording();
  207. };
  208. }, []);
  209. /** 开始按钮:仅在 idle + 已选任务 时可点;Rust 端 spawn ffmpeg 子进程录屏 */
  210. const handleStartRecord = useCallback(async () => {
  211. if (recordState !== "idle" || !activeId) return;
  212. try {
  213. await startRecording(activeId);
  214. } catch (e) {
  215. notifyApi.error({
  216. message: "开始录制失败",
  217. description: String(e),
  218. duration: 6,
  219. });
  220. }
  221. }, [recordState, activeId, notifyApi]);
  222. /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
  223. const handleStopRecord = useCallback(async () => {
  224. if (recordState !== "recording") return;
  225. try {
  226. await stopRecording();
  227. // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹
  228. } catch (e) {
  229. // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理
  230. notifyApi.error({
  231. message: "停止录制失败",
  232. description: String(e),
  233. duration: 6,
  234. });
  235. }
  236. }, [recordState, notifyApi]);
  237. /** 全局快捷键派发:F9 = 开始,F11 = 停止 */
  238. const handleShortcut = useCallback(
  239. async (action: "start" | "stop") => {
  240. try {
  241. if (action === "start") {
  242. if (recordState !== "idle") return;
  243. if (!activeId) {
  244. notifyApi.warning({
  245. message: "无法开始录制",
  246. description: "请先在左栏选择一个任务",
  247. duration: 4,
  248. });
  249. return;
  250. }
  251. await startRecording(activeId);
  252. } else if (action === "stop") {
  253. if (recordState === "recording") {
  254. await stopRecording();
  255. }
  256. }
  257. } catch (e) {
  258. notifyApi.error({
  259. message: "快捷键操作失败",
  260. description: String(e),
  261. duration: 6,
  262. });
  263. }
  264. },
  265. [recordState, activeId, notifyApi],
  266. );
  267. // 订阅 Rust 端的全局快捷键事件 (F9 / F11)
  268. useEffect(() => {
  269. let unlisten: UnlistenFn | null = null;
  270. let disposed = false;
  271. (async () => {
  272. const u = await listen<RecordShortcutPayload>(EVT_RECORD_SHORTCUT, (e) => {
  273. void handleShortcut(e.payload.action);
  274. });
  275. if (disposed) u();
  276. else unlisten = u;
  277. })();
  278. return () => {
  279. disposed = true;
  280. unlisten?.();
  281. };
  282. }, [handleShortcut]);
  283. /** 打开当前任务产物所在的文件夹:优先 reveal mp4 → png → 兜底打开 screenshots 目录 */
  284. const handleOpenFolder = useCallback(async () => {
  285. if (!assets) return;
  286. const target = assets.recording_exists
  287. ? assets.recording_path
  288. : assets.screenshot_exists
  289. ? assets.screenshot_path
  290. : assets.screenshots_dir;
  291. try {
  292. await revealItemInDir(target);
  293. } catch (e) {
  294. notifyApi.error({
  295. message: "打开文件夹失败",
  296. description: String(e),
  297. duration: 6,
  298. });
  299. }
  300. }, [assets, notifyApi]);
  301. /** 在新 WebviewWindow 中预览截图 */
  302. const handlePreviewImage = useCallback(async () => {
  303. if (!assets?.screenshot_exists) return;
  304. try {
  305. await invoke("open_preview_window", {
  306. path: assets.screenshot_path,
  307. kind: "image",
  308. });
  309. } catch (e) {
  310. notifyApi.error({
  311. message: "图片预览失败",
  312. description: String(e),
  313. duration: 6,
  314. });
  315. }
  316. }, [assets, notifyApi]);
  317. /** 在新 WebviewWindow 中预览录制 mp4 */
  318. const handlePreviewVideo = useCallback(async () => {
  319. if (!assets?.recording_exists) return;
  320. try {
  321. await invoke("open_preview_window", {
  322. path: assets.recording_path,
  323. kind: "video",
  324. });
  325. } catch (e) {
  326. notifyApi.error({
  327. message: "视频预览失败",
  328. description: String(e),
  329. duration: 6,
  330. });
  331. }
  332. }, [assets, notifyApi]);
  333. // 开始按钮的图标 / tooltip
  334. const startConfig = (() => {
  335. if (recordState === "stopping") {
  336. return { icon: <LoadingOutlined />, tip: "正在停止…" };
  337. }
  338. if (recordState === "recording") {
  339. // 视觉上保持 Play 图标,但 disabled
  340. return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
  341. }
  342. return {
  343. icon: <PlayCircleFilled />,
  344. tip: activeId ? "开始录制(F9)" : "请先选择任务",
  345. };
  346. })();
  347. // 开始按钮仅在 idle 且已选任务时可点;其它状态 disabled
  348. const startDisabled = recordState !== "idle" || !activeId;
  349. // 停止按钮仅在 recording 时可点
  350. const stopDisabled = recordState !== "recording";
  351. const applyWorkAreaSize = useCallback(async (w: number, h: number) => {
  352. try {
  353. // 先调主窗口,避免子 webview 越界
  354. await invoke("set_window_size", {
  355. width: w + 180,
  356. height: h + 46,
  357. contentWidth: w,
  358. contentHeight: h
  359. });
  360. setContentSize({ w, h })
  361. } catch (e) {
  362. console.error("调整尺寸失败:", e);
  363. }
  364. }, [setContentSize]);
  365. // 首次挂载时按默认 contentSize 调整窗口;依赖项故意为空(仅初始化用)。
  366. useEffect(() => {
  367. applyWorkAreaSize(contentSize.w, contentSize.h);
  368. // eslint-disable-next-line react-hooks/exhaustive-deps
  369. }, []);
  370. // 录制状态机(RecordState 类型已抽到 src/types/ipc.ts),录制功能下一阶段接入
  371. return (
  372. <div className="flex h-screen w-screen overflow-hidden select-none">
  373. {/* antd notification 的渲染容器 —— 必须挂在树中 */}
  374. {notifyContext}
  375. {/* ===== 左栏:任务列表(180px) ===== */}
  376. <aside className="w-[180px] shrink-0 border-r border-gray-4 bg-gray-2 h-full flex flex-col">
  377. <div className="px-3 py-2 text-xs text-gray-7 border-b border-gray-4 select-none">
  378. 任务列表
  379. </div>
  380. <div className="flex-1 overflow-y-auto">
  381. <ul>
  382. {mockTasks.map((t) => {
  383. const active = activeId === t.id;
  384. return (
  385. <li
  386. key={t.id}
  387. className={
  388. "flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors " +
  389. (active
  390. ? "bg-primary-1 text-primary-7"
  391. : "hover:bg-gray-3 text-gray-10")
  392. }
  393. onClick={() => handleSelectTask(t)}
  394. >
  395. <span className="truncate">任务 {t.id}</span>
  396. <button
  397. type="button"
  398. title="在系统浏览器打开"
  399. 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"
  400. onClick={(e) => {
  401. e.stopPropagation();
  402. void openInBrowser(t.url);
  403. }}
  404. >
  405. {/* 外链图标(lucide external-link 同款 path,不引入新依赖) */}
  406. <svg
  407. width="14"
  408. height="14"
  409. viewBox="0 0 24 24"
  410. fill="none"
  411. stroke="currentColor"
  412. strokeWidth="2"
  413. strokeLinecap="round"
  414. strokeLinejoin="round"
  415. >
  416. <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
  417. <polyline points="15 3 21 3 21 9" />
  418. <line x1="10" y1="14" x2="21" y2="3" />
  419. </svg>
  420. </button>
  421. </li>
  422. );
  423. })}
  424. </ul>
  425. </div>
  426. </aside>
  427. {/* ===== 右侧主区 ===== */}
  428. <main className="flex-1 flex flex-col min-w-0">
  429. {/* 工具栏(48px) */}
  430. <header className="h-12 shrink-0 flex items-center justify-between gap-2 px-3 border-b border-gray-4 bg-gray-1">
  431. {/* 左侧:操作按钮区 */}
  432. <div className="flex items-center gap-2">
  433. <Tooltip title={activeId ? "截图整页(覆盖之前的自动截图)" : "请先选择任务"} placement="top">
  434. <Button
  435. size="small"
  436. icon={<PictureOutlined />}
  437. disabled={!activeId}
  438. onClick={() => void handleManualCapture()}
  439. />
  440. </Tooltip>
  441. <Divider type="vertical" />
  442. {/* 开始按钮:仅 idle + 已选任务时可点 */}
  443. <Tooltip title={startConfig.tip} placement="top">
  444. <Button
  445. size="small"
  446. icon={startConfig.icon}
  447. disabled={startDisabled}
  448. onClick={() => void handleStartRecord()}
  449. />
  450. </Tooltip>
  451. {/* 停止按钮:仅 recording 时可用 */}
  452. <Tooltip
  453. title={
  454. stopDisabled
  455. ? "无进行中的录制"
  456. : "停止录制并落盘 mp4(F11)"
  457. }
  458. placement="top"
  459. >
  460. <Button
  461. size="small"
  462. icon={<StopOutlined />}
  463. disabled={stopDisabled}
  464. onClick={() => void handleStopRecord()}
  465. />
  466. </Tooltip>
  467. <Divider type="vertical" />
  468. {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
  469. <Tooltip
  470. title={
  471. !activeId
  472. ? "请先选择任务"
  473. : assets?.recording_exists
  474. ? "在文件管理器中显示录制视频"
  475. : assets?.screenshot_exists
  476. ? "在文件管理器中显示截图"
  477. : "打开截图目录"
  478. }
  479. placement="top"
  480. >
  481. <Button
  482. size="small"
  483. icon={<FolderOpenOutlined />}
  484. disabled={!assets}
  485. onClick={() => void handleOpenFolder()}
  486. />
  487. </Tooltip>
  488. {/* 预览截图:仅当 png 存在 */}
  489. <Tooltip
  490. title={
  491. !assets?.screenshot_exists
  492. ? "暂无截图可预览"
  493. : "在新窗口预览截图"
  494. }
  495. placement="top"
  496. >
  497. <Button
  498. size="small"
  499. icon={<FileImageOutlined />}
  500. disabled={!assets?.screenshot_exists}
  501. onClick={() => void handlePreviewImage()}
  502. />
  503. </Tooltip>
  504. {/* 预览视频:仅当 mp4 存在 */}
  505. <Tooltip
  506. title={
  507. !assets?.recording_exists
  508. ? "暂无录制视频可预览"
  509. : "在新窗口预览录制视频"
  510. }
  511. placement="top"
  512. >
  513. <Button
  514. size="small"
  515. icon={<VideoCameraOutlined />}
  516. disabled={!assets?.recording_exists}
  517. onClick={() => void handlePreviewVideo()}
  518. />
  519. </Tooltip>
  520. </div>
  521. {/* 右侧:尺寸预设 / 自定义 */}
  522. <div className="flex items-center gap-2">
  523. {!customMode ? (
  524. <>
  525. {SIZE_PRESETS.map((p) => (
  526. <Button
  527. key={p.label}
  528. size="small"
  529. disabled={contentSize.w == p.w && contentSize.h == p.h}
  530. onClick={() => applyWorkAreaSize(p.w, p.h)}
  531. >
  532. {p.label}
  533. </Button>
  534. ))}
  535. <Button size="small" onClick={() => setCustomMode(true)}>
  536. 自定义
  537. </Button>
  538. </>
  539. ) : (
  540. <>
  541. <InputNumber
  542. size="small"
  543. min={200}
  544. max={3840}
  545. value={contentSize.w}
  546. onChange={(v) => setContentSize({ w: v || 0, h: contentSize.h })}
  547. style={{ width: 80 }}
  548. />
  549. <span className="text-gray-7 select-none">×</span>
  550. <InputNumber
  551. size="small"
  552. min={200}
  553. max={2160}
  554. value={contentSize.h}
  555. onChange={(v) => setContentSize({ w: contentSize.w, h: v || 0 })}
  556. style={{ width: 80 }}
  557. />
  558. <Button
  559. size="small"
  560. type="primary"
  561. onClick={() => applyWorkAreaSize(contentSize.w, contentSize.h)}
  562. >
  563. 应用
  564. </Button>
  565. <Button size="small" onClick={() => setCustomMode(false)}>
  566. 取消
  567. </Button>
  568. </>
  569. )}
  570. </div>
  571. </header>
  572. {/* 工作区占位:实际由 Rust child webview 覆盖在此区域之上 */}
  573. <section className="flex-1 bg-gray-7" />
  574. <section className="h-10 bg-gray-5 broder border-gray-7" />
  575. </main>
  576. </div>
  577. );
  578. }
  579. export default App;