lv 2 týždňov pred
rodič
commit
b7f3a8ae7a

+ 1 - 0
package.json

@@ -12,6 +12,7 @@
   "dependencies": {
     "@ant-design/icons": "6.x",
     "@tauri-apps/api": "^2",
+    "@tauri-apps/plugin-dialog": "^2",
     "@tauri-apps/plugin-opener": "^2",
     "@tauri-apps/plugin-sql": "^2",
     "antd": "^6.4.0",

+ 10 - 0
pnpm-lock.yaml

@@ -14,6 +14,9 @@ importers:
       '@tauri-apps/api':
         specifier: ^2
         version: 2.11.0
+      '@tauri-apps/plugin-dialog':
+        specifier: ^2
+        version: 2.7.1
       '@tauri-apps/plugin-opener':
         specifier: ^2
         version: 2.5.4
@@ -926,6 +929,9 @@ packages:
     engines: {node: '>= 10'}
     hasBin: true
 
+  '@tauri-apps/plugin-dialog@2.7.1':
+    resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==}
+
   '@tauri-apps/plugin-opener@2.5.4':
     resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
 
@@ -2071,6 +2077,10 @@ snapshots:
       '@tauri-apps/cli-win32-ia32-msvc': 2.11.1
       '@tauri-apps/cli-win32-x64-msvc': 2.11.1
 
+  '@tauri-apps/plugin-dialog@2.7.1':
+    dependencies:
+      '@tauri-apps/api': 2.11.0
+
   '@tauri-apps/plugin-opener@2.5.4':
     dependencies:
       '@tauri-apps/api': 2.11.0

+ 143 - 1
src-tauri/Cargo.lock

@@ -249,6 +249,7 @@ dependencies = [
  "serde_json",
  "tauri",
  "tauri-build",
+ "tauri-plugin-dialog",
  "tauri-plugin-global-shortcut",
  "tauri-plugin-opener",
  "tauri-plugin-sql",
@@ -2590,6 +2591,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
 dependencies = [
  "bitflags 2.11.1",
  "block2",
+ "libc",
  "objc2",
  "objc2-core-foundation",
 ]
@@ -3242,6 +3244,30 @@ dependencies = [
  "web-sys",
 ]
 
+[[package]]
+name = "rfd"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.60.2",
+]
+
 [[package]]
 name = "rkyv"
 version = "0.7.46"
@@ -4288,6 +4314,48 @@ dependencies = [
  "walkdir",
 ]
 
