lv 2 周之前
父節點
當前提交
52364786de
共有 5 個文件被更改,包括 339 次插入132 次删除
  1. 50 48
      src-tauri/src/landing.rs
  2. 153 52
      src-tauri/src/lib.rs
  3. 94 26
      src/App.tsx
  4. 33 6
      src/lib/tasks.ts
  5. 9 0
      src/types/ipc.ts

+ 50 - 48
src-tauri/src/landing.rs

@@ -1,39 +1,41 @@
-//! 中间页(landing)流转模块
+//! 中间页(landing)流转模块 —— 仅 Windows
 //!
 //! 任务整体流程:
 //!   1. 前端调 navigate_webview(task.url)
-//!   2. webview 加载完中间页 → on_page_load 触发,此时 page_stage=Initial
-//!      → lib.rs 把控制权交给 [`spawn_polling`]
+//!   2. webview Started 事件触发(新文档刚提交,HTML 开始 parse)
+//!      → lib.rs handle_page_started 把控制权交给 [`spawn_polling`]
+//!      (故意不等 Finished/load:等到 window.onload 还要白等图片 / 广告 / 三方
+//!       脚本,但我们要找的节点 parse 阶段就在 DOM 里了,能省好几秒)
 //!   3. 本模块每 [`POLL_INTERVAL_MS`] 毫秒用 CDP Runtime.evaluate 同时查询:
 //!      - 标签:div.c-tags > ul > li > strong > a 全部 text
 //!      - Visit Site 链接:.menu-float__content > strong > a 中 text 含 "Visit Site" 的 href
 //!   4. 提取到 tags 后:emit `task-tags-extracted`(仅首次,前端落库)
 //!   5. 提取到 Visit Site 后:把 stage 切到 Final,webview.navigate(href)
-//!   6. webview 加载完最终页 → on_page_load 再次触发,stage=Final
-//!      → lib.rs 把 stage 推进到 Ready,emit `task-page-ready`,再触发 auto capture
+//!   6. 最终页 PageLoadEvent::Finished → lib.rs handle_page_loaded 把 stage
+//!      推进到 Ready,emit `task-page-ready`,再触发 auto capture
 //!
 //! 超时([`POLL_TIMEOUT_MS`]):emit `task-page-timeout`,由前端弹「重试 / 取消」
 //!
-//! 平台:CDP 仅在 Windows 上实现;非 Windows 直接退化为「已就绪」,
-//! 让按钮可用但跳过 tags 抓取与自动跳转。
+//! 平台:整模块 cfg(target_os = "windows")。macOS 走 lib.rs handle_page_loaded
+//! 的 Finished 兜底分支直接进入 Ready,不经过 landing。
+
+#![cfg(target_os = "windows")]
 
 use crate::{AppState, PageStage};
 use tauri::{AppHandle, Emitter, Manager};
 
-/// 与前端 src/types/ipc.ts 中常量保持一致(仅 Windows 真正会 emit)
-#[cfg(target_os = "windows")]
+/// 与前端 src/types/ipc.ts 中常量保持一致
 const EVT_TASK_TAGS_EXTRACTED: &str = "task-tags-extracted";
-#[cfg(target_os = "windows")]
+const EVT_TASK_SITE_URL_FOUND: &str = "task-site-url-found";
 const EVT_TASK_PAGE_TIMEOUT: &str = "task-page-timeout";
 
-/// 轮询间隔;起步 300ms 已经足够覆盖大多数页面渲染节奏
-#[cfg(target_os = "windows")]
-const POLL_INTERVAL_MS: u64 = 300;
-/// 总超时;超过该时长仍未找到 Visit Site,emit timeout 让前端弹「重试/取消」
-#[cfg(target_os = "windows")]
-const POLL_TIMEOUT_MS: u64 = 15_000;
+/// 轮询间隔。注意第一轮不 sleep(页面 SSR / 缓存命中时可立即返回结果),
+/// 之后每 150ms 查一次。再低意义不大(CDP 单次往返 + DOM 查询本身约 5~30ms)。
+const POLL_INTERVAL_MS: u64 = 150;
+/// 总超时。重 JS 渲染的页面 (SPA) 真实显示出 c-tags / Visit Site 链接的时间常常
+/// 在 1~5s 量级,加上偶发慢网络给到 30s 比较稳。仍命中不到就 emit timeout 让用户重试。
+const POLL_TIMEOUT_MS: u64 = 30_000;
 
