From dac0b4ce932cec8fa999aa005bd12fbc886a49f9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 12:48:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E7=BB=9F=E8=AE=A1=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E6=96=87=E6=A1=88=E5=9C=A8=E6=97=A5=E8=A7=86=E5=9B=BE=E6=81=92?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E3=80=81=E5=A4=A9=E6=95=B0=E6=8C=89=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=E6=A1=B6=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.9.0 加的「统计区间:起 → 止 (Nd)」正是用来解释"周 > 月"的,但它自己 在日视图上从来不出现在屏幕上。 原因:区间终点从最后一个 bucket 的标签拼出来,而 bucket 粒度随视图变化 —— 日视图按小时("2026-10-03T09"),周/月按天("2026-10-01")。代码无条件 拼 "T00:00:00Z",日视图就成了 "2026-10-03T09T00:00:00Z",Date 拒绝该值, toISOString() 抛 RangeError,整个赋值语句丢失。页面无任何报错,只是那行 字不见了——恰好是用户最常看的"今日"视图。 第二个问题同源:天数直接用了 st.buckets.length。日视图 5 个桶是 5 个小时, 界面却显示 "(5d)"。改为按窗口起止算天数。 CDP 实测(修复后): day 统计区间: 2026-10-03 → 2026-10-03 (1d) ← 原为空,且曾误报 5d week 统计区间: 2026-09-28 → 2026-10-03 (6d) month 统计区间: 2026-10-01 → 2026-10-03 (3d) all (空,正确:终身累计没有窗口) 判据一条 + 变异:恢复原来的盲目拼接 → 判红。 Co-Authored-By: ModelRouter --- internal/gateway/ui/index.html | 45 +++++++++++++++++++++------- internal/gateway/ui_key_auto_test.go | 21 +++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 9bd187d..611d9a5 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -2016,16 +2016,41 @@ const pnote = $("#period-note"); // like a bug unless the dates are on screen — so print them. const prange = $("#period-range"); if (prange && st.period && st.period !== "all" && st.from) { - const d0 = new Date(st.from); - const d1 = st.buckets && st.buckets.length - ? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z") - : d0; - const f = (d) => - d.toISOString().slice(0, 10); - prange.textContent = - t("periodRange") + - " " + f(d0) + " → " + f(d1) + - (st.buckets ? " (" + st.buckets.length + "d)" : ""); + // Bucket labels are hourly for a day ("2026-10-03T09") and daily + // for a week or month ("2026-10-01"). Appending "T00:00:00Z" to + // the hourly form builds "2026-10-03T09T00:00:00Z", which Date + // rejects — toISOString() then throws and the whole assignment is + // lost, leaving the range line silently empty on the day view (the + // one people look at most). + const f = (d) => d.toISOString().slice(0, 10); + const label = st.buckets && st.buckets.length + ? st.buckets[st.buckets.length - 1].bucket + : ""; + let endMs = 0; + if (/^\d{4}-\d{2}-\d{2}$/.test(label)) { + endMs = new Date(label + "T00:00:00Z").getTime(); + } else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) { + // An hourly bucket covers its hour, so the day it belongs to is + // what the range should end on. + endMs = new Date(label + ":00:00Z").getTime(); + } + const from = new Date(st.from); + const end = endMs ? new Date(endMs) : from; + // The bucket count is not a day count: the day view buckets by + // HOUR, so "(5d)" for five hours of today was simply wrong. Days + // covered comes from the window itself, which is what the reader + // is trying to establish. + let days = 1; + if (endMs) { + days = Math.max( + 1, + Math.round((endMs - from.getTime()) / 86400000) + 1, + ); + } + const tail = " (" + days + "d)"; + prange.textContent = endMs + ? t("periodRange") + " " + f(from) + " → " + f(end) + tail + : t("periodRange") + " " + f(from) + tail; } else if (prange) { prange.textContent = ""; } diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index ba72a15..1caee4c 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -266,3 +266,24 @@ func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) { } } } + +// The stats range line formats its end date from the LAST bucket label. +// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for +// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly +// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString() +// throws, and the assignment is lost. The symptom is an empty range line on +// exactly the view people look at most, with no error anywhere. +func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) { + src := uiSource(t) + el := readFuncBody(t, src, "paintStats") + // It must branch on the label shape instead of concatenating blindly. + if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) { + t.Fatal("paintStats must recognise the hourly bucket label shape " + + "(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date") + } + // And it must not contain the naive form that broke it. + if strings.Contains(el, `bucket + "T00:00:00Z"`) { + t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " + + "this throws on the day view and leaves the range line empty") + } +}