lv пре 2 недеља
родитељ
комит
41f012064a
3 измењених фајлова са 119 додато и 57 уклоњено
  1. 29 3
      src-tauri/src/lib.rs
  2. 85 54
      src/App.tsx
  3. 5 0
      src/lib/utils.tsx

+ 29 - 3
src-tauri/src/lib.rs

@@ -7,11 +7,12 @@ mod recording;
 mod shortcuts;
 
 use serde::Serialize;
-use std::collections::HashMap;
 use std::sync::Mutex;
+use std::{collections::HashMap, process};
 use tauri::{
     webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl,
 };
+use windows::core::HSTRING;
 
 use recording::RecordingSession;
 
@@ -86,6 +87,11 @@ struct TaskAssets {
     recordings_dir: String,
 }
 
+#[tauri::command]
+fn app_quit(_app: AppHandle) {
+    process::exit(0);
+}
+
 /// 查询指定任务有哪些可用的产物文件,供前端:
 /// - 决定"图片预览"/"视频预览"按钮的 disabled 状态
 /// - 决定"打开文件夹"按钮的 reveal 目标(mp4 > png > screenshots 目录)
@@ -121,6 +127,15 @@ pub(crate) fn find_content_webview(app: &tauri::AppHandle) -> Result<tauri::Webv
         .ok_or_else(|| format!("找不到 webview: {CONTENT_WEBVIEW_LABEL}"))
 }
 