+[[package]]
+name = "tauri-plugin-dialog"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
+dependencies = [
+ "log",
+ "raw-window-handle",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-plugin-fs",
+ "thiserror 2.0.18",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-fs"
+version = "2.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
+dependencies = [
+ "anyhow",
+ "dunce",
+ "glob",
+ "log",
+ "objc2-foundation",
+ "percent-encoding",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+]
+
 [[package]]
 name = "tauri-plugin-global-shortcut"
 version = "2.3.1"
@@ -5501,6 +5569,15 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.61.2"
@@ -5549,13 +5626,30 @@ dependencies = [
  "windows_aarch64_gnullvm 0.52.6",
  "windows_aarch64_msvc 0.52.6",
  "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm",
+ "windows_i686_gnullvm 0.52.6",
  "windows_i686_msvc 0.52.6",
  "windows_x86_64_gnu 0.52.6",
  "windows_x86_64_gnullvm 0.52.6",
  "windows_x86_64_msvc 0.52.6",
 ]
 
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
 [[package]]
 name = "windows-threading"
 version = "0.1.0"
@@ -5592,6 +5686,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
 
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
 [[package]]
 name = "windows_aarch64_msvc"
 version = "0.42.2"
@@ -5610,6 +5710,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
 
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
 [[package]]
 name = "windows_i686_gnu"
 version = "0.42.2"
@@ -5628,12 +5734,24 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
 
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
 [[package]]
 name = "windows_i686_gnullvm"
 version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
 
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
 [[package]]
 name = "windows_i686_msvc"
 version = "0.42.2"
@@ -5652,6 +5770,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
 
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
 [[package]]
 name = "windows_x86_64_gnu"
 version = "0.42.2"
@@ -5670,6 +5794,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
 
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
 [[package]]
 name = "windows_x86_64_gnullvm"
 version = "0.42.2"
@@ -5688,6 +5818,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
 
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
 [[package]]
 name = "windows_x86_64_msvc"
 version = "0.42.2"
@@ -5706,6 +5842,12 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
 [[package]]
 name = "winnow"
 version = "0.5.40"

+ 2 - 0
src-tauri/Cargo.toml

@@ -22,6 +22,8 @@ tauri-build = { version = "2", features = [] }
 # Manager::get_window/webviews 等),仍是 Tauri 2 稳定版 crate,只是这些 API 标记为不稳定
 tauri = { version = "2", features = ["protocol-asset", "unstable"] }
 tauri-plugin-opener = "2"
+# 原生 message/ask/confirm 弹窗(前端通过 @tauri-apps/plugin-dialog 调用)
+tauri-plugin-dialog = "2"
 # 全局快捷键:F9/F10/F11 控制录制开始/暂停/停止
 tauri-plugin-global-shortcut = "2"
 # SQLite 持久化任务列表(前端通过 @tauri-apps/plugin-sql 调用)

+ 1 - 0
src-tauri/capabilities/default.json

@@ -10,6 +10,7 @@
     "core:default",
     "opener:default",
     "opener:allow-reveal-item-in-dir",
+    "dialog:default",
     "core:window:allow-close",
     "core:window:allow-center",
     "core:window:allow-minimize",

+ 2 - 67
src-tauri/src/capture.rs

@@ -138,14 +138,11 @@ async fn capture_to_png_bytes(app: &AppHandle) -> anyhow::Result<Vec<u8>> {
 
 #[cfg(target_os = "windows")]
 mod windows_impl {
+    use crate::cdp::call_cdp;
     use anyhow::{anyhow, bail, Context, Result};
     use base64::{engine::general_purpose::STANDARD, Engine};
-    use serde_json::{json, Value};
-    use std::sync::{Arc, Mutex};
+    use serde_json::json;
     use tauri::Webview;
-    use tokio::sync::oneshot;
-    use webview2_com::CallDevToolsProtocolMethodCompletedHandler;
-    use windows::core::HSTRING;
 
     /// 预热脚本:等懒加载内容、字体、图片加载完成;最大 8s 兜底超时。
     ///
@@ -253,66 +250,4 @@ mod windows_impl {
         }
         Ok(bytes)
     }
-
-    /// 调一个 CDP 方法,返回响应 JSON(解析后的 serde_json::Value)。
-    ///
-    /// CallDevToolsProtocolMethod 是异步 API,结果通过 completed handler 回调,
-    /// 我们用 tokio::sync::oneshot 把它桥到 async/await。
-    async fn call_cdp(webview: &Webview, method: &str, params: Value) -> Result<Value> {
-        let (tx, rx) = oneshot::channel::<Result<String, String>>();
-        let tx = Arc::new(Mutex::new(Some(tx)));
-
-        let method_owned = method.to_string();
-        let params_str = serde_json::to_string(&params)?;
-
-        let tx_for_native = tx.clone();
-        let method_for_native = method_owned.clone();
-        webview
-            .with_webview(move |platform| {
-                // platform 在 Windows 上是 wry 的 webview2 包装,controller() 给 ICoreWebView2Controller
-                let result: Result<()> = (|| {
-                    let controller = platform.controller();
-                    let core = unsafe { controller.CoreWebView2()? };
-
-                    let method_h: HSTRING = method_for_native.clone().into();
-                    let params_h: HSTRING = params_str.clone().into();
-                    let tx_handler = tx_for_native.clone();
-                    let handler = CallDevToolsProtocolMethodCompletedHandler::create(Box::new(
-                        move |hr, json_pcwstr| {
-                            let json_str = json_pcwstr.to_string();
-                            let res = if hr.is_ok() {
-                                Ok(json_str)
-                            } else {
-                                Err(format!("CDP HRESULT 错误: {}", hr.err().unwrap()))
-                            };
-                            if let Ok(mut guard) = tx_handler.lock() {
-                                if let Some(sender) = guard.take() {
-                                    let _ = sender.send(res);
-                                }
-                            }
-                            Ok(())
-                        },
-                    ));
-
-                    unsafe {
-                        core.CallDevToolsProtocolMethod(&method_h, &params_h, &handler)?;
-                    }
-                    Ok(())
-                })();
-
-                // 调用本身失败时(不是 CDP 回调失败),把错误送回 rx
-                if let Err(e) = result {
-                    if let Ok(mut guard) = tx_for_native.lock() {
-                        if let Some(sender) = guard.take() {
-                            let _ = sender.send(Err(format!("CDP 同步阶段失败: {e}")));
-                        }
-                    }
-                }
-            })
-            .map_err(|e| anyhow!("with_webview 调用失败: {e}"))?;
-
-        let raw = rx.await?.map_err(|_| anyhow!("CDP oneshot 通道关闭"))?;
-
-        serde_json::from_str(&raw).map_err(|e| anyhow!("CDP 响应不是合法 JSON: {e}; raw={raw}"))
-    }
 }

+ 77 - 0
src-tauri/src/cdp.rs

@@ -0,0 +1,77 @@
+//! Chrome DevTools Protocol(CDP)通用 helper —— 仅 Windows
+//!
+//! 通过 webview2_com 的 CallDevToolsProtocolMethod 调任意 CDP 命令,
+//! 异步回调用 oneshot 桥成 async/await。当前供:
+//!   - capture.rs:截图前的预热脚本 + Page.captureScreenshot
+//!   - landing.rs:中间页轮询 div.c-tags 与 Visit Site 链接
+
+#![cfg(target_os = "windows")]
+
+use anyhow::{anyhow, Result};
+use serde_json::Value;
+use std::sync::{Arc, Mutex};
+use tauri::Webview;
+use tokio::sync::oneshot;
+use webview2_com::CallDevToolsProtocolMethodCompletedHandler;
+use windows::core::HSTRING;
+
+/// 调一个 CDP 方法,返回响应 JSON(已 parse 为 serde_json::Value)。
+///
+/// 失败原因可能来自三处:
+///   1) with_webview 同步阶段(如 controller 为空)
+///   2) CDP HRESULT 错误(远端方法不支持或 webview 已卸载)
+///   3) CDP 响应不是合法 JSON(理论上不会发生)
+pub async fn call_cdp(webview: &Webview, method: &str, params: Value) -> Result<Value> {
+    let (tx, rx) = oneshot::channel::<Result<String, String>>();
+    let tx = Arc::new(Mutex::new(Some(tx)));
+
+    let method_owned = method.to_string();
+    let params_str = serde_json::to_string(&params)?;
+
+    let tx_for_native = tx.clone();
+    let method_for_native = method_owned.clone();
+    webview
+        .with_webview(move |platform| {
+            let result: Result<()> = (|| {
+                let controller = platform.controller();
+                let core = unsafe { controller.CoreWebView2()? };
+
+                let method_h: HSTRING = method_for_native.clone().into();
+                let params_h: HSTRING = params_str.clone().into();
+                let tx_handler = tx_for_native.clone();
+                let handler = CallDevToolsProtocolMethodCompletedHandler::create(Box::new(
+                    move |hr, json_pcwstr| {
+                        let json_str = json_pcwstr.to_string();
+                        let res = if hr.is_ok() {
+                            Ok(json_str)
+                        } else {
+                            Err(format!("CDP HRESULT 错误: {}", hr.err().unwrap()))
+                        };
+                        if let Ok(mut guard) = tx_handler.lock() {
+                            if let Some(sender) = guard.take() {
+                                let _ = sender.send(res);
+                            }
+                        }
+                        Ok(())
+                    },
+                ));
+
+                unsafe {
+                    core.CallDevToolsProtocolMethod(&method_h, &params_h, &handler)?;
+                }
+                Ok(())
+            })();
+
+            if let Err(e) = result {
+                if let Ok(mut guard) = tx_for_native.lock() {
+                    if let Some(sender) = guard.take() {
+                        let _ = sender.send(Err(format!("CDP 同步阶段失败: {e}")));
+                    }
+                }
+            }
+        })
+        .map_err(|e| anyhow!("with_webview 调用失败: {e}"))?;
+
+    let raw = rx.await?.map_err(|_| anyhow!("CDP oneshot 通道关闭"))?;
+    serde_json::from_str(&raw).map_err(|e| anyhow!("CDP 响应不是合法 JSON: {e}; raw={raw}"))
+}

+ 235 - 0
src-tauri/src/landing.rs

@@ -0,0 +1,235 @@
+//! 中间页(landing)流转模块
+//!
+//! 任务整体流程:
+//!   1. 前端调 navigate_webview(task.url)
+//!   2. webview 加载完中间页 → on_page_load 触发,此时 page_stage=Initial
+//!      → lib.rs 把控制权交给 [`spawn_polling`]
+//!   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
+//!
+//! 超时([`POLL_TIMEOUT_MS`]):emit `task-page-timeout`,由前端弹「重试 / 取消」
+//!
+//! 平台:CDP 仅在 Windows 上实现;非 Windows 直接退化为「已就绪」,
+//! 让按钮可用但跳过 tags 抓取与自动跳转。
+
+use crate::{AppState, PageStage};
+use tauri::{AppHandle, Emitter, Manager};
+
+/// 与前端 src/types/ipc.ts 中常量保持一致(仅 Windows 真正会 emit)
+#[cfg(target_os = "windows")]
+const EVT_TASK_TAGS_EXTRACTED: &str = "task-tags-extracted";
+#[cfg(target_os = "windows")]
+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;
+
+#[cfg(target_os = "windows")]
+#[derive(serde::Serialize, Clone)]
+struct TagsExtracted {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    /// 已按逗号 join 好(即使包含逗号本身的标签也会被保留为合并字符串,保持简单)
+    tags: String,
+}
+
+#[cfg(target_os = "windows")]
+#[derive(serde::Serialize, Clone)]
+struct PageTimeout {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    url: String,
+    reason: String,
+}
+
+/// 由 on_page_load 在 Initial 阶段调用。
+///
+/// task_id / intermediate_url 用于自检(用户中途切换任务时尽快退出)以及
+/// 超时事件 payload 的回填(前端「重试」要重新 navigate 到这个 URL)。
+pub fn spawn_polling(app: AppHandle, task_id: String, intermediate_url: String) {
+    tauri::async_runtime::spawn(async move {
+        poll_loop(app, task_id, intermediate_url).await;
+    });
+}
+
+// =====================================================================
+// Windows 实现: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;
+
+        // 自检:当前任务还是我吗?stage 还在 Initial 吗?
+        // 任意一个不满足,说明用户已切换任务 / 已推进到下一阶段,直接退出
+        if !still_active(&app, &task_id) {
+            return;
+        }
+
+        match query_page(&app).await {
+            Ok((tags, visit_href)) => {
+                if !tags_emitted && !tags.is_empty() {
+                    let joined = tags.join(",");
+                    let _ = app.emit(
+                        EVT_TASK_TAGS_EXTRACTED,
+                        TagsExtracted {
+                            task_id: task_id.clone(),
+                            tags: joined,
+                        },
+                    );
+                    tags_emitted = true;
+                }
+
+                if let Some(href) = visit_href {
+                    // 先把 stage 切到 Final,再触发 navigate;
+                    // 这样下一次 on_page_load 来时 lib.rs 能正确分流到「最终页」分支
+                    if let Some(state) = app.try_state::<AppState>() {
+                        if let Ok(mut g) = state.page_stage.lock() {
+                            *g = PageStage::Final;
+                        }
+                    }
+                    match url::Url::parse(&href) {
+                        Ok(parsed) => match crate::find_content_webview(&app) {
+                            Ok(wv) => {
+                                if let Err(e) = wv.navigate(parsed) {
+                                    eprintln!("[landing] 跳转 Visit Site 失败: {e}");
+                                }
+                            }
+                            Err(e) => eprintln!("[landing] 找不到子 webview: {e}"),
+                        },
+                        Err(e) => eprintln!("[landing] Visit Site href 解析失败 ({href}): {e}"),
+                    }
+                    return;
+                }
+            }
+            Err(e) => {
+                // 单次 CDP 失败不致命(可能页面还没起完),下一轮继续
+                eprintln!("[landing] CDP eval 失败: {e}");
+            }
+        }
+    }
+
+    // 超时:再确认一次仍是当前任务,再 emit,避免无效弹窗
+    if still_active(&app, &task_id) {
+        let _ = app.emit(
+            EVT_TASK_PAGE_TIMEOUT,
+            PageTimeout {
+                task_id,
+                url: intermediate_url,
+                reason: format!("等待 {POLL_TIMEOUT_MS} ms 未找到 Visit Site 链接"),
+            },
+        );
+    }
+}
+
+#[cfg(target_os = "windows")]
+fn still_active(app: &AppHandle, task_id: &str) -> bool {
+    let Some(state) = app.try_state::<AppState>() else {
+        return false;
+    };
+    let id_ok = state.current_task().as_deref() == Some(task_id);
+    let stage_ok = state
+        .page_stage
+        .lock()
+        .ok()
+        .map(|g| *g == PageStage::Initial)
+        .unwrap_or(false);
+    id_ok && stage_ok
+}
+
+/// 单次 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;
+
+    const SCRIPT: &str = r#"
+(() => {
+  const tagEls = document.querySelectorAll('div.c-tags > ul > li > strong > a');
+  const tags = Array.from(tagEls)
+    .map(a => (a.textContent || '').trim())
+    .filter(s => s.length > 0);
+
+  let visitHref = null;
+  const linkEls = document.querySelectorAll('.menu-float__content > strong > a');
+  for (const a of linkEls) {
+    const text = (a.textContent || '').trim();
+    if (text.includes('Visit Site')) {
+      visitHref = a.href;
+      break;
+    }
+  }
+  return { tags, visitHref };
+})()
+"#;
+
+    let webview = crate::find_content_webview(app).map_err(anyhow::Error::msg)?;
+    let resp = crate::cdp::call_cdp(
+        &webview,
+        "Runtime.evaluate",
+        json!({
+            "expression": SCRIPT,
+            "returnByValue": true,
+        }),
+    )
+    .await?;
+
+    // 结构形如:{ "result": { "type": "object", "value": { "tags": [...], "visitHref": "..." } } }
+    let value = resp
+        .get("result")
+        .and_then(|r| r.get("value"))
+        .ok_or_else(|| anyhow::anyhow!("CDP 响应缺少 result.value: {resp}"))?;
+
+    let tags: Vec<String> = value
+        .get("tags")
+        .and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|x| x.as_str().map(|s| s.to_string()))
+                .collect()
+        })
+        .unwrap_or_default();
+
+    let visit_href = value
+        .get("visitHref")
+        .and_then(|v| v.as_str())
+        .map(|s| s.to_string());
+
+    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": "" }),
+    );
+}

