lv преди 3 седмици
родител
ревизия
2604f038ab
променени са 17 файла, в които са добавени 1750 реда и са изтрити 174 реда
  1. 1 1
      CLAUDE.md
  2. 1 0
      package.json
  3. 3 0
      pnpm-lock.yaml
  4. 123 19
      src-tauri/Cargo.lock
  5. 25 1
      src-tauri/Cargo.toml
  6. 2 1
      src-tauri/capabilities/default.json
  7. 331 0
      src-tauri/src/capture.rs
  8. 207 4
      src-tauri/src/lib.rs
  9. 99 0
      src-tauri/src/paths.rs
  10. 290 0
      src-tauri/src/recording.rs
  11. 9 3
      src-tauri/tauri.conf.json
  12. 12 106
      src/App.css
  13. 269 39
      src/App.tsx
  14. 16 0
      src/lib/capture.ts
  15. 249 0
      src/lib/recorder.ts
  16. 51 0
      src/mocks/tasks.ts
  17. 62 0
      src/types/ipc.ts

+ 1 - 1
CLAUDE.md

@@ -14,7 +14,7 @@
   - 包管理:pnpm(推荐 ≥ 10,配合 Node.js ≥ 20.19)
   - 桌面壳:Tauri 2.x(官方稳定版)
 - **应用标识**:`com.ewaga.autorecord`(`src-tauri/tauri.conf.json#identifier`)
-- **目标平台**:macOS / Windows
+- **目标平台**  Windows / macOS (windows优先)
                       |
 
 ## 2. 目录结构

+ 1 - 0
package.json

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

+ 3 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@ant-design/icons':
+        specifier: 6.x
+        version: 6.2.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
       '@tauri-apps/api':
         specifier: ^2
         version: 2.11.0

+ 123 - 19
src-tauri/Cargo.lock

@@ -211,11 +211,18 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 name = "auto-record"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
+ "base64 0.22.1",
  "serde",
  "serde_json",
  "tauri",
  "tauri-build",
  "tauri-plugin-opener",
+ "tokio",
+ "url",
+ "uuid",
+ "webview2-com 0.34.0",
+ "windows 0.58.0",
 ]
 
 [[package]]
