App.tsx 26 KB

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