+ 109 - 14
src-tauri/src/lib.rs

@@ -1,4 +1,6 @@
 mod capture;
+mod cdp;
+mod landing;
 mod paths;
 mod preview;
 mod recording;
@@ -23,6 +25,27 @@ const DEFAULT_CONTENT_H: f64 = 720.0f64;
 /// 子 webview 初始 url(空白页占位,等用户从任务列表选)
 const INITIAL_URL: &str = "about:blank";
 
+/// 当前任务页面所处阶段(landing 流转状态机)。
+///
+/// 状态转换:
+///   navigate_webview(task.url)  ──► Initial(等待中间页加载 + 轮询)
+///   landing 找到 Visit Site     ──► Final  (等待最终页加载)
+///   最终页 on_page_load          ──► Ready  (按钮可用,自动截图触发)
+///   再次 navigate_webview        ──► Initial(重置)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PageStage {
+    Initial,
+    Final,
+    Ready,
+}
+
+impl Default for PageStage {
+    fn default() -> Self {
+        // 首次启动 webview 加载 about:blank,没有任务上下文;Initial 不会被使用
+        PageStage::Initial
+    }
+}
+
 /// 全局应用状态:跨命令共享的"当前任务 id"、录制会话等。
 ///
 /// 用 std::sync::Mutex 包裹,命令是同步执行的,锁竞争极低;如果后续要在