@@ -1501,6 +1508,12 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -3293,7 +3306,7 @@ dependencies = [
  "tao-macros",
  "unicode-segmentation",
  "url",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-version",
  "x11-dl",
@@ -3333,6 +3346,7 @@ dependencies = [
  "gtk",
  "heck 0.5.0",
  "http",
+ "http-range",
  "jni",
  "libc",
  "log",
@@ -3362,9 +3376,9 @@ dependencies = [
  "tray-icon",
  "url",
  "webkit2gtk",
- "webview2-com",
+ "webview2-com 0.38.2",
  "window-vibrancy",
- "windows",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -3463,7 +3477,7 @@ dependencies = [
  "tauri-plugin",
  "thiserror 2.0.18",
  "url",
- "windows",
+ "windows 0.61.3",
  "zbus",
 ]
 
@@ -3488,8 +3502,8 @@ dependencies = [
  "thiserror 2.0.18",
  "url",
  "webkit2gtk",
- "webview2-com",
- "windows",
+ "webview2-com 0.38.2",
+ "windows 0.61.3",
 ]
 
 [[package]]
@@ -3513,8 +3527,8 @@ dependencies = [
  "tauri-utils",
  "url",
  "webkit2gtk",
- "webview2-com",
- "windows",
+ "webview2-com 0.38.2",
+ "windows 0.61.3",
  "wry",
 ]
 
@@ -3696,6 +3710,7 @@ dependencies = [
  "libc",
  "mio",
  "pin-project-lite",
+ "signal-hook-registry",
  "socket2",
  "windows-sys 0.61.2",
 ]
@@ -4311,6 +4326,20 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "webview2-com"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "823e7ebcfaea51e78f72c87fc3b65a1e602c321f407a0b36dbb327d7bb7cd921"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys 0.34.0",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+]
+
 [[package]]
 name = "webview2-com"
 version = "0.38.2"
@@ -4318,11 +4347,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
 dependencies = [
  "webview2-com-macros",
- "webview2-com-sys",
- "windows",
+ "webview2-com-sys 0.38.2",
+ "windows 0.61.3",
  "windows-core 0.61.2",
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
 ]
 
 [[package]]
@@ -4336,6 +4365,17 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "webview2-com-sys"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a82bce72db6e5ee83c68b5de1e2cd6ea195b9fbff91cb37df5884cbe3222df4"
+dependencies = [
+ "thiserror 1.0.69",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
 [[package]]
 name = "webview2-com-sys"
 version = "0.38.2"
@@ -4343,7 +4383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
 dependencies = [
  "thiserror 2.0.18",
- "windows",
+ "windows 0.61.3",
  "windows-core 0.61.2",
 ]
 
@@ -4393,6 +4433,16 @@ dependencies = [
  "windows-version",
 ]
 
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows"
 version = "0.61.3"
@@ -4415,14 +4465,27 @@ dependencies = [
  "windows-core 0.61.2",
 ]
 
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-core"
 version = "0.61.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
 dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
  "windows-link 0.1.3",
  "windows-result 0.3.4",
  "windows-strings 0.4.2",
@@ -4434,8 +4497,8 @@ version = "0.62.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
 dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
  "windows-link 0.2.1",
  "windows-result 0.4.1",
  "windows-strings 0.5.1",
@@ -4452,6 +4515,17 @@ dependencies = [
  "windows-threading",
 ]
 
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "windows-implement"
 version = "0.60.2"
@@ -4463,6 +4537,17 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "windows-interface"
 version = "0.59.3"
@@ -4496,6 +4581,15 @@ dependencies = [
  "windows-link 0.1.3",
 ]
 
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-result"
 version = "0.3.4"
@@ -4514,6 +4608,16 @@ dependencies = [
  "windows-link 0.2.1",
 ]
 
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result 0.2.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-strings"
 version = "0.4.2"
@@ -4869,8 +4973,8 @@ dependencies = [
  "url",
  "webkit2gtk",
  "webkit2gtk-sys",
- "webview2-com",
- "windows",
+ "webview2-com 0.38.2",
+ "windows 0.61.3",
  "windows-core 0.61.2",
  "windows-version",
  "x11-dl",

+ 25 - 1
src-tauri/Cargo.toml

@@ -18,8 +18,32 @@ crate-type = ["staticlib", "cdylib", "rlib"]
 tauri-build = { version = "2", features = [] }
 
 [dependencies]
-tauri = { version = "2", features = [] }
+# `unstable` feature 用于启用多 webview API(Window::add_child、WebviewBuilder、
+# Manager::get_window/webviews 等),仍是 Tauri 2 稳定版 crate,只是这些 API 标记为不稳定
+tauri = { version = "2", features = ["protocol-asset", "unstable"] }
 tauri-plugin-opener = "2"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+# 用于解析前端传来的 url 字符串后再交给 webview.navigate
+url = "2"
+# 截图链路:解码 CDP 返回的 base64 PNG
+base64 = "0.22"
+# 异步运行时(与 tauri::async_runtime 兼容;显式启用 time/sync feature 供 sleep 与 oneshot 用)
+tokio = { version = "1", features = ["time", "sync", "process", "io-util", "rt-multi-thread"] }
+# 截图 / 录制相关的统一错误处理
+anyhow = "1"
+# 录制会话标识;不直接落盘的 raw 文件用 uuid 命名避免并发冲突
+uuid = { version = "1", features = ["v4"] }
+
+# ------------------ Windows 平台专属 ------------------
+# 用 webview2-com 直接拿 ICoreWebView2 调 CallDevToolsProtocolMethod 跑 CDP(整页截图)。
+# 版本号需要跟 tauri 当前依赖的 wry 内部使用的 webview2-com 大版本号一致;
+# 若 cargo check 报 ICoreWebView2 类型冲突,运行 `cargo tree -p webview2-com`
+# 查看实际版本并对齐这里。
+[target.'cfg(windows)'.dependencies]
+webview2-com = "0.34"
+windows = { version = "0.58", features = [
+    "Win32_Foundation",
+    "Win32_System_Com",
+] }
 

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

@@ -5,6 +5,7 @@
   "windows": ["main"],
   "permissions": [
     "core:default",
-    "opener:default"
+    "opener:default",
+    "opener:allow-reveal-item-in-dir"
   ]
 }

+ 331 - 0
src-tauri/src/capture.rs

@@ -0,0 +1,331 @@
+//! 子 webview 截图模块
+//!
+//! 暴露:
+//! - 命令 `capture_page`:前端手动触发
+//! - 函数 `trigger_auto_capture`:`on_page_load` 钩子触发
+//!
+//! 平台实现:
+//! - Windows:通过 `ICoreWebView2::CallDevToolsProtocolMethod` 走 CDP
+//!   1) Runtime.evaluate 跑预热脚本(强制 lazy img 急加载、整页滚动一遍触发
+//!      IntersectionObserver、等图片 / 字体 / 布局稳定)
+//!   2) Page.captureScreenshot 带 captureBeyondViewport=true 一次性出整页 PNG
+//! - 非 Windows:暂返回友好错误,下一轮接入 WKWebView.takeSnapshot 做单页截图
+
+use crate::{find_content_webview, paths, AppState};
+use serde::Serialize;
+use tauri::{AppHandle, Emitter, Manager};
+
+/// 事件名常量,与前端 src/types/ipc.ts 保持一致
+const EVT_SCREENSHOT_FINISHED: &str = "screenshot-finished";
+const EVT_SCREENSHOT_FAILED: &str = "screenshot-failed";
+
+#[derive(Serialize, Clone)]
+struct ScreenshotFinished {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    path: String,
+    auto: bool,
+}
+
+#[derive(Serialize, Clone)]
+struct ScreenshotFailed {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    auto: bool,
+    error: String,
+}
+
+/// 前端手动触发的截图命令。
+#[tauri::command]
+pub async fn capture_page(app: AppHandle, task_id: String) -> Result<String, String> {
+    perform_capture(app, task_id, false).await
+}
+
+/// 自动截图入口(由 `on_page_load` 完成回调触发,不直接面向前端)。
+///
+/// - 读 AppState 拿当前 task_id;为空(about:blank 或未选任务)则跳过
+/// - 失败只通过 emit 事件告知前端,不让 panic 影响主循环
+pub async fn trigger_auto_capture(app: AppHandle) {
+    let task_id = {
+        let state = match app.try_state::<AppState>() {
+            Some(s) => s,
+            None => return,
+        };
+        match state.current_task() {
+            Some(id) => id,
+            None => return, // 初始 about:blank,没有任务上下文
+        }
+    };
+
+    if let Err(e) = perform_capture(app, task_id, true).await {
+        // perform_capture 内部失败时已经 emit 过 failed 事件,这里只是兜底日志
+        eprintln!("自动截图失败: {e}");
+    }
+}
+
+/// 真正执行截图:决定文件路径 → 平台调用 → 写盘 → emit 事件。
+///
+/// 成功返回保存路径(用于命令的 invoke 返回值);失败时同时 emit screenshot-failed
+/// 并返回 Err,便于前端通过两种路径感知失败。
+async fn perform_capture(
+    app: AppHandle,
+    task_id: String,
+    auto: bool,
+) -> Result<String, String> {
+    let target_path = paths::screenshot_path_for_task(&app, &task_id)?;
+
+    let result = capture_to_png_bytes(&app).await;
+
+    let bytes = match result {
+        Ok(b) => b,
+        Err(e) => {
+            let msg = e.to_string();
+            let _ = app.emit(
+                EVT_SCREENSHOT_FAILED,
+                ScreenshotFailed {
+                    task_id: task_id.clone(),
+                    auto,
+                    error: msg.clone(),
+                },
+            );
+            return Err(msg);
+        }
+    };
+
+    if let Err(e) = std::fs::write(&target_path, &bytes) {
+        let msg = format!("写截图文件失败 {}: {}", target_path.display(), e);
+        let _ = app.emit(
+            EVT_SCREENSHOT_FAILED,
+            ScreenshotFailed {
+                task_id: task_id.clone(),
+                auto,
+                error: msg.clone(),
+            },
+        );
+        return Err(msg);
+    }
+
+    let path_str = target_path.to_string_lossy().to_string();
+    let _ = app.emit(
+        EVT_SCREENSHOT_FINISHED,
+        ScreenshotFinished {
+            task_id,
+            path: path_str.clone(),
+            auto,
+        },
+    );
+    Ok(path_str)
+}
+
+// =====================================================================
+// 平台分发:拿到子 webview,调用平台特定实现得到 PNG bytes
+// =====================================================================
+
+async fn capture_to_png_bytes(app: &AppHandle) -> anyhow::Result<Vec<u8>> {
+    let webview = find_content_webview(app).map_err(anyhow::Error::msg)?;
+
+    #[cfg(target_os = "windows")]
+    {
+        windows_impl::capture_full_page(webview).await
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    {
+        let _ = webview; // 静默未使用警告
+        anyhow::bail!("当前平台暂未实现截图,仅 Windows 已支持(macOS 接单页截图将在下一阶段加入)")
+    }
+}
+
+// =====================================================================
+// Windows: CDP 走 ICoreWebView2.CallDevToolsProtocolMethod
+// =====================================================================
+
+#[cfg(target_os = "windows")]
+mod windows_impl {
+    use anyhow::{anyhow, bail, Context, Result};
+    use base64::{engine::general_purpose::STANDARD, Engine};
+    use serde_json::{json, Value};
+    use std::sync::{Arc, Mutex};
+    use tauri::Webview;
+    use tokio::sync::oneshot;
+    use webview2_com::{
+        CallDevToolsProtocolMethodCompletedHandler,
+        Microsoft::Web::WebView2::Win32::ICoreWebView2,
+    };
+    use windows::core::HSTRING;
+
+    /// 预热脚本:等懒加载内容、字体、图片加载完成;最大 8s 兜底超时。
+    ///
+    /// 注:通过 CDP Runtime.evaluate 跑,awaitPromise:true,Rust 端拿到 resolved
+    /// 结果后再发 captureScreenshot。
+    const WARMUP_SCRIPT: &str = r#"
+(async () => {
+  const TIMEOUT_MS = 8000;
+  const QUIET_MS = 800;
+
+  const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), TIMEOUT_MS));
+
+  const work = (async () => {
+    // 1) 强制 lazy img 急加载
+    document.querySelectorAll('img[loading="lazy"]').forEach(img => {
+      try { img.loading = 'eager'; } catch (e) {}
+    });
+
+    // 2) 整页滚动一遍触发 IntersectionObserver
+    const se = document.scrollingElement || document.documentElement;
+    const maxScroll = Math.max(0, se.scrollHeight - window.innerHeight);
+    const step = Math.max(window.innerHeight * 0.9, 200);
+    for (let y = 0; y <= maxScroll; y += step) {
+      se.scrollTop = y;
+      await new Promise(r => setTimeout(r, 60));
+    }
+    se.scrollTop = 0;
+
+    // 3) 等所有未完成的 <img> 加载完(或失败)
+    await Promise.all(Array.from(document.images)
+      .filter(img => !img.complete)
+      .map(img => new Promise(r => {
+        const done = () => r();
+        img.addEventListener('load', done, { once: true });
+        img.addEventListener('error', done, { once: true });
+      })));
+
+    // 4) 等字体
+    if (document.fonts && document.fonts.ready) {
+      try { await document.fonts.ready; } catch (e) {}
+    }
+
+    // 5) DOM 静默 QUIET_MS 视为稳定
+    await new Promise(resolve => {
+      let timer = setTimeout(resolve, QUIET_MS);
+      const obs = new MutationObserver(() => {
+        clearTimeout(timer);
+        timer = setTimeout(() => { obs.disconnect(); resolve(); }, QUIET_MS);
+      });
+      obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
+    });
+
+    // 6) 等两帧让最终布局生效
+    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
+
+    return 'ok';
+  })();
+
+  return await Promise.race([work, timeout]);
+})()
+"#;
+
+    /// 在 Windows 上对子 webview 做整页截图,返回 PNG bytes。
+    pub async fn capture_full_page(webview: Webview) -> Result<Vec<u8>> {
+        // 1) 预热
+        let warm_resp = call_cdp(
+            &webview,
+            "Runtime.evaluate",
+            json!({
+                "expression": WARMUP_SCRIPT,
+                "awaitPromise": true,
+                "returnByValue": true,
+            }),
+        )
+        .await
+        .context("CDP Runtime.evaluate 预热失败")?;
+
+        if let Some(detail) = warm_resp.get("exceptionDetails") {
+            // 预热脚本抛异常通常意味着页面 CSP 限制或语法兼容问题,记录但不中断
+            // —— 截图本身仍可继续,只是没预热。
+            eprintln!("CDP 预热脚本异常(已忽略,继续截图): {detail}");
+        }
+
+        // 2) 整页截图
+        let shot_resp = call_cdp(
+            &webview,
+            "Page.captureScreenshot",
+            json!({
+                "format": "png",
+                "captureBeyondViewport": true,
+                "fromSurface": true,
+            }),
+        )
+        .await
+        .context("CDP Page.captureScreenshot 失败")?;
+
+        let b64 = shot_resp
+            .get("data")
+            .and_then(|v| v.as_str())
+            .ok_or_else(|| anyhow!("CDP 响应缺少 data 字段: {shot_resp}"))?;
+
+        let bytes = STANDARD
+            .decode(b64)
+            .context("CDP 返回的 base64 解码失败")?;
+        if bytes.is_empty() {
+            bail!("CDP 截图返回了空数据");
+        }
+        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: ICoreWebView2 = 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 = unsafe { json_pcwstr.to_string().unwrap_or_default() };
+                            let res = if hr.is_ok() {
+                                Ok(json_str)
+                            } else {
+                                Err(format!("CDP HRESULT 错误: 0x{:08x}", hr.0))
+                            };
+                            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}"))
+    }
+}

+ 207 - 4
src-tauri/src/lib.rs

@@ -1,14 +1,217 @@
-// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
+mod capture;
+mod paths;
+mod recording;
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+use serde::Serialize;
+use tauri::{webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl};
+
+use recording::RecordingSession;
+
+/// 子 webview 的 label
+pub(crate) const CONTENT_WEBVIEW_LABEL: &str = "content";
+
+const WIN_FRAME: (f64, f64, f64, f64) = (0.0, 34.0, 0.0, 40.0);
+
+/// 子 webview 初始尺寸:1280×720 横屏
+const DEFAULT_CONTENT_W: f64 = 1280.0;
+const DEFAULT_CONTENT_H: f64 = 720.0;
+
+/// 子 webview 初始 url(空白页占位,等用户从任务列表选)
+const INITIAL_URL: &str = "about:blank";
+
+/// 全局应用状态:跨命令共享的"当前任务 id"、录制会话等。
+///
+/// 用 std::sync::Mutex 包裹,命令是同步执行的,锁竞争极低;如果后续要在
+/// 异步任务中持有锁过临界点,再迁到 tokio::sync::Mutex。
+#[derive(Default)]
+pub struct AppState {
+    /// 当前加载到子 webview 的任务 id;None 表示子 webview 还在 about:blank。
+    /// 自动截图触发时读取本字段决定文件名 (task-<id>.png)。
+    pub current_task_id: Mutex<Option<String>>,
+    /// 进行中的录制会话:session_id → 元数据
+    pub recording_sessions: Mutex<HashMap<String, RecordingSession>>,
+}
+
+impl AppState {
+    /// 读当前 task id(克隆出来,避免持锁跨 await)
+    pub fn current_task(&self) -> Option<String> {
+        self.current_task_id
+            .lock()
+            .ok()
+            .and_then(|g| g.clone())
+    }
+}
+
+/// 任务的产物文件查询结果
+#[derive(Serialize)]
+struct TaskAssets {
+    /// 截图文件预期路径(无论是否存在)
+    screenshot_path: String,
+    /// 截图文件是否存在
+    screenshot_exists: bool,
+    /// 录制成片预期路径
+    recording_path: String,
+    /// 录制成片是否存在
+    recording_exists: bool,
+    /// 截图根目录(兜底 reveal 用)
+    screenshots_dir: String,
+    /// 录制根目录(兜底 reveal 用)
+    recordings_dir: String,
+}
+
+/// 查询指定任务有哪些可用的产物文件,供前端:
+/// - 决定"图片预览"/"视频预览"按钮的 disabled 状态
+/// - 决定"打开文件夹"按钮的 reveal 目标(mp4 > png > screenshots 目录)
 #[tauri::command]
-fn greet(name: &str) -> String {
-    format!("Hello, {}! You've been greeted from Rust!", name)
+fn query_task_assets(app: AppHandle, task_id: String) -> Result<TaskAssets, String> {
+    let screenshot = paths::screenshot_path_for_task(&app, &task_id)?;
+    let recording = paths::recording_final_path_for_task(&app, &task_id)?;
+    let s_dir = paths::screenshots_dir(&app)?;
+    let r_dir = paths::recordings_dir(&app)?;
+
+    Ok(TaskAssets {
+        screenshot_exists: screenshot.exists(),
+        screenshot_path: screenshot.to_string_lossy().to_string(),
+        recording_exists: recording.exists(),
+        recording_path: recording.to_string_lossy().to_string(),
+        screenshots_dir: s_dir.to_string_lossy().to_string(),
+        recordings_dir: r_dir.to_string_lossy().to_string(),
+    })
+}
+
+/// 在所有 webview 中查找 label = `content` 的子 webview
+pub(crate) fn find_content_webview(app: &tauri::AppHandle) -> Result<tauri::Webview, String> {
+    app.webviews()
+        .into_iter()
+        .find_map(|(label, w)| {
+            if label.as_str() == CONTENT_WEBVIEW_LABEL {
+                Some(w)
+            } else {
+                None
+            }
+        })
+        .ok_or_else(|| format!("找不到 webview: {CONTENT_WEBVIEW_LABEL}"))
+}
+
+/// 加载新的 url 到子 webview,并把 task_id 记入 AppState,用于后续自动截图命名。
+#[tauri::command]
+fn navigate_webview(
+    app: tauri::AppHandle,
+    state: tauri::State<'_, AppState>,
+    task_id: String,
+    url: String,
+) -> Result<(), String> {
+    let parsed = url::Url::parse(&url).map_err(|e| format!("url ({})解析失败: {}", &url, e))?;
+
+    // 先更新 state(即便后续 navigate 失败,state 也会被下一次正常调用覆盖)
+    if let Ok(mut guard) = state.current_task_id.lock() {
+        *guard = Some(task_id);
+    }
+
+    let webview = find_content_webview(&app)?;
+    webview.navigate(parsed).map_err(|e| e.to_string())
+}
+
+/// 修改主窗口的「客户区」尺寸(逻辑像素,不含系统 titlebar / 边框)。
+///
+/// 前端传进来的 (width, height) 含义是「左栏 + 工作区宽」「工具栏高 + 工作区高」,
+/// 也就是客户区尺寸;外边的 titlebar / 边框由 helper 自动补偿。
+#[tauri::command]
+fn set_window_size(
+    app: tauri::AppHandle,
+    width: f64,
+    height: f64,
+    content_width: f64,
+    content_height: f64,
+) -> Result<(), String> {
+    println!(
+        "===========:{},{},{},{}",
+        width, height, content_width, content_height
+    );
+    // 这里取底层 `Window`(而不是 `WebviewWindow`),与 setup 阶段保持一致
+    let window = app
+        .get_window("main")
+        .ok_or_else(|| "找不到主窗口".to_string())?;
+    window
+        .set_size(LogicalSize::new(
+            width + WIN_FRAME.0 + WIN_FRAME.2,
+            height + WIN_FRAME.1 + WIN_FRAME.3,
+        ))
+        .map_err(|e| e.to_string())?;
+
+    let content = find_content_webview(&app)?;
+    content
+        .set_position(LogicalPosition::new(
+            WIN_FRAME.0 + width - content_width,
+            WIN_FRAME.1 + height - content_height,
+        ))
+        .map_err(|e| e.to_string())?;
+    content
+        .set_size(LogicalSize::new(content_width, content_height))
+        .map_err(|e| e.to_string())?;
+    Ok(())
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
     tauri::Builder::default()
         .plugin(tauri_plugin_opener::init())
-        .invoke_handler(tauri::generate_handler![greet])
+        .manage(AppState::default())
+        .setup(|app| {
+            // 在主窗口(也是 React UI 所在的 webview)所属的 Window 上挂一个 child webview
+            // 注意:`add_child` 定义在 `Window` 上,不在 `WebviewWindow` 上,所以这里取 Window
+            let window = app.get_window("main").ok_or("主窗口未找到")?;
+
+            // 1) 先把窗口「客户区」调到目标值 = 左栏 + 工作区宽 × 工具栏高 + 工作区高
+            //    必须放在 add_child 之前:否则 child webview 是在还很小的客户区里被创建,
+            //    会被夹紧到当时的客户区大小并把右上方工具栏盖住。
+
+            // 2) 在已经放大的窗口上挂 child webview,定位到工具栏下方
+            //
+            //    on_page_load 钩子用于「页面加载完成 → 自动截图」:
+            //    - PageLoadEvent::Finished 触发后 spawn 一个异步任务
+            //    - 等 1.5s(粗预热,等懒加载 JS 起步)
+            //    - 然后调 capture::trigger_auto_capture
+            //    - capture 内部会再做一次「CDP 撑大视口 + DOM 稳定」预热,详见 capture.rs
+            let initial_url: url::Url = INITIAL_URL.parse()?;
+            let app_handle_for_load = app.handle().clone();
+            let content = window.add_child(
+                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();
+                        tauri::async_runtime::spawn(async move {
+                            tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
+                            capture::trigger_auto_capture(app).await;
+                        });
+                    }),
+                LogicalPosition::new(0, 0),
+                LogicalSize::new(DEFAULT_CONTENT_W, DEFAULT_CONTENT_H),
+            )?;
+
+            content.set_position(LogicalPosition::new(WIN_FRAME.0 + 180f64, WIN_FRAME.1))?;
+            content.set_size(LogicalSize::new(
+                DEFAULT_CONTENT_W + WIN_FRAME.0,
+                DEFAULT_CONTENT_W + WIN_FRAME.1,
+            ))?;
+
+            Ok(())
+        })
+        .invoke_handler(tauri::generate_handler![
+            navigate_webview,
+            set_window_size,
+            query_task_assets,
+            capture::capture_page,
+            recording::prepare_recording,
+            recording::save_recording_raw,
+            recording::finalize_recording,
+            recording::cancel_recording,
+        ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
 }

+ 99 - 0
src-tauri/src/paths.rs

@@ -0,0 +1,99 @@
+//! 输出文件的统一路径规则
+//!
+//! 设计原则:
+//! - 截图:每个任务一份,固定文件名 `task-<id>.png`,重复截图直接覆盖。
+//!   这样自动截图和手动截图共享同一个文件,符合「手动覆盖自动」的语义。
+//! - 录制成片:同样每任务一份固定文件名 `task-<id>.mp4`,重录覆盖。
+//!   预览按钮按 task id 直接拼路径即可,前端无需维护"哪份是最新"。
+//! - 录制原始片:临时 webm,按 session id (uuid) 命名 `raw-<sid>.webm`,
+//!   位于独立 cache 目录;finalize 转码完成后立即删除。
+//! - 落点:截图与最终视频在 `app_data_dir/{screenshots,recordings}/` 下;
+//!   原始 webm 在 `app_cache_dir/recordings_raw/` 下(属临时数据)。
+//!   通过 Tauri 的 PathResolver 拿,跨平台一致。
+
+use std::path::PathBuf;
+use tauri::{AppHandle, Manager};
+
+const SCREENSHOTS_SUBDIR: &str = "screenshots";
+const RECORDINGS_SUBDIR: &str = "recordings";
+const RECORDINGS_RAW_SUBDIR: &str = "recordings_raw";
+
+/// 确保目录存在(递归创建),返回原路径以便链式使用。
+fn ensure_dir(p: PathBuf) -> Result<PathBuf, String> {
+    if !p.exists() {
+        std::fs::create_dir_all(&p)
+            .map_err(|e| format!("创建目录失败 {}: {}", p.display(), e))?;
+    }
+    Ok(p)
+}
+
+/// `<app_data_dir>` 根目录(封装 PathResolver 错误处理)
+fn app_data_root(app: &AppHandle) -> Result<PathBuf, String> {
+    app.path()
+        .app_data_dir()
+        .map_err(|e| format!("解析 app_data_dir 失败: {e}"))
+}
+
+/// `<app_cache_dir>` 根目录
+fn app_cache_root(app: &AppHandle) -> Result<PathBuf, String> {
+    app.path()
+        .app_cache_dir()
+        .map_err(|e| format!("解析 app_cache_dir 失败: {e}"))
+}
+
+// =================== 截图 ===================
+
+/// 截图存放目录:`<app_data_dir>/screenshots/`
+pub fn screenshots_dir(app: &AppHandle) -> Result<PathBuf, String> {
+    ensure_dir(app_data_root(app)?.join(SCREENSHOTS_SUBDIR))
+}
+
+/// 指定任务的截图文件绝对路径(覆盖语义,故不带时间戳)。
+pub fn screenshot_path_for_task(app: &AppHandle, task_id: &str) -> Result<PathBuf, String> {
+    let safe_id = sanitize_task_id(task_id);
+    Ok(screenshots_dir(app)?.join(format!("task-{safe_id}.png")))
+}
+
+// =================== 录制 ===================
+
+/// 录制成片目录:`<app_data_dir>/recordings/`
+pub fn recordings_dir(app: &AppHandle) -> Result<PathBuf, String> {
+    ensure_dir(app_data_root(app)?.join(RECORDINGS_SUBDIR))
+}
+
+/// 录制原始片目录(临时 webm 落点):`<app_cache_dir>/recordings_raw/`
+pub fn recordings_raw_dir(app: &AppHandle) -> Result<PathBuf, String> {
+    ensure_dir(app_cache_root(app)?.join(RECORDINGS_RAW_SUBDIR))
+}
+
+/// 指定任务的最终 mp4 绝对路径(覆盖语义)。
+pub fn recording_final_path_for_task(app: &AppHandle, task_id: &str) -> Result<PathBuf, String> {
+    let safe_id = sanitize_task_id(task_id);
+    Ok(recordings_dir(app)?.join(format!("task-{safe_id}.mp4")))
+}
+
+/// 指定 session 的临时 webm 绝对路径。
+pub fn recording_raw_path_for_session(
+    app: &AppHandle,
+    session_id: &str,
+) -> Result<PathBuf, String> {
+    let safe_sid = sanitize_task_id(session_id); // 同样的消毒规则即可
+    Ok(recordings_raw_dir(app)?.join(format!("raw-{safe_sid}.webm")))
+}
+
+// =================== 工具 ===================
+
+/// 简单消毒:剔除文件系统不友好的字符。
+/// task_id 当前来自 mock 数据是数字字符串,未来可能是 uuid 或外部 id,
+/// 这里只过滤路径分隔符 / 控制字符,其余保留。
+fn sanitize_task_id(raw: &str) -> String {
+    raw.chars()
+        .map(|c| {
+            if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
+                '_'
+            } else {
+                c
+            }
+        })
+        .collect()
+}

+ 290 - 0
src-tauri/src/recording.rs

@@ -0,0 +1,290 @@
+//! 录制会话管理 + ffmpeg 后处理
+//!
+//! 录制实际由前端 MediaRecorder + getDisplayMedia 完成(录到的是整个 AutoRecord
+//! 窗口的 webm 流),Rust 端只负责:
+//!   1. prepare_recording   分配 session id
+//!   2. save_recording_raw  把前端传过来的 webm 字节落到 cache 目录
+//!   3. finalize_recording  调 ffmpeg 把 raw webm crop 到子 webview 区域 + scale 到目标尺寸 + 转 mp4
+//!   4. cancel_recording    用户取消时清现场
+//!
+//! ffmpeg 二进制:本轮直接通过 PATH 调系统 ffmpeg。后续可换成 sidecar 打包,
+//! `run_ffmpeg` 只需要换实现,命令链路本身不动。
+
+use crate::{paths, AppState};
+use serde::{Deserialize, Serialize};
+use tauri::{AppHandle, Emitter, State};
+use uuid::Uuid;
+
+const EVT_RECORDING_FINISHED: &str = "recording-finished";
+const EVT_RECORDING_FAILED: &str = "recording-failed";
+
+/// 录制会话元数据(存在 AppState.recording_sessions 里)
+#[derive(Clone)]
+pub struct RecordingSession {
+    pub task_id: String,
+}
+
+/// 前端传过来的 crop 区域,单位:CSS 逻辑像素(子 webview 在 React UI window 内的坐标)
+#[derive(Deserialize)]
+pub struct CropRect {
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+}
+
+/// 前端传过来的 React UI 主 webview 的 innerWidth/innerHeight(CSS 逻辑像素)。
+/// 用于跟实际录到的 frame 像素算 ratio,弥补 dpr / titlebar 等差异。
+#[derive(Deserialize)]
+pub struct WindowClient {
+    pub w: f64,
+    pub h: f64,
+}
+
+/// 前端通过 `stream.getVideoTracks()[0].getSettings()` 拿到的实际 frame 尺寸(像素)
+#[derive(Deserialize)]
+pub struct FrameSize {
+    pub w: u32,
+    pub h: u32,
+}
+
+/// 期望的最终 mp4 输出尺寸(CSS 逻辑像素,等于用户设置的 contentSize)
+#[derive(Deserialize)]
+pub struct OutputSize {
+    pub w: u32,
+    pub h: u32,
+}
+
+#[derive(Serialize, Clone)]
+struct RecordingFinished {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    #[serde(rename = "sessionId")]
+    session_id: String,
+    path: String,
+}
+
+#[derive(Serialize, Clone)]
+struct RecordingFailed {
+    #[serde(rename = "taskId")]
+    task_id: String,
+    #[serde(rename = "sessionId")]
+    session_id: String,
+    error: String,
+}
+
+/// 开始录制前调一次:分配 session_id,登记到 AppState。
+///
+/// 前端拿到 session_id 后用它驱动后续的 save / finalize / cancel。
+#[tauri::command]
+pub fn prepare_recording(
+    state: State<'_, AppState>,
+    task_id: String,
+) -> Result<String, String> {
+    let sid = Uuid::new_v4().to_string();
+    let session = RecordingSession {
+        task_id,
+    };
+    state
+        .recording_sessions
+        .lock()
+        .map_err(|e| format!("recording_sessions 锁失败: {e}"))?
+        .insert(sid.clone(), session);
+    Ok(sid)
+}
+
+/// 把前端 MediaRecorder 收集到的 webm 二进制写到 cache 目录。
+///
+/// 注意:bytes 是整个录制时长的全部数据,可能上百 MB。前端调本命令时
+/// Tauri 会一次性序列化整段 buffer,目前看体感能扛;如果出现卡顿可以
+/// 改成分段写入。
+#[tauri::command]
+pub fn save_recording_raw(
+    app: AppHandle,
+    state: State<'_, AppState>,
+    session_id: String,
+    bytes: Vec<u8>,
+) -> Result<(), String> {
+    // 校验 session 存在(防御性)
+    {
+        let guard = state
+            .recording_sessions
+            .lock()
+            .map_err(|e| format!("recording_sessions 锁失败: {e}"))?;
+        if !guard.contains_key(&session_id) {
+            return Err(format!("找不到 session: {session_id}"));
+        }
+    }
+
+    let path = paths::recording_raw_path_for_session(&app, &session_id)?;
+    std::fs::write(&path, &bytes)
+        .map_err(|e| format!("写入 raw webm 失败 {}: {}", path.display(), e))?;
+    Ok(())
+}
+
+/// 调 ffmpeg 把 raw webm 转为最终 mp4:
+///   crop 到子 webview 区域 → scale 到用户设置的视口尺寸 → libx264 + faststart
+///
+/// 成功时通过 `recording-finished` 事件回推路径;失败时通过 `recording-failed`。
+/// 同时也把 mp4 路径作为命令返回值返给前端,方便链式处理。
+#[tauri::command]
+pub async fn finalize_recording(
+    app: AppHandle,
+    state: State<'_, AppState>,
+    session_id: String,
+    crop: CropRect,
+    window_client: WindowClient,
+    frame: FrameSize,
+    output: OutputSize,
+) -> Result<String, String> {
+    // 取 session 元数据,并从 map 中移除(finalize 是终点)
+    let session = {
+        let mut guard = state
+            .recording_sessions
+            .lock()
+            .map_err(|e| format!("recording_sessions 锁失败: {e}"))?;
+        guard
+            .remove(&session_id)
+            .ok_or_else(|| format!("找不到 session: {session_id}"))?
+    };
+    let task_id = session.task_id.clone();
+
+    let raw_path = paths::recording_raw_path_for_session(&app, &session_id)?;
+    if !raw_path.exists() {
+        let err = format!("raw webm 不存在: {}", raw_path.display());
+        emit_failed(&app, &task_id, &session_id, &err);
+        return Err(err);
+    }
+
+    let out_path = paths::recording_final_path_for_task(&app, &task_id)?;
+
+    // 计算 crop in frame pixels
+    if window_client.w <= 0.0 || window_client.h <= 0.0 {
+        let err = "window_client 尺寸非法".to_string();
+        emit_failed(&app, &task_id, &session_id, &err);
+        return Err(err);
+    }
+    let ratio_x = frame.w as f64 / window_client.w;
+    let ratio_y = frame.h as f64 / window_client.h;
+    let cx = (crop.x * ratio_x).round() as i64;
+    let cy = (crop.y * ratio_y).round() as i64;
+    let cw = (crop.w * ratio_x).round() as i64;
+    let ch = (crop.h * ratio_y).round() as i64;
+
+    if cw <= 0 || ch <= 0 {
+        let err = format!("无效的 crop 区域: w={cw} h={ch}");
+        emit_failed(&app, &task_id, &session_id, &err);
+        return Err(err);
+    }
+
+    // libx264 要求宽高为偶数;输出尺寸同样对齐
+    let cw_even = cw - (cw % 2);
+    let ch_even = ch - (ch % 2);
+    let ow_even = (output.w as i64) - (output.w as i64 % 2);
+    let oh_even = (output.h as i64) - (output.h as i64 % 2);
+
+    let vf = format!(
+        "crop={cw}:{ch}:{cx}:{cy},scale={ow}:{oh}",
+        cw = cw_even,
+        ch = ch_even,
+        cx = cx,
+        cy = cy,
+        ow = ow_even,
+        oh = oh_even,
+    );
+
+    let result = run_ffmpeg(&raw_path, &out_path, &vf).await;
+
+    // 不论成功失败都删 raw,避免 cache 越堆越大
+    let _ = std::fs::remove_file(&raw_path);
+
+    match result {
+        Ok(()) => {
+            let path_str = out_path.to_string_lossy().to_string();
+            let _ = app.emit(
+                EVT_RECORDING_FINISHED,
+                RecordingFinished {
+                    task_id,
+                    session_id,
+                    path: path_str.clone(),
+                },
+            );
+            Ok(path_str)
+        }
+        Err(e) => {
+            let err = format!("ffmpeg 转码失败: {e}");
+            emit_failed(&app, &task_id, &session_id, &err);
+            Err(err)
+        }
+    }
+}
+
+/// 用户中途取消录制:删除可能存在的 raw 文件,从 session map 移除。
+#[tauri::command]
+pub fn cancel_recording(
+    app: AppHandle,
+    state: State<'_, AppState>,
+    session_id: String,
+) -> Result<(), String> {
+    if let Ok(p) = paths::recording_raw_path_for_session(&app, &session_id) {
+        let _ = std::fs::remove_file(&p);
+    }
+    if let Ok(mut guard) = state.recording_sessions.lock() {
+        guard.remove(&session_id);
+    }
+    Ok(())
+}
+
+fn emit_failed(app: &AppHandle, task_id: &str, session_id: &str, err: &str) {
+    let _ = app.emit(
+        EVT_RECORDING_FAILED,
+        RecordingFailed {
+            task_id: task_id.to_string(),
+            session_id: session_id.to_string(),
+            error: err.to_string(),
+        },
+    );
+}
+
+/// 调系统 PATH 中的 ffmpeg 把 webm 按 `-vf` 滤镜转为 mp4。
+///
+/// 如果系统没装 ffmpeg,会返回带具体提示的错误。后续可以替换为
+/// ffmpeg-sidecar 打包,本函数实现替换即可。
+async fn run_ffmpeg(
+    input: &std::path::Path,
+    output: &std::path::Path,
+    vf: &str,
+) -> anyhow::Result<()> {
+    use tokio::process::Command;
+
+    let status = Command::new("ffmpeg")
+        .arg("-y")
+        .arg("-i")
+        .arg(input)
+        .arg("-vf")
+        .arg(vf)
+        .arg("-c:v")
+        .arg("libx264")
+        .arg("-preset")
+        .arg("veryfast")
+        .arg("-crf")
+        .arg("22")
+        .arg("-pix_fmt")
+        .arg("yuv420p")
+        .arg("-movflags")
+        .arg("+faststart")
+        .arg(output)
+        .stdin(std::process::Stdio::null())
+        .stdout(std::process::Stdio::null())
+        .stderr(std::process::Stdio::null())
+        .status()
+        .await
+        .map_err(|e| {
+            anyhow::anyhow!("启动 ffmpeg 失败(请确认系统已安装 ffmpeg 并在 PATH 中): {e}")
+        })?;
+
+    if !status.success() {
+        anyhow::bail!("ffmpeg 退出码非 0: {status:?}");
+    }
+    Ok(())
+}

+ 9 - 3
src-tauri/tauri.conf.json

@@ -13,12 +13,18 @@
     "windows": [
       {
         "title": "auto-record",
-        "width": 800,
-        "height": 600
+        "width": 1460,
+        "height": 768
       }
     ],
     "security": {
-      "csp": null
+      "csp": null,
+      "assetProtocol": {
+        "enable": true,
+        "scope": [
+          "$APPDATA/**"
+        ]
+      }
     }
   },
   "bundle": {

+ 12 - 106
src/App.css

@@ -91,22 +91,15 @@
     0 9px 28px 8px rgba(0, 0, 0, 0.05);
 }
 
