| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- /* ============================== 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);
- };
|