Преглед на файлове

断点续跑(步 3~5):界面两处入口 + 起跑确认 + 文档

## 界面侧(步 3)

- `resume.js`(新增):读断点、算 snapshot(配置是否变/结果文件是否在)、
  `isRecordResumable`、清空(**覆盖写**,不删文件)
- `engine.js`:`ID_PROGRESS_PATH`;`buildIdConfig(opts)` 给每条取材带上 `key`
  (就是界面的 `id`,断点靠它认"哪条做完了");`launch(..., opts)` 追加
  `--resume --checkpoint <path>`
- `widgets.js`:新增 `W.noticeBar`(标题 + 说明 + 右侧动作)。
  单独做一个而不是用 hint 临时拼:同一种提示要出现在两处,拼两次必然长歪,
  而且 hint 没描边、在玻璃背景上会"浮"在卡片外
- `page-id.js`:顶部提示条三态(可续 / 配置变了 / 结果文件没了),
  后两种只给「清空断点」—— 说清原因比给一个点了没用的按钮强

## 记录页(步 4)

- `W.recordRow` 加**可选动作槽**(全仓只有 1 个调用点,影响面 = 1 处)。
  按钮作为子 View 单独挂监听,自己消费点击,不会冒泡成"进详情"
- `records.js` / `writeRecord` 加 `runId`:记录与断点靠它精确对上 ——
  否则一串历史「手动停止」上都会冒出「继续」,而只有最后一条能真续
- 记录行 + D3 详情页各给一个入口(一个"一眼可见",一个"看完日志顺手继续")

## 起跑确认(步 5)

有可续跑断点时点普通「开始运行」→ 用 `Dialog.choose` 让用户选
「继续上次(已收 N 个)」/「从零开始(断点作废)」,默认前者。
不用 confirm:两个选项都是用户可能真心想要的,只给"确定/取消"没法表达"我要从零开始"。

`resumeRun` **不自己重跑卡密/权限检查**,一律走 `requestRun` ——
否则等于开了个"卡密过期也能续跑"的后门。

## 顺手修的(真机踩出来的)

**「当前屏幕」类取材在续跑时必然取不到人**:那一屏早就没了,而 `flow.launchApp`
刻意不动页面。现在续跑时若存在这类取材,明确 warning 告知(否则用户只看到"又是 0 个")。

**没抛错但一个人都没取到 → 也要说**:`idsource.run` 在"页面不对/列表为空"时只记 warn、
返回 0。现在补一条「取材「X」这次没有取到任何人」,用户才知道是哪条取材白跑了。

## 真机验证(走完整界面链路,不是手起引擎)

```
ID页顶部 → 「上次运行没跑完(已收 3 个)」
           「停在「当前屏幕的评论区」 · 继续会追加到同一个文件」 [继续][清空]
点「继续」→ 引擎收到 id.resume={enabled:true,path:...} + sources[0].key
         → 日志「续跑:上次停在「当前屏幕的评论区」,已收 3 个」
         → 「续跑:1 条取材依赖当前屏幕,可能需要你手动打开对应页面」
         → 「续跑:跳过已收 3 个」
结束文案 → 「完成(续跑):提取 3 个 ID,本轮新增 0 个」
```

自检:PC **114 项** / 界面 smoke **445 项**(新增 23 条:提示条三态、继续带参、
普通运行不带 --resume、取材 key、runId 精确匹配、D3 入口、清空断点覆盖写)/ AUDIT_CLEAN。
两侧 md5 与设备一致。

## 文档

`tiktok-auto/README.md` 新增 3.5.10;`docs/AI_CONTEXT.md` 补 D9/D10/D11
(条目级而非位置级、预载去重导致误判到底、失败源不能标 done)。
梦境 преди 5 дни
родител
ревизия
7a86134099

+ 30 - 0
docs/AI_CONTEXT.md

@@ -552,6 +552,36 @@ adb shell "cat /sdcard/tiktok_app/run-log.jsonl"
   按 caption 去重认不出是同一张;而 `chooseCard` 还有"全都试过就退回全量再挑"的兜底 —— 那就是死循环入口
 - 正解:caption + **容器矩形**双重去重(容差 40px);全试过时返回 null,由调用方决定滚动还是收工
 
+**D9|断点续跑是「条目级」,不是「位置级」(★ 2026-10-28)**
+
+- 为什么不做屏/行游标:TikTok 列表内容会变、滚动位置无法复原。记"第几屏第几行"
+  只会漏掉别人或重复劳动;「哪条取材走完了」才是稳定事实
+- 未完成的取材从头重走,靠已收集合跳过重复的人(列表型几乎零成本,
+  视频作者/评论者要 6~8 秒/个,必须在提示条与日志里如实写)
+- 断点文件 `<结果目录>/id_progress.json`,`state ∈ running/stopped/done/none`;
+  **清空断点 = 覆盖写 `state:'none'`**,绝不删文件(`remove(` 命中危险黑名单)
+- 配置指纹覆盖 `sources/filter/output`;**不覆盖** `limit/resolveUid/dryRun/filter.videos/guard`
+  —— 多算一个字段,用户改个"至少运行几分钟"就会白丢断点
+- 判定顺序:无断点 → 已跑完 → 演练 → 配置变 → 结果文件没了 → 才允许续;
+  **全部在 `launchApp` 之前**,拒绝时零成本,不折腾用户手机
+
+**D10|预载去重集合会让 walkList 误判"列表到底"(★ 真 bug)**
+
+- 现象:跨轮去重第二轮跑同一账号,一个新人都不收;续跑更是必然如此
+- 根因:`idle` 同时被"本屏没有新 uid"和"翻屏后签名不变"累加;
+  预载历史后前两屏全是旧人 → `idle=2` → 在列表顶部就收工
+- 正解:**只认"翻不翻得动"**(`walkStep` 纯函数,PC 可回归)。
+  一屏全是旧人只说明"旧人多",不代表列表到头
+- 连带:`cardContainer` 那类"上限 80%"的经验在这里同样适用 ——
+  判据要落在**物理事实**(滚不动了)上,而不是"业务上有没有新东西"
+
+**D11|失败的那条取材不能标 `done`**
+
+- `idsource.run` 打不开列表时记 error 后**返回 0、不抛异常**
+- 一律标 done 的后果:续跑再也不会重试它,用户以为"这条取材就是没数据"
+- 正解:本轮新增了 error → 保持 `running`(下次再试);否则 → `done`;
+  另外"没抛错但一个人都没取到"要单独给一条 warning(列表空 / 「当前屏幕」页面不对)
+
 ### 6.E 数据与配置
 
 | 编号 | 现象 | 正解 |

+ 493 - 24
tiktok-app/dist/tiktok-app.js

@@ -1,7 +1,7 @@
 "ui";
 /*! tiktok-app v2026.10.13
- *  built 2026-10-28T14:42:55.859Z
- *  modules: theme, store, records, sources, device, service, fingerprint, engine, session, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
+ *  built 2026-10-28T15:01:13.992Z
+ *  modules: theme, store, records, sources, device, service, fingerprint, engine, session, resume, widgets, dialog, floatball, page-id, page-dm, page-video, page-tasks, page-records, page-login, page-mine, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
 (function () {
@@ -1383,6 +1383,9 @@ Records.add = function (record) {
     /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
      * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
     one.mapFile = one.mapFile || "";
+    /* runId:记录页要靠它和断点里的 runId 对上(见 Resume.isRecordResumable)。
+     * 老记录没有这个字段 → 归一化成空串,于是永远不会显示「继续」。 */
+    one.runId = one.runId || "";
     one.logs = one.logs instanceof Array ? one.logs : [];
 
     list.unshift(one);