-/* ===== 项目自定义样式(保留原 Tauri 模板演示样式) ===== */
-.logo.vite:hover {
-  filter: drop-shadow(0 0 2em #747bff);
-}
-
-.logo.react:hover {
-  filter: drop-shadow(0 0 2em #61dafb);
-}
+/* ===== 全局基础样式 ===== */
 :root {
-  font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
-  font-size: 16px;
-  line-height: 24px;
-  font-weight: 400;
-
-  color: #0f0f0f;
-  background-color: #f6f6f6;
+  font-family:
+    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
+    Arial, "Noto Sans", sans-serif;
+  font-size: 14px;
+  line-height: 1.5714;
+  color: #1f1f1f;
+  background-color: #ffffff;
 
   font-synthesis: none;
   text-rendering: optimizeLegibility;
@@ -115,96 +108,9 @@
   -webkit-text-size-adjust: 100%;
 }
 
-.container {
+html,
+body,
+#root {
+  height: 100%;
   margin: 0;
-  padding-top: 10vh;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  text-align: center;
-}
-
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-  transition: 0.75s;
-}
-
-.logo.tauri:hover {
-  filter: drop-shadow(0 0 2em #24c8db);
-}
-
-.row {
-  display: flex;
-  justify-content: center;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-
-a:hover {
-  color: #535bf2;
-}
-
-h1 {
-  text-align: center;
-}
-
-input,
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  color: #0f0f0f;
-  background-color: #ffffff;
-  transition: border-color 0.25s;
-  box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
-}
-
-button {
-  cursor: pointer;
-}
-
-button:hover {
-  border-color: #396cd8;
-}
-button:active {
-  border-color: #396cd8;
-  background-color: #e8e8e8;
-}
-
-input,
-button {
-  outline: none;
-}
-
-#greet-input {
-  margin-right: 5px;
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    color: #f6f6f6;
-    background-color: #2f2f2f;
-  }
-
-  a:hover {
-    color: #24c8db;
-  }
-
-  input,
-  button {
-    color: #ffffff;
-    background-color: #0f0f0f98;
-  }
-  button:active {
-    background-color: #0f0f0f69;
-  }
 }

