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