@@ -34,6 +57,8 @@ pub struct AppState {
     pub current_task_id: Mutex<Option<String>>,
     /// 进行中的录制会话:session_id → 元数据
     pub recording_sessions: Mutex<HashMap<String, RecordingSession>>,
+    /// landing 流转阶段;on_page_load 据此分流到「轮询」/「就绪」分支
+    pub page_stage: Mutex<PageStage>,
 }
 
 impl AppState {
@@ -104,10 +129,15 @@ fn navigate_webview(
 ) -> Result<(), String> {
     let parsed = url::Url::parse(&url).map_err(|e| format!("url ({})解析失败: {}", &url, e))?;
 
-    // 先更新 state(即便后续 navigate 失败,state 也会被下一次正常调用覆盖)
+    // 先更新 state(即便后续 navigate 失败,state 也会被下一次正常调用覆盖)。
+    // 同时把 page_stage 重置为 Initial:这次 navigate 永远指向「中间页」,
+    // 后续 landing 模块决定是否再 navigate 到 Visit Site URL。
     if let Ok(mut guard) = state.current_task_id.lock() {
         *guard = Some(task_id);
     }
+    if let Ok(mut guard) = state.page_stage.lock() {
+        *guard = PageStage::Initial;
+    }
 
     let webview = find_content_webview(&app)?;
     webview.navigate(parsed).map_err(|e| e.to_string())
@@ -149,19 +179,85 @@ fn set_window_size(
     Ok(())
 }
 
+/// 子 webview 任意一次「页面加载完成」时的总入口(由 on_page_load 异步派发)。
+///
+/// 根据 page_stage 分流:
+///   - Initial:当前是中间页 → spawn landing 轮询;
+///   - Final:当前是 Visit Site 最终页 → emit task-page-ready,1.5s 后自动截图;
+///   - Ready:已就绪还在加载(重新刷新等),不做额外动作。
+///
+/// about:blank / 无任务上下文(current_task_id=None)的加载直接忽略。
+async fn handle_page_loaded(app: AppHandle, loaded_url: String) {
+    use tauri::Emitter;
+
+    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)
+    };
+
+    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 => {
+            // 已就绪状态下二次加载(用户刷新页面等)—— 不做额外动作
+        }
+    }
+}
+
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
     // tauri-plugin-sql 的 schema 迁移定义
     // db 名固定为 sqlite:autorecord.db,落在 APPDATA 目录