+#[tauri::command]
+fn clean_webview(app: tauri::AppHandle) -> Result<(), String> {
+    let parsed = url::Url::parse(INITIAL_URL)
+        .map_err(|e| format!("url ({})解析失败: {}", INITIAL_URL, e))?;
+
+    let webview = find_content_webview(&app)?;
+    webview.navigate(parsed).map_err(|e| e.to_string())
+}
+
 /// 加载新的 url 到子 webview,并把 task_id 记入 AppState,用于后续自动截图命名。
 #[tauri::command]
 fn navigate_webview(
@@ -386,26 +401,35 @@ pub fn run() {
             //                macOS 没 CDP,把 Initial 也当作 Final 处理,直接 Ready。
             let initial_url: url::Url = INITIAL_URL.parse()?;
             let app_handle_for_load = app.handle().clone();
-            let window = app.get_window("main").ok_or("主窗口未找到")?;
 
+            let window = app.get_window("main").ok_or("主窗口未找到")?;
             let content = window.add_child(
                 WebviewBuilder::new(CONTENT_WEBVIEW_LABEL, WebviewUrl::External(initial_url))
+                    .on_new_window(|url, nwf| {
+                        let url_str: HSTRING = url.to_string().into();
+                        let _ = unsafe { nwf.opener().webview.Navigate(&url_str) };
+                        tauri::webview::NewWindowResponse::Deny
+                    })
                     .on_page_load(move |_webview, payload| {
                         use tauri::webview::PageLoadEvent;
                         let app = app_handle_for_load.clone();
                         let loaded_url = payload.url().to_string();
+
                         match payload.event() {
                             PageLoadEvent::Started => {
+                                use tauri::Emitter;
+                                let _ = app.emit("page_started", payload.url().to_string());
                                 tauri::async_runtime::spawn(async move {
                                     handle_page_started(app, loaded_url).await;
                                 });
                             }
                             PageLoadEvent::Finished => {
+                                use tauri::Emitter;
+                                let _ = app.emit("page_loaded(", payload.url().to_string());
                                 tauri::async_runtime::spawn(async move {
                                     handle_page_loaded(app, loaded_url).await;
                                 });
                             }
-                            _ => {}
                         }
                     }),
                 LogicalPosition::new(0, 0),
@@ -421,7 +445,9 @@ pub fn run() {
             Ok(())
         })
         .invoke_handler(tauri::generate_handler![
+            app_quit,
             navigate_webview,
+            clean_webview,
             webview_history_action,
             set_window_size,
             query_task_assets,

+ 85 - 54
src/App.tsx

@@ -63,6 +63,7 @@ import {
   StopOutlined,
   VideoCameraOutlined,
 } from "@ant-design/icons";
+import { asyncSleep } from "./lib/utils";
 
 const { Header, Content, Sider } = Layout;
 
@@ -96,14 +97,15 @@ const SIZE_PRESETS = [
 ];
 
 
-/** 从绝对/相对路径里取最后一段文件名(同时兼容 Windows `\` 与 POSIX `/`) */
-function basename(p: string): string {
-  const parts = p.split(/[\\/]/);
-  return parts[parts.length - 1] || "";
-}
 
 /** 点条目:把 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) {
@@ -111,14 +113,6 @@ async function loadInWebview(taskId: string, url: string) {
   }
 }
 
-/** 点条目右侧图标:调系统浏览器打开 */
-// async function openInBrowser(url: string) {
-//   try {
-//     await openUrl(url);
-//   } catch (e) {
-//     console.error("打开系统浏览器失败:", e);
-//   }
-// }
 
 function App() {
   // 当前激活的任务 id(仅用于左栏视觉高亮)
@@ -154,23 +148,10 @@ function App() {
    */
   const [pageReady, setPageReady] = useState(false);
 
-  /**
-   * 截图进行中标志。覆盖两个来源:
-   *   - 手动截图:handleManualCapture 点下时置 true
-   *   - 自动截图:page-ready 触发后置 true(Rust 会在 1.5s 后跑 CDP,紧跟着的
-   *     screenshot-finished/failed 事件回 false)
-   * screenshot-finished 与 screenshot-failed 都会重置回 false,保证不会卡死。
-   */
-  const [isCapturing, setIsCapturing] = useState(false);
 
-  /**
-   * 派生「忙碌」状态,给底部状态栏 loading 图标 + 后退/刷新按钮的 disabled 用:
-   *   - 选了任务但还没就绪(landing 在查 URL / tags)→ true
-   *   - 截图进行中 → true
-   *   - 未选任务或已就绪且无截图任务 → false
-   */
-  const isLoading = activeId !== null && !pageReady;
-  const isWorking = isLoading || isCapturing;
+  const [isWorking, setIsWorking] = useState(false);
+
+  const [isLoading, setIsLoading] = useState(false);
 
   // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
   // 导致 listen 频繁重订)
@@ -209,9 +190,12 @@ function App() {
     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);
@@ -247,18 +231,24 @@ function App() {
     }
   }, [showStatus]);
 
-  function handleSelectTask({ key }: { key: string }) {
+  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) {
@@ -287,11 +277,11 @@ function App() {
       const pending = await countPendingTasks().catch(() => 0);
       const list = await reloadTasks();
       if (pending === 0 && list.length === 0) {
-        setTimeout(() =>
-          openImportWindow(), 500);
+        await asyncSleep(500);
+        openImportWindow();
       }
     })();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+
   }, []);
 
   // 监听导入窗口发出的 tasks-imported 事件,刷新主列表
@@ -324,6 +314,8 @@ function App() {
     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 () => {
@@ -331,6 +323,12 @@ function App() {
         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();
@@ -346,9 +344,15 @@ function App() {
         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();
+            // await reloadTasks();
           } catch (err) {
             console.error("写入 site_url 失败:", err);
           }
@@ -359,6 +363,7 @@ function App() {
         // 只对当前激活任务生效,避免切走后还把按钮放开
         if (activeIdRef.current !== taskId) return;
         setPageReady(true);
+        setIsWorking(false);
         showStatus("success", "页面就绪", "已加载到最终 URL,可截图 / 录制", false);
       });
       const u4 = await listen<TaskPageTimeoutPayload>(
@@ -378,22 +383,37 @@ function App() {
             setPageReady(false);
             await loadInWebview(taskId, url);
           } else {
+            setActiveId(null);
+
+            setIsLoading(false);
+            setIsWorking(false);
             // 用户取消:保持按钮禁用,写状态栏告知
-            showStatus("warning", "已取消重试", "重新点击任务可继续尝试");
+            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;
       }
     })();
 
@@ -403,8 +423,10 @@ function App() {
       unlistenSite?.();
       unlistenReady?.();
       unlistenTimeout?.();
+      unlistenPageStarted?.();
+      unlistenPageLoaded?.();
     };
-  }, [reloadTasks, showStatus]);
+  }, [showStatus, setAssets]);
 
   /** 工具栏 "完成" 按钮:把当前激活任务标记为已完成(status=1)→ 刷新 → 清空选中 */
   const handleCompleteTask = useCallback(async () => {
@@ -413,11 +435,16 @@ function App() {
       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));
     }