@@ -2874,11 +2877,11 @@ function seconds(ms) {
  * @returns { config, notes }
  *   notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  */
-Engine.buildConfig = function (task) {
+Engine.buildConfig = function (task, opts) {
     /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
      * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
     if (task && task.task === "id") {
-        return Engine.buildIdConfig();
+        return Engine.buildIdConfig(opts);
     }
     return Engine.buildVideoConfig();
 };
@@ -2888,6 +2891,14 @@ Engine.buildConfig = function (task) {
 /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
 Engine.ID_DIR = "/sdcard/Download/tiktok_app";
 
+/**
+ * 断点文件(ID提取的"跑到哪了")。
+ *
+ * 与产物同目录:用户清产物时看得见它;而且它引用的是 runFile 的绝对路径,
+ * 放在一起语义最直白。引擎侧同一份路径由 `id.resume.path` 传下去。
+ */
+Engine.ID_PROGRESS_PATH = Engine.ID_DIR + "/id_progress.json";
+
 /**
  * 界面 → 引擎(task = 'id')。
  *
@@ -2898,7 +2909,7 @@ Engine.ID_DIR = "/sdcard/Download/tiktok_app";
  * ## 做不到的项一律进 notes
  * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
  */
-Engine.buildIdConfig = function () {
+Engine.buildIdConfig = function (opts) {
     var notes = [];
     var base = "tabs.id.features.";
 
@@ -2906,8 +2917,11 @@ Engine.buildIdConfig = function () {
     var engineSources = [];
     for (var i = 0; i < sources.length; i++) {
         var one = sources[i];
-        /* 只带引擎认识的字段。`id` 是界面的稳定标识(删除/重排用),引擎不需要。 */
+        /* `key` 就是界面的 `id`:断点按它认"哪条取材做完了"。
+         * 以前刻意不带(引擎不需要),但续跑必须带 —— 否则断点里的条目和配置里的
+         * 条目只能靠下标对齐,用户删一条就全错位。 */
         engineSources.push({
+            key: one.id || "",
             kind: one.kind,
             anchors: one.anchors || "",
             lists: one.lists || [],
@@ -2949,6 +2963,16 @@ Engine.buildIdConfig = function () {
             guard: {
                 /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
                 maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
+            },
+            /**
+             * 断点续跑。**只有点「继续」时才为 true**。
+             *
+             * 引擎侧看到它才会去读断点文件;普通「开始运行」传 false,
+             * 于是"想从头跑一次"永远是从头跑(不会被上一轮的断点悄悄接管)。
+             */
+            resume: {
+                enabled: !!(opts && opts.resume),
+                path: Engine.ID_PROGRESS_PATH
             }
         },
         runtime: {
@@ -3131,7 +3155,7 @@ Engine.summarizeNotes = function (notes) {
  * @returns { ok, scriptPath, config, notes, error }
  *   error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  */
-Engine.prepare = function (task) {
+Engine.prepare = function (task, opts) {
     var path = Engine.locate();
     if (!path) {
         return {
@@ -3143,7 +3167,7 @@ Engine.prepare = function (task) {
         };
     }
 
-    var built = Engine.buildConfig(task);
+    var built = Engine.buildConfig(task, opts);
 
     /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
      * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
@@ -3198,16 +3222,24 @@ Engine.writeConfig = function (config) {
  * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
  * 桥那边按数字下标取,两边约定越简单越不容易出错。
  */
-Engine.launch = function (runId, scriptPath) {
+Engine.launch = function (runId, scriptPath, opts) {
     try {
+        var argv = [
+            "--config", Engine.CONFIG_PATH,
+            "--state", Engine.STATE_PATH,
+            "--stop-flag", Engine.STOP_FLAG_PATH,
+            "--log", Engine.LOG_PATH,
+            "--run-id", runId
+        ];
+        /* 只有点「继续」才带这两个参数。普通「开始运行」绝不能带 ——
+         * 不然用户想从头跑一次,结果它接着上次跑。 */
+        if (opts && opts.resume) {
+            argv.push("--resume");
+            argv.push("--checkpoint");
+            argv.push(Engine.ID_PROGRESS_PATH);
+        }
         var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
-            arguments: [
-                "--config", Engine.CONFIG_PATH,
-                "--state", Engine.STATE_PATH,
-                "--stop-flag", Engine.STOP_FLAG_PATH,
-                "--log", Engine.LOG_PATH,
-                "--run-id", runId
-            ]
+            arguments: argv
         });
         if (!handle) {
             return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };
@@ -3473,6 +3505,222 @@ Session.stopTimer = function () {
 };
 
 
+/* ============================== src/resume.js ============================== */
+/**
+ * resume.js — 断点续跑的界面侧(读断点、判断能不能续、清空)
+ *
+ * ## 职责边界
+ * 界面**不写**断点 —— 断点是引擎在跑的时候写的(它才知道"这条取材走完了")。
+ * 界面只做三件事:读出来、判断还能不能用、在用户点「继续」时把开关传给引擎。
+ * 唯一会写的一处是「清空断点」:覆盖成 `state:"none"`(**不删文件**,
+ * `remove(` 命中 MCP 危险调用黑名单,见 `tiktok-auto/tools/build.js`)。
+ *
+ * ## 为什么判断要放在界面
+ * 用户点「继续」之前就得知道"这个断点还用不用得上",否则会出现
+ * 点了继续 → 引擎启动 → 打开 TikTok → 才发现配置变了 → 白折腾一趟。
+ * 引擎侧也会再判一次(有人可能直接改配置手起引擎),两处判据一致
+ * (指纹算法在 `fingerprint.js`,两份逐字节相同)。
+ *
+ * ## 与记录页的关系
+ * 断点里带 `runId`,记录里也带 `runId` —— 只有两者相等才认为"这条记录就是那个断点"。
+ * 不这么做的话,用户会在一堆历史「手动停止」记录上都看到「继续」,
+ * 而其中只有最后一条是真的能续的。
+ */
+var Resume = (function () {
+    'use strict';
+
+    var PATH = Engine.ID_PROGRESS_PATH;
+    var VERSION = 1;
+
+    function nameOf(key) {
+        if (key === 'sources') { return '取材'; }
+        if (key === 'filter') { return '过滤条件'; }
+        if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
+        return key;
+    }
+
+    /** 读断点。解析不了就当"没有断点"(界面不显示提示条,绝不报错吓人) */
+    function read() {
+        try {
+            if (!files.exists(PATH)) {
+                return null;
+            }
+            var text = String(files.read(PATH) || '');
+            if (text.replace(/\s/g, '') === '') {
+                return null;
+            }
+            var cp = JSON.parse(text);
+            if (!cp || cp.v !== VERSION) {
+                return null;
+            }
+            return cp;
+        } catch (e) {
+            console.log("[resume] 断点文件读不懂(当作没有断点): " + e);
+            return null;
+        }
+    }
+
+    function stoppedAt(cp) {
+        var list = cp && cp.sources ? cp.sources : [];
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].status !== 'done') {
+                return list[i].label || '';
+            }
+        }
+        return '';
+    }
+
+    function countBy(cp) {
+        var list = cp && cp.sources ? cp.sources : [];
+        var done = 0;
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].status === 'done') {
+                done += 1;
+            }
+        }
+        return { done: done, total: list.length };
+    }
+
+    /**
+     * 一次读盘算出"能不能续",供提示条与记录页共用。
+     *
+     * @returns {
+     *   exists, cp, valid, finished, reason, message, changed[],
+     *   extracted, runFile, stoppedAt, doneSources, totalSources
+     * }
+     */
+    function snapshot() {
+        var cp = read();
+        var out = {
+            exists: !!cp,
+            cp: cp,
+            valid: false,
+            finished: false,
+            reason: '',
+            message: '',
+            changed: [],
+            extracted: 0,
+            runFile: '',
+            stoppedAt: '',
+            doneSources: 0,
+            totalSources: 0
+        };
+        if (!cp) {
+            out.reason = 'no-checkpoint';
+            return out;
+        }
+
+        out.runFile = cp.runFile || '';
+        out.stoppedAt = stoppedAt(cp);
+        out.extracted = (cp.counts && typeof cp.counts.extracted === 'number')
+            ? cp.counts.extracted : 0;
+        var prog = countBy(cp);
+        out.doneSources = prog.done;
+        out.totalSources = prog.total;
+
+        if (cp.state === 'none') {
+            out.reason = 'cleared';
+            return out;
+        }
+        if (cp.state === 'done') {
+            out.finished = true;
+            out.reason = 'last-complete';
+            return out;
+        }
+
+        var sig = Fingerprint.ofIdSection(Engine.buildIdConfig().config.id);
+        var cmp = Fingerprint.compare(cp.sig, sig);
+        if (!cmp.same) {
+            var names = [];
+            for (var i = 0; i < cmp.changed.length; i++) {
+                names.push(nameOf(cmp.changed[i]));
+            }
+            out.changed = cmp.changed;
+            out.reason = 'config-changed';
+            out.message = names.join('、') + '改过了,只能重新开始';
+            return out;
+        }
+        if (!out.runFile || !files.exists(out.runFile)) {
+            out.reason = 'run-file-missing';
+            out.message = '上次的结果文件已被删除';
+            return out;
+        }
+
+        out.valid = true;
+        out.reason = 'ok';
+        return out;
+    }
+
+    /** 现在能不能点「继续」(还要叠加"当前没有别的任务在跑") */
+    function canResume(snap) {
+        var s = snap || snapshot();
+        return s.valid === true && Session.running !== true;
+    }
+
+    /**
+     * 这条运行记录能不能续。
+     *
+     * 判据必须精确到"就是那次运行",否则一堆历史记录上都会冒出「继续」:
+     *   · 任务必须是 ID 提取
+     *   · 结果必须是 stopped(手动停止)或 failed(被杀/异常退出)
+     *   · 断点本身还得有效(配置没变、结果文件还在)
+     *   · **记录里的 runId 必须等于断点里的 runId**
+     */
+    function isRecordResumable(record, snap) {
+        var s = snap || snapshot();
+        if (!record || record.taskKey !== 'id') {
+            return false;
+        }
+        if (record.result !== 'stopped' && record.result !== 'failed') {
+            return false;
+        }
+        if (!s.valid || !s.cp) {
+            return false;
+        }
+        return !!(record.runId && s.cp.runId && record.runId === s.cp.runId);
+    }
+
+    /** 提示条上那句副文案 */
+    function detail(snap) {
+        var s = snap || snapshot();
+        if (s.valid) {
+            var where = s.stoppedAt ? '停在「' + s.stoppedAt + '」' : '上次没跑完';
+            return where + ' · 继续会追加到同一个文件';
+        }
+        if (s.finished) {
+            return '上次已跑完';
+        }
+        return s.message || '';
+    }
+
+    /** 清空断点 = 覆盖成空壳。**不删文件** */
+    function clear() {
+        try {
+            files.write(PATH, JSON.stringify({
+                v: VERSION,
+                state: 'none',
+                updatedAt: Date.now()
+            }));
+            return true;
+        } catch (e) {
+            console.log("[resume] 清空断点失败: " + e);
+            return false;
+        }
+    }
+
+    return {
+        PATH: PATH,
+        read: read,
+        snapshot: snapshot,
+        canResume: canResume,
+        isRecordResumable: isRecordResumable,
+        detail: detail,
+        nameOf: nameOf,
+        clear: clear
+    };
+}());
+
+
 /* ============================== src/widgets.js ============================== */
 /* ============================== src/widgets.js ==============================
  * 通用组件工厂。
@@ -4652,6 +4900,19 @@ W.recordRow = function (opts) {
     titleLine.addView(U.hgap(8));
     titleLine.addView(W.badge(opts.resultLabel, opts.resultTone).root);
 
+    /* 可选动作槽(现在只有「继续」用它)。
+     * 按钮作为子 View 单独挂监听 —— 子 View 会自己消费点击,
+     * 不会冒泡到整卡的 onOpen,所以"点按钮续跑、点别处进详情"两件事互不干扰。
+     * `W.checkRow` 里处理同一问题的手法可以对照着看。 */
+    if (opts.actionLabel) {
+        var action = W.smallButton(opts.actionLabel, opts.actionTone || "accent",
+                                   function () {
+                                       if (opts.onAction) { opts.onAction(); }
+                                   });
+        titleLine.addView(U.flexible());
+        titleLine.addView(action.root);
+    }
+
     var resultColor = opts.resultTone === "danger" ? Theme.hex.danger : Theme.hex.textDim;
 
     var textGroup = U.column();
@@ -4720,6 +4981,63 @@ W.hint = function (content, center) {
     return view;
 };
 
+/**
+ * 提示条:标题 + 一行说明 + 右侧动作按钮。
+ *
+ * 为什么单独做一个组件、而不是用 `W.hint` + `W.smallButton` 临时拼:
+ * 同一种提示要出现在「ID提取页顶部」与「记录详情页」两处,拼两次必然长歪;
+ * 而且 `W.hint` 没有描边,直接在玻璃背景上会"浮"在卡片外 ——
+ * 这个界面的分层靠的就是 `U.strokeBg` 的描边(`W.card` 同理)。
+ *
+ * @param opts { title, detail, tone:'warn'|'accent'|'mute'|'ok', actions:[{label,tone,onClick}] }
+ * @returns { root, setTitle, setDetail }
+ */
+W.noticeBar = function (opts) {
+    var o = opts || {};
+    var tone = o.tone || "warn";
+    var t = W.TONES[tone] || W.TONES["warn"];
+
+    var box = U.column();
+    box.setBackground(U.strokeBg(0.10, 0.24, t.text, Theme.m.cardRadius));
+    box.setPadding(U.dp(12), U.dp(12), U.dp(12), U.dp(12));
+
+    var title = U.text(o.title || "", Theme.m.labelSize, t.text, true);
+    box.addView(title);
+
+    var detail = null;
+    if (o.detail) {
+        box.addView(U.spacer(4));
+        detail = U.text(o.detail, Theme.m.chipSize, Theme.hex.textDim, false);
+        box.addView(detail);
+    }
+
+    var actions = o.actions || [];
+    if (actions.length > 0) {
+        var row = U.row();
+        row.addView(U.flexible());
+        for (var i = 0; i < actions.length; i++) {
+            if (i > 0) { row.addView(U.hgap(8)); }
+            /* 用闭包把当前这一项绑住:直接在循环里引用 actions[i] 会因为
+             * 点击时才求值而全指向最后一项(这类"全点成同一个"极难发现)。 */
+            var btn = W.smallButton(actions[i].label, actions[i].tone || "accent",
+                                    (function (one) {
+                                        return function () {
+                                            if (one && one.onClick) { one.onClick(); }
+                                        };
+                                    }(actions[i])));
+            row.addView(btn.root);
+        }
+        box.addView(U.spacer(10));
+        box.addView(row);
+    }
+
+    return {
+        root: box,
+        setTitle: function (text) { title.setText(text); },
+        setDetail: function (text) { if (detail) { detail.setText(text); } }
+    };
+};
+
 /* ------------------------------------------------------------ 底部页签 */
 
 /* 页签顺序即设计稿从左到右的顺序。
@@ -7139,12 +7457,57 @@ function buildOutput() {
     return card;
 }
 
+/* ============================================== ⓪ 断点提示条 */
+
+/**
+ * 「上次没跑完」的提示条。三种状态各自一套文案与动作:
+ *
+ * | 状态 | 标题 | 动作 |
+ * |---|---|---|
+ * | 可续跑 | 上次运行没跑完(已收 N 个) | 继续 / 清空 |
+ * | 配置变了 | 上次运行无法继续 | 清空断点 |
+ * | 结果文件没了 | 上次运行无法继续 | 清空断点 |
+ *
+ * 后两种只给「清空断点」——没有别的能做的,说清原因比给一个点了没用的按钮强。
+ */
+function buildResumeNotice(snap) {
+    if (snap.valid) {
+        return W.noticeBar({
+            title: "上次运行没跑完(已收 " + snap.extracted + " 个)",
+            detail: Resume.detail(snap),
+            tone: "accent",
+            actions: [
+                { label: "继续", tone: "accent", onClick: function () { App.resumeRun(); } },
+                { label: "清空", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
+            ]
+        }).root;
+    }
+    return W.noticeBar({
+        title: "上次运行无法继续",
+        detail: snap.message || "断点已失效",
+        tone: "warn",
+        actions: [
+            { label: "清空断点", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
+        ]
+    }).root;
+}
+
 /* ============================================== 页面 */
 
 Pages.id = function (rerender) {
     var inner = U.column();
     var blocks = [];
 
+    /* ——— ⓪ 断点提示(有未跑完的才出现)———
+     * 放在最上面:用户进这个页最想知道的就是"我上次那轮还没跑完"。
+     * 只在没有运行中时出现 —— 跑着的时候点"继续"只会撞上"已有任务在跑"。
+     * snapshot 一次读盘,不在下面各处重复读。 */
+    var snap = Resume.snapshot();
+    if (!Session.running && snap.exists && !snap.finished && snap.reason !== 'cleared') {
+        blocks.push(buildResumeNotice(snap));
+        blocks.push(U.spacer(Theme.m.gapCard));
+    }
+
     /* ——— ① 取材 ——— */
     blocks.push(W.sectionTitle("取材"));
     blocks.push(W.hint("从哪儿找人。可添加多条,按顺序执行;共用同一份去重与输出", false));
@@ -7845,9 +8208,10 @@ function buildLogLine(entry) {
 
 /* ------------------------------------------------------------ D1 记录列表 */
 
-function buildRecordRow(record) {
+function buildRecordRow(record, snap) {
     var visual = recordVisual(record);
     var result = Records.RESULT[record.result] || Records.RESULT.failed;
+    var resumable = Resume.isRecordResumable(record, snap);
 
     return W.recordRow({
         title: Pages.recordTitle(record),
@@ -7857,6 +8221,12 @@ function buildRecordRow(record) {
         resultTone: result.tone,
         timeLine: Records.timeLine(record),
         resultLine: Records.resultLine(record),
+        /* 只有"这条记录就是那个没跑完的断点"时才给「继续」。
+         * 判据在 Resume.isRecordResumable 里(含 runId 精确匹配)——
+         * 否则一串历史「手动停止」记录上都会冒出「继续」,而只有最后一条能真续。 */
+        actionLabel: resumable ? "\u7EE7\u7EED" : "",
+        actionTone: "accent",
+        onAction: function () { App.resumeRun(record); },
         onOpen: function () { App.openRecord(record.id); }
     });
 }
@@ -7866,6 +8236,8 @@ Pages.records = function () {
     var inner = U.column();
     var blocks = [];
     var list = Records.list();
+    /* 断点只读一次:50 条记录各读一次盘是没必要的浪费 */
+    var snap = Resume.snapshot();
 
     /* 标题区由路由层统一加(见 App.renderContent)——页面再加一次就会渲染两遍 */
     var section = U.column();
@@ -7878,7 +8250,7 @@ Pages.records = function () {
     blocks.push(U.spacer(Theme.m.gapCard));
 
     for (var i = 0; i < list.length; i++) {
-        blocks.push(buildRecordRow(list[i]));
+        blocks.push(buildRecordRow(list[i], snap));
         if (i < list.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
     }
     blocks.push(U.spacer(8));
@@ -8021,6 +8393,22 @@ Pages.recordDetail = function (record) {
     }
 
     inner.addView(U.spacer(Theme.m.gapCard));
+    /* 这条记录还能接着跑 → 在看日志的地方也放一个入口(列表行那个是"一眼可见",
+     * 这个是"看完日志顺手继续")。判据与列表行同一份。 */
+    var snap = Resume.snapshot();
+    if (Resume.isRecordResumable(record, snap)) {
+        inner.addView(W.noticeBar({
+            title: "\u8FD9\u6B21\u8FD0\u884C\u6CA1\u8DD1\u5B8C\uFF08\u5DF2\u6536 " + snap.extracted + " \u4E2A\uFF09",
+            detail: Resume.detail(snap),
+            tone: "accent",
+            actions: [{
+                label: "\u7EE7\u7EED",
+                tone: "accent",
+                onClick: function () { App.resumeRun(record); }
+            }]
+        }).root);
+        inner.addView(U.spacer(Theme.m.gapCard));
+    }
     inner.addView(buildSummaryCard(record));
     inner.addView(U.spacer(Theme.m.gapCard));
     inner.addView(buildLogCard(record));
@@ -9250,6 +9638,9 @@ App.writeRecord = function (result) {
          * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、
          * 点不开也分享不了 —— 等于白生成。 */
         mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "",
+        /* runId:记录页要靠它和断点里的 runId 对上,才知道"这条记录就是那个没跑完的"。
+         * 不带的话,一串历史「手动停止」记录上都会冒出「继续」。 */
+        runId: Session.runId || "",
         logs: Engine.readLog()
     });
     console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);
@@ -9332,7 +9723,7 @@ App.exportRunConfig = function (checked, plan) {
  *
  * @param taskKey 要运行哪个任务(任务卡上的按钮直接给,不必先切到那个页)
  */
-App.requestRun = function (taskKey) {
+App.requestRun = function (taskKey, opts) {
     /* ① 卡密:失效就直接回登录页,不弹提示 —— 那里才有可做的事 */
     if (Service.licenseStatus().status !== "active") {
         App.showLogin();
@@ -9365,12 +9756,88 @@ App.requestRun = function (taskKey) {
     /* ⑤ 全局单跑:运行中点别的任务不禁用按钮,而是弹 F3 确认(规格书 §2.3) */
     if (Session.running) {
         Dialog.switchTask(Pages.taskName(Session.tab), Pages.taskName(taskKey), function () {
-            App.startRun(script);
+            App.startRun(script, opts);
         });
         return;
     }
 
-    App.startRun(script);
+    /* ⑦ 有没跑完的断点、而用户点的是普通「开始运行」→ 先问一句。
+     *
+     * 不问的话,用户会静默丢掉上一轮的进度(断点被新一轮覆盖),
+     * 而且界面上完全看不出来发生过这件事。用「选一个」而不是「确认/取消」:
+     * 两个选项都是用户可能真心想要的,只给"确定/取消"会让人没法表达"我要从零开始"。
+     *
+     * 只有 ID 提取有断点,别的任务直接走原路。
+     */
+    if (taskKey === "id" && !(opts && opts.resume)) {
+        var pending = Resume.snapshot();
+        if (pending.valid) {
+            Dialog.choose({
+                title: "\u4E0A\u6B21\u8FD0\u884C\u8FD8\u6CA1\u8DD1\u5B8C",
+                options: [
+                    "\u7EE7\u7EED\u4E0A\u6B21\uFF08\u5DF2\u6536 " + pending.extracted + " \u4E2A\uFF09",
+                    "\u4ECE\u96F6\u5F00\u59CB\uFF08\u65AD\u70B9\u4F5C\u5E9F\uFF09"
+                ],
+                currentIndex: 0,
+                onConfirm: function (index) {
+                    if (index === 0) {
+                        App.resumeRun();
+                        return;
+                    }
+                    App.startRun(script, opts);
+                }
+            });
+            return;
+        }
+    }
+
+    App.startRun(script, opts);
+};
+
+/**
+ * 继续上次没跑完的那一轮(提示条、记录行、记录详情都走这里)。
+ *
+ * **不自己重跑一遍卡密/权限检查** —— 交给 `requestRun` 统一做。
+ * 续跑如果绕过卡密校验,就等于开了一个"卡密过期也能跑"的后门。
+ *
+ * @param record 可选。从某条运行记录点进来时传,用来核对"这条记录是不是那个断点"
+ */
+App.resumeRun = function (record) {
+    if (Session.running) {
+        toast("\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u505C\u6B62");
+        return;
+    }
+
+    var snap = Resume.snapshot();
+    if (!snap.exists || snap.finished || snap.reason === "cleared") {
+        Dialog.alert("\u6CA1\u6709\u53EF\u7EE7\u7EED\u7684\u8FD0\u884C",
+                     "\u4E0A\u6B21\u7684\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u6216\u65AD\u70B9\u5DF2\u88AB\u6E05\u7A7A\u3002");
+        return;
+    }
+    if (!snap.valid) {
+        Dialog.alert("\u65AD\u70B9\u65E0\u6CD5\u7EE7\u7EED",
+                     (snap.message || "\u65AD\u70B9\u5DF2\u5931\u6548") +
+                     "\n\n\u6E05\u7A7A\u65AD\u70B9\u540E\u53EF\u4EE5\u91CD\u65B0\u5F00\u59CB\u3002");
+        return;
+    }
+    /* 从记录点进来时必须核对 runId:断点只保留最后一份,可能是后来那一轮的 */
+    if (record && (!record.runId || !snap.cp || record.runId !== snap.cp.runId)) {
+        Dialog.alert("\u8FD9\u6761\u8BB0\u5F55\u4E0D\u80FD\u7EE7\u7EED",
+                     "\u65AD\u70B9\u5DF2\u88AB\u4E4B\u540E\u7684\u8FD0\u884C\u8986\u76D6\u3002");
+        return;
+    }
+
+    App.requestRun("id", { resume: true });
+};
+
+/** 清空断点(覆盖成 state:'none',不删文件) */
+App.clearCheckpoint = function () {
+    if (!Resume.clear()) {
+        toast("\u6E05\u7A7A\u65AD\u70B9\u5931\u8D25");
+        return;
+    }
+    App.renderScreen();
+    toast("\u65AD\u70B9\u5DF2\u6E05\u7A7A\uFF0C\u4E0B\u6B21\u4ECE\u5934\u5F00\u59CB");
 };
 
 /**
@@ -9393,8 +9860,8 @@ App.toggleRun = function () {
  * 顺序不能换:配置必须先落盘(引擎一起来就读它),
  * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  */
-App.startRun = function (task) {
-    var plan = Engine.prepare(task);
+App.startRun = function (task, opts) {
+    var plan = Engine.prepare(task, opts);
     if (!plan.ok) {
         Dialog.engineUnavailable(plan.error);
         return;
@@ -9412,7 +9879,7 @@ App.startRun = function (task) {
         return;
     }
 
-    var launched = Engine.launch(App.pendingRunId, plan.scriptPath);
+    var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
     if (!launched.ok) {
         Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
         return;
@@ -9824,6 +10291,8 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
         Service: Service,
         Engine: Engine,
         Session: Session,
+        Resume: Resume,
+        Fingerprint: Fingerprint,
         W: W,
         Dialog: Dialog,
         Pages: Pages,

+ 43 - 14
tiktok-app/src/engine.js

@@ -179,11 +179,11 @@ function seconds(ms) {
  * @returns { config, notes }
  *   notes 是「用户表达了意图、但引擎这次做不到」的条目,逐条是人话。
  */
-Engine.buildConfig = function (task) {
+Engine.buildConfig = function (task, opts) {
     /* 两条链路的配置形状完全不同(视频链路是"搜索→看→评",ID提取是"取材清单"),
      * 所以在这里分派,而不是往一个函数里塞两套 if —— 那样两边都会变得难读且易改错。 */
     if (task && task.task === "id") {
-        return Engine.buildIdConfig();
+        return Engine.buildIdConfig(opts);
     }
     return Engine.buildVideoConfig();
 };
@@ -193,6 +193,14 @@ Engine.buildConfig = function (task) {
 /** ID 提取的产物目录(与其它通道文件同根,方便一起清理/查看) */
 Engine.ID_DIR = "/sdcard/Download/tiktok_app";
 
+/**
+ * 断点文件(ID提取的"跑到哪了")。
+ *
+ * 与产物同目录:用户清产物时看得见它;而且它引用的是 runFile 的绝对路径,
+ * 放在一起语义最直白。引擎侧同一份路径由 `id.resume.path` 传下去。
+ */
+Engine.ID_PROGRESS_PATH = Engine.ID_DIR + "/id_progress.json";
+
 /**
  * 界面 → 引擎(task = 'id')。
  *
@@ -203,7 +211,7 @@ Engine.ID_DIR = "/sdcard/Download/tiktok_app";
  * ## 做不到的项一律进 notes
  * 这是项目一贯的规矩:用户勾了却什么都没发生,比明确告知"这次不生效"糟得多。
  */
-Engine.buildIdConfig = function () {
+Engine.buildIdConfig = function (opts) {
     var notes = [];
     var base = "tabs.id.features.";
 
@@ -211,8 +219,11 @@ Engine.buildIdConfig = function () {
     var engineSources = [];
     for (var i = 0; i < sources.length; i++) {
         var one = sources[i];
-        /* 只带引擎认识的字段。`id` 是界面的稳定标识(删除/重排用),引擎不需要。 */
+        /* `key` 就是界面的 `id`:断点按它认"哪条取材做完了"。
+         * 以前刻意不带(引擎不需要),但续跑必须带 —— 否则断点里的条目和配置里的
+         * 条目只能靠下标对齐,用户删一条就全错位。 */
         engineSources.push({
+            key: one.id || "",
             kind: one.kind,
             anchors: one.anchors || "",
             lists: one.lists || [],
@@ -254,6 +265,16 @@ Engine.buildIdConfig = function () {
             guard: {
                 /* 抓取阶段最长多久(分钟 → 毫秒)。到点收尾,已抓的不丢。 */
                 maxRuntimeMs: Math.max(1, Store.getNumber(base + "maxRuntimeMin")) * 60 * 1000
+            },
+            /**
+             * 断点续跑。**只有点「继续」时才为 true**。
+             *
+             * 引擎侧看到它才会去读断点文件;普通「开始运行」传 false,
+             * 于是"想从头跑一次"永远是从头跑(不会被上一轮的断点悄悄接管)。
+             */
+            resume: {
+                enabled: !!(opts && opts.resume),
+                path: Engine.ID_PROGRESS_PATH
             }
         },
         runtime: {
@@ -436,7 +457,7 @@ Engine.summarizeNotes = function (notes) {
  * @returns { ok, scriptPath, config, notes, error }
  *   error 是给用户看的(说清楚"缺什么、放哪"),不是技术异常串。
  */
-Engine.prepare = function (task) {
+Engine.prepare = function (task, opts) {
     var path = Engine.locate();
     if (!path) {
         return {
@@ -448,7 +469,7 @@ Engine.prepare = function (task) {
         };
     }
 
-    var built = Engine.buildConfig(task);
+    var built = Engine.buildConfig(task, opts);
 
     /* 「关键词为空」是**视频链路**的硬前提(空关键词搜索必失败)。
      * ID 提取不需要关键词 —— 它可以只从粉丝列表或文本文件取材,
@@ -503,16 +524,24 @@ Engine.writeConfig = function (config) {
  * 参数用 `--key value` 的平铺形式传:Rhino 会把数组包装成 {"0":..,"1":..},
  * 桥那边按数字下标取,两边约定越简单越不容易出错。
  */
-Engine.launch = function (runId, scriptPath) {
+Engine.launch = function (runId, scriptPath, opts) {
     try {
+        var argv = [
+            "--config", Engine.CONFIG_PATH,
+            "--state", Engine.STATE_PATH,
+            "--stop-flag", Engine.STOP_FLAG_PATH,
+            "--log", Engine.LOG_PATH,
+            "--run-id", runId
+        ];
+        /* 只有点「继续」才带这两个参数。普通「开始运行」绝不能带 ——
+         * 不然用户想从头跑一次,结果它接着上次跑。 */
+        if (opts && opts.resume) {
+            argv.push("--resume");
+            argv.push("--checkpoint");
+            argv.push(Engine.ID_PROGRESS_PATH);
+        }
         var handle = engines.execScriptFile(scriptPath || Engine.scriptPath, {
-            arguments: [
-                "--config", Engine.CONFIG_PATH,
-                "--state", Engine.STATE_PATH,
-                "--stop-flag", Engine.STOP_FLAG_PATH,
-                "--log", Engine.LOG_PATH,
-                "--run-id", runId
-            ]
+            arguments: argv
         });
         if (!handle) {
             return { ok: false, error: "\u5F15\u64CE\u542F\u52A8\u8FD4\u56DE\u7A7A\uFF08\u53EF\u80FD\u88AB\u7CFB\u7EDF\u62E6\u622A\uFF09" };

+ 87 - 6
tiktok-app/src/main.js

@@ -720,6 +720,9 @@ App.writeRecord = function (result) {
          * 以前这里只传 resultFile,映射文件虽然在手机上,界面上却看不到路径、
          * 点不开也分享不了 —— 等于白生成。 */
         mapFile: result.summary && result.summary.mapFile ? result.summary.mapFile : "",
+        /* runId:记录页要靠它和断点里的 runId 对上,才知道"这条记录就是那个没跑完的"。
+         * 不带的话,一串历史「手动停止」记录上都会冒出「继续」。 */
+        runId: Session.runId || "",
         logs: Engine.readLog()
     });
     console.log("[records] 已记录本次运行:" + record.id + " / " + record.result);
@@ -802,7 +805,7 @@ App.exportRunConfig = function (checked, plan) {
  *
  * @param taskKey 要运行哪个任务(任务卡上的按钮直接给,不必先切到那个页)
  */
-App.requestRun = function (taskKey) {
+App.requestRun = function (taskKey, opts) {
     /* ① 卡密:失效就直接回登录页,不弹提示 —— 那里才有可做的事 */
     if (Service.licenseStatus().status !== "active") {
         App.showLogin();
@@ -835,12 +838,88 @@ App.requestRun = function (taskKey) {
     /* ⑤ 全局单跑:运行中点别的任务不禁用按钮,而是弹 F3 确认(规格书 §2.3) */
     if (Session.running) {
         Dialog.switchTask(Pages.taskName(Session.tab), Pages.taskName(taskKey), function () {
-            App.startRun(script);
+            App.startRun(script, opts);
         });
         return;
     }
 
-    App.startRun(script);
+    /* ⑦ 有没跑完的断点、而用户点的是普通「开始运行」→ 先问一句。
+     *
+     * 不问的话,用户会静默丢掉上一轮的进度(断点被新一轮覆盖),
+     * 而且界面上完全看不出来发生过这件事。用「选一个」而不是「确认/取消」:
+     * 两个选项都是用户可能真心想要的,只给"确定/取消"会让人没法表达"我要从零开始"。
+     *
+     * 只有 ID 提取有断点,别的任务直接走原路。
+     */
+    if (taskKey === "id" && !(opts && opts.resume)) {
+        var pending = Resume.snapshot();
+        if (pending.valid) {
+            Dialog.choose({
+                title: "\u4E0A\u6B21\u8FD0\u884C\u8FD8\u6CA1\u8DD1\u5B8C",
+                options: [
+                    "\u7EE7\u7EED\u4E0A\u6B21\uFF08\u5DF2\u6536 " + pending.extracted + " \u4E2A\uFF09",
+                    "\u4ECE\u96F6\u5F00\u59CB\uFF08\u65AD\u70B9\u4F5C\u5E9F\uFF09"
+                ],
+                currentIndex: 0,
+                onConfirm: function (index) {
+                    if (index === 0) {
+                        App.resumeRun();
+                        return;
+                    }
+                    App.startRun(script, opts);
+                }
+            });
+            return;
+        }
+    }
+
+    App.startRun(script, opts);
+};
+
+/**
+ * 继续上次没跑完的那一轮(提示条、记录行、记录详情都走这里)。
+ *
+ * **不自己重跑一遍卡密/权限检查** —— 交给 `requestRun` 统一做。
+ * 续跑如果绕过卡密校验,就等于开了一个"卡密过期也能跑"的后门。
+ *
+ * @param record 可选。从某条运行记录点进来时传,用来核对"这条记录是不是那个断点"
+ */
+App.resumeRun = function (record) {
+    if (Session.running) {
+        toast("\u5DF2\u6709\u4EFB\u52A1\u5728\u8FD0\u884C\uFF0C\u8BF7\u5148\u505C\u6B62");
+        return;
+    }
+
+    var snap = Resume.snapshot();
+    if (!snap.exists || snap.finished || snap.reason === "cleared") {
+        Dialog.alert("\u6CA1\u6709\u53EF\u7EE7\u7EED\u7684\u8FD0\u884C",
+                     "\u4E0A\u6B21\u7684\u8FD0\u884C\u5DF2\u7ECF\u7ED3\u675F\uFF0C\u6216\u65AD\u70B9\u5DF2\u88AB\u6E05\u7A7A\u3002");
+        return;
+    }
+    if (!snap.valid) {
+        Dialog.alert("\u65AD\u70B9\u65E0\u6CD5\u7EE7\u7EED",
+                     (snap.message || "\u65AD\u70B9\u5DF2\u5931\u6548") +
+                     "\n\n\u6E05\u7A7A\u65AD\u70B9\u540E\u53EF\u4EE5\u91CD\u65B0\u5F00\u59CB\u3002");
+        return;
+    }
+    /* 从记录点进来时必须核对 runId:断点只保留最后一份,可能是后来那一轮的 */
+    if (record && (!record.runId || !snap.cp || record.runId !== snap.cp.runId)) {
+        Dialog.alert("\u8FD9\u6761\u8BB0\u5F55\u4E0D\u80FD\u7EE7\u7EED",
+                     "\u65AD\u70B9\u5DF2\u88AB\u4E4B\u540E\u7684\u8FD0\u884C\u8986\u76D6\u3002");
+        return;
+    }
+
+    App.requestRun("id", { resume: true });
+};
+
+/** 清空断点(覆盖成 state:'none',不删文件) */
+App.clearCheckpoint = function () {
+    if (!Resume.clear()) {
+        toast("\u6E05\u7A7A\u65AD\u70B9\u5931\u8D25");
+        return;
+    }
+    App.renderScreen();
+    toast("\u65AD\u70B9\u5DF2\u6E05\u7A7A\uFF0C\u4E0B\u6B21\u4ECE\u5934\u5F00\u59CB");
 };
 
 /**
@@ -863,8 +942,8 @@ App.toggleRun = function () {
  * 顺序不能换:配置必须先落盘(引擎一起来就读它),
  * 计时必须在唤起成功之后才开始(否则引擎没起来,界面却在计时,是最难解释的一种状态)。
  */
-App.startRun = function (task) {
-    var plan = Engine.prepare(task);
+App.startRun = function (task, opts) {
+    var plan = Engine.prepare(task, opts);
     if (!plan.ok) {
         Dialog.engineUnavailable(plan.error);
         return;
@@ -882,7 +961,7 @@ App.startRun = function (task) {
         return;
     }
 
-    var launched = Engine.launch(App.pendingRunId, plan.scriptPath);
+    var launched = Engine.launch(App.pendingRunId, plan.scriptPath, opts);
     if (!launched.ok) {
         Dialog.engineUnavailable("\u5F15\u64CE\u542F\u52A8\u5931\u8D25\uFF1A" + launched.error);
         return;
@@ -1294,6 +1373,8 @@ if (typeof globalThis !== "undefined" && globalThis.__FEIZHOU_TEST__) {
         Service: Service,
         Engine: Engine,
         Session: Session,
+        Resume: Resume,
+        Fingerprint: Fingerprint,
         W: W,
         Dialog: Dialog,
         Pages: Pages,

+ 45 - 0
tiktok-app/src/page-id.js

@@ -453,12 +453,57 @@ function buildOutput() {
     return card;
 }
 
+/* ============================================== ⓪ 断点提示条 */
+
+/**
+ * 「上次没跑完」的提示条。三种状态各自一套文案与动作:
+ *
+ * | 状态 | 标题 | 动作 |
+ * |---|---|---|
+ * | 可续跑 | 上次运行没跑完(已收 N 个) | 继续 / 清空 |
+ * | 配置变了 | 上次运行无法继续 | 清空断点 |
+ * | 结果文件没了 | 上次运行无法继续 | 清空断点 |
+ *
+ * 后两种只给「清空断点」——没有别的能做的,说清原因比给一个点了没用的按钮强。
+ */
+function buildResumeNotice(snap) {
+    if (snap.valid) {
+        return W.noticeBar({
+            title: "上次运行没跑完(已收 " + snap.extracted + " 个)",
+            detail: Resume.detail(snap),
+            tone: "accent",
+            actions: [
+                { label: "继续", tone: "accent", onClick: function () { App.resumeRun(); } },
+                { label: "清空", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
+            ]
+        }).root;
+    }
+    return W.noticeBar({
+        title: "上次运行无法继续",
+        detail: snap.message || "断点已失效",
+        tone: "warn",
+        actions: [
+            { label: "清空断点", tone: "mute", onClick: function () { App.clearCheckpoint(); } }
+        ]
+    }).root;
+}
+
 /* ============================================== 页面 */
 
 Pages.id = function (rerender) {
     var inner = U.column();
     var blocks = [];
 
+    /* ——— ⓪ 断点提示(有未跑完的才出现)———
+     * 放在最上面:用户进这个页最想知道的就是"我上次那轮还没跑完"。
+     * 只在没有运行中时出现 —— 跑着的时候点"继续"只会撞上"已有任务在跑"。
+     * snapshot 一次读盘,不在下面各处重复读。 */
+    var snap = Resume.snapshot();
+    if (!Session.running && snap.exists && !snap.finished && snap.reason !== 'cleared') {
+        blocks.push(buildResumeNotice(snap));
+        blocks.push(U.spacer(Theme.m.gapCard));
+    }
+
     /* ——— ① 取材 ——— */
     blocks.push(W.sectionTitle("取材"));
     blocks.push(W.hint("从哪儿找人。可添加多条,按顺序执行;共用同一份去重与输出", false));

+ 27 - 2
tiktok-app/src/page-records.js

@@ -72,9 +72,10 @@ function buildLogLine(entry) {
 
 /* ------------------------------------------------------------ D1 记录列表 */
 
-function buildRecordRow(record) {
+function buildRecordRow(record, snap) {
     var visual = recordVisual(record);
     var result = Records.RESULT[record.result] || Records.RESULT.failed;
+    var resumable = Resume.isRecordResumable(record, snap);
 
     return W.recordRow({
         title: Pages.recordTitle(record),
@@ -84,6 +85,12 @@ function buildRecordRow(record) {
         resultTone: result.tone,
         timeLine: Records.timeLine(record),
         resultLine: Records.resultLine(record),
+        /* 只有"这条记录就是那个没跑完的断点"时才给「继续」。
+         * 判据在 Resume.isRecordResumable 里(含 runId 精确匹配)——
+         * 否则一串历史「手动停止」记录上都会冒出「继续」,而只有最后一条能真续。 */
+        actionLabel: resumable ? "\u7EE7\u7EED" : "",
+        actionTone: "accent",
+        onAction: function () { App.resumeRun(record); },
         onOpen: function () { App.openRecord(record.id); }
     });
 }
@@ -93,6 +100,8 @@ Pages.records = function () {
     var inner = U.column();
     var blocks = [];
     var list = Records.list();
+    /* 断点只读一次:50 条记录各读一次盘是没必要的浪费 */
+    var snap = Resume.snapshot();
 
     /* 标题区由路由层统一加(见 App.renderContent)——页面再加一次就会渲染两遍 */
     var section = U.column();
@@ -105,7 +114,7 @@ Pages.records = function () {
     blocks.push(U.spacer(Theme.m.gapCard));
 
     for (var i = 0; i < list.length; i++) {
-        blocks.push(buildRecordRow(list[i]));
+        blocks.push(buildRecordRow(list[i], snap));
         if (i < list.length - 1) { blocks.push(U.spacer(Theme.m.gapCard)); }
     }
     blocks.push(U.spacer(8));
@@ -248,6 +257,22 @@ Pages.recordDetail = function (record) {
     }
 
     inner.addView(U.spacer(Theme.m.gapCard));
+    /* 这条记录还能接着跑 → 在看日志的地方也放一个入口(列表行那个是"一眼可见",
+     * 这个是"看完日志顺手继续")。判据与列表行同一份。 */
+    var snap = Resume.snapshot();
+    if (Resume.isRecordResumable(record, snap)) {
+        inner.addView(W.noticeBar({
+            title: "\u8FD9\u6B21\u8FD0\u884C\u6CA1\u8DD1\u5B8C\uFF08\u5DF2\u6536 " + snap.extracted + " \u4E2A\uFF09",
+            detail: Resume.detail(snap),
+            tone: "accent",
+            actions: [{
+                label: "\u7EE7\u7EED",
+                tone: "accent",
+                onClick: function () { App.resumeRun(record); }
+            }]
+        }).root);
+        inner.addView(U.spacer(Theme.m.gapCard));
+    }
     inner.addView(buildSummaryCard(record));
     inner.addView(U.spacer(Theme.m.gapCard));
     inner.addView(buildLogCard(record));

+ 3 - 0
tiktok-app/src/records.js

@@ -113,6 +113,9 @@ Records.add = function (record) {
     /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
      * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
     one.mapFile = one.mapFile || "";
+    /* runId:记录页要靠它和断点里的 runId 对上(见 Resume.isRecordResumable)。
+     * 老记录没有这个字段 → 归一化成空串,于是永远不会显示「继续」。 */
+    one.runId = one.runId || "";
     one.logs = one.logs instanceof Array ? one.logs : [];
 
     list.unshift(one);

+ 213 - 0
tiktok-app/src/resume.js

@@ -0,0 +1,213 @@
+/**
+ * resume.js — 断点续跑的界面侧(读断点、判断能不能续、清空)
+ *
+ * ## 职责边界
+ * 界面**不写**断点 —— 断点是引擎在跑的时候写的(它才知道"这条取材走完了")。
+ * 界面只做三件事:读出来、判断还能不能用、在用户点「继续」时把开关传给引擎。
+ * 唯一会写的一处是「清空断点」:覆盖成 `state:"none"`(**不删文件**,
+ * `remove(` 命中 MCP 危险调用黑名单,见 `tiktok-auto/tools/build.js`)。
+ *
+ * ## 为什么判断要放在界面
+ * 用户点「继续」之前就得知道"这个断点还用不用得上",否则会出现
+ * 点了继续 → 引擎启动 → 打开 TikTok → 才发现配置变了 → 白折腾一趟。
+ * 引擎侧也会再判一次(有人可能直接改配置手起引擎),两处判据一致
+ * (指纹算法在 `fingerprint.js`,两份逐字节相同)。
+ *
+ * ## 与记录页的关系
+ * 断点里带 `runId`,记录里也带 `runId` —— 只有两者相等才认为"这条记录就是那个断点"。
+ * 不这么做的话,用户会在一堆历史「手动停止」记录上都看到「继续」,
+ * 而其中只有最后一条是真的能续的。
+ */
+var Resume = (function () {
+    'use strict';
+
+    var PATH = Engine.ID_PROGRESS_PATH;
+    var VERSION = 1;
+
+    function nameOf(key) {
+        if (key === 'sources') { return '取材'; }
+        if (key === 'filter') { return '过滤条件'; }
+        if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; }
+        return key;
+    }
+
+    /** 读断点。解析不了就当"没有断点"(界面不显示提示条,绝不报错吓人) */
+    function read() {
+        try {
+            if (!files.exists(PATH)) {
+                return null;
+            }
+            var text = String(files.read(PATH) || '');
+            if (text.replace(/\s/g, '') === '') {
+                return null;
+            }
+            var cp = JSON.parse(text);
+            if (!cp || cp.v !== VERSION) {
+                return null;
+            }
+            return cp;
+        } catch (e) {
+            console.log("[resume] 断点文件读不懂(当作没有断点): " + e);
+            return null;
+        }
+    }
+
+    function stoppedAt(cp) {
+        var list = cp && cp.sources ? cp.sources : [];
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].status !== 'done') {
+                return list[i].label || '';
+            }
+        }
+        return '';
+    }
+
+    function countBy(cp) {
+        var list = cp && cp.sources ? cp.sources : [];
+        var done = 0;
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].status === 'done') {
+                done += 1;
+            }
+        }
+        return { done: done, total: list.length };
+    }
+
+    /**
+     * 一次读盘算出"能不能续",供提示条与记录页共用。
+     *
+     * @returns {
+     *   exists, cp, valid, finished, reason, message, changed[],
+     *   extracted, runFile, stoppedAt, doneSources, totalSources
+     * }
+     */
+    function snapshot() {
+        var cp = read();
+        var out = {
+            exists: !!cp,
+            cp: cp,
+            valid: false,
+            finished: false,
+            reason: '',
+            message: '',
+            changed: [],
+            extracted: 0,
+            runFile: '',
+            stoppedAt: '',
+            doneSources: 0,
+            totalSources: 0
+        };
+        if (!cp) {
+            out.reason = 'no-checkpoint';
+            return out;
+        }
+
+        out.runFile = cp.runFile || '';
+        out.stoppedAt = stoppedAt(cp);
+        out.extracted = (cp.counts && typeof cp.counts.extracted === 'number')
+            ? cp.counts.extracted : 0;
+        var prog = countBy(cp);
+        out.doneSources = prog.done;
+        out.totalSources = prog.total;
+
+        if (cp.state === 'none') {
+            out.reason = 'cleared';
+            return out;
+        }
+        if (cp.state === 'done') {
+            out.finished = true;
+            out.reason = 'last-complete';
+            return out;
+        }
+
+        var sig = Fingerprint.ofIdSection(Engine.buildIdConfig().config.id);
+        var cmp = Fingerprint.compare(cp.sig, sig);
+        if (!cmp.same) {
+            var names = [];
+            for (var i = 0; i < cmp.changed.length; i++) {
+                names.push(nameOf(cmp.changed[i]));
+            }
+            out.changed = cmp.changed;
+            out.reason = 'config-changed';
+            out.message = names.join('、') + '改过了,只能重新开始';
+            return out;
+        }
+        if (!out.runFile || !files.exists(out.runFile)) {
+            out.reason = 'run-file-missing';
+            out.message = '上次的结果文件已被删除';
+            return out;
+        }
+
+        out.valid = true;
+        out.reason = 'ok';
+        return out;
+    }
+
+    /** 现在能不能点「继续」(还要叠加"当前没有别的任务在跑") */
+    function canResume(snap) {
+        var s = snap || snapshot();
+        return s.valid === true && Session.running !== true;
+    }
+
+    /**
+     * 这条运行记录能不能续。
+     *
+     * 判据必须精确到"就是那次运行",否则一堆历史记录上都会冒出「继续」:
+     *   · 任务必须是 ID 提取
+     *   · 结果必须是 stopped(手动停止)或 failed(被杀/异常退出)
+     *   · 断点本身还得有效(配置没变、结果文件还在)
+     *   · **记录里的 runId 必须等于断点里的 runId**
+     */
+    function isRecordResumable(record, snap) {
+        var s = snap || snapshot();
+        if (!record || record.taskKey !== 'id') {
+            return false;
+        }
+        if (record.result !== 'stopped' && record.result !== 'failed') {
+            return false;
+        }
+        if (!s.valid || !s.cp) {
+            return false;
+        }
+        return !!(record.runId && s.cp.runId && record.runId === s.cp.runId);
+    }
+
+    /** 提示条上那句副文案 */
+    function detail(snap) {
+        var s = snap || snapshot();
+        if (s.valid) {
+            var where = s.stoppedAt ? '停在「' + s.stoppedAt + '」' : '上次没跑完';
+            return where + ' · 继续会追加到同一个文件';
+        }
+        if (s.finished) {
+            return '上次已跑完';
+        }
+        return s.message || '';
+    }
+
+    /** 清空断点 = 覆盖成空壳。**不删文件** */
+    function clear() {
+        try {
+            files.write(PATH, JSON.stringify({
+                v: VERSION,
+                state: 'none',
+                updatedAt: Date.now()
+            }));
+            return true;
+        } catch (e) {
+            console.log("[resume] 清空断点失败: " + e);
+            return false;
+        }
+    }
+
+    return {
+        PATH: PATH,
+        read: read,
+        snapshot: snapshot,
+        canResume: canResume,
+        isRecordResumable: isRecordResumable,
+        detail: detail,
+        nameOf: nameOf,
+        clear: clear
+    };
+}());

+ 70 - 0
tiktok-app/src/widgets.js

@@ -1176,6 +1176,19 @@ W.recordRow = function (opts) {
     titleLine.addView(U.hgap(8));
     titleLine.addView(W.badge(opts.resultLabel, opts.resultTone).root);
 
+    /* 可选动作槽(现在只有「继续」用它)。
+     * 按钮作为子 View 单独挂监听 —— 子 View 会自己消费点击,
+     * 不会冒泡到整卡的 onOpen,所以"点按钮续跑、点别处进详情"两件事互不干扰。
+     * `W.checkRow` 里处理同一问题的手法可以对照着看。 */
+    if (opts.actionLabel) {
+        var action = W.smallButton(opts.actionLabel, opts.actionTone || "accent",
+                                   function () {
+                                       if (opts.onAction) { opts.onAction(); }
+                                   });
+        titleLine.addView(U.flexible());
+        titleLine.addView(action.root);
+    }
+
     var resultColor = opts.resultTone === "danger" ? Theme.hex.danger : Theme.hex.textDim;
 
     var textGroup = U.column();
@@ -1244,6 +1257,63 @@ W.hint = function (content, center) {
     return view;
 };
 
+/**
+ * 提示条:标题 + 一行说明 + 右侧动作按钮。
+ *
+ * 为什么单独做一个组件、而不是用 `W.hint` + `W.smallButton` 临时拼:
+ * 同一种提示要出现在「ID提取页顶部」与「记录详情页」两处,拼两次必然长歪;
+ * 而且 `W.hint` 没有描边,直接在玻璃背景上会"浮"在卡片外 ——
+ * 这个界面的分层靠的就是 `U.strokeBg` 的描边(`W.card` 同理)。
+ *
+ * @param opts { title, detail, tone:'warn'|'accent'|'mute'|'ok', actions:[{label,tone,onClick}] }
+ * @returns { root, setTitle, setDetail }
+ */
+W.noticeBar = function (opts) {
+    var o = opts || {};
+    var tone = o.tone || "warn";
+    var t = W.TONES[tone] || W.TONES["warn"];
+
+    var box = U.column();
+    box.setBackground(U.strokeBg(0.10, 0.24, t.text, Theme.m.cardRadius));
+    box.setPadding(U.dp(12), U.dp(12), U.dp(12), U.dp(12));
+
+    var title = U.text(o.title || "", Theme.m.labelSize, t.text, true);
+    box.addView(title);
+
+    var detail = null;
+    if (o.detail) {
+        box.addView(U.spacer(4));
+        detail = U.text(o.detail, Theme.m.chipSize, Theme.hex.textDim, false);
+        box.addView(detail);
+    }
+
+    var actions = o.actions || [];
+    if (actions.length > 0) {
+        var row = U.row();
+        row.addView(U.flexible());
+        for (var i = 0; i < actions.length; i++) {
+            if (i > 0) { row.addView(U.hgap(8)); }
+            /* 用闭包把当前这一项绑住:直接在循环里引用 actions[i] 会因为
+             * 点击时才求值而全指向最后一项(这类"全点成同一个"极难发现)。 */
+            var btn = W.smallButton(actions[i].label, actions[i].tone || "accent",
+                                    (function (one) {
+                                        return function () {
+                                            if (one && one.onClick) { one.onClick(); }
+                                        };
+                                    }(actions[i])));
+            row.addView(btn.root);
+        }
+        box.addView(U.spacer(10));
+        box.addView(row);
+    }
+
+    return {
+        root: box,
+        setTitle: function (text) { title.setText(text); },
+        setDetail: function (text) { if (detail) { detail.setText(text); } }
+    };
+};
+
 /* ------------------------------------------------------------ 底部页签 */
 
 /* 页签顺序即设计稿从左到右的顺序。

+ 1 - 0
tiktok-app/tools/build.js

@@ -47,6 +47,7 @@ const MODULE_ORDER = [
     'fingerprint',  // 配置指纹:与 tiktok-auto/src/fingerprint.js 同算法(断点续跑用)
     'engine',       // 执行引擎桥接:界面与 tiktok-auto 之间的文件通道
     'session',
+    'resume',       // 断点续跑:读断点/判断能否续/清空(只读引擎写的进度文件)
     'widgets',
     'dialog',
     'floatball',    // 全局悬浮球:用 floaty 开窗,只依赖 theme/store/device/session

+ 128 - 0
tiktok-app/tools/smoke.js

@@ -934,6 +934,134 @@ env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
 env.flushIntervals();
 eq('ID提取的终止态被接住', api.Session.running, false);
 
+/* ------------------------------------- 12. 断点续跑(提示条 / 记录页「继续」) */
+console.log('[smoke] 断点续跑 · 提示条 · 记录页「继续」');
+
+const ID_RUNFILE = api.Engine.ID_DIR + '/uid_20261028_2000.txt';
+
+/** 造一份断点。指纹必须与"此刻的配置"一致,否则会被判成"配置改过了" */
+function seedCheckpoint(patch) {
+    const cp = Object.assign({
+        v: 1, state: 'stopped', runId: 'run-break-1', beganAt: 1, updatedAt: 1,
+        sig: api.Fingerprint.ofIdSection(api.Engine.buildIdConfig().config.id),
+        runFile: ID_RUNFILE, mapFile: '', limitBase: 37,
+        counts: { extracted: 37, skipped: 0, duplicates: 0, openedProfiles: 0 },
+        sources: [
+            { key: 'src-a', kind: 'anchor', label: '账号 A 的 fans',
+              status: 'done', screens: 3, handled: 40, added: 37 },
+            { key: 'src-b', kind: 'search', label: '用户「小猫」',
+              status: 'pending', screens: 0, handled: 0, added: 0 }
+        ],
+        expand: { queue: [], done: [] }
+    }, patch || {});
+    env.memoryFiles[api.Engine.ID_PROGRESS_PATH] = JSON.stringify(cp);
+    env.memoryFiles[ID_RUNFILE] = 'u1\nu2\n';
+    return cp;
+}
+
+function endRun() {
+    env.memoryFiles[api.Engine.STATE_PATH] = JSON.stringify({
+        runId: api.Session.runId, status: 'stopped', phase: '', message: '已停止'
+    });
+    env.flushIntervals();
+}
+
+api.App.openDetail('id');
+if (api.Sources.count() === 0) { api.Sources.add('anchor'); }
+const idSource = api.Sources.list()[0];
+
+/* ① 先验回归:**没有断点**时普通「开始运行」绝不带 --resume */
+delete env.memoryFiles[api.Engine.ID_PROGRESS_PATH];
+const spawn0 = env.engineState.spawned.length;
+api.App.requestRun('id');
+eq('普通运行唤起了引擎', env.engineState.spawned.length, spawn0 + 1);
+const argv0 = env.engineState.spawned[env.engineState.spawned.length - 1].config.arguments || [];
+check('普通「开始运行」不带 --resume', argv0.indexOf('--resume') < 0, JSON.stringify(argv0));
+eq('普通运行时配置里 resume.enabled = false',
+   JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]).id.resume.enabled, false);
+endRun();
+
+/* ② 有断点 → ID页顶部出现提示条 */
+seedCheckpoint();
+api.App.renderScreen();
+let cpTexts = textsOf(api.App.contentHost).join(' | ');
+check('有未跑完的断点 → ID页顶部出现提示条',
+      cpTexts.indexOf('上次运行没跑完') >= 0, cpTexts);
+check('提示条写清已收个数', cpTexts.indexOf('已收 37 个') >= 0, cpTexts);
+check('提示条给出「继续」与「清空」两个动作',
+      cpTexts.indexOf('继续') >= 0 && cpTexts.indexOf('清空') >= 0, cpTexts);
+
+/* ③ 点「继续」→ 起引擎、带 --resume、配置里 resume.enabled = true */
+const spawn1 = env.engineState.spawned.length;
+api.App.resumeRun();
+eq('「继续」唤起了引擎', env.engineState.spawned.length, spawn1 + 1);
+const argv1 = env.engineState.spawned[env.engineState.spawned.length - 1].config.arguments || [];
+check('续跑的引擎参数带 --resume', argv1.indexOf('--resume') >= 0, JSON.stringify(argv1));
+check('续跑指明了断点文件',
+      argv1[argv1.indexOf('--checkpoint') + 1] === api.Engine.ID_PROGRESS_PATH,
+      JSON.stringify(argv1));
+const resumedConfig = JSON.parse(env.memoryFiles[api.Engine.CONFIG_PATH]);
+eq('续跑时配置里 resume.enabled = true', resumedConfig.id.resume.enabled, true);
+eq('续跑时取材带上了界面的稳定 key', resumedConfig.id.sources[0].key, idSource.id);
+endRun();
+
+/* ④ 配置改过 → 提示条改口,且不再给「继续」 */
+seedCheckpoint({ sig: { v: 1, sources: 'x', filter: 'y', output: 'z', all: 'w' } });
+api.App.renderScreen();
+cpTexts = textsOf(api.App.contentHost).join(' | ');
+check('配置改过 → 提示条说「无法继续」', cpTexts.indexOf('上次运行无法继续') >= 0, cpTexts);
+check('配置改过 → 指出是哪一段改了', cpTexts.indexOf('取材') >= 0 || cpTexts.indexOf('改过') >= 0, cpTexts);
+/* 注意不能只查「继续」二字:失效态的标题本身就是「上次运行无法继续」,
+ * 里面有「继续」但意思是"不能续"。判据要落在**可续态专属文案**是否消失。 */
+check('配置改过 → 不再出现可续文案',
+      cpTexts.indexOf('上次运行没跑完') < 0 && cpTexts.indexOf('已收 37 个') < 0, cpTexts);
+check('断点被判定为失效', api.Resume.snapshot().valid === false);
+
+/* ⑤ 清空断点 → 覆盖成 state:'none'(不删文件) */
+api.App.clearCheckpoint();
+const cleared = JSON.parse(env.memoryFiles[api.Engine.ID_PROGRESS_PATH]);
+eq('清空断点后 state = none', cleared.state, 'none');
+check('清空断点是覆盖写而不是删文件',
+      env.memoryFiles[api.Engine.ID_PROGRESS_PATH] !== undefined);
+
+/* ⑥ 已跑完的断点 → 不显示提示条 */
+seedCheckpoint({ state: 'done' });
+api.App.renderScreen();
+cpTexts = textsOf(api.App.contentHost).join(' | ');
+check('上次已跑完 → 不显示提示条', cpTexts.indexOf('上次运行没跑完') < 0, cpTexts);
+
+/* ⑦ 记录页:只有"就是那个断点"的记录才有「继续」 */
+seedCheckpoint();
+const recA = api.Records.add({
+    taskKey: 'id', taskName: 'ID提取', scriptKey: 'id', scriptName: 'ID提取',
+    result: 'stopped', startedAt: 1, endedAt: 2, resultText: '已停止:累计 37 个 ID',
+    runId: 'run-break-1'
+});
+const recB = api.Records.add({
+    taskKey: 'id', taskName: 'ID提取', scriptKey: 'id', scriptName: 'ID提取',
+    result: 'stopped', startedAt: 1, endedAt: 2, resultText: '已停止:累计 5 个 ID',
+    runId: 'run-other-9'
+});
+check('记录保留了 runId', recA.runId === 'run-break-1', recA.runId);
+check('runId 对得上的记录可续', api.Resume.isRecordResumable(recA) === true);
+check('runId 对不上的记录不可续', api.Resume.isRecordResumable(recB) === false);
+
+api.App.switchTab('records');
+const recTexts = textsOf(api.App.contentHost).join(' | ');
+check('记录列表出现「继续」', recTexts.indexOf('继续') >= 0, recTexts);
+
+/* ⑧ D3 详情页也给一个入口 */
+api.App.openRecord(recA.id);
+const d3Texts = textsOf(api.App.contentHost).join(' | ');
+check('记录详情页显示「没跑完」并给继续',
+      d3Texts.indexOf('没跑完') >= 0 && d3Texts.indexOf('继续') >= 0, d3Texts);
+api.App.closeRecord();
+
+/* 还原现场:清掉断点与这两条假记录,别影响后面的用例 */
+api.App.clearCheckpoint();
+api.Records.save(api.Records.list().filter((r) => r.id !== recA.id && r.id !== recB.id));
+api.App.switchTab('tasks');
+
 /* ⑤ 仍有脚本没接入引擎(UID私信)→ 必须继续被**明确拦下**。
  *    这条不能因为"ID提取接入了"就一并删掉:它是"绝不静默跑不相关的东西"那道闸。 */
 api.App.openDetail('dm');

+ 40 - 0
tiktok-auto/README.md

@@ -415,6 +415,46 @@ summary `likedVideos=1 / followedAuthors=1`。
 
 ---
 
+## 3.5.10 断点续跑(`--resume`)
+
+跑一半被停止/被杀,下次接着跑:**结果追加到同一个文件**,不重复收人。
+
+### 粒度是「取材条目级」,不是「第几屏第几行」
+
+TikTok 列表内容会变、滚动位置无法复原,记行号只会漏人或重复劳动。
+所以断点记的是「哪条取材走完了」:
+
+- 已完成的取材 → **整条跳过**
+- 未完成的那条 → **从头重走**,靠已收集合跳过重复的人
+  (代价:列表要重新滚一遍;视频作者/评论者这类逐个点开的必然从头点,约 6~8 秒/个)
+
+### 文件:`<结果目录>/id_progress.json`
+
+一份,全局单跑。`state` 有 `running / stopped / done / none` 四态。
+「清空断点」= 覆盖写成 `state:"none"`,**不删文件**(`remove(` 命中危险调用黑名单)。
+
+### 配置指纹
+
+断点里存 `sig`(`fingerprint.ofIdSection`),覆盖 **sources / filter / output(dir,historyPath,dedupeHistory)**。
+改这些 → 断点失效,启动 App **之前**就拒绝(零成本)。
+**不覆盖** `limit / resolveUid / dryRun / filter.videos / guard` —— 改这些不该让断点失效。
+
+### 三个必须记住的坑
+
+1. **预载去重集合会让人误判「列表到底」**。`walkList` 原来把「本屏没有新 uid」也算成到底,
+   于是历史/断点一预载,前两屏全是旧人 → 在列表顶部就收工。
+   现在只认「翻屏后签名没变」(`walkStep`)。
+2. **失败的那条取材不能标 `done`**。`idsource.run` 打不开列表时记 error 后返回 0(不抛异常),
+   一律标 done 的话续跑再也不会重试它。
+3. **上限要按累计算**。否则设 100 个、跑 60 个被停,续跑又收 100 个 = 一共 160 个。
+
+### 界面入口
+
+「ID提取」页顶部提示条(继续 / 清空)+「记录」页那条记录的「继续」。
+两处都先判:配置没变、结果文件还在、**记录里的 runId 等于断点里的 runId**。
+
+---
+
 ## 4. 需求 → 设计对照
 
 ### 需求 1:网络延迟下也要稳定,不能只靠 sleep

+ 28 - 1
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-28T14:48:00.291Z
+ *  built 2026-10-28T15:01:13.710Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, fingerprint, checkpoint, filters, profile, idsource, resolve, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -7824,6 +7824,26 @@ TikTokAuto.idflow = (function () {
                     log.info('idflow', '续跑:上次停在「' +
                         (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
                         (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
+
+                    /* 「当前屏幕」类取材的前提是"用户此刻停在那个页面"。
+                     * 续跑时那一屏早就没了,引擎也不会替他跳回去
+                     * (`flow.launchApp` 刻意不动页面,见它的注释)。
+                     * 这种情况必须说清楚 —— 否则用户只会看到"这条取材又是 0 个"。 */
+                    var screenSources = 0;
+                    for (var ssi = 0; ssi < settings.sources.length; ssi += 1) {
+                        var src = settings.sources[ssi];
+                        if (src.kind === 'screen' ||
+                            (src.kind === 'video' && src.videoSource === 'screen')) {
+                            screenSources += 1;
+                        }
+                    }
+                    if (screenSources > 0) {
+                        summary.warnings.push('续跑提示:有 ' + screenSources +
+                            ' 条取材依赖「你当前停留的页面」,而上次那一屏已经不在了 —— ' +
+                            '这几条可能取不到人(失败会保留断点,下次还能再试)');
+                        log.warn('idflow', '续跑:' + screenSources +
+                            ' 条取材依赖当前屏幕,可能需要你手动打开对应页面');
+                    }
                 } else {
                     log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
                 }
@@ -7996,6 +8016,13 @@ TikTokAuto.idflow = (function () {
                  * 中途被停止时这里不会执行,那条取材保持 running,
                  * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
                 var sourceFailed = summary.errors.length > errorsBefore;
+                /* 没抛错但一个人都没取到:列表是空的、或者「当前屏幕」那一刻页面不对
+                 * (这种情况 idsource 只记 warn 不记 error)。必须说出来 ——
+                 * 否则用户只会看到"总数不对",却不知道是哪条取材白跑了。 */
+                if (!sourceFailed && handled === 0 && (summary.extracted - before) === 0) {
+                    summary.warnings.push('取材「' + TikTokAuto.idsource.describe(source) +
+                        '」这次没有取到任何人(列表为空或页面不合适)');
+                }
                 TikTokAuto.checkpoint.markSource(cp, sourceKey,
                     sourceFailed ? 'running' : 'done', {
                         screens: stat.screens || 0,

+ 27 - 0
tiktok-auto/src/idflow.js

@@ -450,6 +450,26 @@ TikTokAuto.idflow = (function () {
                     log.info('idflow', '续跑:上次停在「' +
                         (TikTokAuto.checkpoint.stoppedAt(cp) || '未知') + '」,已收 ' +
                         (cp.counts && cp.counts.extracted ? cp.counts.extracted : 0) + ' 个');
+
+                    /* 「当前屏幕」类取材的前提是"用户此刻停在那个页面"。
+                     * 续跑时那一屏早就没了,引擎也不会替他跳回去
+                     * (`flow.launchApp` 刻意不动页面,见它的注释)。
+                     * 这种情况必须说清楚 —— 否则用户只会看到"这条取材又是 0 个"。 */
+                    var screenSources = 0;
+                    for (var ssi = 0; ssi < settings.sources.length; ssi += 1) {
+                        var src = settings.sources[ssi];
+                        if (src.kind === 'screen' ||
+                            (src.kind === 'video' && src.videoSource === 'screen')) {
+                            screenSources += 1;
+                        }
+                    }
+                    if (screenSources > 0) {
+                        summary.warnings.push('续跑提示:有 ' + screenSources +
+                            ' 条取材依赖「你当前停留的页面」,而上次那一屏已经不在了 —— ' +
+                            '这几条可能取不到人(失败会保留断点,下次还能再试)');
+                        log.warn('idflow', '续跑:' + screenSources +
+                            ' 条取材依赖当前屏幕,可能需要你手动打开对应页面');
+                    }
                 } else {
                     log.info('idflow', '没有可用的断点(' + decision.reason + '),从零开始');
                 }
@@ -622,6 +642,13 @@ TikTokAuto.idflow = (function () {
                  * 中途被停止时这里不会执行,那条取材保持 running,
                  * 续跑就从它开头重走 —— 这就是"最多回退一条取材"。 */
                 var sourceFailed = summary.errors.length > errorsBefore;
+                /* 没抛错但一个人都没取到:列表是空的、或者「当前屏幕」那一刻页面不对
+                 * (这种情况 idsource 只记 warn 不记 error)。必须说出来 ——
+                 * 否则用户只会看到"总数不对",却不知道是哪条取材白跑了。 */
+                if (!sourceFailed && handled === 0 && (summary.extracted - before) === 0) {
+                    summary.warnings.push('取材「' + TikTokAuto.idsource.describe(source) +
+                        '」这次没有取到任何人(列表为空或页面不合适)');
+                }
                 TikTokAuto.checkpoint.markSource(cp, sourceKey,
                     sourceFailed ? 'running' : 'done', {
                         screens: stat.screens || 0,