-    let sql_migrations = vec![tauri_plugin_sql::Migration {
-        version: 1,
-        description: "create_tasks_table",
-        sql: "CREATE TABLE IF NOT EXISTS tasks (\n            id TEXT PRIMARY KEY,\n            url TEXT NOT NULL,\n            status INTEGER NOT NULL DEFAULT 0,\n            desc TEXT NOT NULL DEFAULT ''\n        );",
-        kind: tauri_plugin_sql::MigrationKind::Up,
-    }];
+    //
+    // 注意:plugin-sql 用 SQL 文本 hash 做幂等校验,已经下发到用户机的 migration 字段
+    // 一律不能再动(哪怕只是空白),否则启动会报 "migration X was previously applied
+    // but has been modified" 并整个 DB 加载失败。新增改动一律走新的 version。
+    let sql_migrations = vec![
+        tauri_plugin_sql::Migration {
+            version: 1,
+            description: "create_tasks_table",
+            sql: "CREATE TABLE IF NOT EXISTS tasks (\n            id TEXT PRIMARY KEY,\n            url TEXT NOT NULL,\n            status INTEGER NOT NULL DEFAULT 0,\n            desc TEXT NOT NULL DEFAULT ''\n        );",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
+        // v2:landing 页提取出的 tags(逗号分隔),SQLite 无 VARCHAR 容量限制,统一用 TEXT
+        tauri_plugin_sql::Migration {
+            version: 2,
+            description: "add_tasks_tags",
+            sql: "ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '';",
+            kind: tauri_plugin_sql::MigrationKind::Up,
+        },
+    ];
 
     tauri::Builder::default()
         .plugin(tauri_plugin_opener::init())
+        .plugin(tauri_plugin_dialog::init())
         .plugin(shortcuts::record_shortcut_plugin())
         .plugin(
             tauri_plugin_sql::Builder::default()
@@ -185,11 +281,10 @@ pub fn run() {
 
             // 2) 在已经放大的窗口上挂 child webview,定位到工具栏下方
             //
-            //    on_page_load 钩子用于「页面加载完成 → 自动截图」:
-            //    - PageLoadEvent::Finished 触发后 spawn 一个异步任务
-            //    - 等 1.5s(粗预热,等懒加载 JS 起步)
-            //    - 然后调 capture::trigger_auto_capture
-            //    - capture 内部会再做一次「CDP 撑大视口 + DOM 稳定」预热,详见 capture.rs
+            //    on_page_load 钩子分流到 [`handle_page_loaded`]:
+            //    - Initial 阶段:spawn landing 轮询(找 tags + Visit Site 链接)
+            //    - Final   阶段:标记 Ready + 1.5s 后自动截图
+            //    - Ready   阶段:忽略(用户手动刷新 / 同 URL 重新加载)
             let initial_url: url::Url = INITIAL_URL.parse()?;
             let app_handle_for_load = app.handle().clone();
             let window = app.get_window("main").ok_or("主窗口未找到")?;
@@ -202,9 +297,9 @@ pub fn run() {
                             return;
                         }
                         let app = app_handle_for_load.clone();
+                        let loaded_url = payload.url().to_string();
                         tauri::async_runtime::spawn(async move {
-                            tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
-                            capture::trigger_auto_capture(app).await;
+                            handle_page_loaded(app, loaded_url).await;
                         });
                     }),
                 LogicalPosition::new(0, 0),

+ 206 - 131
src/App.tsx

@@ -4,11 +4,13 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
 import { getCurrentWindow } from "@tauri-apps/api/window";
 import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
 import { revealItemInDir } from "@tauri-apps/plugin-opener";
-import { Button, Divider, InputNumber, Layout, Menu, notification, Space, theme, Tooltip } from "antd";
+import { ask, message } from "@tauri-apps/plugin-dialog";
+import { Button, Divider, InputNumber, Layout, Menu, Space, theme, Tooltip } from "antd";
 import {
   countPendingTasks,
   listPendingTasks,
   markTaskDone,
+  updateTaskTags,
   type Task,
 } from "./lib/tasks";
 import {
@@ -17,6 +19,9 @@ import {
   EVT_RECORD_SHORTCUT,
   EVT_SCREENSHOT_FAILED,
   EVT_SCREENSHOT_FINISHED,
+  EVT_TASK_PAGE_READY,
+  EVT_TASK_PAGE_TIMEOUT,
+  EVT_TASK_TAGS_EXTRACTED,
   EVT_TASKS_IMPORTED,
   type RecordingFailedPayload,
   type RecordingFinishedPayload,
@@ -25,6 +30,9 @@ import {
   type ScreenshotFailedPayload,
   type ScreenshotFinishedPayload,
   type TaskAssets,
+  type TaskPageReadyPayload,
+  type TaskPageTimeoutPayload,
+  type TaskTagsExtractedPayload,
 } from "./types/ipc";
 import { capturePage } from "./lib/capture";
 import {
@@ -54,6 +62,19 @@ import {
 
 const { Header, Content, Sider } = Layout;
 
+/** showStatus 支持的三类语义,对应底部状态栏色块颜色 + 原生对话框 kind */
+type StatusKind = "success" | "error" | "warning";
+const STATUS_COLOR: Record<StatusKind, string> = {
+  success: "#52c41a",
+  error: "#ff4d4f",
+  warning: "#faad14",
+};
+const STATUS_DIALOG_KIND: Record<StatusKind, "info" | "warning" | "error"> = {
+  success: "info",
+  error: "error",
+  warning: "warning",
+};
+
 /**
  * 与 Rust 端常量保持一致(src-tauri/src/lib.rs):
  *   LEFT_PANEL_WIDTH = 180
@@ -107,6 +128,14 @@ function App() {
   // 当前任务的产物文件信息(截图 / 录制 mp4 是否存在 + 路径),驱动右侧三个按钮的 disabled
   const [assets, setAssets] = useState<TaskAssets | null>(null);
 
+  /**
+   * landing 流转门控:true 表示已加载到最终 (Visit Site) URL,可截图 / 录制。
+   * - handleSelectTask 触发 navigate 时强制 false
+   * - 监听 EVT_TASK_PAGE_READY 后变 true
+   * - 监听 EVT_TASK_PAGE_TIMEOUT 时保持 false,弹窗让用户「重试 / 取消」
+   */
+  const [pageReady, setPageReady] = useState(false);
+
   // activeId 的最新值快照,供 listen 闭包中读取(避免在每个 effect 上加 activeId 依赖
   // 导致 listen 频繁重订)
   const activeIdRef = useRef<string | null>(activeId);
@@ -114,8 +143,27 @@ function App() {
     activeIdRef.current = activeId;
   }, [activeId]);
 
-  // antd 6 notification 必须用 hook + contextHolder 才能正确取到主题
-  const [notifyApi, notifyContext] = notification.useNotification();
+  // 底部状态栏文案与左侧色块;showStatus 同时弹原生对话框 + 写状态栏
+  const [statusColor, setStatusColor] = useState("#888");
+  const [statusText, setStatusText] = useState("请选择任务");
+
+  /**
+   * 统一的状态提示通道:
+   *   1) 调 @tauri-apps/plugin-dialog 的 message() 弹一个原生对话框
+   *   2) 同步把 statusText / statusColor 更新到底部状态栏
+   * 对话框为非阻塞触发(fire-and-forget),失败仅打日志兜底
+   */
+  const showStatus = useCallback(
+    (kind: StatusKind, title: string, description?: string) => {
+      setStatusColor(STATUS_COLOR[kind]);
+      setStatusText(title);
+      void message(description ? `${title}\n${description}` : title, {
+        title: "提示",
+        kind: STATUS_DIALOG_KIND[kind],
+      }).catch((e) => console.error("显示对话框失败:", e));
+    },
+    [],
+  );
 
   /**
    * 重新拉取 sqlite 中 status=0 的任务列表。
@@ -131,14 +179,10 @@ function App() {
       return list;
     } catch (e) {
       console.error("加载任务列表失败:", e);
-      notifyApi.error({
-        message: "加载任务失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "加载任务失败", String(e));
       return [];
     }
-  }, [notifyApi]);
+  }, [showStatus]);
 
   /** 启动「批量导入 URL」窗口(独立 WebviewWindow,label = "import") */
   const openImportWindow = useCallback(async () => {
@@ -163,13 +207,9 @@ function App() {
       });
     } catch (e) {
       console.error("openImportWindow 失败:", e);
-      notifyApi.error({
-        message: "无法打开导入窗口",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "无法打开导入窗口", String(e));
     }
-  }, [notifyApi]);
+  }, [showStatus]);
 
   function handleSelectTask({ key }: { key: string }) {
     const t = tasks.find((v) => v.id == key);
@@ -177,6 +217,8 @@ function App() {
       return;
     }
     setActiveId(t.id);
+    // 新一轮 landing 流转开始:先把按钮门控锁住,等 Rust 端发 task-page-ready 再放开
+    setPageReady(false);
     void loadInWebview(t.id, t.url);
   }
 
@@ -221,11 +263,7 @@ function App() {
     let disposed = false;
     (async () => {
       const u = await listen<{ count: number }>(EVT_TASKS_IMPORTED, (e) => {
-        notifyApi.success({
-          message: "导入完成",
-          description: `已写入 ${e.payload.count} 条任务`,
-          duration: 3,
-        });
+        showStatus("success", "导入完成", `已写入 ${e.payload.count} 条任务`);
         void reloadTasks();
       });
       if (disposed) u();
@@ -235,18 +273,91 @@ function App() {
       disposed = true;
       unlisten?.();
     };
-  }, [notifyApi, reloadTasks]);
+  }, [showStatus, reloadTasks]);
+
+  /**
+   * 监听 landing 三个事件:
+   *   - task-tags-extracted:中间页抓到标签 → 写库 + reload
+   *   - task-page-ready:跳到最终 URL 完成 → 解锁按钮 + 状态栏提示
+   *   - task-page-timeout:未找到 Visit Site → ask() 弹「重试 / 取消」
+   * 全部按 activeIdRef 过滤,避免给已切走的旧任务弹无效提示。
+   */
+  useEffect(() => {
+    let unlistenTags: UnlistenFn | null = null;
+    let unlistenReady: UnlistenFn | null = null;
+    let unlistenTimeout: UnlistenFn | null = null;
+    let disposed = false;
+
+    (async () => {
+      const u1 = await listen<TaskTagsExtractedPayload>(
+        EVT_TASK_TAGS_EXTRACTED,
+        async (e) => {
+          const { taskId, tags } = e.payload;
+          try {
+            await updateTaskTags(taskId, tags);
+            await reloadTasks();
+            showStatus("success", "已提取标签", tags || "(空)");
+          } catch (err) {
+            console.error("写入 tags 失败:", err);
+            showStatus("error", "写入标签失败", String(err));
+          }
+        },
+      );
+      const u2 = 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>(
+        EVT_TASK_PAGE_TIMEOUT,
+        async (e) => {
+          const { taskId, url, reason } = e.payload;
+          if (activeIdRef.current !== taskId) return;
+          // 弹原生 ask 对话框:确认 = 重试;取消 = 保持禁用
+          const retry = await ask(`${reason}\n\n是否刷新重试?`, {
+            title: "未找到 Visit Site 链接",
+            kind: "warning",
+            okLabel: "重试",
+            cancelLabel: "取消",
+          }).catch(() => false);
+          if (retry) {
+            // 重新 navigate:Rust 端会重置 page_stage=Initial 并重新轮询
+            setPageReady(false);
+            await loadInWebview(taskId, url);
+          } else {
+            // 用户取消:保持按钮禁用,写状态栏告知
+            showStatus("warning", "已取消重试", "重新点击任务可继续尝试");
+          }
+        },
+      );
+
+      if (disposed) {
+        u1();
+        u2();
+        u3();
+      } else {
+        unlistenTags = u1;
+        unlistenReady = u2;
+        unlistenTimeout = u3;
+      }
+    })();
+
+    return () => {
+      disposed = true;
+      unlistenTags?.();
+      unlistenReady?.();
+      unlistenTimeout?.();
+    };
+  }, [reloadTasks, showStatus]);
 
   /** 工具栏 "完成" 按钮:把当前激活任务标记为已完成(status=1)→ 刷新 → 清空选中 */
   const handleCompleteTask = useCallback(async () => {
     if (!activeId) return;
     try {
       await markTaskDone(activeId);
-      notifyApi.success({
-        message: "任务已完成",
-        description: `任务 ${activeId} 已从列表移除`,
-        duration: 3,
-      });
+      showStatus("success", "任务已完成", `任务 ${activeId} 已从列表移除`);
       setActiveId(null);
       const list = await reloadTasks();
       // 若已无任务,自动再拉起一次导入窗口(用户可继续粘贴)
@@ -254,26 +365,23 @@ function App() {
         await openImportWindow();
       }
     } catch (e) {
-      notifyApi.error({
-        message: "标记完成失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "标记完成失败", String(e));
     }
-  }, [activeId, reloadTasks, openImportWindow, notifyApi]);
+  }, [activeId, reloadTasks, openImportWindow, showStatus]);
 
   /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */
   const handleManualCapture = useCallback(async () => {
-    if (!activeId) return;
+    // 必须已加载到最终页才能截图,否则截到的是中间页
+    if (!activeId || !pageReady) return;
     try {
       await capturePage(activeId);
     } catch (e) {
       // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底
       console.error("手动截图失败:", e);
     }
-  }, [activeId]);
+  }, [activeId, pageReady]);
 
