|
|
@@ -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>
|