+ 269 - 39
src/App.tsx

@@ -1,50 +1,280 @@
-import { useState } from "react";
-import reactLogo from "./assets/react.svg";
+import { useCallback, useEffect, useState } from "react";
 import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { Button, Divider, InputNumber, notification, Tooltip } from "antd";
+import { mockTasks, type Task } from "./mocks/tasks";
+import {
+  EVT_SCREENSHOT_FAILED,
+  EVT_SCREENSHOT_FINISHED,
+  type ScreenshotFailedPayload,
+  type ScreenshotFinishedPayload,
+} from "./types/ipc";
+import { capturePage } from "./lib/capture";
 import "./App.css";
+import { PauseCircleFilled, PictureOutlined, PlayCircleFilled } from "@ant-design/icons";
+
+/**
+ * 与 Rust 端常量保持一致(src-tauri/src/lib.rs):
+ *   LEFT_PANEL_WIDTH = 180
+ *   TOOLBAR_HEIGHT   = 48
+ *
+ * 导出以避免 noUnusedLocals 误报;后续如有组件需要可直接 import。
+ */
+export const LEFT_PANEL_WIDTH = 180;
+export const TOOLBAR_HEIGHT = 48;
+
+/** 工具栏右侧的尺寸预设(统一横屏:宽 × 高) */
+const SIZE_PRESETS = [
+  { label: "1920×1080", w: 1920, h: 1080 },
+  { label: "1024×768", w: 1024, h: 768 },
+  { label: "1280×720", w: 1280, h: 720 },
+];
+
+
+/** 点条目:把 url 加载到子 webview,并把 task_id 一并下发(用于截图命名 + 自动截图回调) */
+async function loadInWebview(taskId: string, url: string) {
+  try {
+    await invoke("navigate_webview", { taskId, url });
+  } catch (e) {
+    console.error("webview 加载失败:", e);
+  }
+}
+
+/** 点条目右侧图标:调系统浏览器打开 */
+async function openInBrowser(url: string) {
+  try {
+    await openUrl(url);
+  } catch (e) {
+    console.error("打开系统浏览器失败:", e);
+  }
+}
 
 function App() {
-  const [greetMsg, setGreetMsg] = useState("");
-  const [name, setName] = useState("");
+  // 当前激活的任务 id(仅用于左栏视觉高亮)
+  const [activeId, setActiveId] = useState<string | null>(null);
+
+  const [contentSize, setContentSize] = useState<{ w: number; h: number }>({ w: 1280, h: 720 });
+  const [customMode, setCustomMode] = useState(false);
+
+  // antd 6 notification 必须用 hook + contextHolder 才能正确取到主题
+  const [notifyApi, notifyContext] = notification.useNotification();
 
-  async function greet() {
-    // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
-    setGreetMsg(await invoke("greet", { name }));
+  function handleSelectTask(t: Task) {
+    setActiveId(t.id);
+    void loadInWebview(t.id, t.url);
   }
 
+  /** 手动截图:交给 Rust 命令,结果通过事件回推(统一与自动截图的提示路径) */
+  const handleManualCapture = useCallback(async () => {
+    if (!activeId) return;
+    try {
+      await capturePage(activeId);
+    } catch (e) {
+      // Rust 侧失败时已经 emit 过 screenshot-failed,这里仅打日志兜底
+      console.error("手动截图失败:", e);
+    }
+  }, [activeId]);
+
+  // 订阅 Rust 端的截图事件,统一用 notification 提示
+  useEffect(() => {
+    let unlistenFinished: UnlistenFn | null = null;
+    let unlistenFailed: UnlistenFn | null = null;
+    let disposed = false;
+
+    (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,
+        });
+      });
+      const u2 = await listen<ScreenshotFailedPayload>(EVT_SCREENSHOT_FAILED, (e) => {
+        const { taskId, auto, error } = e.payload;
+        notifyApi.error({
+          message: auto ? "自动截图失败" : "截图失败",
+          description: `任务 ${taskId}:${error}`,
+          duration: 6,
+        });
+      });
+
+      if (disposed) {
+        u1();
+        u2();
+      } else {
+        unlistenFinished = u1;
+        unlistenFailed = u2;
+      }
+    })();
+
+    return () => {
+      disposed = true;
+      unlistenFinished?.();
+      unlistenFailed?.();
+    };
+  }, [notifyApi]);
+
+  const applyWorkAreaSize = useCallback(async (w: number, h: number) => {
+    try {
+      // 先调主窗口,避免子 webview 越界
+      await invoke("set_window_size", {
+        width: w + 180,
+        height: h + 46,
+        contentWidth: w,
+        contentHeight: h
+      });
+      setContentSize({ w, h })
+    } catch (e) {
+      console.error("调整尺寸失败:", e);
+    }
+  }, [setContentSize]);
+
+  // 首次挂载时按默认 contentSize 调整窗口;依赖项故意为空(仅初始化用)。
+  useEffect(() => {
+    applyWorkAreaSize(contentSize.w, contentSize.h);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // 录制状态机(RecordState 类型已抽到 src/types/ipc.ts),录制功能下一阶段接入
+
   return (
-    <main className="container">
-      <h1>Welcome to Tauri + React</h1>
-
-      <div className="row">
-        <a href="https://vite.dev" target="_blank">
-          <img src="/vite.svg" className="logo vite" alt="Vite logo" />
-        </a>
-        <a href="https://tauri.app" target="_blank">
-          <img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
-        </a>
-        <a href="https://react.dev" target="_blank">
-          <img src={reactLogo} className="logo react" alt="React logo" />
-        </a>
-      </div>
-      <p>Click on the Tauri, Vite, and React logos to learn more.</p>
-
-      <form
-        className="row"
-        onSubmit={(e) => {
-          e.preventDefault();
-          greet();
-        }}
-      >
-        <input
-          id="greet-input"
-          onChange={(e) => setName(e.currentTarget.value)}
-          placeholder="Enter a name..."
-        />
-        <button type="submit">Greet</button>
-      </form>
-      <p>{greetMsg}</p>
-    </main>
+    <div className="flex h-screen w-screen overflow-hidden select-none">
+      {/* antd notification 的渲染容器 —— 必须挂在树中 */}
+      {notifyContext}
+      {/* ===== 左栏:任务列表(180px) ===== */}
+      <aside className="w-[180px] shrink-0 border-r border-gray-4 bg-gray-2 h-full flex flex-col">
+        <div className="px-3 py-2 text-xs text-gray-7 border-b border-gray-4 select-none">
+          任务列表
+        </div>
+        <div className="flex-1 overflow-y-auto">
+          <ul>
+            {mockTasks.map((t) => {
+              const active = activeId === t.id;
+              return (
+                <li
+                  key={t.id}
+                  className={
+                    "flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors " +
+                    (active
+                      ? "bg-primary-1 text-primary-7"
+                      : "hover:bg-gray-3 text-gray-10")
+                  }
+                  onClick={() => handleSelectTask(t)}
+                >
+                  <span className="truncate">任务 {t.id}</span>
+                  <button
+                    type="button"
+                    title="在系统浏览器打开"
+                    className="ml-2 inline-flex items-center justify-center w-6 h-6 rounded-sm hover:bg-gray-4 text-gray-7 hover:text-primary-6"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      void openInBrowser(t.url);
+                    }}
+                  >
+                    {/* 外链图标(lucide external-link 同款 path,不引入新依赖) */}
+                    <svg
+                      width="14"
+                      height="14"
+                      viewBox="0 0 24 24"
+                      fill="none"
+                      stroke="currentColor"
+                      strokeWidth="2"
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                    >
+                      <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+                      <polyline points="15 3 21 3 21 9" />
+                      <line x1="10" y1="14" x2="21" y2="3" />
+                    </svg>
+                  </button>
+                </li>
+              );
+            })}
+          </ul>
+        </div>
+      </aside>
+
+      {/* ===== 右侧主区 ===== */}
+      <main className="flex-1 flex flex-col min-w-0">
+        {/* 工具栏(48px) */}
+        <header className="h-12 shrink-0 flex items-center justify-between gap-2 px-3 border-b border-gray-4 bg-gray-1">
+          {/* 左侧:操作按钮区 */}
+          <div className="flex items-center gap-2">
+            <Tooltip title={activeId ? "截图整页(覆盖之前的自动截图)" : "请先选择任务"} placement="top">
+              <Button
+                size="small"
+                icon={<PictureOutlined />}
+                disabled={!activeId}
+                onClick={() => void handleManualCapture()}
+              />
+            </Tooltip>
+            <Divider type="vertical" />
+            {/* 录制按钮:下一阶段接入 recorder.ts,本轮暂保持 disabled 视觉占位 */}
+            <Tooltip title="开始录制(即将上线)" placement="top">
+              <Button size="small" disabled icon={<PlayCircleFilled />} />
+            </Tooltip>
+            <Button size="small" disabled icon={<PauseCircleFilled />} />
+          </div>
+
+          {/* 右侧:尺寸预设 / 自定义 */}
+          <div className="flex items-center gap-2">
+            {!customMode ? (
+              <>
+                {SIZE_PRESETS.map((p) => (
+                  <Button
+                    key={p.label}
+                    size="small"
+                    disabled={contentSize.w == p.w && contentSize.h == p.h}
+                    onClick={() => applyWorkAreaSize(p.w, p.h)}
+                  >
+                    {p.label}
+                  </Button>
+                ))}
+                <Button size="small" onClick={() => setCustomMode(true)}>
+                  自定义
+                </Button>
+              </>
+            ) : (
+              <>
+                <InputNumber
+                  size="small"
+                  min={200}
+                  max={3840}
+                  value={contentSize.w}
+                  onChange={(v) => setContentSize({ w: v || 0, h: contentSize.h })}
+                  style={{ width: 80 }}
+                />
+                <span className="text-gray-7 select-none">×</span>
+                <InputNumber
+                  size="small"
+                  min={200}
+                  max={2160}
+                  value={contentSize.h}
+                  onChange={(v) => setContentSize({ w: contentSize.w, h: v || 0 })}
+                  style={{ width: 80 }}
+                />
+                <Button
+                  size="small"
+                  type="primary"
+                  onClick={() => applyWorkAreaSize(contentSize.w, contentSize.h)}
+                >
+                  应用
+                </Button>
+                <Button size="small" onClick={() => setCustomMode(false)}>
+                  取消
+                </Button>
+              </>
+            )}
+          </div>
+        </header>
+
+        {/* 工作区占位:实际由 Rust child webview 覆盖在此区域之上 */}
+        <section className="flex-1 bg-gray-7" />
+        <section className="h-10 bg-gray-5 broder border-gray-7" />
+      </main>
+    </div>
   );
 }
 

