records.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. /* ============================== src/records.js ==============================
  2. * 运行记录(规格书 D1/D2/D3 的数据来源)。
  3. *
  4. * ## 为什么单独一个模块,而不是塞进 Store.FIELDS
  5. * Store 管的是「**固定字段**的配置」——一张声明表,每个路径一个值。
  6. * 运行记录是**不定长的列表**(跑一次多一条),塞进字段表就等于
  7. * 让 validate() 去校验一个数组,还把「路径 → 值控件」的那套审计规则带歪。
  8. * 所以它走同一份 storages 的**另一个 key**,自己管读写。
  9. *
  10. * ## 记录里存什么
  11. * 只存**界面真的知道的东西**:任务/脚本、结果分类、起止时间、结果文案、
  12. * 结果文件、日志行。不编造没发生过的字段(例如"实际提取了几个 ID"只有
  13. * 引擎回传了才写;没回传就留空,D3 那一行就不显示)。
  14. *
  15. * ## 日志从哪来
  16. * 引擎(tiktok-auto)通过 `--log` 通道把真实输出按行 append 到
  17. * run-log.jsonl,界面在结束时读回来。**没有日志就不显示日志卡的空壳** ——
  18. * 详见 `Engine.readLog()` 与 §「日志缺失时如实说明」。
  19. */
  20. var Records = {};
  21. /** 与 Store 共用一份 storages,只是换一个 key(见文件头注释)。 */
  22. Records.STORAGE_NAME = "feizhou_app";
  23. Records.STORAGE_KEY = "records";
  24. /**
  25. * 最多保留多少条。
  26. *
  27. * 上限是**必要的**:记录里含日志行,跑几十次之后 JSON 会长到
  28. * storages 读取明显变慢(它每次整体读字符串)。
  29. * 50 条足够回溯,超出的从尾部丢弃(新记录永远在最前)。
  30. */
  31. Records.MAX = 50;
  32. /** 结果分类 → 界面文案 + 色调。tone 直接喂给 W.pill。 */
  33. Records.RESULT = {
  34. success: { key: "success", label: "\u5DF2\u5B8C\u6210", tone: "ok" },
  35. stopped: { key: "stopped", label: "\u624B\u52A8\u505C\u6B62", tone: "mute" },
  36. failed: { key: "failed", label: "\u62A5\u9519\u4E2D\u65AD", tone: "danger" }
  37. };
  38. /** 归一化结果分类:引擎给的终态与记录分类不是一套词(finished/stopped/failed)。 */
  39. Records.resultOf = function (status) {
  40. if (status === "finished" || status === "success") { return Records.RESULT.success; }
  41. if (status === "stopped") { return Records.RESULT.stopped; }
  42. return Records.RESULT.failed;
  43. };
  44. /* ------------------------------------------------------------ 读写 */
  45. function storage() {
  46. return storages.create(Records.STORAGE_NAME);
  47. }
  48. /** 读全部记录(新的在前)。任何异常都退回空数组——记录读不出来不该让界面起不来。 */
  49. Records.list = function () {
  50. var raw = null;
  51. try {
  52. raw = storage().get(Records.STORAGE_KEY, null);
  53. } catch (e) {
  54. console.log("[records] 读取失败: " + e);
  55. }
  56. if (typeof raw === "string" && raw.length > 0) {
  57. try {
  58. raw = JSON.parse(raw);
  59. } catch (e2) {
  60. console.log("[records] JSON 解析失败,按空处理");
  61. raw = null;
  62. }
  63. }
  64. if (!(raw instanceof Array)) { return []; }
  65. /* 逐条清洗:缺 id / 缺任务名的脏条目直接丢掉,
  66. * 免得 D1 上出现一行点不开、也说不清是什么的东西。 */
  67. var out = [];
  68. for (var i = 0; i < raw.length; i++) {
  69. var one = raw[i];
  70. if (!one || typeof one !== "object") { continue; }
  71. if (!one.id || !one.taskKey) { continue; }
  72. out.push(one);
  73. }
  74. return out;
  75. };
  76. Records.save = function (list) {
  77. try {
  78. storage().put(Records.STORAGE_KEY, JSON.stringify(list));
  79. return true;
  80. } catch (e) {
  81. console.log("[records] 保存失败: " + e);
  82. return false;
  83. }
  84. };
  85. /**
  86. * 追加一条记录(新的在最前)。
  87. * @returns 落盘后的那条记录(含 id)——调用方(F5 的「查看日志」)要用它跳 D3
  88. */
  89. Records.add = function (record) {
  90. var list = Records.list();
  91. var one = record || {};
  92. one.id = one.id || ("rec-" + Date.now() + "-" + Math.floor(Math.random() * 10000));
  93. one.taskKey = one.taskKey || "";
  94. one.taskName = one.taskName || "";
  95. one.scriptKey = one.scriptKey || "";
  96. one.scriptName = one.scriptName || "";
  97. one.result = one.result || "success";
  98. one.startedAt = one.startedAt || 0;
  99. one.endedAt = one.endedAt || Date.now();
  100. one.resultText = one.resultText || "";
  101. one.resultFile = one.resultFile || "";
  102. /* uid 映射文件的路径(ID提取且勾了「同时解析数字 uid」时才有)。
  103. * 老记录里没有这个字段 → 归一化成空串,渲染层据此决定要不要多一行。 */
  104. one.mapFile = one.mapFile || "";
  105. /* runId:记录页要靠它和断点里的 runId 对上(见 Resume.isRecordResumable)。
  106. * 老记录没有这个字段 → 归一化成空串,于是永远不会显示「继续」。 */
  107. one.runId = one.runId || "";
  108. one.logs = one.logs instanceof Array ? one.logs : [];
  109. list.unshift(one);
  110. if (list.length > Records.MAX) { list = list.slice(0, Records.MAX); }
  111. Records.save(list);
  112. return one;
  113. };
  114. /** 按 id 取一条(D3 用)。找不到返回 null。 */
  115. Records.find = function (id) {
  116. var list = Records.list();
  117. for (var i = 0; i < list.length; i++) {
  118. if (list[i].id === id) { return list[i]; }
  119. }
  120. return null;
  121. };
  122. /** 某个任务最近一条记录(B1 任务卡的「上次…」)。没有则 null。 */
  123. Records.latestOf = function (taskKey) {
  124. var list = Records.list();
  125. for (var i = 0; i < list.length; i++) {
  126. if (list[i].taskKey === taskKey) { return list[i]; }
  127. }
  128. return null;
  129. };
  130. Records.clear = function () {
  131. try {
  132. storage().remove(Records.STORAGE_KEY);
  133. } catch (e) {
  134. console.log("[records] 清空失败: " + e);
  135. }
  136. };
  137. /* ------------------------------------------------------------ 文案 */
  138. function pad2(n) {
  139. return n < 10 ? "0" + n : String(n);
  140. }
  141. /**
  142. * 时长 → 人话。
  143. *
  144. * 设计稿上三种形态都出现过(「耗时 12 分 30 秒」/「已运行 41 分钟」/「运行 6 分钟」),
  145. * 统一走这一个函数,规则是:
  146. * < 1 分钟 → "N 秒"
  147. * < 1 小时 → 整分钟说「N 分钟」,带零头说「N 分 M 秒」
  148. * ≥ 1 小时 → "H 小时 M 分"
  149. * 为什么整分钟要单独说一句:0 秒是大多数「手动停止」的真实情况,
  150. * 写成「12 分 0 秒」会让人以为数据不准。
  151. */
  152. Records.formatDuration = function (ms) {
  153. var total = Math.max(0, Math.floor((ms || 0) / 1000));
  154. if (total < 60) { return total + " \u79D2"; }
  155. if (total < 3600) {
  156. var m = Math.floor(total / 60);
  157. var s = total % 60;
  158. return s === 0 ? (m + " \u5206\u949F") : (m + " \u5206 " + s + " \u79D2");
  159. }
  160. var h = Math.floor(total / 3600);
  161. var mm = Math.floor((total % 3600) / 60);
  162. return h + " \u5C0F\u65F6 " + mm + " \u5206";
  163. };
  164. /**
  165. * 时间点 → 「今天 10:24」/「昨天 09:58」/「10-11 09:58」。
  166. * 相对日期的判断必须用**本机日历**(不是 UTC):跨时区时 UTC 会让
  167. * 「今天」错成昨天,而这条文案的全部意义就在于它读起来直观。
  168. */
  169. Records.formatDay = function (timestamp, nowMs) {
  170. var d = new Date(timestamp || 0);
  171. var now = new Date(nowMs === undefined ? Date.now() : nowMs);
  172. var clock = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
  173. var sameDay = d.getFullYear() === now.getFullYear()
  174. && d.getMonth() === now.getMonth()
  175. && d.getDate() === now.getDate();
  176. if (sameDay) { return "\u4ECA\u5929 " + clock; }
  177. var yesterday = new Date(now.getTime() - 86400000);
  178. var isYesterday = d.getFullYear() === yesterday.getFullYear()
  179. && d.getMonth() === yesterday.getMonth()
  180. && d.getDate() === yesterday.getDate();
  181. if (isYesterday) { return "\u6628\u5929 " + clock; }
  182. return pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + clock;
  183. };
  184. /** D1 条目的时间行:「今天 10:24 · 耗时 12 分 30 秒」 */
  185. Records.timeLine = function (record, nowMs) {
  186. if (!record) { return ""; }
  187. var start = record.startedAt || record.endedAt || 0;
  188. var end = record.endedAt || start;
  189. var head = Records.formatDay(start, nowMs);
  190. if (record.durationMs === undefined || record.durationMs === null) {
  191. record.durationMs = Math.max(0, end - start);
  192. }
  193. return head + " \u00B7 \u8017\u65F6 " + Records.formatDuration(record.durationMs);
  194. };
  195. /**
  196. * D1 条目的结果行。
  197. * 没给出结果文案时不编 —— 返回空串,由调用方决定这一行要不要出现。
  198. */
  199. Records.resultLine = function (record) {
  200. if (!record) { return ""; }
  201. var text = record.resultText || "";
  202. if (record.resultFile && text.indexOf(record.resultFile) < 0) {
  203. text = text === "" ? record.resultFile : text + " \u2192 " + record.resultFile;
  204. }
  205. return text;
  206. };
  207. /**
  208. * B1 任务卡的「上次…」一行。
  209. * 没有记录 → 「还未运行过」(设计稿 C9 的视频功能卡就是这个文案,不是空白)。
  210. */
  211. Records.lastRunLine = function (taskKey, nowMs) {
  212. var one = Records.latestOf(taskKey);
  213. if (!one) { return "\u8FD8\u672A\u8FD0\u884C\u8FC7"; }
  214. var clock = Records.formatDay(one.startedAt || one.endedAt, nowMs);
  215. /* 「今天 10:24」太长,卡上只留时分;跨天的记录才带上日期。 */
  216. var short = clock.replace("\u4ECA\u5929 ", "\u4E0A\u6B21 ").replace("\u6628\u5929 ", "\u6628\u5929 ");
  217. var tail = Records.resultLine(one);
  218. return short + (tail === "" ? "" : " \u00B7 " + tail);
  219. };