-  // 订阅 Rust 端的截图事件,统一用 notification 提示
+  // 订阅 Rust 端的截图事件,统一走 showStatus(原生 dialog + 状态栏)
   useEffect(() => {
     let unlistenFinished: UnlistenFn | null = null;
     let unlistenFailed: UnlistenFn | null = null;
@@ -282,11 +390,11 @@ function App() {
     (async () => {
       const u1 = await listen<ScreenshotFinishedPayload>(EVT_SCREENSHOT_FINISHED, (e) => {
         const { taskId, path, auto } = e.payload;
-        notifyApi.success({
-          message: auto ? "自动截图完成" : "截图完成",
-          description: `任务 ${taskId} → ${path}`,
-          duration: 4,
-        });
+        showStatus(
+          "success",
+          auto ? "自动截图完成" : "截图完成",
+          `任务 ${taskId} → ${path}`,
+        );
         // 当前选中即此任务时刷新 assets,让预览图按钮立即变可点
         if (activeIdRef.current === taskId) {
           void refreshAssets(taskId);
@@ -294,11 +402,11 @@ function App() {
       });
       const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
         const { taskId, auto, error } = e.payload;
-        notifyApi.error({
-          message: auto ? "自动截图失败" : "截图失败",
-          description: `任务 ${taskId}:${error}`,
-          duration: 6,
-        });
+        showStatus(
+          "error",
+          auto ? "自动截图失败" : "截图失败",
+          `任务 ${taskId}:${error}`,
+        );
       });
 
       if (disposed) {
@@ -315,14 +423,14 @@ function App() {
       unlistenFinished?.();
       unlistenFailed?.();
     };
-  }, [notifyApi, refreshAssets]);
+  }, [showStatus, refreshAssets]);
 
   // 订阅录制状态机变化,驱动按钮 UI 切换
   useEffect(() => {
     return subscribeRecordState(setRecordState);
   }, []);
 
-  // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一 notification 提示
+  // 订阅 Rust 端的录制事件(finalize 转码完成 / 失败),统一走 showStatus
   // 与截图事件订阅采用同一套 disposed 模式,避免 StrictMode 双调用的清理竞态。
   useEffect(() => {
     let unlistenFinished: UnlistenFn | null = null;
@@ -332,22 +440,14 @@ function App() {
     (async () => {
       const u1 = await listen<RecordingFinishedPayload>(EVT_RECORDING_FINISHED, (e) => {
         const { taskId, path } = e.payload;
-        notifyApi.success({
-          message: "录制完成",
-          description: `任务 ${taskId} → ${path}`,
-          duration: 5,
-        });
+        showStatus("success", "录制完成", `任务 ${taskId} → ${path}`);
         if (activeIdRef.current === taskId) {
           void refreshAssets(taskId);
         }
       });
       const u2 = await listen<RecordingFailedPayload>(EVT_RECORDING_FAILED, (e) => {
         const { taskId, error } = e.payload;
-        notifyApi.error({
-          message: "录制失败",
-          description: `任务 ${taskId}:${error}`,
-          duration: 6,
-        });
+        showStatus("error", "录制失败", `任务 ${taskId}:${error}`);
       });
 
       if (disposed) {
@@ -364,7 +464,7 @@ function App() {
       unlistenFinished?.();
       unlistenFailed?.();
     };
-  }, [notifyApi, refreshAssets]);
+  }, [showStatus, refreshAssets]);
 
   // 组件卸载兜底:若正在录制 / 暂停 / 转码中,主动取消,避免悬挂 stream
   useEffect(() => {
@@ -373,19 +473,15 @@ function App() {
     };
   }, []);
 
-  /** 开始按钮:仅在 idle + 已选任务 时可点;Rust 端 spawn ffmpeg 子进程录屏 */
+  /** 开始按钮:仅在 idle + 已选任务 + 页面已就绪 时可点;Rust 端 spawn ffmpeg 子进程录屏 */
   const handleStartRecord = useCallback(async () => {
-    if (recordState !== "idle" || !activeId) return;
+    if (recordState !== "idle" || !activeId || !pageReady) return;
     try {
       await startRecording(activeId, contentSize.w, contentSize.h);
     } catch (e) {
-      notifyApi.error({
-        message: "开始录制失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "开始录制失败", String(e));
     }
-  }, [recordState, activeId, contentSize, notifyApi]);
+  }, [recordState, activeId, pageReady, contentSize, showStatus]);
 
   /** 停止按钮:触发 ffmpeg 优雅退出 + 落盘 */
   const handleStopRecord = useCallback(async () => {
@@ -395,13 +491,9 @@ function App() {
       // 成功时由 EVT_RECORDING_FINISHED 事件统一提示;这里不重复弹
     } catch (e) {
       // 本地异常(如内部状态不一致)兜底提示;ffmpeg 失败由 EVT_RECORDING_FAILED 处理
-      notifyApi.error({
-        message: "停止录制失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "停止录制失败", String(e));
     }
-  }, [recordState, notifyApi]);
+  }, [recordState, showStatus]);
 
   /** 全局快捷键派发:F9 = 开始,F11 = 停止 */
   const handleShortcut = useCallback(
@@ -410,11 +502,11 @@ function App() {
         if (action === "start") {
           if (recordState !== "idle") return;
           if (!activeId) {
-            notifyApi.warning({
-              message: "无法开始录制",
-              description: "请先在左栏选择一个任务",
-              duration: 4,
-            });
+            showStatus("warning", "无法开始录制", "请先在左栏选择一个任务");
+            return;
+          }
+          if (!pageReady) {
+            showStatus("warning", "无法开始录制", "等待最终页加载完成后再试");
             return;
           }
           await startRecording(activeId, contentSize.w, contentSize.h);
@@ -424,14 +516,10 @@ function App() {
           }
         }
       } catch (e) {
-        notifyApi.error({
-          message: "快捷键操作失败",
-          description: String(e),
-          duration: 6,
-        });
+        showStatus("error", "快捷键操作失败", String(e));
       }
     },
-    [recordState, activeId, contentSize, notifyApi],
+    [recordState, activeId, pageReady, contentSize, showStatus],
   );
 
   // 订阅 Rust 端的全局快捷键事件 (F9 / F11)
@@ -462,13 +550,9 @@ function App() {
     try {
       await revealItemInDir(target);
     } catch (e) {
-      notifyApi.error({
-        message: "打开文件夹失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "打开文件夹失败", String(e));
     }
-  }, [assets, notifyApi]);
+  }, [assets, showStatus]);
 
   /** 在新 WebviewWindow 中预览截图 */
   const handlePreviewImage = useCallback(async () => {
@@ -479,13 +563,9 @@ function App() {
         kind: "image",
       });
     } catch (e) {
-      notifyApi.error({
-        message: "图片预览失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "图片预览失败", String(e));
     }
-  }, [assets, notifyApi]);
+  }, [assets, showStatus]);
 
   /** 在新 WebviewWindow 中预览录制 mp4 */
   const handlePreviewVideo = useCallback(async () => {
@@ -496,13 +576,9 @@ function App() {
         kind: "video",
       });
     } catch (e) {
-      notifyApi.error({
-        message: "视频预览失败",
-        description: String(e),
-        duration: 6,
-      });
+      showStatus("error", "视频预览失败", String(e));
     }
