/* ============================== src/records.js ============================== * 运行记录(规格书 D1/D2/D3 的数据来源)。 * * ## 为什么单独一个模块,而不是塞进 Store.FIELDS * Store 管的是「**固定字段**的配置」——一张声明表,每个路径一个值。 * 运行记录是**不定长的列表**(跑一次多一条),塞进字段表就等于 * 让 validate() 去校验一个数组,还把「路径 → 值控件」的那套审计规则带歪。 * 所以它走同一份 storages 的**另一个 key**,自己管读写。 * * ## 记录里存什么 * 只存**界面真的知道的东西**:任务/脚本、结果分类、起止时间、结果文案、 * 结果文件、日志行。不编造没发生过的字段(例如"实际提取了几个 ID"只有 * 引擎回传了才写;没回传就留空,D3 那一行就不显示)。 * * ## 日志从哪来 * 引擎(tiktok-auto)通过 `--log` 通道把真实输出按行 append 到 * run-log.jsonl,界面在结束时读回来。**没有日志就不显示日志卡的空壳** —— * 详见 `Engine.readLog()` 与 §「日志缺失时如实说明」。 */ var Records = {}; /** 与 Store 共用一份 storages,只是换一个 key(见文件头注释)。 */ Records.STORAGE_NAME = "feizhou_app"; Records.STORAGE_KEY = "records"; /** * 最多保留多少条。 * * 上限是**必要的**:记录里含日志行,跑几十次之后 JSON 会长到 * storages 读取明显变慢(它每次整体读字符串)。 * 50 条足够回溯,超出的从尾部丢弃(新记录永远在最前)。 */ Records.MAX = 50; /** 结果分类 → 界面文案 + 色调。tone 直接喂给 W.pill。 */ Records.RESULT = { success: { key: "success", label: "\u5DF2\u5B8C\u6210", tone: "ok" }, stopped: { key: "stopped", label: "\u624B\u52A8\u505C\u6B62", tone: "mute" }, failed: { key: "failed", label: "\u62A5\u9519\u4E2D\u65AD", tone: "danger" } }; /** 归一化结果分类:引擎给的终态与记录分类不是一套词(finished/stopped/failed)。 */ Records.resultOf = function (status) { if (status === "finished" || status === "success") { return Records.RESULT.success; } if (status === "stopped") { return Records.RESULT.stopped; } return Records.RESULT.failed; }; /* ------------------------------------------------------------ 读写 */ function storage() { return storages.create(Records.STORAGE_NAME); } /** 读全部记录(新的在前)。任何异常都退回空数组——记录读不出来不该让界面起不来。 */ Records.list = function () { var raw = null; try { raw = storage().get(Records.STORAGE_KEY, null); } catch (e) { console.log("[records] 读取失败: " + e); } if (typeof raw === "string" && raw.length > 0) { try { raw = JSON.parse(raw); } catch (e2) { console.log("[records] JSON 解析失败,按空处理"); raw = null; } } if (!(raw instanceof Array)) { return []; } /* 逐条清洗:缺 id / 缺任务名的脏条目直接丢掉, * 免得 D1 上出现一行点不开、也说不清是什么的东西。 */ var out = []; for (var i = 0; i < raw.length; i++) { var one = raw[i]; if (!one || typeof one !== "object") { continue; } if (!one.id || !one.taskKey) { continue; } out.push(one); } return out; }; Records.save = function (list) { try { storage().put(Records.STORAGE_KEY, JSON.stringify(list)); return true; } catch (e) { console.log("[records] 保存失败: " + e); return false; } }; /** * 追加一条记录(新的在最前)。 * @returns 落盘后的那条记录(含 id)——调用方(F5 的「查看日志」)要用它跳 D3 */ Records.add = function (record) { var list = Records.list(); var one = record || {}; one.id = one.id || ("rec-" + Date.now() + "-" + Math.floor(Math.random() * 10000)); one.taskKey = one.taskKey || ""; one.taskName = one.taskName || ""; one.scriptKey = one.scriptKey || ""; one.scriptName = one.scriptName || ""; one.result = one.result || "success"; one.startedAt = one.startedAt || 0; one.endedAt = one.endedAt || Date.now(); one.resultText = one.resultText || ""; one.resultFile = one.resultFile || ""; /* 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); if (list.length > Records.MAX) { list = list.slice(0, Records.MAX); } Records.save(list); return one; }; /** 按 id 取一条(D3 用)。找不到返回 null。 */ Records.find = function (id) { var list = Records.list(); for (var i = 0; i < list.length; i++) { if (list[i].id === id) { return list[i]; } } return null; }; /** 某个任务最近一条记录(B1 任务卡的「上次…」)。没有则 null。 */ Records.latestOf = function (taskKey) { var list = Records.list(); for (var i = 0; i < list.length; i++) { if (list[i].taskKey === taskKey) { return list[i]; } } return null; }; Records.clear = function () { try { storage().remove(Records.STORAGE_KEY); } catch (e) { console.log("[records] 清空失败: " + e); } }; /* ------------------------------------------------------------ 文案 */ function pad2(n) { return n < 10 ? "0" + n : String(n); } /** * 时长 → 人话。 * * 设计稿上三种形态都出现过(「耗时 12 分 30 秒」/「已运行 41 分钟」/「运行 6 分钟」), * 统一走这一个函数,规则是: * < 1 分钟 → "N 秒" * < 1 小时 → 整分钟说「N 分钟」,带零头说「N 分 M 秒」 * ≥ 1 小时 → "H 小时 M 分" * 为什么整分钟要单独说一句:0 秒是大多数「手动停止」的真实情况, * 写成「12 分 0 秒」会让人以为数据不准。 */ Records.formatDuration = function (ms) { var total = Math.max(0, Math.floor((ms || 0) / 1000)); if (total < 60) { return total + " \u79D2"; } if (total < 3600) { var m = Math.floor(total / 60); var s = total % 60; return s === 0 ? (m + " \u5206\u949F") : (m + " \u5206 " + s + " \u79D2"); } var h = Math.floor(total / 3600); var mm = Math.floor((total % 3600) / 60); return h + " \u5C0F\u65F6 " + mm + " \u5206"; }; /** * 时间点 → 「今天 10:24」/「昨天 09:58」/「10-11 09:58」。 * 相对日期的判断必须用**本机日历**(不是 UTC):跨时区时 UTC 会让 * 「今天」错成昨天,而这条文案的全部意义就在于它读起来直观。 */ Records.formatDay = function (timestamp, nowMs) { var d = new Date(timestamp || 0); var now = new Date(nowMs === undefined ? Date.now() : nowMs); var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes()); var sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); if (sameDay) { return "\u4ECA\u5929 " + clock; } var yesterday = new Date(now.getTime() - 86400000); var isYesterday = d.getFullYear() === yesterday.getFullYear() && d.getMonth() === yesterday.getMonth() && d.getDate() === yesterday.getDate(); if (isYesterday) { return "\u6628\u5929 " + clock; } return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock; }; /** D1 条目的时间行:「今天 10:24 · 耗时 12 分 30 秒」 */ Records.timeLine = function (record, nowMs) { if (!record) { return ""; } var start = record.startedAt || record.endedAt || 0; var end = record.endedAt || start; var head = Records.formatDay(start, nowMs); if (record.durationMs === undefined || record.durationMs === null) { record.durationMs = Math.max(0, end - start); } return head + " \u00B7 \u8017\u65F6 " + Records.formatDuration(record.durationMs); }; /** * D1 条目的结果行。 * 没给出结果文案时不编 —— 返回空串,由调用方决定这一行要不要出现。 */ Records.resultLine = function (record) { if (!record) { return ""; } var text = record.resultText || ""; if (record.resultFile && text.indexOf(record.resultFile) < 0) { text = text === "" ? record.resultFile : text + " \u2192 " + record.resultFile; } return text; }; /** * B1 任务卡的「上次…」一行。 * 没有记录 → 「还未运行过」(设计稿 C9 的视频功能卡就是这个文案,不是空白)。 */ Records.lastRunLine = function (taskKey, nowMs) { var one = Records.latestOf(taskKey); if (!one) { return "\u8FD8\u672A\u8FD0\u884C\u8FC7"; } var clock = Records.formatDay(one.startedAt || one.endedAt, nowMs); /* 「今天 10:24」太长,卡上只留时分;跨天的记录才带上日期。 */ var short = clock.replace("\u4ECA\u5929 ", "\u4E0A\u6B21 ").replace("\u6628\u5929 ", "\u6628\u5929 "); var tail = Records.resultLine(one); return short + (tail === "" ? "" : " \u00B7 " + tail); };