+ 16 - 0
src/lib/capture.ts

@@ -0,0 +1,16 @@
+// 前端截图接口:包一层 invoke,统一错误日志格式
+//
+// 注意:自动截图不走这里,而是由 Rust 端 on_page_load 钩子直接触发,
+// 完成后通过 'screenshot-finished' / 'screenshot-failed' 事件回推前端。
+
+import { invoke } from "@tauri-apps/api/core";
+
+/**
+ * 手动触发整页截图。
+ *
+ * @param taskId 当前任务 id;用于命名输出文件(task-<id>.png,会覆盖之前的自动截图)
+ * @returns 截图保存的绝对路径
+ */
+export async function capturePage(taskId: string): Promise<string> {
+  return await invoke<string>("capture_page", { taskId });
+}

+ 249 - 0
src/lib/recorder.ts

@@ -0,0 +1,249 @@
+// 子 webview 视频录制器(前端侧)
+//
+// 流程:
+//   1. invoke('prepare_recording', taskId) → sessionId(Rust 登记会话)
+//   2. getDisplayMedia 让用户选 AutoRecord 应用窗口
+//   3. MediaRecorder 录制 webm,chunks 累积到内存
+//   4. stop 时把 webm 二进制 → invoke('save_recording_raw') 落到 cache
+//   5. invoke('finalize_recording', crop/windowClient/frame/output) → Rust 调 ffmpeg
+//      crop 到子 webview 区域 + scale 到 contentSize + 转 mp4
+//   6. mp4 落 app_data/recordings/task-<id>.mp4,最终结果通过 'recording-finished'
+//      事件回推前端
+//
+// 为什么 crop 在后端做:getDisplayMedia 录到的是整个 AutoRecord 窗口的 webm,
+// 包含左栏 + 工具栏。我们只需要子 webview 那一块。客户端裁剪需要把 webm
+// 解码为帧再处理,太重;ffmpeg 后处理简洁可靠。
+
+import { invoke } from "@tauri-apps/api/core";
+import type { RecordState } from "../types/ipc";
+
+/** 开始录制时由调用方传入的上下文 */
+export interface StartOptions {
+  /** 当前任务 id;决定最终 mp4 文件名(task-<id>.mp4,重录覆盖) */
+  taskId: string;
+  /** 用户设置的 webview 视口尺寸(CSS 逻辑像素),即最终 mp4 的输出尺寸 */
+  contentSize: { w: number; h: number };
+  /**
+   * 子 webview 在主 UI window 内的左上角逻辑坐标(CSS 像素)。
+   * 在当前布局下 = (LEFT_PANEL_WIDTH, TOOLBAR_HEIGHT) = (180, 48)。
+   */
+  origin: { x: number; y: number };
+}
+
+// =================== 内部单例状态 ===================
+
+let currentState: RecordState = "idle";
+let currentSessionId: string | null = null;
+let currentStartOpts: StartOptions | null = null;
+let mediaRecorder: MediaRecorder | null = null;
+let mediaStream: MediaStream | null = null;
+let recordedChunks: Blob[] = [];
+
+const stateListeners = new Set<(s: RecordState) => void>();
+
+function setState(next: RecordState) {
+  if (currentState === next) return;
+  currentState = next;
+  for (const l of stateListeners) l(next);
+}
+
+export function getRecordState(): RecordState {
+  return currentState;
+}
+
+/** 订阅状态变化;返回取消函数 */
+export function subscribeRecordState(listener: (s: RecordState) => void): () => void {
+  stateListeners.add(listener);
+  return () => {
+    stateListeners.delete(listener);
+  };
+}
+
+// =================== 工具函数 ===================
+
+function pickMimeType(): string | null {
+  if (typeof MediaRecorder === "undefined") return null;
+  const candidates = [
+    "video/webm;codecs=vp9",
+    "video/webm;codecs=vp8",
+    "video/webm",
+  ];
+  for (const t of candidates) {
+    if (MediaRecorder.isTypeSupported(t)) return t;
+  }
+  return null;
+}
+
+function teardownStream() {
+  if (mediaStream) {
+    for (const tr of mediaStream.getTracks()) tr.stop();
+    mediaStream = null;
+  }
+  mediaRecorder = null;
+  recordedChunks = [];
+}
+
+function resetSession() {
+  currentSessionId = null;
+  currentStartOpts = null;
+}
+
+// =================== 对外 API ===================
+
+/**
+ * 开始录制。
+ *
+ * 注意:会弹出系统级"选择共享窗口"对话框,让用户选 AutoRecord 应用窗口。
+ * 用户取消选择 / 拒绝权限时,会抛错并自动清理 Rust 侧 session。
+ */
+export async function startRecording(opts: StartOptions): Promise<void> {
+  if (currentState !== "idle") {
+    throw new Error(`当前录制状态为 ${currentState},无法开始新录制`);
+  }
+
+  // 1) 先在 Rust 侧分配 session id
+  const sessionId = await invoke<string>("prepare_recording", { taskId: opts.taskId });
+  currentSessionId = sessionId;
+  currentStartOpts = opts;
+
+  // 2) 请求屏幕共享 —— 失败时清理 session
+  let stream: MediaStream;
+  try {
+    stream = await navigator.mediaDevices.getDisplayMedia({
+      // displaySurface 'window' 是 Chromium 扩展项,TS 标准类型还没收录
+      video: {
+        frameRate: 30,
+        // @ts-expect-error displaySurface 是 Screen Capture API spec 扩展
+        displaySurface: "window",
+      } as MediaTrackConstraints,
+      audio: false,
+    });
+  } catch (e) {
+    await invoke("cancel_recording", { sessionId }).catch(() => {});
+    resetSession();
+    throw new Error(`getDisplayMedia 失败或被用户取消: ${String(e)}`);
+  }
+
+  mediaStream = stream;
+
+  // 用户在 OS 共享条上点"停止共享"时,video track 触发 ended,自动收尾
+  const videoTrack = stream.getVideoTracks()[0];
+  if (videoTrack) {
+    videoTrack.addEventListener("ended", () => {
+      if (currentState === "recording" || currentState === "paused") {
+        void stopRecording().catch((err) => console.error("自动停止录制失败:", err));
+      }
+    });
+  }
+
+  // 3) 启动 MediaRecorder
+  const mimeType = pickMimeType();
+  const recorder = mimeType
+    ? new MediaRecorder(stream, { mimeType })
+    : new MediaRecorder(stream);
+  recorder.ondataavailable = (ev) => {
+    if (ev.data && ev.data.size > 0) recordedChunks.push(ev.data);
+  };
+  mediaRecorder = recorder;
+  recorder.start(1000); // 1 秒一个 chunk,便于增量收集 & 异常时丢失最少
+  setState("recording");
+}
+
+export function pauseRecording(): void {
+  if (currentState !== "recording" || !mediaRecorder) {
+    throw new Error(`当前状态 ${currentState},无法暂停`);
+  }
+  mediaRecorder.pause();
+  setState("paused");
+}
+
+export function resumeRecording(): void {
+  if (currentState !== "paused" || !mediaRecorder) {
+    throw new Error(`当前状态 ${currentState},无法恢复`);
+  }
+  mediaRecorder.resume();
+  setState("recording");
+}
+
+/**
+ * 停止录制 → 转码 → 拿到 mp4 路径。
+ *
+ * 转码期间状态为 'processing',结束后回到 'idle'。
+ * 整个流程的进度也会通过 Tauri 的 'recording-finished' / 'recording-failed' 事件
+ * 回推前端,App.tsx 可统一在事件回调里给 toast。
+ */
+export async function stopRecording(): Promise<string> {
+  if (currentState !== "recording" && currentState !== "paused") {
+    throw new Error(`当前状态 ${currentState},无法停止`);
+  }
+  if (!mediaRecorder || !mediaStream || !currentSessionId || !currentStartOpts) {
+    throw new Error("录制器内部状态不一致");
+  }
+
+  const recorder = mediaRecorder;
+  const stream = mediaStream;
+  const sessionId = currentSessionId;
+  const opts = currentStartOpts;
+
+  setState("processing");
+
+  // 1) 在 stop 之前先抓 frame 实际尺寸(stop 后 track 状态会变)
+  const videoTrack = stream.getVideoTracks()[0];
+  const settings = videoTrack?.getSettings?.() ?? {};
+  const frameW = Math.round(settings.width ?? window.innerWidth * window.devicePixelRatio);
+  const frameH = Math.round(settings.height ?? window.innerHeight * window.devicePixelRatio);
+
+  // 2) stop recorder 并等 onstop
+  const stopped = new Promise<void>((resolve) => {
+    recorder.addEventListener("stop", () => resolve(), { once: true });
+  });
+  recorder.stop();
+  await stopped;
+
+  // 3) 拼合 Blob
+  const blob = new Blob(recordedChunks, {
+    type: recordedChunks[0]?.type || "video/webm",
+  });
+  const arrayBuffer = await blob.arrayBuffer();
+  const bytes = new Uint8Array(arrayBuffer);
+
+  // 4) 关 stream(释放系统共享指示)
+  teardownStream();
+  resetSession();
+
+  // 5) 把 webm 送到 Rust → ffmpeg 转码
+  try {
+    await invoke("save_recording_raw", { sessionId, bytes });
+    const path = await invoke<string>("finalize_recording", {
+      sessionId,
+      crop: {
+        x: opts.origin.x,
+        y: opts.origin.y,
+        w: opts.contentSize.w,
+        h: opts.contentSize.h,
+      },
+      windowClient: { w: window.innerWidth, h: window.innerHeight },
+      frame: { w: frameW, h: frameH },
+      output: { w: opts.contentSize.w, h: opts.contentSize.h },
+    });
+    setState("idle");
+    return path;
+  } catch (e) {
+    setState("idle");
+    throw e;
+  }
+}
+
+/**
+ * 取消录制:不调 finalize、清掉所有现场(stream、session、raw 文件)。
+ */
+export async function cancelRecording(): Promise<void> {
+  if (currentState === "idle") return;
+  const sid = currentSessionId;
+  teardownStream();
+  resetSession();
+  setState("idle");
+  if (sid) {
+    await invoke("cancel_recording", { sessionId: sid }).catch(() => {});
+  }
+}

