From 04a52d6d3b239c6887c317c3ffe6b781fd238af4 Mon Sep 17 00:00:00 2001 From: argszero Date: Mon, 24 Aug 2026 09:59:57 +0800 Subject: [PATCH] feat(ui): trend chart polish + metric switcher (rant 2026-08-23T16:17:18) - Metric switcher (Spend/Income/Net/Tokens, default Spend) with delegated click binding - Smooth Catmull-Rom line, gradient area fill, per-metric theme colors - Adaptive nice-scale axis (1/2/5x10^n; symmetric zero-axis for Net) - Hover tooltip + guide line (replaces ), dashed grid, legend total - i18n keys zh+en, cache-bust 20260824-1 --- ui/css/style.css | 57 ++++++++++++++++--- ui/index.html | 10 ++-- ui/js/app.js | 140 ++++++++++++++++++++++++++++++++++++++--------- ui/js/i18n.js | 10 ++++ 4 files changed, 178 insertions(+), 39 deletions(-) diff --git a/ui/css/style.css b/ui/css/style.css index 2722931..45521f4 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -329,17 +329,60 @@ select.input option { background: var(--bg-card); } /* 交易趋势图(rant 2026-08-23T16:01:07 需求 2):SVG 折线图,无外部依赖 */ .tx-trend { padding: 10px 16px 8px; border-bottom: 1px solid var(--border); } .tx-trend-title { color: var(--text-mute); font-size: 12px; margin-bottom: 2px; } +/* 指标切换(rant 2026-08-23T16:17:18 需求 2) */ +.tx-trend-switch { display: flex; gap: 6px; margin: 6px 0 8px; flex-wrap: wrap; } +.tsw-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-dim); + padding: 3px 12px; + border-radius: 999px; + cursor: pointer; + font-size: 12px; +} +.tsw-btn:hover { color: var(--text); } +.tsw-btn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-text); } +.tx-trend-chart { position: relative; } .tx-trend svg { width: 100%; height: auto; display: block; } -.tx-trend-grid { stroke: var(--border); stroke-width: 1; } +.tx-trend-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 4; } .tx-trend-axis { fill: var(--text-mute); font-size: 10px; } -.tx-trend svg .tx-trend-inc { fill: none; stroke: var(--ok); stroke-width: 2; } -.tx-trend svg .tx-trend-exp { fill: none; stroke: var(--danger-text); stroke-width: 2; } -.tx-trend svg circle.tx-trend-inc { fill: var(--ok); stroke: none; } -.tx-trend svg circle.tx-trend-exp { fill: var(--danger-text); stroke: none; } -.tx-trend-legend { display: flex; gap: 18px; font-size: 12px; color: var(--text-mute); padding-top: 2px; } +/* 平滑折线 + 渐变面积(指标色随切换,rant 2026-08-23T16:17:18 需求 1) */ +.tx-trend svg .trend-line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.tx-trend svg .trend-area { stroke: none; } +.tx-trend svg .trend-dot { fill: var(--bg-card); stroke-width: 1.5; } +.tx-trend svg .tg-0 { stop-opacity: 0; } +.tx-trend svg .tg-1 { stop-opacity: 0; } +.tx-trend svg.m-exp .trend-line { stroke: var(--danger-text); } +.tx-trend svg.m-exp .trend-dot { stroke: var(--danger-text); } +.tx-trend svg.m-exp .tg-0 { stop-color: var(--danger-text); stop-opacity: 0.22; } +.tx-trend svg.m-exp .tg-1 { stop-color: var(--danger-text); } +.tx-trend svg.m-inc .trend-line { stroke: var(--ok); } +.tx-trend svg.m-inc .trend-dot { stroke: var(--ok); } +.tx-trend svg.m-inc .tg-0 { stop-color: var(--ok); stop-opacity: 0.22; } +.tx-trend svg.m-inc .tg-1 { stop-color: var(--ok); } +.tx-trend svg.m-net .trend-line { stroke: var(--accent-text); } +.tx-trend svg.m-net .trend-dot { stroke: var(--accent-text); } +.tx-trend svg.m-net .tg-0 { stop-color: var(--accent-text); stop-opacity: 0.22; } +.tx-trend svg.m-net .tg-1 { stop-color: var(--accent-text); } +.tx-trend svg.m-tok .trend-line { stroke: var(--warn); } +.tx-trend svg.m-tok .trend-dot { stroke: var(--warn); } +.tx-trend svg.m-tok .tg-0 { stop-color: var(--warn); stop-opacity: 0.22; } +.tx-trend svg.m-tok .tg-1 { stop-color: var(--warn); } +/* 悬停引导线 + tooltip */ +.tx-trend-guide { position: absolute; top: 2px; bottom: 6px; width: 1px; background: var(--text-dim); opacity: 0.45; display: none; pointer-events: none; } +.tx-trend-tip { + position: absolute; display: none; pointer-events: none; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; + padding: 6px 9px; font-size: 12px; color: var(--text); + box-shadow: var(--shadow); z-index: 5; white-space: nowrap; line-height: 1.5; +} +.tx-trend-tip b { color: var(--accent-text); font-weight: 600; } +.tx-trend-legend { display: flex; gap: 18px; font-size: 12px; color: var(--text-mute); padding-top: 4px; align-items: center; } .tx-trend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } -.tx-trend-dot.inc { background: var(--ok); } .tx-trend-dot.exp { background: var(--danger-text); } +.tx-trend-dot.inc { background: var(--ok); } +.tx-trend-dot.net { background: var(--accent-text); } +.tx-trend-dot.tok { background: var(--warn); } .tx-trend-empty { color: var(--text-mute); font-size: 13px; padding: 14px 16px; border-bottom: 1px solid var(--border); } .table-wrap { overflow-x: auto; } diff --git a/ui/index.html b/ui/index.html index 07c9410..4f99571 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AITokenPool - + @@ -683,9 +683,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 04056e3..0c83f45 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -17,6 +17,7 @@ let txRange = "24h"; // 交易时间段快捷范围:24h / 7d / 30d / all / custom(默认最近 24 小时,rant 2026-08-22T10:50:00) let txCustomStart = ""; // 自定义开始(datetime-local 值,本地时区) let txCustomEnd = ""; // 自定义结束 + let txTrendMetric = "expense"; // 趋势图指标(rant 2026-08-23T16:17:18 需求 2):expense / income / net / tokens,默认消费点数 let isGuest = false; // 游客模式(US-1:未登录可浏览市场) let pendingHashView = null; // URL hash 路由(rant 20:39:30 A):刷新后登录时恢复上次视图 let mkExpanded = null; // 市场行展开(rant 20:39:30 F):当前展开的模型 id,null=全部收起;仅展开当前行 @@ -1240,34 +1241,69 @@ $("#tx-summary").innerHTML = html; } - // 交易趋势图(rant 2026-08-23T16:01:07 需求 2):手写 SVG 折线图(无外部依赖), + // 交易趋势图(rant 2026-08-23T16:01:07 需求 2 + 2026-08-23T16:17:18 优化):手写 SVG(无外部依赖), // 数据源 = /api/transactions/trend(跟随 tab + 外部时间段筛选,与 summary 同口径); - // 横轴 = 时间桶(hour/day/week),纵轴 = 点数;收入(绿) / 支出(红) 两条线,点悬停显示精确值。 + // 指标可切换(消费/收入/净变化/Token,默认消费点数);平滑折线 + 渐变面积 + 自适应刻度 + 悬停 tooltip。 + let _trendCtx = null; // 悬停 tooltip 上下文(renderTxTrend 写入,事件委托读取) function renderTxTrend() { const el = $("#tx-trend"); const tr = (Live.transactions && Live.transactions.trend) ? Live.transactions.trend : null; const buckets = (tr && Array.isArray(tr.buckets)) ? tr.buckets : []; if (!buckets.length) { el.innerHTML = '
' + esc(T("tx.trend.empty")) + "
"; + _trendCtx = null; return; } const bucket = tr.bucket || "day"; - const income = buckets.map((b) => b.income || 0); - const expense = buckets.map((b) => b.expense || 0); - const maxV = Math.max(1, ...income, ...expense); - const W = 640, H = 170, PL = 46, PR = 12, PT = 12, PB = 24; + const METRICS = [ + { key: "expense", label: T("tx.trend.metric.expense"), pick: (b) => b.expense || 0, cls: "exp" }, + { key: "income", label: T("tx.trend.metric.income"), pick: (b) => b.income || 0, cls: "inc" }, + { key: "net", label: T("tx.trend.metric.net"), pick: (b) => (b.net === undefined ? (b.income || 0) - (b.expense || 0) : b.net) || 0, cls: "net", signed: true }, + { key: "tokens", label: T("tx.trend.metric.tokens"), pick: (b) => b.tokens || 0, cls: "tok" }, + ]; + const m = METRICS.find((x) => x.key === txTrendMetric) || METRICS[0]; + const vals = buckets.map(m.pick); + const lbl = (b) => bucketLabel(b.t, bucket); + const fmtSigned = (n) => (n > 0 ? "+" : n < 0 ? "-" : "") + D.fmt(Math.abs(n)); + const fmtAxis = (n) => { + const a = Math.abs(n); + const s = a >= 1e6 ? (a / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M" + : a >= 1000 ? Math.round(a / 1000) + "K" + : (Math.round(a * 10) / 10).toString(); + return (n < 0 ? "-" : "") + s; + }; + // 自适应刻度:nice 上限(1/2/5×10^n);负值指标(净变化)±maxV 对称,零轴居中 + const neg = vals.some((v) => v < 0); + const absMax = Math.max(0, ...vals.map((v) => Math.abs(v))); + const np = Math.pow(10, Math.floor(Math.log10(Math.max(absMax, 1e-9)))); + const nm = Math.max(absMax, 1e-9) / np; + const maxV = Math.max((nm <= 1 ? 1 : nm <= 2 ? 2 : nm <= 5 ? 5 : 10) * np, 4); + const minV = neg ? -maxV : 0; + const W = 640, H = 190, PL = 46, PR = 14, PT = 14, PB = 26; const iw = W - PL - PR, ih = H - PT - PB; const X = (i) => PL + (buckets.length === 1 ? iw / 2 : iw * i / (buckets.length - 1)); - const Y = (v) => PT + ih * (1 - v / maxV); - const lbl = (b) => bucketLabel(b.t, bucket); - const fmt = (n) => (n > 0 ? "+" : "") + D.fmt(n); - const fmtShort = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : (n >= 1000 ? Math.round(n / 1000) + "K" : String(Math.round(n)))); - // 网格 + 纵轴刻度(4 档) + const Y = (v) => PT + ih * (maxV - v) / (maxV - minV || 1); + const pts = vals.map((v, i) => [X(i), Y(v)]); + // 平滑折线(Catmull-Rom → 三次贝塞尔,单点退化为点) + const smoothPath = (p) => { + if (!p.length) return ""; + if (p.length === 1) return "M" + p[0][0].toFixed(1) + "," + p[0][1].toFixed(1) + " L" + (p[0][0] + 1).toFixed(1) + "," + p[0][1].toFixed(1); + let d = "M" + p[0][0].toFixed(1) + "," + p[0][1].toFixed(1); + for (let i = 0; i < p.length - 1; i++) { + const p0 = p[Math.max(0, i - 1)], p1 = p[i], p2 = p[i + 1], p3 = p[Math.min(p.length - 1, i + 2)]; + const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; + const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; + d += " C" + c1x.toFixed(1) + "," + c1y.toFixed(1) + " " + c2x.toFixed(1) + "," + c2y.toFixed(1) + " " + p2[0].toFixed(1) + "," + p2[1].toFixed(1); + } + return d; + }; + // 网格 + 纵轴刻度(4 等分,5 条线,负值指标含负刻度) let grid = ""; for (let g = 0; g <= 4; g++) { const gy = PT + ih * g / 4; + const gv = minV + (maxV - minV) * (4 - g) / 4; grid += '' + - '' + fmtShort(maxV * (4 - g) / 4) + ""; + '' + fmtAxis(gv) + ""; } // 横轴标签:首 / 中 / 尾 const idxs = [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1].filter((v, i, a) => a.indexOf(v) === i); @@ -1275,27 +1311,40 @@ idxs.forEach((i) => { xlabels += '' + esc(lbl(buckets[i])) + ""; }); - // 折线 + 数据点( 悬停显示精确值) - const lineHtml = (arr, cls, name) => { - let pts = "", dots = ""; - arr.forEach((v, i) => { - const xi = X(i).toFixed(1), yi = Y(v).toFixed(1); - pts += (i ? " " : "") + xi + "," + yi; - dots += '<circle class="' + cls + '" cx="' + xi + '" cy="' + yi + '" r="2.5"><title>' + - esc(lbl(buckets[i]) + " · " + name + " " + fmt(v)) + ""; + // 折线 + 渐变面积 + 数据点(面积基线:负值指标取零轴,正值指标取图表底) + const yBase = Y(neg ? 0 : minV); + const areaPath = pts.length + ? smoothPath(pts) + " L" + pts[pts.length - 1][0].toFixed(1) + "," + yBase.toFixed(1) + " L" + pts[0][0].toFixed(1) + "," + yBase.toFixed(1) + " Z" + : ""; + const lineHtml = () => { + let dots = ""; + pts.forEach((p) => { + dots += ''; }); - return '' + dots; + return '' + + '' + dots; }; + const switchHtml = + '
' + + METRICS.map((x) => + '" + ).join("") + + "
"; + const total = vals.reduce((a, b) => a + b, 0); const html = '
' + esc(T("tx.trend.title")) + "
" + - '' + - grid + xlabels + - lineHtml(income, "tx-trend-inc", T("tx.summary.income")) + - lineHtml(expense, "tx-trend-exp", T("tx.summary.expense")) + + switchHtml + + '
' + + '' + + '' + + grid + xlabels + lineHtml() + "" + - '
' + esc(T("tx.summary.income")) + "" + - '' + esc(T("tx.summary.expense")) + "
"; + '
' + + '
' + + "
" + + '
' + esc(m.label) + " · " + (m.signed ? fmtSigned(total) : D.fmt(total)) + "
"; el.innerHTML = html; + _trendCtx = { buckets, vals, m, lbl, X, iw, W, PL }; } // 趋势桶标签:hour → "MM-DD HH:00";day/week → "MM-DD"(bucket 起点均为 UTC,转本地显示) @@ -3118,6 +3167,43 @@ showCustom(); } + // 趋势图指标切换 + 悬停 tooltip(rant 2026-08-23T16:17:18 需求 1/2:事件委托,重渲染不丢绑定) + const txTrendEl = $("#tx-trend"); + if (txTrendEl) { + txTrendEl.addEventListener("click", (e) => { + const b = e.target.closest ? e.target.closest(".tsw-btn") : null; + if (!b || b.dataset.metric === txTrendMetric) return; + txTrendMetric = b.dataset.metric; + renderTxTrend(); + }); + txTrendEl.addEventListener("mousemove", (e) => { + const ctx = _trendCtx; + const tip = $("#tx-trend-tip"), guide = $("#tx-trend-guide"); + const svg = txTrendEl.querySelector("svg"), chartEl = txTrendEl.querySelector(".tx-trend-chart"); + if (!ctx || !tip || !guide || !svg || !chartEl) return; + const rect = svg.getBoundingClientRect(); + if (!rect.width) return; + const vb = svg.getAttribute("viewBox").split(" ").map(Number); + const x = (e.clientX - rect.left) / rect.width * vb[2]; + const step = ctx.buckets.length > 1 ? ctx.X(1) - ctx.X(0) : ctx.iw; + const i = Math.max(0, Math.min(ctx.buckets.length - 1, Math.round((x - ctx.PL) / step))); + const v = ctx.vals[i]; + const cr = chartEl.getBoundingClientRect(); + const valTxt = ctx.m.signed ? (v > 0 ? "+" : v < 0 ? "-" : "") + D.fmt(Math.abs(v)) : D.fmt(v); + tip.innerHTML = esc(ctx.lbl(ctx.buckets[i])) + "
" + esc(ctx.m.label) + " " + valTxt + ""; + tip.style.display = "block"; + guide.style.display = "block"; + guide.style.left = (ctx.X(i) / vb[2] * cr.width) + "px"; + tip.style.left = Math.min(Math.max(e.clientX - cr.left + 14, 4), Math.max(cr.width - tip.offsetWidth - 4, 4)) + "px"; + tip.style.top = Math.min(Math.max(e.clientY - cr.top - tip.offsetHeight - 10, 4), Math.max(cr.height - tip.offsetHeight - 4, 4)) + "px"; + }); + txTrendEl.addEventListener("mouseleave", () => { + const tip = $("#tx-trend-tip"), guide = $("#tx-trend-guide"); + if (tip) tip.style.display = "none"; + if (guide) guide.style.display = "none"; + }); + } + // 表格键盘导航(rant 20:46:57 F):点击行 → 激活高亮,之后 ↑/↓/Enter/Esc 可用 KBD_TABLE_IDS.forEach((id) => { const c = document.getElementById(id); diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 3ada100..e6c6b93 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -322,6 +322,11 @@ "tx.summary.output": "输出", "tx.trend.title": "点数趋势", "tx.trend.empty": "当前时间段暂无趋势数据", + "tx.trend.metricLabel": "趋势图指标", + "tx.trend.metric.expense": "消费", + "tx.trend.metric.income": "收入", + "tx.trend.metric.net": "净变化", + "tx.trend.metric.tokens": "Token", "tx.col.time": "时间", "tx.col.type": "类型", "tx.col.user": "用户", @@ -1032,6 +1037,11 @@ "tx.summary.output": "Output", "tx.trend.title": "Points Trend", "tx.trend.empty": "No trend data in this range", + "tx.trend.metricLabel": "Trend metric", + "tx.trend.metric.expense": "Spend", + "tx.trend.metric.income": "Income", + "tx.trend.metric.net": "Net", + "tx.trend.metric.tokens": "Tokens", "tx.col.time": "Time", "tx.col.type": "Type", "tx.col.user": "User",