-#[cfg(target_os = "windows")]
 #[derive(serde::Serialize, Clone)]
 struct TagsExtracted {
     #[serde(rename = "taskId")]
@@ -42,7 +44,14 @@ struct TagsExtracted {
     tags: String,
 }
 
-#[cfg(target_os = "windows")]
+#[derive(serde::Serialize, Clone)]
+struct SiteUrlFound {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    /// <a href> 原值,未经 webview 重定向
+    url: String,
+}
+
 #[derive(serde::Serialize, Clone)]
 struct PageTimeout {
     #[serde(rename = "taskId")]
@@ -51,7 +60,7 @@ struct PageTimeout {
     reason: String,
 }
 
-/// 由 on_page_load 在 Initial 阶段调用。
+/// 由 lib.rs handle_page_started 在 Initial 阶段调用。
 ///
 /// task_id / intermediate_url 用于自检(用户中途切换任务时尽快退出)以及
 /// 超时事件 payload 的回填(前端「重试」要重新 navigate 到这个 URL)。
@@ -62,18 +71,21 @@ pub fn spawn_polling(app: AppHandle, task_id: String, intermediate_url: String)
 }
 
 // =====================================================================
-// Windows 实现:CDP Runtime.evaluate 真实轮询
+// CDP Runtime.evaluate 真实轮询
 // =====================================================================
 
-#[cfg(target_os = "windows")]
 async fn poll_loop(app: AppHandle, task_id: String, intermediate_url: String) {
     use std::time::Duration;
 
     let max_iter = POLL_TIMEOUT_MS / POLL_INTERVAL_MS;
     let mut tags_emitted = false;
 
-    for _ in 0..max_iter {
-        tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+    for i in 0..max_iter {
+        // 第一轮立即查(SSR 页 / 已缓存的页可能 DOM 已就绪,没必要白等 150ms);
+        // 之后每轮间隔 POLL_INTERVAL_MS
+        if i > 0 {
+            tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+        }
 
         // 自检:当前任务还是我吗?stage 还在 Initial 吗?
         // 任意一个不满足,说明用户已切换任务 / 已推进到下一阶段,直接退出
@@ -96,8 +108,18 @@ async fn poll_loop(app: AppHandle, task_id: String, intermediate_url: String) {
                 }
 
                 if let Some(href) = visit_href {
-                    // 先把 stage 切到 Final,再触发 navigate;
-                    // 这样下一次 on_page_load 来时 lib.rs 能正确分流到「最终页」分支
+                    // 1) 抓到 Visit Site 链接的瞬间就 emit 出去,让前端尽早把 site_url
+                    //    落库;这样即便后续 navigate / 最终页加载失败,site_url 也已经存在
+                    let _ = app.emit(
+                        EVT_TASK_SITE_URL_FOUND,
+                        SiteUrlFound {
+                            task_id: task_id.clone(),
+                            url: href.clone(),
+                        },
+                    );
+
+                    // 2) 把 stage 切到 Final,再触发 navigate;
+                    //    这样下一次 on_page_load Finished 来时 lib.rs 能正确分流到 Final 分支
                     if let Some(state) = app.try_state::<AppState>() {
                         if let Ok(mut g) = state.page_stage.lock() {
                             *g = PageStage::Final;
@@ -137,7 +159,6 @@ async fn poll_loop(app: AppHandle, task_id: String, intermediate_url: String) {
     }
 }
 
-#[cfg(target_os = "windows")]
 fn still_active(app: &AppHandle, task_id: &str) -> bool {
     let Some(state) = app.try_state::<AppState>() else {
         return false;
@@ -153,7 +174,6 @@ fn still_active(app: &AppHandle, task_id: &str) -> bool {
 }
 
 /// 单次 CDP Runtime.evaluate:合并 tags 与 Visit Site 查询,减少往返次数。
-#[cfg(target_os = "windows")]
 async fn query_page(app: &AppHandle) -> anyhow::Result<(Vec<String>, Option<String>)> {
     use serde_json::json;
 
@@ -212,24 +232,6 @@ async fn query_page(app: &AppHandle) -> anyhow::Result<(Vec<String>, Option<Stri
     Ok((tags, visit_href))
 }
 
-// =====================================================================
-// 非 Windows 降级实现
-// =====================================================================
-// 没有 CDP 可用,无法注入 JS 拿 DOM 节点。直接把 stage 推进到 Ready,
-// 这样按钮立即可用——代价是没有 tags、不会自动跳到 Visit Site URL,
-// 需要用户手动在 webview 里点链接进入最终页。后续接入 WKWebView 时补齐。
-
-#[cfg(not(target_os = "windows"))]
-async fn poll_loop(app: AppHandle, task_id: String, _intermediate_url: String) {
-    eprintln!("[landing] 非 Windows 平台暂跳过 landing 轮询,直接标记 Ready");
-    if let Some(state) = app.try_state::<AppState>() {
-        if let Ok(mut g) = state.page_stage.lock() {
-            *g = PageStage::Ready;
-        }
-    }
-    // 直接 emit ready,让前端按钮可用;url 字段留空(无法判定最终 URL)
-    let _ = app.emit(
-        "task-page-ready",
-        serde_json::json!({ "taskId": task_id, "url": "" }),
-    );
-}
+// 非 Windows 没有 CDP 路径,本模块在 macOS 上整体不参与编译(见文件顶部
+// `#![cfg(target_os = "windows")]`)。macOS 的「最终页就绪」由 lib.rs
+// handle_page_loaded 在 Finished 事件里直接 promote_to_ready 完成。

+ 153 - 52
src-tauri/src/lib.rs

@@ -143,6 +143,30 @@ fn navigate_webview(
     webview.navigate(parsed).map_err(|e| e.to_string())
 }
 
+/// 在子 webview 上执行历史导航 / 刷新动作。
+///
+/// 通过 webview.eval 跑很短的一行 JS,避开各平台 API 差异:
+///   - "back"    → history.back()
+///   - "forward" → history.forward()
+///   - "reload"  → location.reload()
+///
+/// 不主动重置 page_stage:用户点 Back 回到中间页 / 点 Refresh 重载最终页时,
+/// 状态机已经是 Final/Ready,handle_page_started/loaded 会按当前 stage 静默处理
+/// (即不会重新启动 landing 轮询,也不会重复触发自动截图)。如果用户想完整重跑
+/// landing 流程,应通过左侧任务列表再点一下任务(走 navigate_webview,那个命令
+/// 会把 stage 重置为 Initial)。
+#[tauri::command]
+fn webview_history_action(app: tauri::AppHandle, action: String) -> Result<(), String> {
+    let script = match action.as_str() {
+        "back" => "history.back()",
+        "forward" => "history.forward()",
+        "reload" => "location.reload()",
+        other => return Err(format!("未知的 webview 历史动作: {other}")),
+    };
+    let webview = find_content_webview(&app)?;
+    webview.eval(script).map_err(|e| e.to_string())
+}
+
 /// 修改主窗口的「客户区」尺寸(逻辑像素,不含系统 titlebar / 边框)。
 ///
 /// 前端传进来的 (width, height) 含义是「左栏 + 工作区宽」「工具栏高 + 工作区高」,
@@ -179,56 +203,93 @@ fn set_window_size(
     Ok(())
 }
 
-/// 子 webview 任意一次「页面加载完成」时的总入口(由 on_page_load 异步派发)。
+/// PageLoadEvent::Started 处理:尽早启动 landing 轮询。
+///
+/// 之所以不等到 Finished(= window.onload)才启动,是因为 onload 必须等所有图片 /
+/// 广告 / 第三方脚本加载完成,而 `div.c-tags`、`.menu-float__content` 这些节点在
+/// HTML parse 阶段就已经进 DOM 了。Started 触发时新文档刚提交,立即跑 CDP 轮询
+/// 就能在几百毫秒内捕获到目标节点,无需等几秒的尾资源。
 ///
-/// 根据 page_stage 分流:
-///   - Initial:当前是中间页 → spawn landing 轮询;
-///   - Final:当前是 Visit Site 最终页 → emit task-page-ready,1.5s 后自动截图;
-///   - Ready:已就绪还在加载(重新刷新等),不做额外动作。
+/// 平台说明:landing 轮询仅 Windows 实现(CDP 路径)。macOS 这里 no-op,靠
+/// handle_page_loaded 的 Finished 分支把 Initial 当 Final 处理。
+async fn handle_page_started(app: AppHandle, loaded_url: String) {
+    let (task_id, stage) = match read_task_and_stage(&app) {
+        Some(v) => v,
+        None => return,
+    };
+
+    if stage != PageStage::Initial {
+        // Final / Ready 阶段下的 Started(比如 final URL 已开始重定向),交给 Finished 处理
+        return;
+    }
+
+    #[cfg(target_os = "windows")]
+    {
+        landing::spawn_polling(app, task_id, loaded_url);
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    {
+        // macOS:什么都不做,等 Finished 一起处理
+        let _ = (app, task_id, loaded_url);
+    }
+}
+
+/// PageLoadEvent::Finished 处理:把 stage 推进到 Ready + 触发自动截图。
 ///
-/// about:blank / 无任务上下文(current_task_id=None)的加载直接忽略。
+/// 分流:
+///   - Initial:Windows 下不动(轮询负责);macOS 直接 promote_to_ready(无 CDP fallback)。
+///   - Final :跨平台都 promote_to_ready。
+///   - Ready :忽略(用户刷新等同 URL 重新加载)。
 async fn handle_page_loaded(app: AppHandle, loaded_url: String) {
-    use tauri::Emitter;
+    let (task_id, stage) = match read_task_and_stage(&app) {
+        Some(v) => v,
+        None => return,
+    };
 
-    let (task_id, stage) = {
-        let Some(state) = app.try_state::<AppState>() else {
-            return;
-        };
-        let Some(id) = state.current_task() else {
-            return; // about:blank / 还没选任务
-        };
-        let stage = state
-            .page_stage
-            .lock()
-            .ok()
-            .map(|g| *g)
-            .unwrap_or(PageStage::Initial);
-        (id, stage)
+    let should_promote = match stage {
+        PageStage::Final => true,
+        // 非 Windows 没有 CDP,Initial 阶段也没有 Visit Site 自动跳转,
+        // 把 Finished 当作就绪信号;window.onload 已触发,页面已可见,
+        // 不会出现「按钮就绪但页面白屏」的尴尬。
+        #[cfg(not(target_os = "windows"))]
+        PageStage::Initial => true,
+        _ => false,
     };
 
-    match stage {
-        PageStage::Initial => {
-            // landing 模块自己负责 emit tags / 触发 Visit Site 跳转 / 超时
-            landing::spawn_polling(app, task_id, loaded_url);
-        }
-        PageStage::Final => {
-            // 已跳到最终页:推进到 Ready + 通知前端解锁按钮 + 等 1.5s 后自动截图
-            if let Some(state) = app.try_state::<AppState>() {
-                if let Ok(mut g) = state.page_stage.lock() {
-                    *g = PageStage::Ready;
-                }
-            }
-            let _ = app.emit(
-                "task-page-ready",
-                serde_json::json!({ "taskId": task_id, "url": loaded_url }),
-            );
-            tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
-            capture::trigger_auto_capture(app).await;
-        }
-        PageStage::Ready => {
-            // 已就绪状态下二次加载(用户刷新页面等)—— 不做额外动作
+    if should_promote {
+        promote_to_ready(&app, &task_id, &loaded_url).await;
+    }
+}
+
+/// 从 AppState 读 (task_id, stage) 的小帮手;无任务上下文(about:blank 等)返回 None。
+fn read_task_and_stage(app: &AppHandle) -> Option<(String, PageStage)> {
+    let state = app.try_state::<AppState>()?;
+    let task_id = state.current_task()?;
+    let stage = state
+        .page_stage
+        .lock()
+        .ok()
+        .map(|g| *g)
+        .unwrap_or(PageStage::Initial);
+    Some((task_id, stage))
+}
+
+/// 把 stage 切到 Ready + emit `task-page-ready` + 1.5s 后触发自动截图。
+async fn promote_to_ready(app: &AppHandle, task_id: &str, loaded_url: &str) {
+    use tauri::Emitter;
+
+    if let Some(state) = app.try_state::<AppState>() {
+        if let Ok(mut g) = state.page_stage.lock() {
+            *g = PageStage::Ready;
         }
     }
+    let _ = app.emit(
+        "task-page-ready",
+        serde_json::json!({ "taskId": task_id, "url": loaded_url }),
+    );
+    tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
+    capture::trigger_auto_capture(app.clone()).await;
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -253,6 +314,35 @@ pub fn run() {
             sql: "ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '';",
             kind: tauri_plugin_sql::MigrationKind::Up,
         },
+        // v3~v6:补三个产物字段 + status 普通索引。拆成单语句条目避免不同 sqlx 版本
+        // 对多语句 migration 的处理差异(id 已是 PRIMARY KEY,自带唯一索引不重复建)。
+        //   - site_url:landing 跳转后落地的最终 URL
+        //   - pic / video:最近一次截图 / 录制 mp4 的文件名(仅 basename)
+        //   - idx_tasks_status:主列表 WHERE status=0 谓词的非唯一索引
+        tauri_plugin_sql::Migration {
+            version: 3,
+            description: "add_tasks_site_url",
+            sql: "ALTER TABLE tasks ADD COLUMN site_url TEXT NOT NULL DEFAULT '';",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
+        tauri_plugin_sql::Migration {
+            version: 4,
+            description: "add_tasks_pic",
+            sql: "ALTER TABLE tasks ADD COLUMN pic TEXT NOT NULL DEFAULT '';",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
+        tauri_plugin_sql::Migration {
+            version: 5,
+            description: "add_tasks_video",
+            sql: "ALTER TABLE tasks ADD COLUMN video TEXT NOT NULL DEFAULT '';",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
+        tauri_plugin_sql::Migration {
+            version: 6,
+            description: "create_idx_tasks_status",
+            sql: "CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
     ];
 
     tauri::Builder::default()
@@ -281,10 +371,13 @@ pub fn run() {
 
             // 2) 在已经放大的窗口上挂 child webview,定位到工具栏下方
             //
-            //    on_page_load 钩子分流到 [`handle_page_loaded`]:
-            //    - Initial 阶段:spawn landing 轮询(找 tags + Visit Site 链接)
-            //    - Final   阶段:标记 Ready + 1.5s 后自动截图
-            //    - Ready   阶段:忽略(用户手动刷新 / 同 URL 重新加载)
+            //    on_page_load 同时处理 Started / Finished 两个事件:
+            //    - Started :导航刚开始,新文档刚提交(DOM 开始 parse)。Initial 阶段
+            //                立即 spawn landing 轮询——比等到 Finished (window.onload)
+            //                能早好几秒,因为 c-tags / Visit Site 链接 parse 阶段就在 DOM 里。
+            //    - Finished:window.onload,所有资源到位。这里只用来分流 Final → Ready
+            //                + 触发自动截图。
+            //                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("主窗口未找到")?;
@@ -293,14 +386,21 @@ pub fn run() {
                 WebviewBuilder::new(CONTENT_WEBVIEW_LABEL, WebviewUrl::External(initial_url))
                     .on_page_load(move |_webview, payload| {
                         use tauri::webview::PageLoadEvent;
-                        if !matches!(payload.event(), PageLoadEvent::Finished) {
-                            return;
-                        }
                         let app = app_handle_for_load.clone();
                         let loaded_url = payload.url().to_string();
-                        tauri::async_runtime::spawn(async move {
-                            handle_page_loaded(app, loaded_url).await;
-                        });
+                        match payload.event() {
+                            PageLoadEvent::Started => {
+                                tauri::async_runtime::spawn(async move {
+                                    handle_page_started(app, loaded_url).await;
+                                });
+                            }
+                            PageLoadEvent::Finished => {
+                                tauri::async_runtime::spawn(async move {
+                                    handle_page_loaded(app, loaded_url).await;
+                                });
+                            }
+                            _ => {}
+                        }
                     }),
                 LogicalPosition::new(0, 0),
                 LogicalSize::new(
@@ -316,6 +416,7 @@ pub fn run() {
         })
         .invoke_handler(tauri::generate_handler![
             navigate_webview,
+            webview_history_action,
             set_window_size,
             query_task_assets,
             capture::capture_page,

+ 94 - 26
src/App.tsx

@@ -10,7 +10,10 @@ import {
   countPendingTasks,
   listPendingTasks,
   markTaskDone,
+  updateTaskPic,
+  updateTaskSiteUrl,
   updateTaskTags,
+  updateTaskVideo,
   type Task,
 } from "./lib/tasks";
 import {
@@ -21,6 +24,7 @@ import {
   EVT_SCREENSHOT_FINISHED,
   EVT_TASK_PAGE_READY,
   EVT_TASK_PAGE_TIMEOUT,
+  EVT_TASK_SITE_URL_FOUND,
   EVT_TASK_TAGS_EXTRACTED,
   EVT_TASKS_IMPORTED,
   type RecordingFailedPayload,
@@ -32,6 +36,7 @@ import {
   type TaskAssets,
   type TaskPageReadyPayload,
   type TaskPageTimeoutPayload,
+  type TaskSiteUrlFoundPayload,
   type TaskTagsExtractedPayload,
 } from "./types/ipc";
 import { capturePage } from "./lib/capture";
@@ -49,6 +54,7 @@ import {
   CloseOutlined,
   FileImageOutlined,
   FolderOpenOutlined,
+  LeftOutlined,
   LinkOutlined,
   LoadingOutlined,
   MinusOutlined,
@@ -56,6 +62,7 @@ import {
   PictureOutlined,
   PlayCircleFilled,
   PlusOutlined,
+  RedoOutlined,
   StopOutlined,
   VideoCameraOutlined,
 } from "@ant-design/icons";
@@ -91,6 +98,12 @@ 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 {
@@ -136,6 +149,24 @@ 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;
+
   // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
   // 导致 listen 频繁重订)
   const activeIdRef = useRef<string | null>(activeId);
@@ -276,14 +307,17 @@ function App() {
   }, [showStatus, reloadTasks]);
 
   /**
-   * 监听 landing 个事件:
+   * 监听 landing 个事件:
    *   - task-tags-extracted:中间页抓到标签 → 写库 + reload
-   *   - task-page-ready:跳到最终 URL 完成 → 解锁按钮 + 状态栏提示
+   *   - task-site-url-found:landing 命中 Visit Site 链接的瞬间 → 立即写 site_url + reload
+   *     (此时还没 navigate,更不用说 Finished;即便后续 redirect/加载失败,site_url 也已落库)
+   *   - task-page-ready:跳到最终 URL 加载完成 → 解锁按钮 + 状态栏提示
    *   - task-page-timeout:未找到 Visit Site → ask() 弹「重试 / 取消」
-   * 全部按 activeIdRef 过滤,避免给已切走的旧任务弹无效提示。
+   * 全部按 activeIdRef 过滤,避免给已切走的旧任务弹无效提示(写库不过滤,所有命中任务都要落库)
    */
   useEffect(() => {
     let unlistenTags: UnlistenFn | null = null;
+    let unlistenSite: UnlistenFn | null = null;
     let unlistenReady: UnlistenFn | null = null;
     let unlistenTimeout: UnlistenFn | null = null;
     let disposed = false;
@@ -303,14 +337,27 @@ function App() {
           }
         },
       );
-      const u2 = await listen<TaskPageReadyPayload>(EVT_TASK_PAGE_READY, (e) => {
+      const u2 = await listen<TaskSiteUrlFoundPayload>(
+        EVT_TASK_SITE_URL_FOUND,
+        async (e) => {
+          const { taskId, url } = e.payload;
+          if (!url) return;
+          try {
+            await updateTaskSiteUrl(taskId, url);
+            await reloadTasks();
+          } catch (err) {
+            console.error("写入 site_url 失败:", err);
+          }
+        },
+      );
+      const u3 = await listen<TaskPageReadyPayload>(EVT_TASK_PAGE_READY, (e) => {
         const { taskId } = e.payload;
         // 只对当前激活任务生效,避免切走后还把按钮放开
         if (activeIdRef.current !== taskId) return;
         setPageReady(true);
         showStatus("success", "页面就绪", "已加载到最终 URL,可截图 / 录制");
       });
-      const u3 = await listen<TaskPageTimeoutPayload>(
+      const u4 = await listen<TaskPageTimeoutPayload>(
         EVT_TASK_PAGE_TIMEOUT,
         async (e) => {
           const { taskId, url, reason } = e.payload;
@@ -337,16 +384,19 @@ function App() {
         u1();
         u2();
         u3();
+        u4();
       } else {
         unlistenTags = u1;
-        unlistenReady = u2;
-        unlistenTimeout = u3;
+        unlistenSite = u2;
+        unlistenReady = u3;
+        unlistenTimeout = u4;
       }
     })();
 
     return () => {
       disposed = true;
       unlistenTags?.();
+      unlistenSite?.();
       unlistenReady?.();
       unlistenTimeout?.();
     };
@@ -388,18 +438,27 @@ function App() {
     let disposed = false;
 
     (async () => {
-      const u1 = await listen<ScreenshotFinishedPayload>(EVT_SCREENSHOT_FINISHED, (e) => {
-        const { taskId, path, auto } = e.payload;
-        showStatus(
-          "success",
-          auto ? "自动截图完成" : "截图完成",
-          `任务 ${taskId} → ${path}`,
-        );
-        // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
-        if (activeIdRef.current === taskId) {
-          void refreshAssets(taskId);
-        }
-      });
+      const u1 = await listen<ScreenshotFinishedPayload>(
+        EVT_SCREENSHOT_FINISHED,
+        async (e) => {
+          const { taskId, path, auto } = e.payload;
+          showStatus(
+            "success",
+            auto ? "自动截图完成" : "截图完成",
+            `任务 ${taskId} → ${path}`,
+          );
+          // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
+          if (activeIdRef.current === taskId) {
+            void refreshAssets(taskId);
+          }
+          // 把截图 basename 落库;失败仅打日志,不影响用户后续操作
+          try {
+            await updateTaskPic(taskId, basename(path));
+          } catch (err) {
+            console.error("写入 pic 失败:", err);
+          }
+        },
+      );
       const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
         const { taskId, auto, error } = e.payload;
         showStatus(
@@ -438,13 +497,22 @@ function App() {
     let disposed = false;
 
     (async () => {
-      const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, (e) => {
-        const { taskId, path } = e.payload;
-        showStatus("success", "录制完成", `任务 ${taskId} → ${path}`);
-        if (activeIdRef.current === taskId) {
-          void refreshAssets(taskId);
-        }
-      });
+      const u1 = await listen<RecordingFinishedPayload>(
+        EVT_RECORDING_FINISHED,
+        async (e) => {
+          const { taskId, path } = e.payload;
+          showStatus("success", "录制完成", `任务 ${taskId} → ${path}`);
+          if (activeIdRef.current === taskId) {
+            void refreshAssets(taskId);
+          }
+          // 把录制 mp4 basename 落库;失败仅打日志
+          try {
+            await updateTaskVideo(taskId, basename(path));
+          } catch (err) {
+            console.error("写入 video 失败:", err);
+          }
+        },
+      );
       const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
         const { taskId, error } = e.payload;
         showStatus("error", "录制失败", `任务 ${taskId}:${error}`);

+ 33 - 6
src/lib/tasks.ts

@@ -4,11 +4,14 @@
 // 数据库名与 src-tauri/src/lib.rs 中的 migration 一致:sqlite:autorecord.db
 //
 // 表结构:
-//   id      TEXT  PRIMARY KEY                -- uuid 缩短到 12 位(v1)
-//   url     TEXT  NOT NULL                   -- (v1)
-//   status  INT   NOT NULL DEFAULT 0         -- 0=待处理, 1=已完成 (v1)
-//   "desc"  TEXT  NOT NULL DEFAULT ''        -- DESC 是 SQLite 关键字,列名要带引号 (v1)
-//   tags    TEXT  NOT NULL DEFAULT ''        -- 中间页提取出的标签,逗号分隔 (v2)
+//   id        TEXT  PRIMARY KEY              -- uuid 缩短到 12 位(v1,自带唯一索引)
+//   url       TEXT  NOT NULL                 -- (v1) 中间页 url
+//   status    INT   NOT NULL DEFAULT 0       -- 0=待处理, 1=已完成 (v1,v3 加普通索引)
+//   "desc"    TEXT  NOT NULL DEFAULT ''      -- DESC 是 SQLite 关键字,列名要带引号 (v1)
+//   tags      TEXT  NOT NULL DEFAULT ''      -- 中间页提取出的标签,逗号分隔 (v2)
+//   site_url  TEXT  NOT NULL DEFAULT ''      -- landing 跳转后落地的最终 URL (v3)
+//   pic       TEXT  NOT NULL DEFAULT ''      -- 最近一次截图文件名 basename (v3)
+//   video     TEXT  NOT NULL DEFAULT ''      -- 最近一次录制 mp4 文件名 basename (v3)
 
 import Database from "@tauri-apps/plugin-sql";
 
@@ -26,6 +29,12 @@ export interface Task {
   desc: string;
   /** 中间页 div.c-tags 抓出的标签,逗号分隔;未抓取/无标签为空串 */
   tags: string;
+  /** landing 跳转后落地的最终 URL;landing 未走通则为空串 */
+  site_url: string;
+  /** 最近一次截图文件 basename(不含目录),未截图则为空串 */
+  pic: string;
+  /** 最近一次录制 mp4 文件 basename(不含目录),未录则为空串 */
+  video: string;
 }
 
 let _dbPromise: Promise<Database> | null = null;
@@ -48,7 +57,7 @@ export function genTaskId(): string {
 export async function listPendingTasks(): Promise<Task[]> {
   const db = await getDb();
   return await db.select<Task[]>(
-    'SELECT id, url, status, "desc", tags FROM tasks WHERE status = 0 ORDER BY rowid ASC',
+    'SELECT id, url, status, "desc", tags, site_url, pic, video FROM tasks WHERE status = 0 ORDER BY rowid ASC',
   );
 }
 
@@ -97,3 +106,21 @@ export async function updateTaskTags(id: string, tags: string): Promise<void> {
   const db = await getDb();
   await db.execute("UPDATE tasks SET tags = $1 WHERE id = $2", [tags, id]);
 }
+
+/** 写入 landing 跳转后的最终 URL(page-ready 事件触发后调用) */
+export async function updateTaskSiteUrl(id: string, siteUrl: string): Promise<void> {
+  const db = await getDb();
+  await db.execute("UPDATE tasks SET site_url = $1 WHERE id = $2", [siteUrl, id]);
+}
+
+/** 写入最近一次截图的文件 basename(screenshot-finished 事件触发后调用) */
+export async function updateTaskPic(id: string, pic: string): Promise<void> {
+  const db = await getDb();
+  await db.execute("UPDATE tasks SET pic = $1 WHERE id = $2", [pic, id]);
+}
+
+/** 写入最近一次录制 mp4 的文件 basename(recording-finished 事件触发后调用) */
+export async function updateTaskVideo(id: string, video: string): Promise<void> {
+  const db = await getDb();
+  await db.execute("UPDATE tasks SET video = $1 WHERE id = $2", [video, id]);
+}

+ 9 - 0
src/types/ipc.ts

@@ -40,6 +40,8 @@ export const EVT_RECORD_SHORTCUT = "record-shortcut";
 export const EVT_TASKS_IMPORTED = "tasks-imported";
 /** landing 中间页提取到 tags 后由 Rust 发出,前端写库 */
 export const EVT_TASK_TAGS_EXTRACTED = "task-tags-extracted";
+/** landing 命中 Visit Site 链接的瞬间(早于 navigate / Finished),用于尽早把 site_url 落库 */
+export const EVT_TASK_SITE_URL_FOUND = "task-site-url-found";
 /** Visit Site 跳转后,最终 url 加载完成,前端解锁按钮 */
 export const EVT_TASK_PAGE_READY = "task-page-ready";
 /** landing 轮询超时(未找到 Visit Site 链接),前端弹「重试 / 取消」 */
@@ -79,6 +81,13 @@ export interface TaskTagsExtractedPayload {
   tags: string;
 }
 
+/** landing 命中 Visit Site 链接事件 payload;这里的 url 是 <a href> 原值,未经 redirect */
+export interface TaskSiteUrlFoundPayload {
+  taskId: string;
+  /** 中间页 .menu-float__content > strong > a 上提取到的原始 href */
+  url: string;
+}
+
 /** landing 最终页加载完成事件 payload */
 export interface TaskPageReadyPayload {
   taskId: string;