+ 51 - 0
src/mocks/tasks.ts

@@ -0,0 +1,51 @@
+// Mock 任务数据
+//
+// 后期接入真数据时,只需替换数据源(如改为从后端读取),
+// 保持 Task 的字段形状即可,前端组件不用变。
+
+export interface Task {
+  /** 任务唯一标识,列表中可见 */
+  id: string;
+  /** 任务对应的目标网页 url,列表中不显示 */
+  url: string;
+}
+
+export const mockTasks: Task[] = `https://www.awwwards.com/sites/reasonal
+https://www.awwwards.com/sites/percare-mobile-visualizers
+https://www.awwwards.com/sites/edwin-le-portfolio
+https://www.awwwards.com/sites/funy-ai-video-image
+https://www.awwwards.com/sites/2manybooks-com
+https://www.awwwards.com/sites/linearity-2
+https://www.awwwards.com/sites/reelmuse-ai
+https://www.awwwards.com/sites/nectar-ai
+https://www.awwwards.com/sites/postcards
+https://www.awwwards.com/sites/animal-face
+https://www.awwwards.com/sites/helm-modern-web-app
+https://www.awwwards.com/sites/bart-andrzejewski-ships-fast
+https://www.awwwards.com/sites/vandslab
+https://www.awwwards.com/sites/facilpay
+https://www.awwwards.com/sites/sinqlo
+https://www.awwwards.com/sites/gtpinf
+https://www.awwwards.com/sites/mathical
+https://www.awwwards.com/sites/anima
+https://www.awwwards.com/sites/avalon-platforms
+https://www.awwwards.com/sites/mockupper
+https://www.awwwards.com/sites/minitap-ai
+https://www.awwwards.com/sites/time-garden
+https://www.awwwards.com/sites/ligue-nationale-de-basket
+https://www.awwwards.com/sites/unstructured
+https://www.awwwards.com/sites/barn-til-bords
+https://www.awwwards.com/sites/xinyi-zhaos-portfolio
+https://www.awwwards.com/sites/tuyo-banking-from-the-future
+https://www.awwwards.com/sites/things-inc
+https://www.awwwards.com/sites/maggie
+https://www.awwwards.com/sites/newform
+https://www.awwwards.com/sites/murphycares-com
+https://www.awwwards.com/sites/wa-solutions
+https://www.awwwards.com/sites/patio
+https://www.awwwards.com/sites/pagegrid
+https://www.awwwards.com/sites/auger-dubord
+https://www.awwwards.com/sites/ulf-online`.split("\n").map((item,idx) => ({
+  id: idx+"",
+  url: item.replace(/\s+/g, ''),
+}));