-  }, [assets, notifyApi]);
+  }, [assets, showStatus]);
 
   // 开始按钮的图标 / tooltip
   const startConfig = (() => {
@@ -513,14 +589,17 @@ function App() {
       // 视觉上保持 Play 图标,但 disabled
       return { icon: <PlayCircleFilled />, tip: "录制中…(F11 停止)" };
     }
-    return {
-      icon: <PlayCircleFilled />,
-      tip: activeId ? "开始录制(F9)" : "请先选择任务",
-    };
+    if (!activeId) {
+      return { icon: <PlayCircleFilled />, tip: "请先选择任务" };
+    }
+    if (!pageReady) {
+      return { icon: <LoadingOutlined />, tip: "等待最终 URL 加载完成…" };
+    }
+    return { icon: <PlayCircleFilled />, tip: "开始录制(F9)" };
   })();
 
-  // 开始按钮仅在 idle 且已选任务时可点;其它状态 disabled
-  const startDisabled = recordState !== "idle" || !activeId;
+  // 开始按钮仅在 idle + 已选任务 + 最终页已就绪时可点
+  const startDisabled = recordState !== "idle" || !activeId || !pageReady;
   // 停止按钮仅在 recording 时可点
   const stopDisabled = recordState !== "recording";
 
@@ -572,9 +651,6 @@ function App() {
     token: { colorBgContainer },
   } = theme.useToken();
 
-  const [statusColor] = useState("#888");
-  const [statusText] = useState("请选择任务");
-
   // 当前窗口句柄,用于自定义标题栏的最小化 / 关闭
   const appWindow = useMemo(() => getCurrentWindow(), []);
 
@@ -593,7 +669,6 @@ function App() {
       console.error("appWindow.minimize 失败:", e);
     }
   }, [appWindow]);
