codebee 0.1.47 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/ui/app.js CHANGED
@@ -1002,6 +1002,7 @@ function applyState(d) {
1002
1002
  $("conn").textContent = t("已连接");
1003
1003
  $("conn").className = "conn ok";
1004
1004
  restoreInspector(); // 刷新后恢复上次打开的检查器(只在已开时为空操作)
1005
+ ovMaybeRefresh(); // 停在概览页时:概览语与最近运行跟着这次状态一起刷新
1005
1006
  }
1006
1007
 
1007
1008
  /* ---------------------------------------------------------- 供应商健康告警横幅 */
@@ -8931,7 +8932,31 @@ function ztProdChanged(el) {
8931
8932
 
8932
8933
  /* 工作目录「选择…」:复用全局 pickFolder(原生对话框→网页目录弹框回落),回填同一行的输入框 */
8933
8934
  function ztWdPick(btn) {
8934
- window.pickFolder(btn.closest(".zt-wd-row").querySelector(".zt-p-wd"), true);
8935
+ const inp = btn.closest(".zt-wd-row").querySelector(".zt-p-wd");
8936
+ window.pickFolder(inp, true);
8937
+ // 选完目录顺手拉该仓库的分支清单,基线分支变下拉可选
8938
+ const card = btn.closest(".zt-prof");
8939
+ const side = inp.dataset.side;
8940
+ if (card && side) setTimeout(() => ztRevFill(card, side), 400);
8941
+ }
8942
+
8943
+ /* 基线分支下拉(datalist):从仓库工作目录拉本地分支列表。
8944
+ * 复用 /api/git/info(只读探测,仅限本机);拉不到就保持手填,不报错打扰。 */
8945
+ function ztRevFill(card, side, force) {
8946
+ if (!card) return;
8947
+ const wd = (card.querySelector(".zt-p-wd") || {}).value || "";
8948
+ const rev = card.querySelector(".zt-p-rev");
8949
+ const dl = card.querySelector('datalist[data-side="' + side + '"]');
8950
+ if (!rev || !dl) return;
8951
+ if (!wd.trim()) { dl.innerHTML = ""; return; }
8952
+ if (!force && dl.options.length) return; // 已有清单不重拉
8953
+ api("/api/git/info?workdir=" + encodeURIComponent(wd.trim()))
8954
+ .then((r) => {
8955
+ if (!r || !r.repo) return;
8956
+ dl.innerHTML = (r.branches || []).map((b) =>
8957
+ '<option value="' + esc(b) + '">').join("");
8958
+ })
8959
+ .catch(() => {});
8935
8960
  }
8936
8961
 
8937
8962
  function ztRepoHtml(i, side) {
@@ -8942,7 +8967,10 @@ function ztRepoHtml(i, side) {
8942
8967
  return '<label>' + cn + t("仓库·工作目录") + '</label><span class="zt-wd-row">' +
8943
8968
  '<input class="zt-p-wd" data-side="' + side + '" value="' + esc(repo.workdir || "") + '" placeholder="' + t("空 = 用默认保存路径") + '">' +
8944
8969
  '<button class="ghost small" type="button" title="' + t("浏览本机目录选择") + '" onclick="ztWdPick(this)">' + t("选择…") + "</button></span>" +
8945
- '<label>' + cn + t("仓库·基线分支") + '</label><input class="zt-p-rev" data-side="' + side + '" value="' + esc(repo.git_rev || "") + '" placeholder="' + t("如 main;空 = 直接改工作目录") + '">' +
8970
+ '<label>' + cn + t("仓库·基线分支") + '</label><span class="zt-wd-row">' +
8971
+ '<input class="zt-p-rev" data-side="' + side + '" list="zt-rev-' + i + '-' + side + '" value="' + esc(repo.git_rev || "") + '" placeholder="' + t("如 main;空 = 直接改工作目录") + '" onfocus="ztRevFill(this.closest(\'.zt-prof\'), \'' + side + '\')">' +
8972
+ '<button class="ghost small" type="button" title="' + t("刷新分支列表") + '" onclick="ztRevFill(this.closest(\'.zt-prof\'), \'' + side + '\', true)">▾</button>' +
8973
+ '<datalist id="zt-rev-' + i + '-' + side + '" data-side="' + side + '"></datalist></span>' +
8946
8974
  '<label>' + cn + t("仓库·验证命令") + '</label><input class="zt-p-verify" data-side="' + side + '" value="' + esc(repo.verify_command || "") + '" placeholder="' + t("如 npm test;空 = 靠评审把关") + '">' +
8947
8975
  '<label>' + cn + t("仓库·一句话描述") + '</label><input class="zt-p-hint" data-side="' + side + '" value="' + esc(hint) + '" placeholder="' + t("给 AI 排查看,如:Vue3 管理台前端") + '">';
8948
8976
  }
@@ -9090,7 +9118,10 @@ async function ztFetchMods(i, silent) {
9090
9118
 
9091
9119
  /* 拉产品/账号清单:产品 ID 变下拉,负责人/转派账号挂 datalist。silent=进页自动拉 */
9092
9120
  async function ztFetchCatalog(silent) {
9093
- const grab = (url) => api(url, { method: "POST", body: "{}" })
9121
+ // 后台拉清单:静默(不画顶部滑动线、不挂按钮转圈)+ 前端 45s 超时——
9122
+ // 禅道内网不可达时此前会挂住页面加载指示十几二十秒(用户实测反馈)
9123
+ const grab = (url) => api(url, { method: "POST", body: "{}",
9124
+ timeout: 45000, busy: false, silent: true })
9094
9125
  .catch((e) => ({ ok: false, error: e.message }));
9095
9126
  const [pr, ur] = await Promise.all([grab("/api/zentao/products"), grab("/api/zentao/users")]);
9096
9127
  if (pr && pr.ok) S.ztProducts = pr.products || [];
@@ -9111,6 +9142,99 @@ async function ztFetchCatalog(silent) {
9111
9142
  else toast((pr && pr.error) || (ur && ur.error) || t("拉取失败——先「测试连接」确认可用"), true);
9112
9143
  }
9113
9144
 
9145
+ /* ---------------------------------------------------------- 钩子(任务生命周期) */
9146
+ /* 设置 → 钩子:task_start / message_submit / run_end 三事件挂本机命令。
9147
+ * stdin 收 JSON、stdout 回 {"inject"} 或纯文本即注入;失败超时静默跳过。 */
9148
+ function loadHooksPanel() {
9149
+ api("/api/hooks").then((d) => {
9150
+ S.hooks = d.hooks || [];
9151
+ S.hookEvents = d.events || [];
9152
+ renderHooks();
9153
+ }).catch((e) => toast(t("加载失败:") + (e.message || e), true));
9154
+ }
9155
+
9156
+ function renderHooks() {
9157
+ const box = $("hooks-list"), empty = $("hooks-empty");
9158
+ if (!box) return;
9159
+ const list = S.hooks || [];
9160
+ if (empty) empty.classList.toggle("hidden", list.length > 0);
9161
+ box.innerHTML = list.map((h, i) => {
9162
+ const evName = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[h.event] || h.event;
9163
+ return '<div class="card hook-card" data-i="' + i + '">' +
9164
+ '<div class="hook-head">' +
9165
+ '<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("名称")) + '" data-hf="name">' +
9166
+ '<select class="hook-event" data-hf="event">' +
9167
+ ["task_start", "message_submit", "run_end"].map((ev) =>
9168
+ '<option value="' + ev + '"' + (h.event === ev ? " selected" : "") + ">" +
9169
+ esc({ task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[ev]) + "</option>").join("") +
9170
+ "</select>" +
9171
+ '<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
9172
+ (h.enabled ? " checked" : "") + '><span></span></label>' +
9173
+ '<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
9174
+ "</div>" +
9175
+ '<input class="hook-cmd mono" value="' + esc(h.cmd || "") + '" placeholder="' +
9176
+ esc(t('命令,例:python E:\\proj\\hook.py --event {event}')) + '" data-hf="cmd">' +
9177
+ '<div class="hook-foot">' +
9178
+ '<span class="hint">' + esc(t("超时(秒)")) + '</span><input class="hook-timeout" type="number" min="1" max="120" value="' +
9179
+ Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
9180
+ '<button type="button" class="ghost small" data-hook-test="' + i + '">' + t("测试") + "</button>" +
9181
+ '<span class="hint hook-test-out"></span>' +
9182
+ "</div></div>";
9183
+ }).join("");
9184
+ }
9185
+
9186
+ window.removeHook = function (i) {
9187
+ (S.hooks || []).splice(i, 1);
9188
+ renderHooks();
9189
+ };
9190
+
9191
+ function hookHarvest() {
9192
+ return Array.from(document.querySelectorAll("#hooks-list .hook-card")).map((card) => {
9193
+ const gv = (f) => card.querySelector('[data-hf="' + f + '"]');
9194
+ return { id: (S.hooks || [])[Number(card.dataset.i)]?.id || "",
9195
+ name: gv("name").value.trim(), event: gv("event").value,
9196
+ cmd: gv("cmd").value.trim(), enabled: gv("enabled").checked,
9197
+ timeout_s: Number(gv("timeout_s").value || 10) };
9198
+ });
9199
+ }
9200
+
9201
+ function bindHooksPanel() {
9202
+ if ($("btn-hooks-add").dataset.bound) return;
9203
+ $("btn-hooks-add").dataset.bound = "1";
9204
+ $("btn-hooks-add").addEventListener("click", () => {
9205
+ S.hooks = S.hooks || [];
9206
+ S.hooks.push({ id: "hk-" + Date.now(), name: "", event: "task_start",
9207
+ cmd: "", enabled: true, timeout_s: 10 });
9208
+ renderHooks();
9209
+ });
9210
+ $("btn-hooks-save").addEventListener("click", async () => {
9211
+ try {
9212
+ const r = await api("/api/hooks/save", { method: "POST",
9213
+ body: JSON.stringify({ hooks: hookHarvest() }) });
9214
+ S.hooks = r.hooks || [];
9215
+ renderHooks();
9216
+ toast(t("钩子已保存"));
9217
+ } catch (e) { toast(t("保存失败:") + (e.message || e), true); }
9218
+ });
9219
+ $("hooks-list").addEventListener("click", async (e) => {
9220
+ const btn = e.target.closest("[data-hook-test]");
9221
+ if (!btn) return;
9222
+ const card = btn.closest(".hook-card");
9223
+ const gv = (f) => card.querySelector('[data-hf="' + f + '"]');
9224
+ const out = card.querySelector(".hook-test-out");
9225
+ btn.disabled = true;
9226
+ out.textContent = t("运行中…");
9227
+ try {
9228
+ const r = await api("/api/hooks/test", { method: "POST",
9229
+ body: JSON.stringify({ cmd: gv("cmd").value.trim(), event: gv("event").value,
9230
+ timeout_s: Number(gv("timeout_s").value || 10) }) });
9231
+ out.textContent = (r.ok ? "✓ " : "✗ ") + String(r.output || "").slice(0, 120);
9232
+ } catch (e2) { out.textContent = "✗ " + (e2.message || e2); }
9233
+ btn.disabled = false;
9234
+ });
9235
+ }
9236
+ bindHooksPanel();
9237
+
9114
9238
  /* ---- 状态行 / 修复记录 ---- */
9115
9239
 
9116
9240
  function zentaoStateTag(st) {
@@ -10333,7 +10457,7 @@ async function suStartupCheck() {
10333
10457
  }
10334
10458
 
10335
10459
  /* ---------------------------------------------------------- 页签 & 初始化 */
10336
- const TAB_TITLES = { tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
10460
+ const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
10337
10461
  const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 设置导航里的子页(__phone 是弹框,不算)
10338
10462
 
10339
10463
  function tabTitle(name) {
@@ -10516,12 +10640,57 @@ function setLangBtn(lang) {
10516
10640
  }
10517
10641
  }
10518
10642
 
10519
- /* 卡片标签:图标 + 名称(图标用精灵表,stroke=currentColor 自动跟随主题) */
10520
- function kpiCard(label, value, sub, wide, icon) {
10521
- const ico = icon ? '<svg class="ico" aria-hidden="true"><use href="#' + icon + '"></use></svg>' : "";
10522
- return '<div class="kpi' + (wide ? " wide" : "") + '"><div class="kpi-v">' + value +
10523
- '</div><div class="kpi-l">' + ico + esc(label) + "</div>" +
10524
- (sub ? '<div class="kpi-s">' + esc(sub) + "</div>" : "") + "</div>";
10643
+ /* KPI 卡:图标块 + 环比徽章 + 数值 + 标签 + 副行 + 迷你走势图。
10644
+ * opts 全部可选;不传时回落成旧版布局(图标内联在标签行),老调用点无需改动。
10645
+ * tone: 图标块/走势图色相(accent|ok|warn|bad|calm)
10646
+ * delta: { pct:Number, invert?:true } 环比;invert 给花钱类指标(涨了显示告警色)
10647
+ * spark: [Number,...] 迷你走势序列 */
10648
+ function kpiCard(label, value, sub, wide, icon, opts) {
10649
+ opts = opts || {};
10650
+ const tone = opts.tone || "accent";
10651
+ const ico = icon ? '<span class="kpi-ico t-' + esc(tone) + '">' +
10652
+ '<svg class="ico" aria-hidden="true"><use href="#' + esc(icon) + '"></use></svg></span>' : "";
10653
+ const delta = kpiDelta(opts.delta);
10654
+ const top = (ico || delta) ? '<div class="kpi-top">' + ico + delta + "</div>" : "";
10655
+ const spark = (opts.spark && opts.spark.length > 1)
10656
+ ? '<div class="kpi-spark">' + kpiSparkSvg(opts.spark, tone) + "</div>" : "";
10657
+ return '<div class="kpi' + (wide ? " wide" : "") + (top ? " has-top" : "") +
10658
+ (spark ? " has-spark" : "") + '">' + top +
10659
+ '<div class="kpi-v">' + value + "</div>" +
10660
+ '<div class="kpi-l">' + (top ? "" : ico) + esc(label) + "</div>" +
10661
+ (sub ? '<div class="kpi-s">' + esc(sub) + "</div>" : "") + spark + "</div>";
10662
+ }
10663
+
10664
+ /* 环比徽章:涨跌用颜色区分,不用箭头字符(几何字形跨平台宽度不一)。
10665
+ * 花钱类指标要 invert——费用涨 20% 不是好事,不能标绿。 */
10666
+ function kpiDelta(d) {
10667
+ if (!d || typeof d.pct !== "number" || !isFinite(d.pct)) return "";
10668
+ const pct = d.pct;
10669
+ const flat = Math.abs(pct) < 0.05;
10670
+ const dir = flat ? "flat" : (pct > 0 ? "up" : "down");
10671
+ const happy = d.invert ? (dir === "down") : (dir === "up");
10672
+ const cls = flat ? "flat" : (happy ? "good" : "bad");
10673
+ const sign = pct > 0 ? "+" : "";
10674
+ return '<span class="kpi-delta ' + cls + '" title="' +
10675
+ esc(t("与上一个同长周期相比")) + '">' + esc(t("较上期")) + " " +
10676
+ esc(sign + pct.toFixed(1) + "%") + "</span>";
10677
+ }
10678
+
10679
+ /* 迷你走势图:折线 + 面积。preserveAspectRatio=none 让图随卡宽拉伸,
10680
+ * 折线靠 vector-effect 保住描边不被横向拉粗。 */
10681
+ function kpiSparkSvg(vals, tone) {
10682
+ const w = 88, h = 26, n = vals.length;
10683
+ const max = Math.max.apply(null, vals), min = Math.min.apply(null, vals);
10684
+ const span = Math.max(1, max - min);
10685
+ const px = (i) => (n <= 1 ? w : i * w / (n - 1));
10686
+ const py = (v) => h - 3 - (v - min) * (h - 8) / span;
10687
+ const line = vals.map((v, i) => px(i).toFixed(1) + "," + py(v).toFixed(1)).join(" ");
10688
+ const area = "0," + h + " " + line + " " + w + "," + h;
10689
+ return '<svg class="kspark t-' + esc(tone) + '" viewBox="0 0 ' + w + " " + h +
10690
+ '" preserveAspectRatio="none" aria-hidden="true" focusable="false">' +
10691
+ '<polygon class="ks-area" points="' + area + '"></polygon>' +
10692
+ '<polyline class="ks-line" points="' + line +
10693
+ '" vector-effect="non-scaling-stroke"></polyline></svg>';
10525
10694
  }
10526
10695
 
10527
10696
  /* 单日没有趋势可言,一根孤柱悬在空白里很难看:改画全宽的
@@ -10875,20 +11044,28 @@ function renderUsage() {
10875
11044
  // 回填记录只有总量、没有输入/输出细分:细分全为 0 时说明当前范围只有历史数据,
10876
11045
  // 不提示的话「输入 0 · 输出 0」会被误读成统计坏了
10877
11046
  const noBreakdown = tot.tokens > 0 && !tot.input && !tot.output && !tot.cached;
11047
+ // 迷你走势取范围内逐日真值(by_day 已连续补零);单日范围只有一格,kpiCard 自动不出图。
11048
+ // 用量页不做环比:范围由用户任选(含「全部」),没有等长上一期可比,硬算会是假数。
11049
+ const dayTok = (u.by_day || []).map((d) => d.tokens || 0);
11050
+ const dayDur = (u.by_day || []).map((d) => d.duration_s || 0);
10878
11051
  $("usage-kpis").innerHTML = [
10879
11052
  kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
10880
11053
  t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
10881
11054
  (tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
10882
11055
  t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
10883
- true, "i-sigma"),
11056
+ true, "i-sigma", { tone: "accent", spark: dayTok }),
10884
11057
  kpiCard(t("峰值 Token 数"), fmtTok(tot.peak_tokens),
10885
- t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0), false, "i-gauge"),
11058
+ t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0),
11059
+ false, "i-gauge", { tone: "warn" }),
10886
11060
  kpiCard(t("最长单次时长"), fmtDur(tot.max_duration_s),
10887
- t("累计调用 ") + fmtDur(tot.duration_s), false, "i-history"),
11061
+ t("累计调用 ") + fmtDur(tot.duration_s), false, "i-history",
11062
+ { tone: "calm", spark: dayDur }),
10888
11063
  kpiCard(t("当前连续天数"), fmtTok(tot.streak_current) + t(" 天"),
10889
- t("范围内活跃 ") + fmtTok(activeDays) + t(" 天"), false, "i-calendar-days"),
11064
+ t("范围内活跃 ") + fmtTok(activeDays) + t(" 天"), false, "i-calendar-days",
11065
+ { tone: "ok" }),
10890
11066
  kpiCard(t("最长连续天数"), fmtTok(tot.streak_longest) + t(" 天"),
10891
- t("缓存命中率 ") + (tot.cache_rate || 0) + "%", false, "i-calendar-days"),
11067
+ t("缓存命中率 ") + (tot.cache_rate || 0) + "%", false, "i-calendar-days",
11068
+ { tone: "ok" }),
10892
11069
  ].join("");
10893
11070
  const note = $("usage-note");
10894
11071
  if (note) {
@@ -10912,6 +11089,213 @@ function renderUsage() {
10912
11089
 
10913
11090
 
10914
11091
 
11092
+ /* ---------------------------------------------------------- 概览首页 */
11093
+ /* 数据全部来自真实台账与运行记录,没有任何占位假数:
11094
+ * - /api/usage?days=60 一次拿两期:by_day 是连续补零的日序列,前 30 天=上期、
11095
+ * 后 30 天=本期,环比在本地对半切算出来,不额外发请求、不加后端端点;
11096
+ * - 最近运行取 S.state.runs(SSE 推送,天然实时,不需要自己轮询)。
11097
+ * 周期固定 60 天而不是可调:环比必须有等长的上一期,做成可选范围就得再拉一次。 */
11098
+ const OV_PERIOD = 30; // 一期长度(上期 / 本期各 30 天)
11099
+ const OV_DAYS_KEY = "orch.ovDays"; // 趋势图范围(7 / 30),与用量页的范围各自独立
11100
+ let _ovSeq = 0; // 请求序号:重入/切走时丢弃过期响应
11101
+
11102
+ function ovDays() {
11103
+ if (S.ovDays === undefined || S.ovDays === null || S.ovDays === "") {
11104
+ let saved = null;
11105
+ try { saved = localStorage.getItem(OV_DAYS_KEY); } catch (e) { /* 隐私模式忽略 */ }
11106
+ S.ovDays = Number(saved) === 30 ? 30 : 7;
11107
+ }
11108
+ return S.ovDays === 30 ? 30 : 7;
11109
+ }
11110
+
11111
+ function setOvDays(n) {
11112
+ S.ovDays = Number(n) === 30 ? 30 : 7;
11113
+ try { localStorage.setItem(OV_DAYS_KEY, String(S.ovDays)); } catch (e) { /* 隐私模式忽略 */ }
11114
+ syncOvRanges();
11115
+ renderOverviewTrend();
11116
+ }
11117
+
11118
+ function syncOvRanges() {
11119
+ const cur = ovDays();
11120
+ document.querySelectorAll("#ov-ranges [data-days]").forEach((b) =>
11121
+ b.classList.toggle("active", Number(b.dataset.days) === cur));
11122
+ }
11123
+
11124
+ async function loadOverview() {
11125
+ const seq = ++_ovSeq;
11126
+ const kp = $("ov-kpis");
11127
+ if (kp) kp.innerHTML = '<p class="hint">' + t("加载中…") + "</p>";
11128
+ try {
11129
+ const u = await api("/api/usage?days=" + (OV_PERIOD * 2));
11130
+ if (seq !== _ovSeq) return; // 期间已切走/重入:过期响应不落盘
11131
+ S.ovUsage = u;
11132
+ } catch (e) {
11133
+ if (seq !== _ovSeq) return;
11134
+ S.ovUsage = null;
11135
+ }
11136
+ renderOverview();
11137
+ }
11138
+
11139
+ /* 日序列对半切:前一半=上期,后一半=本期。服务刚装不久、总天数不足两期时
11140
+ * 上期为空数组,环比自然不显示(而不是拿不足一期的数据硬算出一个假百分比)。 */
11141
+ function ovSplit(byDay) {
11142
+ const days = byDay || [];
11143
+ const cut = Math.max(0, days.length - OV_PERIOD);
11144
+ return { prev: days.slice(0, cut), cur: days.slice(cut) };
11145
+ }
11146
+
11147
+ function ovSum(rows, k) {
11148
+ return (rows || []).reduce((s, r) => s + (Number(r[k]) || 0), 0);
11149
+ }
11150
+
11151
+ /* 环比:上期为 0 时不显示——除零得到 Infinity,硬算出来是假数 */
11152
+ function ovDelta(cur, prev, invert) {
11153
+ if (!prev) return null;
11154
+ return { pct: (cur - prev) * 100 / prev, invert: !!invert };
11155
+ }
11156
+
11157
+ /* 日期眉标:跟随界面语言排版(en 走 en-US),拿不到就回落空串不阻塞渲染 */
11158
+ function ovDateText() {
11159
+ try {
11160
+ return new Date().toLocaleDateString(getLang() === "en" ? "en-US" : "zh-CN",
11161
+ { weekday: "long", month: "long", day: "numeric" });
11162
+ } catch (e) { return ""; }
11163
+ }
11164
+
11165
+ function renderOverview() {
11166
+ ovRenderHead();
11167
+ ovRenderKpis();
11168
+ syncOvRanges();
11169
+ renderOverviewTrend();
11170
+ ovRenderQuick();
11171
+ ovRenderRecent();
11172
+ }
11173
+
11174
+ function ovRenderHead() {
11175
+ const dateEl = $("ov-date");
11176
+ if (dateEl) dateEl.textContent = ovDateText();
11177
+ const sub = $("ov-sub");
11178
+ if (!sub) return;
11179
+ const u = S.ovUsage;
11180
+ if (!u || !u.totals) { sub.textContent = ""; return; }
11181
+ const cur = ovSplit(u.by_day).cur;
11182
+ const calls = ovSum(cur, "calls"), ok = ovSum(cur, "ok");
11183
+ const rate = calls ? Math.round(ok * 100 / calls) : 0;
11184
+ let s = t("近 30 天执行 ") + fmtTok(calls) + t(" 次 · 成功率 ") + rate + "%" +
11185
+ t(" · 消耗 ") + fmtTok(ovSum(cur, "tokens")) + t(" tokens · 费用 ") + fmtUsd(ovSum(cur, "cost_usd"));
11186
+ const running = ((S.state && S.state.runs) || []).filter((r) => r.status === "running").length;
11187
+ if (running) s += t(" · 当前 ") + running + t(" 个任务运行中");
11188
+ sub.textContent = s;
11189
+ }
11190
+
11191
+ function ovRenderKpis() {
11192
+ const box = $("ov-kpis");
11193
+ if (!box) return;
11194
+ const u = S.ovUsage;
11195
+ if (!u || !u.totals) {
11196
+ box.innerHTML = '<p class="hint">' + t("暂无用量记录。跑一个任务后这里会出现统计。") + "</p>";
11197
+ return;
11198
+ }
11199
+ const { prev, cur } = ovSplit(u.by_day);
11200
+ const C = { calls: ovSum(cur, "calls"), ok: ovSum(cur, "ok"),
11201
+ tok: ovSum(cur, "tokens"), cost: ovSum(cur, "cost_usd") };
11202
+ const P = { calls: ovSum(prev, "calls"), ok: ovSum(prev, "ok"),
11203
+ tok: ovSum(prev, "tokens"), cost: ovSum(prev, "cost_usd") };
11204
+ const cRate = C.calls ? C.ok * 100 / C.calls : 0;
11205
+ const pRate = P.calls ? P.ok * 100 / P.calls : 0;
11206
+ // 两个「日均」按真实活跃天数算:刚装不久只跑了 3 天,除以 30 会得到一个
11207
+ // 看着像「用量很低」的假平均值
11208
+ const activeDays = Math.max(1, cur.filter((d) => (d.calls || 0) > 0).length);
11209
+ box.innerHTML = [
11210
+ kpiCard(t("执行次数"), fmtTok(C.calls),
11211
+ t("成功 ") + fmtTok(C.ok) + t(" · 失败 ") + fmtTok(Math.max(0, C.calls - C.ok)),
11212
+ false, "i-tasks",
11213
+ { tone: "accent", delta: ovDelta(C.calls, P.calls),
11214
+ spark: cur.map((d) => d.calls || 0) }),
11215
+ // 成功率环比给「百分点差」:98.7% 比 96.3% 高 2.4 个点,写成 (+2.4/96.3)=+2.5%
11216
+ // 反而绕;kpiDelta 输出的 +X.X% 在这里就是百分点,读起来是对的
11217
+ kpiCard(t("成功率"), C.calls ? cRate.toFixed(1) + "%" : "—",
11218
+ t("上期 ") + (P.calls ? pRate.toFixed(1) + "%" : "—"),
11219
+ false, "i-clip-check",
11220
+ { tone: "ok", delta: (P.calls && C.calls) ? { pct: cRate - pRate } : null,
11221
+ spark: cur.map((d) => ((d.calls || 0) ? (d.ok || 0) * 100 / d.calls : 0)) }),
11222
+ kpiCard(t("Token 消耗"), fmtTok(C.tok),
11223
+ t("日均 ") + fmtTok(C.tok / activeDays),
11224
+ false, "i-sigma",
11225
+ { tone: "calm", delta: ovDelta(C.tok, P.tok),
11226
+ spark: cur.map((d) => d.tokens || 0) }),
11227
+ kpiCard(t("费用"), fmtUsd(C.cost),
11228
+ t("日均 ") + fmtUsd(C.cost / activeDays),
11229
+ false, "i-coin",
11230
+ { tone: "warn", delta: ovDelta(C.cost, P.cost, true),
11231
+ spark: cur.map((d) => d.cost_usd || 0) }),
11232
+ ].join("");
11233
+ }
11234
+
11235
+ /* 趋势复用用量页同一套折线(Top5 模型 + 其他),只是把日序列截到所选范围 */
11236
+ function renderOverviewTrend() {
11237
+ const box = $("ov-trend-body");
11238
+ if (!box) return;
11239
+ const u = S.ovUsage;
11240
+ if (!u || !u.by_day || !u.by_day.length) {
11241
+ box.innerHTML = '<p class="hint">' + t("(暂无数据)") + "</p>";
11242
+ return;
11243
+ }
11244
+ const byDay = u.by_day.slice(-ovDays());
11245
+ const modelOfDay = {};
11246
+ (u.by_day_model || []).forEach((d) => { modelOfDay[d.day] = d.models || {}; });
11247
+ box.innerHTML = usageMultilineSvg(byDay,
11248
+ byDay.map((d) => ({ day: d.day, models: modelOfDay[d.day] || {} })));
11249
+ }
11250
+
11251
+ function ovRenderQuick() {
11252
+ const box = $("ov-quick-body");
11253
+ if (!box) return;
11254
+ const items = [
11255
+ { key: "tasks", icon: "i-plus", label: t("新建任务"), sub: t("描述目标,交给智能体执行") },
11256
+ { key: "knowledge", icon: "i-sigma", label: t("知识库"), sub: t("项目资料与沉淀") },
11257
+ { key: "market", icon: "i-blocks", label: t("插件市场"), sub: t("装技能扩展能力") },
11258
+ { key: "automation", icon: "i-calendar-days", label: t("自动化"), sub: t("定时与周期执行") },
11259
+ ];
11260
+ box.innerHTML = items.map((it) =>
11261
+ '<button class="ov-q" type="button" onclick="switchTab(\'' + it.key + '\')">' +
11262
+ '<span class="ov-q-ico"><svg class="ico" aria-hidden="true"><use href="#' + it.icon + '"></use></svg></span>' +
11263
+ '<span class="ov-q-text"><b>' + esc(it.label) + "</b><i>" + esc(it.sub) + "</i></span>" +
11264
+ '<span class="ov-q-go" aria-hidden="true"><svg class="ico" aria-hidden="true"><use href="#i-arrow-up"></use></svg></span>' +
11265
+ "</button>").join("");
11266
+ }
11267
+
11268
+ /* 最近运行:状态点复用侧栏那套字形(staskGlyph),行样式照参考站的
11269
+ * 「图标 + 主副标题 + 右侧状态与相对时间」。点击走 jumpToRun——
11270
+ * 它会先切到运行记录页再开详情;在概览页直接 openRun 会把详情开在错的分区上。 */
11271
+ function ovRenderRecent() {
11272
+ const box = $("ov-recent-body");
11273
+ if (!box) return;
11274
+ const runs = ((S.state && S.state.runs) || []).slice(0, 5);
11275
+ if (!runs.length) { box.innerHTML = '<div class="empty">' + t("暂无运行记录") + "</div>"; return; }
11276
+ box.innerHTML = runs.map((r) => {
11277
+ const st = String(r.status || "");
11278
+ const sub = String(r.summary || r.error || "").trim();
11279
+ return '<button class="ov-run" type="button" onclick="jumpToRun(\'' + esc(r.id) + '\')">' +
11280
+ '<span class="ov-run-ico">' + staskGlyph(st) + "</span>" +
11281
+ '<span class="ov-run-main"><b>' + esc(r.title || "") + "</b>" +
11282
+ (sub ? "<i>" + esc(sub.length > 90 ? sub.slice(0, 89) + "…" : sub) + "</i>" : "") +
11283
+ "</span>" +
11284
+ '<span class="ov-run-meta">' +
11285
+ '<span class="chip ' + esc(st) + '">' + esc(runStatusText(r)) + "</span>" +
11286
+ '<span class="ov-run-time">' + esc(relTime(r.created_at)) + "</span>" +
11287
+ "</span></button>";
11288
+ }).join("");
11289
+ }
11290
+
11291
+ /* 运行状态经 SSE 推来:停在概览页时只重画依赖 runs 的两处(概览语 + 最近运行),
11292
+ * 不重新拉用量(台账不会因为一次运行状态变化就变) */
11293
+ function ovMaybeRefresh() {
11294
+ if (S.tab !== "overview") return;
11295
+ ovRenderHead();
11296
+ ovRenderRecent();
11297
+ }
11298
+
10915
11299
  /* ---------------------------------------------------------- 手机连接(扫码) */
10916
11300
  /* ZCode 桌面端式样:大二维码居中,地址+复制在下方;多来源(Tailscale/局域网)用 chips 切换 */
10917
11301
  async function openPhoneConnect() {
@@ -11189,11 +11573,13 @@ function switchTab(name) {
11189
11573
  if (name === "knowledge") loadKnowledge(); // 进知识库页拉取条目
11190
11574
  if (name === "automation") { loadAutomation(); startAutoPoll(); } // 进自动化页:拉取 + 页面可见时每 8s 轮询
11191
11575
  else stopAutoPoll(); // 离开自动化页(或切到别的子页)即停表
11576
+ if (name === "hooks") loadHooksPanel(); // 进钩子页:拉配置渲染管理列表
11192
11577
  if (name === "zentao") loadZentao(); // 进禅道页:拉配置与修复记录回填表单
11193
11578
  if (name === "__wxdigest") { beeRefresh(); beeMarkSeen(); } // 进群摘要页:拉取视图并清未读
11194
11579
  if (name === "market") loadMarket(); // 进插件市场页拉取目录
11195
11580
  if (name === "data") loadDataPage(); // 进数据与备份页:清理配置/状态/可清理预估
11196
11581
  if (name === "usage") { syncUsageRange(); loadUsage(); } // 进用量页:对齐范围选中态并拉取
11582
+ if (name === "overview") { ovDays(); loadOverview(); } // 进概览页:对齐趋势范围并拉两期台账
11197
11583
  if (name === "appearance") renderAppearance(); // 进皮肤页:按当前皮肤/明暗重画卡片
11198
11584
  if (name === "appearance") renderCodeSettings(); // 代码设置行 + 双主题预览卡同步当前值
11199
11585
  if (name === "about") loadSelfupdate(false); // 进关于页:拉版本与更新状态
@@ -11247,6 +11633,8 @@ window.bindPromote = bindPromote;
11247
11633
  window.mgmt = mgmt;
11248
11634
  window.switchTab = switchTab;
11249
11635
  window.setUsageDays = setUsageDays;
11636
+ window.setOvDays = setOvDays;
11637
+ window.jumpToRun = jumpToRun;
11250
11638
  window.saveProvider = saveProvider;
11251
11639
  window.delProvider = delProvider;
11252
11640
  window.openAddProviderDialog = openAddProviderDialog;
@@ -11567,7 +11955,8 @@ document.addEventListener("DOMContentLoaded", () => {
11567
11955
  if (e.target.closest("button, summary, .stask")) collapseDrawerIfMobile();
11568
11956
  });
11569
11957
  $("btn-new-task").addEventListener("click", exitSettings);
11570
- // 主侧栏快捷入口:直达自动化 / 插件市场(switchTab 自己会切设置模式并挂载页面)
11958
+ // 主侧栏快捷入口:直达概览 / 自动化 / 插件市场(switchTab 自己会切设置模式并挂载页面)
11959
+ $("btn-q-overview").addEventListener("click", () => switchTab("overview"));
11571
11960
  $("btn-q-automation").addEventListener("click", () => switchTab("automation"));
11572
11961
  $("btn-q-market").addEventListener("click", () => switchTab("market"));
11573
11962
  bindCtxMenus();
package/app/ui/i18n.js CHANGED
@@ -2411,6 +2411,32 @@
2411
2411
  "导出与导入前自动备份的旧 zip": "Old zips from exports and pre-import auto-backups",
2412
2412
  "只保留每个日志的末尾 1MB": "Keeps only the last 1MB of each log",
2413
2413
  "含平台登录态,清理后发布时需重新扫码登录": "Contains platform logins; re-scan the QR code to publish after clearing",
2414
+
2415
+ // —— 概览首页 & KPI 卡 ——
2416
+ "工作台": "Workspace",
2417
+ "概览": "Overview",
2418
+ "用量趋势": "Usage trend",
2419
+ "快捷入口": "Quick actions",
2420
+ "最近运行": "Recent runs",
2421
+ "查看全部": "View all",
2422
+ "执行次数": "Runs",
2423
+ "成功率": "Success rate",
2424
+ "Token 消耗": "Tokens used",
2425
+ "上期": "Previous",
2426
+ "日均 ": "Daily avg ",
2427
+ "成功 ": "OK ",
2428
+ "较上期": "vs prev",
2429
+ "与上一个同长周期相比": "Compared with the previous period of equal length",
2430
+ "近 30 天执行 ": "Last 30 days: ",
2431
+ " · 消耗 ": " · used ",
2432
+ " tokens · 费用 ": " tokens · cost ",
2433
+ " · 当前 ": " · ",
2434
+ " 个任务运行中": " task(s) running now",
2435
+ "暂无用量记录。跑一个任务后这里会出现统计。": "No usage yet. Stats appear here once you run a task.",
2436
+ "描述目标,交给智能体执行": "Describe a goal and let agents run it",
2437
+ "项目资料与沉淀": "Project docs and notes",
2438
+ "装技能扩展能力": "Install skills to extend",
2439
+ "定时与周期执行": "Scheduled and recurring runs",
2414
2440
  };
2415
2441
 
2416
2442
  // ---------- 工具 ----------