+ 62 - 0
src/types/ipc.ts

@@ -0,0 +1,62 @@
+// 前后端共享的 IPC 类型定义
+//
+// 与 Rust 端 (src-tauri/src/) 的命令签名 / 事件 payload 保持同步。
+
+/**
+ * 录制状态机
+ * - idle:       未在录制(初始 / 已完成 / 已取消)
+ * - recording:  正在录制
+ * - paused:     已暂停(可恢复 / 停止)
+ * - processing: 已停止,正在后端 ffmpeg 转码
+ */
+export type RecordState = "idle" | "recording" | "paused" | "processing";
+
+/** 截图完成事件 payload(对应 Rust emit 的 screenshot-finished) */
+export interface ScreenshotFinishedPayload {
+  /** 触发本次截图的任务 id */
+  taskId: string;
+  /** 截图绝对路径 */
+  path: string;
+  /** 是否为自动触发(false = 手动按钮) */
+  auto: boolean;
+}
+
+/** 截图失败事件 payload(对应 Rust emit 的 screenshot-failed) */
+export interface ScreenshotFailedPayload {
+  taskId: string;
+  /** 是否为自动触发 */
+  auto: boolean;
+  /** 错误描述 */
+  error: string;
+}
+
+/** Tauri 事件名常量,统一来源避免拼写漂移 */
+export const EVT_SCREENSHOT_FINISHED = "screenshot-finished";
+export const EVT_SCREENSHOT_FAILED = "screenshot-failed";
+export const EVT_RECORDING_FINISHED = "recording-finished";
+export const EVT_RECORDING_FAILED = "recording-failed";
+
+/** 录制完成事件 payload(对应 Rust emit 的 recording-finished) */
+export interface RecordingFinishedPayload {
+  taskId: string;
+  sessionId: string;
+  /** 最终 mp4 绝对路径 */
+  path: string;
+}
+
+/** 录制失败事件 payload */
+export interface RecordingFailedPayload {
+  taskId: string;
+  sessionId: string;
+  error: string;
+}
+
+/** query_task_assets 命令的返回结构(字段名与 Rust serde 一致) */
+export interface TaskAssets {
+  screenshot_path: string;
+  screenshot_exists: boolean;
+  recording_path: string;
+  recording_exists: boolean;
+  screenshots_dir: string;
+  recordings_dir: string;
+}