@@ -447,7 +474,7 @@ function App() {
         showStatus(
           "success",
           auto ? "自动截图完成" : "截图完成",
-          `任务 ${taskId} → ${path}`,
+          `任务 ${taskId} → ${path}`, false
         );
         // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
         if (activeIdRef.current === taskId) {
@@ -492,11 +519,13 @@ function App() {
     let disposed = false;
 
     (async () => {
-      const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, (e) => {
+      const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, async (e) => {
         const { taskId, path } = e.payload;
-        showStatus("success", "录制完成", `任务 ${taskId} → ${path}`);
+        showStatus("success", "录制完成", `任务 ${taskId} → ${path}`, false);
         if (activeIdRef.current === taskId) {
-          void refreshAssets(taskId);
+          await refreshAssets(taskId);
+          await asyncSleep(100);
+          handleCompleteTask();
         }
       });
       const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
@@ -564,6 +593,7 @@ function App() {
             return;
           }
           await startRecording(activeId, contentSize.w, contentSize.h);
+
         } else if (action === "stop") {
           if (recordState === "recording") {
             await stopRecording();
@@ -737,7 +767,7 @@ function App() {
        *   触发系统级拖窗,比手写 startDragging 更稳定(Windows 尤其如此)。 */}
       <Header
         className="app-title h-8 select-none"
-        style={{ paddingLeft: 8 }}
+        style={{ paddingLeft: 8, paddingRight: 8 }}
         data-tauri-drag-region
       >
         <div className="flex h-full flex-row items-center" data-tauri-drag-region>
@@ -762,19 +792,16 @@ function App() {
           <div className="w-8" />
           <Button
             icon={<LeftOutlined />}
-            // size="large"
-            type="text"
             data-tauri-drag-region="no-drag"
             onClick={handleBack}
           />
           <Button
             icon={<RedoOutlined />}
-            // size="large"
-            type="text"
+
             data-tauri-drag-region="no-drag"
             onClick={handleRefresh}
           />
-
+          <Divider orientation="vertical" />
           <Tooltip
             title={
               !activeId
@@ -881,6 +908,9 @@ function App() {
               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>
@@ -951,10 +981,11 @@ function App() {
                 defaultSelectedKeys={[activeId || '']}
                 selectedKeys={[activeId || '']}
                 style={{ borderInlineEnd: 0, height: '100%' }}
-                onClick={handleSelectTask}
+
+                onClick={isWorking ? undefined : handleSelectTask}
 
               >
-                {tasks.map((t) => <Menu.Item icon={<LinkOutlined />} key={t.id}>
+                {tasks.map((t) => <Menu.Item icon={isWorking && t.id == activeId ? <LoadingOutlined /> : <LinkOutlined />} key={t.id}>
                   {t.id}
                 </Menu.Item>
                 )}
@@ -966,11 +997,11 @@ function App() {
           <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} {isWorking && <LoadingOutlined />}</div>
-            <div className="flex-1 overflow-hidden">{assets?.site_url || activeTaskRef.current?.url}</div>
-            <div className="flex-1 overflow-hidden">{assets?.tags}</div>
+            <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: statusColor }} />
+            <div className="bg-gray-6 rounded-full w-3 h-3 m-2" style={{ backgroundColor: isWorking ? '#ff0' : statusColor }} />
           </div>
         </Layout>
       </Layout>

+ 5 - 0
src/lib/utils.tsx

@@ -0,0 +1,5 @@
+export async function asyncSleep(t: number) {
+    return new Promise<void>((reslove) => {
+        setTimeout(reslove, t);
+    });
+}