-
   return (
     <Layout className="m-0 p-0 h-full overflow-hidden">
       {/* data-tauri-drag-region:Tauri 2 原生支持,webview 层会自动 hook mousedown
@@ -604,7 +679,7 @@ function App() {
         data-tauri-drag-region
       >
         <div className="flex h-full flex-row items-center" data-tauri-drag-region>
-          <Space size={4}>
+          <Space size={8}>
             <Button
               className="app-close"
               icon={<CloseOutlined />}
@@ -624,12 +699,19 @@ function App() {
             />
           </Space>
           <div className="w-32" />
-          <Tooltip title={activeId ? "截图整页(覆盖之前的自动截图)" : "请先选择任务"} placement="right">
+          <Tooltip
+            title={
+              !activeId
+                ? "请先选择任务"
+                : !pageReady
+                  ? "等待最终 URL 加载完成…"
+                  : "截图整页(覆盖之前的自动截图)"
+            }
+            placement="right"
+          >
             <Button
-
-              shape="circle"
               icon={<PictureOutlined />}
-              disabled={!activeId}
+              disabled={!activeId || !pageReady}
               onClick={handleManualCapture}
             />
           </Tooltip>
@@ -638,7 +720,6 @@ function App() {
           <Tooltip title={startConfig.tip} placement="right">
             <Button
 
-              shape="circle"
               icon={startConfig.icon}
               disabled={startDisabled}
               onClick={() => void handleStartRecord()}
@@ -655,25 +736,25 @@ function App() {
           >
             <Button
 
-              shape="circle"
               icon={<StopOutlined />}
               disabled={stopDisabled}
               onClick={() => void handleStopRecord()}
             />
           </Tooltip>
+
+          <Divider orientation="vertical" />
           {/* 完成按钮:把当前任务在 sqlite 标记为 status=1,从主列表移除 */}
           <Tooltip
             title={activeId ? "标记当前任务为已完成" : "请先选择任务"}
             placement="right"
           >
             <Button
-              shape="circle"
+              type="primary"
               icon={<CheckCircleOutlined />}
-              disabled={!activeId}
+              disabled={!activeId || !assets?.recording_exists || !assets.screenshot_exists}
               onClick={() => void handleCompleteTask()}
-            />
+            >完成</Button>
           </Tooltip>
-          <Divider orientation="vertical" />
           {/* 打开文件夹:reveal mp4 → png → 兜底 screenshots 目录 */}
           <Tooltip
             title={
@@ -689,7 +770,6 @@ function App() {
           >
             <Button
 
-              shape="circle"
               icon={<FolderOpenOutlined />}
               disabled={!assets}
               onClick={() => void handleOpenFolder()}
@@ -705,8 +785,6 @@ function App() {
             placement="right"
           >
             <Button
-
-              shape="circle"
               icon={<FileImageOutlined />}
               disabled={!assets?.screenshot_exists}
               onClick={() => void handlePreviewImage()}
@@ -722,8 +800,6 @@ function App() {
             placement="right"
           >
             <Button
-
-              shape="circle"
               icon={<VideoCameraOutlined />}
               disabled={!assets?.recording_exists}
               onClick={() => void handlePreviewVideo()}
@@ -793,7 +869,7 @@ function App() {
       <Layout>
         <Sider className="select-none" collapsible collapsed={collapsed} onCollapse={(c) => handleCollaspsed(c)} width={180} style={{ background: colorBgContainer }}>
           <div className="h-full w-full flex-row">
-            <div className="flex justify-between items-center text-md pl-1 font-bold text-white"><OrderedListOutlined />{collapsed ? '' : '所有任务'}<Button icon={<PlusOutlined />} onClick={openImportWindow} /> </div>
+            <div className="flex justify-between items-center text-md pl-4 font-bold text-white"><OrderedListOutlined />{collapsed ? '' : '所有任务'}<Button icon={<PlusOutlined />} onClick={openImportWindow} /> </div>
             <div className="flex-1 overflow-y-auto">
               <Menu
                 mode="vertical"
@@ -813,10 +889,9 @@ function App() {
         </Sider>
         <Layout className="flex flex-row">
           <Content className="w-full m-0 p-1 flex-1" />
-          <div className="w-full h-6 flex items-center bg-red-400 px-2 text-gray-3">
+          <div className="w-full h-6 flex items-center px-2 text-gray-3">
             <div className="bg-gray-6 rounded-full w-3 h-3 m-2" style={{ backgroundColor: statusColor }} />
-            <div className="w-32">{statusText}</div>
-            <span className="flex-1">{notifyContext}</span>
+            <div className="flex-1">{statusText}</div>
           </div>
         </Layout>
       </Layout>

+ 19 - 7
src/lib/tasks.ts

@@ -3,11 +3,12 @@
 // 替代之前的 src/mocks/tasks.ts,所有数据落 SQLite(@tauri-apps/plugin-sql)。
 // 数据库名与 src-tauri/src/lib.rs 中的 migration 一致:sqlite:autorecord.db
 //
-// 表结构(migration v1):
-//   id      TEXT  PRIMARY KEY     -- uuid 缩短到 12 位
-//   url     TEXT  NOT NULL
-//   status  INT   NOT NULL DEFAULT 0   -- 0=待处理, 1=已完成
-//   "desc"  TEXT  NOT NULL DEFAULT ''  -- DESC 是 SQLite 关键字,列名要带引号
+// 表结构:
+//   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)
 
 import Database from "@tauri-apps/plugin-sql";
 
@@ -17,12 +18,14 @@ const DB_URI = "sqlite:autorecord.db";
 export interface Task {
   /** 任务唯一标识(uuid 截短 12 位) */
   id: string;
-  /** 目标网页 url */
+  /** 目标网页 url(即 landing 中间页 url) */
   url: string;
   /** 0=待处理(出现在主列表),1=已完成(隐藏) */
   status: number;
   /** 备注(当前导入流程统一留空,后续可补) */
   desc: string;
+  /** 中间页 div.c-tags 抓出的标签,逗号分隔;未抓取/无标签为空串 */
+  tags: string;
 }
 
 let _dbPromise: Promise<Database> | null = null;
@@ -45,7 +48,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" FROM tasks WHERE status = 0 ORDER BY rowid ASC',
+    'SELECT id, url, status, "desc", tags FROM tasks WHERE status = 0 ORDER BY rowid ASC',
   );
 }
 
@@ -85,3 +88,12 @@ export async function markTaskDone(id: string): Promise<void> {
   const db = await getDb();
   await db.execute("UPDATE tasks SET status = 1 WHERE id = $1", [id]);
 }
+
+/**
+ * 更新指定任务的 tags 字段(landing 流程提取出标签后调用)。
+ * tags 已在 Rust 端按逗号拼接好;这里直接整字符串覆盖。
+ */
+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]);
+}

+ 29 - 0
src/types/ipc.ts

@@ -38,6 +38,12 @@ export const EVT_RECORDING_FAILED = "recording-failed";
 export const EVT_RECORD_SHORTCUT = "record-shortcut";
 /** 「批量导入 URL」窗口写入完成后通知主窗口刷新列表 */
 export const EVT_TASKS_IMPORTED = "tasks-imported";
+/** landing 中间页提取到 tags 后由 Rust 发出,前端写库 */
+export const EVT_TASK_TAGS_EXTRACTED = "task-tags-extracted";
+/** Visit Site 跳转后,最终 url 加载完成,前端解锁按钮 */
+export const EVT_TASK_PAGE_READY = "task-page-ready";
+/** landing 轮询超时(未找到 Visit Site 链接),前端弹「重试 / 取消」 */
+export const EVT_TASK_PAGE_TIMEOUT = "task-page-timeout";
 
 /** 批量导入完成事件 payload */
 export interface TasksImportedPayload {
@@ -66,6 +72,29 @@ export interface RecordingFailedPayload {
   error: string;
 }
 
+/** landing 提取标签事件 payload */
+export interface TaskTagsExtractedPayload {
+  taskId: string;
+  /** 已按逗号 join 好;为空字符串表示未提取到任何标签 */
+  tags: string;
+}
+
+/** landing 最终页加载完成事件 payload */
+export interface TaskPageReadyPayload {
+  taskId: string;
+  /** 最终 URL(跳转后的 Visit Site 链接) */
+  url: string;
+}
+
+/** landing 轮询超时事件 payload */
+export interface TaskPageTimeoutPayload {
+  taskId: string;
+  /** 中间页 URL(用于重试时再次 navigate) */
+  url: string;
+  /** 描述性原因,前端可在弹窗里展示 */
+  reason: string;
+}
+
 /** query_task_assets 命令的返回结构(字段名与 Rust serde 一致) */
 export interface TaskAssets {
   screenshot_path: string;