@lyk308/dsh-token-dashboard 0.1.3 → 0.1.5

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/lib/client.js CHANGED
@@ -1,393 +1,561 @@
1
- /**
2
- * dsh-token-dashboard — client half (Token Ledger style).
3
- *
4
- * Layout mirrors a token ledger: Balance card, Token-usage stat cards
5
- * (Today / This month / All time + requests + cache rate), a grouped share bar
6
- * (by model OR by workspace, toggleable), an Activity heatmap with a
7
- * 30/90/180-day range switch, and a Models table (Req / Input / Cache /
8
- * Output). Uses react/jsx-runtime jsx/jsxs correctly and never puts a raw null
9
- * inside a children array. Wrapped in an ErrorBoundary.
10
- */
11
- window.__ModuleLoader__.load({
12
- id: "dsh-token-dashboard",
13
- factory: (require) => {
14
- const module = { exports: {} };
15
- const exports = module.exports;
16
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
-
18
- const React = require("react");
19
- const { useEffect, useState, useCallback, Component } = React;
20
- const jsx = require("react/jsx-runtime").jsx;
21
- const jsxs = require("react/jsx-runtime").jsxs;
22
-
23
- // ---- CSS-ish tokens (follow DSH theme vars) ----
24
- const CARD = {
25
- border: "1px solid var(--dsw-alias-border-l1)",
26
- borderRadius: "12px",
27
- padding: "16px",
28
- margin: "12px 0",
29
- background: "var(--dsw-alias-bg-overlay, transparent)"
30
- };
31
- const SEC_TITLE = { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-secondary)", marginBottom: "10px", letterSpacing: "0.02em" };
32
- const NUM = { fontSize: "26px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", lineHeight: 1.3 };
33
- const LABEL = { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" };
34
- const ROW = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 0" };
35
- const MONO = { fontVariantNumeric: "tabular-nums" };
36
-
37
- function fmt(n) { return (n == null || isNaN(n)) ? "0" : Number(n).toLocaleString(); }
38
- function fmtMoney(n) { return (n == null || isNaN(n)) ? "0.00" : Number(n).toFixed(2); }
39
- function pct(v, max) { return max > 0 ? ((v / max) * 100) : 0; }
40
-
41
- // A segmented control ("By model" / "By workspace" and the heatmap range).
42
- function Seg({ options, value, onChange }) {
43
- return jsxs("div", {
44
- style: { display: "flex", gap: "4px", background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.14))", borderRadius: "8px", padding: "3px" },
45
- children: options.map((o) => jsx("button", {
46
- onClick: () => onChange(o.value),
47
- style: {
48
- flex: "1 1 auto", minWidth: "0", padding: "5px 14px", fontSize: "12px", borderRadius: "6px", cursor: "pointer",
49
- border: "none", color: "var(--dsw-alias-label-primary)",
50
- whiteSpace: "nowrap",
51
- background: (o.value === value)
52
- ? "var(--dsw-alias-bg-overlay, rgba(255,255,255,.9))"
53
- : "transparent",
54
- fontWeight: (o.value === value) ? 600 : 400
55
- },
56
- children: o.label
57
- }, o.value))
58
- });
59
- }
60
-
61
- // Color scale for heatmap (green shades like the reference).
62
- function heatColor(val, max) {
63
- if (val <= 0) return "var(--dsw-alias-border-l1)";
64
- const t = max > 0 ? (val / max) : 0;
65
- const a = 0.25 + 0.75 * t;
66
- return "rgba(76,175,80," + a.toFixed(2) + ")";
67
- }
68
-
69
- // A stable color per group entry; the "其他" (other) aggregate is grey so it
70
- // reads as "everything else", distinct from the named top groups.
71
- const GROUP_COLORS = ["#4caf50", "#2196f3", "#ff9800", "#9c27b0", "#00bcd4"];
72
- function groupColor(key, i) {
73
- if (key === "其他" || key === "other") return "var(--dsw-alias-label-tertiary, #9e9e9e)";
74
- return GROUP_COLORS[i % GROUP_COLORS.length];
75
- }
76
-
77
- class Boundary extends Component {
78
- constructor(props) { super(props); this.state = { error: null }; }
79
- static getDerivedStateFromError(error) { return { error }; }
80
- componentDidCatch(error, info) { console.error("[token-dashboard] render error:", error, info); }
81
- render() {
82
- if (this.state.error) {
83
- return jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", padding: "8px 0" }, children: "渲染出错:" + String(this.state.error?.message || this.state.error) });
84
- }
85
- return jsx("div", { children: this.props.children });
86
- }
87
- }
88
-
89
- function TokenDashboardPanel() {
90
- const [stats, setStats] = useState(null);
91
- const [balance, setBalance] = useState(null);
92
- const [error, setError] = useState(null);
93
- const [loading, setLoading] = useState(true);
94
- const [balanceLoading, setBalanceLoading] = useState(true);
95
- const [groupBy, setGroupBy] = useState("model"); // "model" | "workspace"
96
- const [range, setRange] = useState(90); // 30 | 90 | 180 days
97
- const [metric, setMetric] = useState("cost"); // "cost" | "token"
98
-
99
- const loadStats = useCallback(async () => {
100
- try {
101
- const res = await fetch("/api/token-dashboard/stats?refresh=1");
102
- if (!res.ok) throw new Error("stats http " + res.status);
103
- const data = await res.json();
104
- if (data && data.status === "computing") { setTimeout(loadStats, 1200); return; }
105
- setStats((data && typeof data === "object") ? data : {});
106
- setLoading(false);
107
- } catch (e2) { setError(String(e2)); setLoading(false); }
108
- }, []);
109
- const loadBalance = useCallback(async (force) => {
110
- setBalanceLoading(true);
111
- try {
112
- const res = await fetch("/api/token-dashboard/balance" + (force ? "?force=1" : ""));
113
- if (!res.ok) throw new Error("balance http " + res.status);
114
- setBalance((await res.json()) || {});
115
- } catch (e2) { setError(String(e2)); }
116
- finally { setBalanceLoading(false); }
117
- }, []);
118
- useEffect(() => { loadStats(); loadBalance(false); }, [loadStats, loadBalance]);
119
-
120
- if (!loading && stats) {
121
- // ---- derived ----
122
- const infos = balance?.data?.balance_infos || [];
123
- let balInfo = infos.find((x) => x && x.currency === "CNY")
124
- || infos.find((x) => x && Number(x.total_balance ?? x.balance) > 0)
125
- || infos[0] || balance?.data || null;
126
- const balValue = balInfo ? (balInfo.total_balance ?? balInfo.balance ?? null) : null;
127
- const balCur = balInfo ? (balInfo.currency || "") : "";
128
-
129
- const total = Number(stats.total) || 0;
130
- const req = Number(stats.req) || Number(stats.events) || 0;
131
- const today = Number(stats.today) || 0;
132
- const thisMonth = Number(stats.thisMonth) || 0;
133
- const cacheRate = (Number(stats.cacheHits) && total) ? (Number(stats.cacheHits) / total * 100).toFixed(1) : "0.0";
134
-
135
- // grouped share data. Show top TOP_N entries by total, then fold the
136
- // rest into a single "其他" (other) aggregate so the card never grows
137
- // unbounded and tiny shares don't get lost in the bar.
138
- const TOP_N = 8;
139
- const groups = groupBy === "model" ? stats.byModel : stats.byWorkspace;
140
- const allEntries = Object.keys(groups || {}).map((k) => ({ key: k, ...(typeof groups[k] === "object" ? groups[k] : { total: groups[k] }) }))
141
- .sort((a, b) => b.total - a.total);
142
- let groupEntries;
143
- if (allEntries.length > TOP_N) {
144
- const top = allEntries.slice(0, TOP_N);
145
- // fold the remainder into a synthetic "其他" entry; keep broken-down
146
- // counters for the bar/list (we only display total/req, but stay safe).
147
- const rest = allEntries.slice(TOP_N);
148
- const other = { key: "其他", req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0 };
149
- for (const r of rest) {
150
- other.total += Number(r.total) || 0;
151
- other.req += Number(r.req) || 0;
152
- other.input += Number(r.input) || 0;
153
- other.output += Number(r.output) || 0;
154
- other.cacheRead += Number(r.cacheRead) || 0;
155
- other.reasoning += Number(r.reasoning) || 0;
156
- }
157
- groupEntries = [...top, other];
158
- } else {
159
- groupEntries = allEntries;
160
- }
161
- // Percentages use the SUM of all group totals as the denominator, so the
162
- // bar segments (flex-grow by total) and the printed % agree exactly and
163
- // both reflect "share of all usage".
164
- const groupSum = groupEntries.reduce((s, g) => s + (Number(g.total) || 0), 0);
165
-
166
- // heatmap: build days from byDay within range. byDay entries are either
167
- // {total, cost, ...} objects (new) or a bare number (legacy), so normalize.
168
- const byDay = stats.byDay || {};
169
- const todayD = new Date();
170
- const cells = [];
171
- for (let i = range - 1; i >= 0; i--) {
172
- const d = new Date(todayD); d.setDate(d.getDate() - i);
173
- const key = d.toISOString().slice(0, 10);
174
- const rec = byDay[key];
175
- const total = (rec && typeof rec === "object") ? Number(rec.total) || 0 : Number(rec) || 0;
176
- const cost = (rec && typeof rec === "object") ? Number(rec.cost) || 0 : 0;
177
- cells.push({ key, val: total, cost });
178
- }
179
- const heatMax = Math.max(1, ...cells.map((c) => c.val));
180
- // group cells into weeks for the grid (7 per week)
181
- const weeks = [];
182
- for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
183
-
184
- // models table rows
185
- const modelRows = Object.keys(stats.byModel || {})
186
- .map((m) => ({ key: m, ...stats.byModel[m] }))
187
- .sort((a, b) => b.total - a.total);
188
-
189
- // ---- render ----
190
- const balanceInner = balanceLoading ? jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: "加载中…" })
191
- : (balance && balance.status === "ok")
192
- ? jsxs("div", { children: [
193
- jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "6px" }, children: [
194
- jsx("span", { style: { ...NUM, ...MONO }, children: (balCur === "CNY" ? "¥" : "$") + fmtMoney(balValue) }),
195
- jsx("span", { style: LABEL, children: balCur })
196
- ] }),
197
- jsxs("div", { style: { marginTop: "8px" }, children: [
198
- jsx("button", { onClick: () => loadBalance(true), style: { cursor: "pointer", fontSize: "12px", padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l1)", background: "transparent", color: "var(--dsw-alias-label-primary)" }, children: "强制刷新" }),
199
- balance.cached ? jsx("span", { style: { fontSize: "11px", color: "var(--dsw-alias-label-tertiary)", marginLeft: "8px" }, children: "缓存" }) : null
200
- ] })
201
- ] })
202
- : jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: (balance?.message || "未配置或不可用") });
203
-
204
- const statCards = [
205
- { label: "今日", val: today },
206
- { label: "本月", val: thisMonth },
207
- { label: "总计", val: total }
208
- ];
209
- const statBar = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: "10px" }, children: statCards.map((s) =>
210
- jsxs("div", { style: { background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.08))", borderRadius: "10px", padding: "12px" }, children: [
211
- jsx("div", { style: LABEL, children: s.label }),
212
- jsx("div", { style: { ...NUM, ...MONO, fontSize: "22px" }, children: fmt(s.val) })
213
- ] }, s.label)
214
- ) });
215
-
216
- const shareBar = jsxs("div", { children: [
217
- jsxs("div", { style: { display: "flex", height: "12px", borderRadius: "6px", overflow: "hidden", gap: "2px", backgroundColor: "var(--dsw-alias-border-l1)" }, children: groupEntries.map((g, i) =>
218
- // flex-grow proportional to total: each segment takes its share of the
219
- // full width. grow = g.total, shrink = 0, basis = 0 segments split
220
- // the row exactly by their token totals (no fixed/min width clobbering).
221
- jsx("div", { style: { flex: g.total + " 0 0", background: groupColor(g.key, i) }, title: g.key + " " + fmt(g.total) }, g.key)
222
- ) }),
223
- jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px", marginTop: "10px" }, children: groupEntries.map((g, i) =>
224
- jsxs("div", { style: ROW, children: [
225
- jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", minWidth: "0" }, children: [
226
- jsx("div", { style: { width: "10px", height: "10px", borderRadius: "2px", flexShrink: "0", background: groupColor(g.key, i) } }),
227
- jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "240px", color: "var(--dsw-alias-label-primary)", fontSize: "13px" }, children: g.key })
228
- ] }),
229
- jsxs("div", { style: { display: "flex", gap: "14px", color: "var(--dsw-alias-label-secondary)", ...MONO, fontSize: "13px", flexShrink: "0" }, children: [
230
- jsx("span", { children: fmt(g.total) }),
231
- jsx("span", { style: { color: "var(--dsw-alias-label-tertiary)" }, children: pct(g.total, groupSum).toFixed(0) + "%" })
232
- ] })
233
- ] }, g.key)
234
- ) })
235
- ] });
236
-
237
- // heatmap grid (weeks as columns is complex; render day cells in a wrap grid)
238
- const heatGrid = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14px, 1fr))", gap: "3px" }, children: cells.map((c) =>
239
- jsx("div", { style: { width: "14px", height: "14px", borderRadius: "2px", background: heatColor(c.val, heatMax) }, title: c.key + ": " + fmt(c.val) }, c.key)
240
- ) });
241
-
242
- // --- trend chart: a lightweight SVG polyline of daily metric over `range`.
243
- // metric = "cost" or "token". Uses only inline SVG — no chart lib.
244
- const trendValues = cells.map((c) => (metric === "cost" ? c.cost : c.val));
245
- const tMax = Math.max(1, ...trendValues);
246
- const TW = 320, TH = 80, PAD = 6;
247
- let trendSvg;
248
- if (trendValues.length <= 1) {
249
- trendSvg = jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "8px 0" }, children: "数据不足" });
250
- } else {
251
- const step = TW / (trendValues.length - 1);
252
- const pts = trendValues.map((v, i) => {
253
- const x = PAD + i * step;
254
- const y = TH - PAD - (v / tMax) * (TH - 2 * PAD);
255
- return x.toFixed(1) + "," + y.toFixed(1);
256
- }).join(" ");
257
- const lastPt = pts.split(" ")[pts.split(" ").length - 1];
258
- const lastV = trendValues[trendValues.length - 1];
259
- trendSvg = jsxs("svg", { width: "100%", viewBox: "0 0 " + TW + " " + TH, style: { display: "block", maxHeight: "90px", width: "100%" }, children: [
260
- jsx("polyline", { points: pts, fill: "none", stroke: "var(--dsw-alias-label-primary)", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round" }),
261
- jsx("circle", { cx: lastPt.split(",")[0], cy: lastPt.split(",")[1], r: "3", fill: "var(--dsw-alias-label-primary)" }),
262
- jsx("text", { x: "10", y: "12", fill: "var(--dsw-alias-label-tertiary)", fontSize: "10", children: (metric === "cost" ? ("¥" + fmtMoney(lastV)) : fmt(lastV)) })
263
- ] });
264
- }
265
-
266
- const modelTable = jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: "13px" }, children: [
267
- jsxs("thead", { children: [jsxs("tr", { children: [
268
- jsx("th", { style: thStyle, children: "模型" }), jsx("th", { style: thR, children: "Req" }),
269
- jsx("th", { style: thR, children: "Input" }), jsx("th", { style: thR, children: "Cache" }),
270
- jsx("th", { style: thR, children: "Output" }), jsx("th", { style: thR, children: "Cost" })
271
- ] })] }),
272
- jsxs("tbody", { children: modelRows.map((r) => jsxs("tr", { style: { borderTop: "1px solid var(--dsw-alias-border-l1)" }, children: [
273
- jsx("td", { style: tdStyle, children: r.key }),
274
- jsx("td", { style: tdR, children: r.req }),
275
- jsx("td", { style: tdR, children: fmt(r.input) }),
276
- jsx("td", { style: tdR, children: fmt(r.cacheRead) }),
277
- jsx("td", { style: tdR, children: fmt(r.output) }),
278
- jsx("td", { style: { ...tdR, color: "var(--dsw-alias-label-primary)", fontWeight: 600 }, children: "¥" + fmtMoney(r.cost || 0) })
279
- ] }, r.key)) })
280
- ] });
281
-
282
- return jsxs("div", { children: [
283
- jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "4px" }, children: "用量看板" }),
284
- (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
285
-
286
- // Balance
287
- jsxs("div", { style: CARD, children: [
288
- jsx("div", { style: SEC_TITLE, children: "💰 账户余额(DeepSeek 官方)" }), balanceInner
289
- ] }),
290
-
291
- // Token usage
292
- jsxs("div", { style: CARD, children: [
293
- jsx("div", { style: SEC_TITLE, children: "📊 Token 使用量" }),
294
- statBar,
295
- jsxs("div", { style: { ...ROW, marginTop: "10px" }, children: [
296
- jsx("span", { children: "今日花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.todayCost) })
297
- ] }),
298
- jsxs("div", { style: ROW, children: [
299
- jsx("span", { children: "本月花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.thisMonthCost) })
300
- ] }),
301
- jsxs("div", { style: ROW, children: [
302
- jsx("span", { children: "总花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.cost) })
303
- ] }),
304
- jsxs("div", { style: { ...ROW, marginTop: "6px", borderTop: "1px solid var(--dsw-alias-border-l1)", paddingTop: "6px" }, children: [
305
- jsx("span", { children: "请求数" }), jsx("span", { ...MONO, children: fmt(req) })
306
- ] }),
307
- jsxs("div", { style: ROW, children: [
308
- jsx("span", { children: "缓存命中率" }), jsx("span", { ...MONO, children: cacheRate + "%" })
309
- ] }),
310
- jsxs("div", { style: ROW, children: [
311
- jsx("span", { children: "会话数" }), jsx("span", { ...MONO, children: Number(stats.sessions) || 0 })
312
- ] })
313
- ] }),
314
-
315
- // Grouped share
316
- jsxs("div", { style: CARD, children: [
317
- jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", marginBottom: "10px" }, children: [
318
- jsx("div", { style: SEC_TITLE, children: "📈 用量分布" }),
319
- jsx(Seg, { options: [{ label: "按模型", value: "model" }, { label: "按工作区", value: "workspace" }], value: groupBy, onChange: setGroupBy })
320
- ] }),
321
- shareBar
322
- ] }),
323
-
324
- // Activity heatmap
325
- jsxs("div", { style: CARD, children: [
326
- jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }, children: [
327
- jsx("div", { style: SEC_TITLE, children: "🗓 活跃度" }),
328
- jsx(Seg, { options: [{ label: "30天", value: 30 }, { label: "90天", value: 90 }, { label: "180天", value: 180 }], value: range, onChange: setRange })
329
- ] }),
330
- heatGrid
331
- ] }),
332
-
333
- // Trend chart
334
- jsxs("div", { style: CARD, children: [
335
- jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }, children: [
336
- jsx("div", { style: SEC_TITLE, children: "📉 趋势" }),
337
- jsx(Seg, { options: [{ label: "成本", value: "cost" }, { label: "Token", value: "token" }], value: metric, onChange: setMetric })
338
- ] }),
339
- trendSvg
340
- ] }),
341
-
342
- // Models table
343
- jsxs("div", { style: CARD, children: [
344
- jsx("div", { style: SEC_TITLE, children: "🤖 按模型" }),
345
- modelTable
346
- ] })
347
- ] });
348
- }
349
-
350
- // loading / no data -> simple spinner-ish text
351
- return jsxs("div", { children: [
352
- jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "12px" }, children: "用量看板" }),
353
- (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
354
- jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", padding: "8px 0" }, children: "加载中…" })
355
- ] });
356
- }
357
-
358
- const thStyle = { textAlign: "left", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px" };
359
- const thR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px", ...MONO };
360
- const tdStyle = { padding: "8px 6px", color: "var(--dsw-alias-label-primary)" };
361
- const tdR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-secondary)", ...MONO };
362
-
363
- function apply(ctx) {
364
- ctx.slots.inject("settings.section", () => ctx.slots.register(
365
- {
366
- name: "settings.section",
367
- id: "token-dashboard",
368
- order: 30,
369
- label: () => {
370
- try {
371
- const bound = ctx.locale && typeof ctx.locale.bind === "function" && ctx.locale.bind("token-dashboard");
372
- const v = bound && typeof bound === "function" ? bound("title") : null;
373
- return (typeof v === "string" && v !== "title") ? v : "用量看板";
374
- } catch {
375
- return "用量看板";
376
- }
377
- },
378
- inject: () => ({})
379
- },
380
- (props) => jsx(Boundary, { children: jsx(TokenDashboardPanel, props) })
381
- ));
382
- }
383
-
384
- exports.apply = apply;
385
- exports.inject = ["slots", "locale"];
386
- exports.TokenDashboardPanel = TokenDashboardPanel;
387
- exports.Boundary = Boundary;
388
- exports.fmt = fmt;
389
- exports.fmtMoney = fmtMoney;
390
-
391
- return module.exports;
392
- }
393
- });
1
+ /**
2
+ * dsh-token-dashboard — client half (Token Ledger style).
3
+ *
4
+ * Layout mirrors a token ledger: Balance card, Token-usage stat cards
5
+ * (Today / This month / All time + requests + cache rate), a grouped share bar
6
+ * (by model OR by workspace, toggleable), an Activity heatmap with a
7
+ * 30/90/180-day range switch, and a Models table (Req / Input / Cache /
8
+ * Output). Uses react/jsx-runtime jsx/jsxs correctly and never puts a raw null
9
+ * inside a children array. Wrapped in an ErrorBoundary.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-token-dashboard",
13
+ factory: (require) => {
14
+ const module = { exports: {} };
15
+ const exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+
18
+ const React = require("react");
19
+ const { useEffect, useState, useCallback, Component } = React;
20
+ const jsx = require("react/jsx-runtime").jsx;
21
+ const jsxs = require("react/jsx-runtime").jsxs;
22
+
23
+ // ---- CSS-ish tokens (follow DSH theme vars) ----
24
+ const CARD = {
25
+ border: "1px solid var(--dsw-alias-border-l1)",
26
+ borderRadius: "12px",
27
+ padding: "16px",
28
+ margin: "12px 0",
29
+ background: "var(--dsw-alias-bg-overlay, transparent)"
30
+ };
31
+ const SEC_TITLE = { fontSize: "13px", fontWeight: 600, color: "var(--dsw-alias-label-secondary)", marginBottom: "10px", letterSpacing: "0.02em" };
32
+ const NUM = { fontSize: "26px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", lineHeight: 1.3 };
33
+ const LABEL = { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" };
34
+ const ROW = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 0" };
35
+ const MONO = { fontVariantNumeric: "tabular-nums" };
36
+ const btnStyle = { cursor: "pointer", fontSize: "12px", padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l1)", background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.14))", color: "var(--dsw-alias-label-primary)", whiteSpace: "nowrap" };
37
+
38
+ function fmt(n) { return (n == null || isNaN(n)) ? "0" : Number(n).toLocaleString(); }
39
+ function fmtMoney(n) { return (n == null || isNaN(n)) ? "0.00" : Number(n).toFixed(2); }
40
+ function pct(v, max) { return max > 0 ? ((v / max) * 100) : 0; }
41
+
42
+ // A segmented control ("By model" / "By workspace" and the heatmap range).
43
+ function Seg({ options, value, onChange }) {
44
+ return jsxs("div", {
45
+ style: { display: "flex", gap: "4px", background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.14))", borderRadius: "8px", padding: "3px" },
46
+ children: options.map((o) => jsx("button", {
47
+ onClick: () => onChange(o.value),
48
+ style: {
49
+ flex: "1 1 auto", minWidth: "0", padding: "5px 14px", fontSize: "12px", borderRadius: "6px", cursor: "pointer",
50
+ border: "none", color: "var(--dsw-alias-label-primary)",
51
+ whiteSpace: "nowrap",
52
+ background: (o.value === value)
53
+ ? "var(--dsw-alias-bg-overlay, rgba(255,255,255,.9))"
54
+ : "transparent",
55
+ fontWeight: (o.value === value) ? 600 : 400
56
+ },
57
+ children: o.label
58
+ }, o.value))
59
+ });
60
+ }
61
+
62
+ // Color scale for heatmap (green shades like the reference).
63
+ function heatColor(val, max) {
64
+ if (val <= 0) return "var(--dsw-alias-border-l1)";
65
+ const t = max > 0 ? (val / max) : 0;
66
+ const a = 0.25 + 0.75 * t;
67
+ return "rgba(76,175,80," + a.toFixed(2) + ")";
68
+ }
69
+
70
+ // A stable color per group entry; the "其他" (other) aggregate is grey so it
71
+ // reads as "everything else", distinct from the named top groups.
72
+ const GROUP_COLORS = ["#4caf50", "#2196f3", "#ff9800", "#9c27b0", "#00bcd4"];
73
+ function groupColor(key, i) {
74
+ if (key === "其他" || key === "other") return "var(--dsw-alias-label-tertiary, #9e9e9e)";
75
+ return GROUP_COLORS[i % GROUP_COLORS.length];
76
+ }
77
+
78
+ // ---- export helpers: build a CSV/JSON string from stats and download it.
79
+ function downloadText(filename, text, mime) {
80
+ try {
81
+ const blob = new Blob([text], { type: mime });
82
+ const url = URL.createObjectURL(blob);
83
+ const a = document.createElement("a");
84
+ a.href = url; a.download = filename;
85
+ document.body.appendChild(a); a.click();
86
+ document.body.removeChild(a);
87
+ URL.revokeObjectURL(url);
88
+ } catch (e) { console.error("[token-dashboard] export failed", e); }
89
+ }
90
+ function exportJson(stats) {
91
+ downloadText("dsh-token-dashboard.json", JSON.stringify(stats, null, 2), "application/json");
92
+ }
93
+ function exportCsv(stats, metric) {
94
+ const esc = (v) => { const s = String(v ?? ""); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
95
+ const rows = [];
96
+ // by-model lines
97
+ rows.push("type,key,req,input,output,cacheRead,cost,total");
98
+ for (const m of Object.keys(stats.byModel || {})) {
99
+ const x = stats.byModel[m];
100
+ rows.push(["model", m, x.req, x.input, x.output, x.cacheRead, (x.cost || 0).toFixed(4), x.total].map(esc).join(","));
101
+ }
102
+ for (const w of Object.keys(stats.byWorkspace || {})) {
103
+ const x = stats.byWorkspace[w];
104
+ rows.push(["workspace", w, x.req, x.input, x.output, x.cacheRead, (x.cost || 0).toFixed(4), x.total].map(esc).join(","));
105
+ }
106
+ for (const d of Object.keys(stats.byDay || {}).sort()) {
107
+ const x = stats.byDay[d];
108
+ rows.push(["day", d, "", "", "", "", (x.cost || 0).toFixed(4), x.total].map(esc).join(","));
109
+ }
110
+ downloadText("dsh-token-dashboard.csv", rows.join("\n"), "text/csv");
111
+ }
112
+
113
+ class Boundary extends Component {
114
+ constructor(props) { super(props); this.state = { error: null }; }
115
+ static getDerivedStateFromError(error) { return { error }; }
116
+ componentDidCatch(error, info) { console.error("[token-dashboard] render error:", error, info); }
117
+ render() {
118
+ if (this.state.error) {
119
+ return jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", padding: "8px 0" }, children: "渲染出错:" + String(this.state.error?.message || this.state.error) });
120
+ }
121
+ return jsx("div", { children: this.props.children });
122
+ }
123
+ }
124
+
125
+ function TokenDashboardPanel() {
126
+ const [stats, setStats] = useState(null);
127
+ const [balance, setBalance] = useState(null);
128
+ const [error, setError] = useState(null);
129
+ const [loading, setLoading] = useState(true);
130
+ const [balanceLoading, setBalanceLoading] = useState(true);
131
+ const [groupBy, setGroupBy] = useState("model"); // "model" | "workspace"
132
+ const [range, setRange] = useState(90); // 30 | 90 | 180 days
133
+ const [metric, setMetric] = useState("cost"); // "cost" | "token"
134
+ const [shareMetric, setShareMetric] = useState("total"); // "total" | "cost" for the share bar
135
+ const [hoverIdx, setHoverIdx] = useState(null); // hovered trend day index
136
+ const [days, setDays] = useState(30); // global window: 7 | 30 | 90 | 0(all)
137
+
138
+ const loadStats = useCallback(async (winDays) => {
139
+ try {
140
+ const d = winDays || days;
141
+ const res = await fetch("/api/token-dashboard/stats?refresh=1&days=" + d);
142
+ if (!res.ok) throw new Error("stats http " + res.status);
143
+ const data = await res.json();
144
+ if (data && data.status === "computing") { setTimeout(() => loadStats(winDays), 1200); return; }
145
+ setStats((data && typeof data === "object") ? data : {});
146
+ setLoading(false);
147
+ } catch (e2) { setError(String(e2)); setLoading(false); }
148
+ }, [days]);
149
+ const loadBalance = useCallback(async (force) => {
150
+ setBalanceLoading(true);
151
+ try {
152
+ const res = await fetch("/api/token-dashboard/balance" + (force ? "?force=1" : ""));
153
+ if (!res.ok) throw new Error("balance http " + res.status);
154
+ setBalance((await res.json()) || {});
155
+ } catch (e2) { setError(String(e2)); }
156
+ finally { setBalanceLoading(false); }
157
+ }, []);
158
+ useEffect(() => { loadStats(); loadBalance(false); }, [loadStats, loadBalance]);
159
+
160
+ if (!loading && stats) {
161
+ // ---- derived ----
162
+ const infos = balance?.data?.balance_infos || [];
163
+ let balInfo = infos.find((x) => x && x.currency === "CNY")
164
+ || infos.find((x) => x && Number(x.total_balance ?? x.balance) > 0)
165
+ || infos[0] || balance?.data || null;
166
+ const balValue = balInfo ? (balInfo.total_balance ?? balInfo.balance ?? null) : null;
167
+ const balCur = balInfo ? (balInfo.currency || "") : "";
168
+
169
+ const total = Number(stats.total) || 0;
170
+ const req = Number(stats.req) || Number(stats.events) || 0;
171
+ const today = Number(stats.today) || 0;
172
+ const thisMonth = Number(stats.thisMonth) || 0;
173
+ const cacheRate = (Number(stats.cacheHits) && total) ? (Number(stats.cacheHits) / total * 100).toFixed(1) : "0.0";
174
+
175
+ // grouped share data. Show top TOP_N entries by total, then fold the
176
+ // rest into a single "其他" (other) aggregate so the card never grows
177
+ // unbounded and tiny shares don't get lost in the bar.
178
+ const TOP_N = 8;
179
+ const groups = groupBy === "model" ? stats.byModel : stats.byWorkspace;
180
+ // shareBy = "total" or "cost" — pick the field used for share sorting/percent.
181
+ const shareBy = shareMetric === "cost" ? "cost" : "total";
182
+ const allEntries = Object.keys(groups || {}).map((k) => ({ key: k, ...(typeof groups[k] === "object" ? groups[k] : { total: groups[k], cost: 0 }) }))
183
+ .sort((a, b) => (b[shareBy] || 0) - (a[shareBy] || 0));
184
+ let groupEntries;
185
+ if (allEntries.length > TOP_N) {
186
+ const top = allEntries.slice(0, TOP_N);
187
+ // fold the remainder into a synthetic "其他" entry; keep broken-down
188
+ // counters for the bar/list (we only display total/req, but stay safe).
189
+ const rest = allEntries.slice(TOP_N);
190
+ const other = { key: "其他", req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0, cost: 0 };
191
+ for (const r of rest) {
192
+ other.total += Number(r.total) || 0;
193
+ other.cost += Number(r.cost) || 0;
194
+ other.req += Number(r.req) || 0;
195
+ other.input += Number(r.input) || 0;
196
+ other.output += Number(r.output) || 0;
197
+ other.cacheRead += Number(r.cacheRead) || 0;
198
+ other.reasoning += Number(r.reasoning) || 0;
199
+ }
200
+ groupEntries = [...top, other];
201
+ } else {
202
+ groupEntries = allEntries;
203
+ }
204
+ // Percentages use the SUM of all group values as the denominator, so the
205
+ // bar segments (flex-grow by value) and the printed % agree exactly and
206
+ // both reflect "share of all usage" (by tokens or by cost).
207
+ const groupSum = groupEntries.reduce((s, g) => s + (Number(g[shareBy]) || 0), 0);
208
+ const groupMax = Math.max(...groupEntries.map((g) => (Number(g[shareBy]) || 0)), 1);
209
+
210
+ // heatmap: build days from byDay within range. byDay entries are either
211
+ // {total, cost, ...} objects (new) or a bare number (legacy), so normalize.
212
+ const byDay = stats.byDay || {};
213
+ const todayD = new Date();
214
+ const cells = [];
215
+ for (let i = range - 1; i >= 0; i--) {
216
+ const d = new Date(todayD); d.setDate(d.getDate() - i);
217
+ const key = d.toISOString().slice(0, 10);
218
+ const rec = byDay[key];
219
+ const total = (rec && typeof rec === "object") ? Number(rec.total) || 0 : Number(rec) || 0;
220
+ const cost = (rec && typeof rec === "object") ? Number(rec.cost) || 0 : 0;
221
+ cells.push({ key, val: total, cost });
222
+ }
223
+ const heatMax = Math.max(1, ...cells.map((c) => c.val));
224
+ // group cells into weeks for the grid (7 per week)
225
+ const weeks = [];
226
+ for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
227
+
228
+ // models table rows
229
+ const modelRows = Object.keys(stats.byModel || {})
230
+ .map((m) => ({ key: m, ...stats.byModel[m] }))
231
+ .sort((a, b) => b.total - a.total);
232
+
233
+ // recent sessions: sort by last activity, take newest N
234
+ const sessionRows = Object.keys(stats.bySession || {})
235
+ .map((id) => ({ key: id, ...stats.bySession[id] }))
236
+ .sort((a, b) => (b.last || 0) - (a.last || 0))
237
+ .slice(0, 12);
238
+ function fmtDate(ts) {
239
+ if (!ts) return "-";
240
+ const d = new Date(ts);
241
+ const pad = (n) => String(n).padStart(2, "0");
242
+ return (d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
243
+ }
244
+
245
+ // ---- render ----
246
+ const balanceInner = balanceLoading ? jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: "加载中…" })
247
+ : (balance && balance.status === "ok")
248
+ ? jsxs("div", { children: [
249
+ jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: "6px" }, children: [
250
+ jsx("span", { style: { ...NUM, ...MONO }, children: (balCur === "CNY" ? "¥" : "$") + fmtMoney(balValue) }),
251
+ jsx("span", { style: LABEL, children: balCur })
252
+ ] }),
253
+ jsxs("div", { style: { marginTop: "8px" }, children: [
254
+ jsx("button", { onClick: () => loadBalance(true), style: { cursor: "pointer", fontSize: "12px", padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l1)", background: "transparent", color: "var(--dsw-alias-label-primary)" }, children: "强制刷新" }),
255
+ balance.cached ? jsx("span", { style: { fontSize: "11px", color: "var(--dsw-alias-label-tertiary)", marginLeft: "8px" }, children: "缓存" }) : null
256
+ ] })
257
+ ] })
258
+ : jsx("div", { style: { color: "var(--dsw-alias-label-secondary)" }, children: (balance?.message || "未配置或不可用") });
259
+
260
+ const statCards = [
261
+ { label: "今日", val: today },
262
+ { label: "本月", val: thisMonth },
263
+ { label: "总计", val: total }
264
+ ];
265
+ const statBar = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: "10px" }, children: statCards.map((s) =>
266
+ jsxs("div", { style: { background: "var(--dsw-alias-interactive-bg-active, rgba(127,127,127,.08))", borderRadius: "10px", padding: "12px" }, children: [
267
+ jsx("div", { style: LABEL, children: s.label }),
268
+ jsx("div", { style: { ...NUM, ...MONO, fontSize: "22px" }, children: fmt(s.val) })
269
+ ] }, s.label)
270
+ ) });
271
+
272
+ const shareBar = jsxs("div", { children: [
273
+ jsxs("div", { style: { display: "flex", height: "12px", borderRadius: "6px", overflow: "hidden", gap: "2px", backgroundColor: "var(--dsw-alias-border-l1)" }, children: groupEntries.map((g, i) =>
274
+ // flex-grow proportional to the chosen share field (tokens or cost).
275
+ jsx("div", { style: { flex: (Number(g[shareBy]) || 0) + " 0 0", background: groupColor(g.key, i) }, title: g.key + " " + (shareMetric === "cost" ? ("¥" + fmtMoney(g.cost)) : fmt(g.total)) }, g.key)
276
+ ) }),
277
+ jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "6px", marginTop: "10px" }, children: groupEntries.map((g, i) =>
278
+ jsxs("div", { style: ROW, children: [
279
+ jsxs("div", { style: { display: "flex", alignItems: "center", gap: "8px", minWidth: "0" }, children: [
280
+ jsx("div", { style: { width: "10px", height: "10px", borderRadius: "2px", flexShrink: "0", background: groupColor(g.key, i) } }),
281
+ jsx("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "240px", color: "var(--dsw-alias-label-primary)", fontSize: "13px" }, children: g.key })
282
+ ] }),
283
+ jsxs("div", { style: { display: "flex", gap: "14px", color: "var(--dsw-alias-label-secondary)", ...MONO, fontSize: "13px", flexShrink: "0" }, children: [
284
+ jsx("span", { children: (shareMetric === "cost" ? ("¥" + fmtMoney(g.cost)) : fmt(g.total)) }),
285
+ jsx("span", { style: { color: "var(--dsw-alias-label-tertiary)" }, children: pct((Number(g[shareBy]) || 0), groupSum).toFixed(0) + "%" })
286
+ ] })
287
+ ] }, g.key)
288
+ ) })
289
+ ] });
290
+
291
+ // heatmap grid (weeks as columns is complex; render day cells in a wrap grid)
292
+ const heatGrid = jsxs("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(14px, 1fr))", gap: "3px" }, children: cells.map((c) =>
293
+ jsx("div", { style: { width: "14px", height: "14px", borderRadius: "2px", background: heatColor(c.val, heatMax) }, title: c.key + ": " + fmt(c.val) }, c.key)
294
+ ) });
295
+
296
+ // --- trend chart: a lightweight SVG polyline of daily metric over `range`.
297
+ // metric = "cost" or "token". Full mini area chart (grid + axes + fill).
298
+ const trendValues = cells.map((c) => (metric === "cost" ? c.cost : c.val));
299
+ const tMax = Math.max(1, ...trendValues);
300
+ // viewBox: leave room on left for y-axis labels and bottom for x-axis dates.
301
+ const TW = 320, TH = 120, PAD = 6, PAD_LEFT = 34, PAD_BOTTOM = 22, PAD_TOP = 12;
302
+ const plotW = TW - PAD_LEFT - PAD;
303
+ const plotH = TH - PAD_TOP - PAD_BOTTOM;
304
+ let trendSvg;
305
+ if (trendValues.length <= 1) {
306
+ trendSvg = jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "8px 0" }, children: "数据不足" });
307
+ } else {
308
+ const step = plotW / (trendValues.length - 1);
309
+ const xAt = (i) => PAD_LEFT + i * step;
310
+ const yAt = (v) => PAD_TOP + plotH - (v / tMax) * plotH;
311
+ const linePts = trendValues.map((v, i) => (xAt(i).toFixed(1) + "," + yAt(v).toFixed(1))).join(" ");
312
+ // area polygon = line points + bottom-right + bottom-left to close the fill.
313
+ const areaPts = linePts + " " + (PAD_LEFT + plotW).toFixed(1) + "," + (PAD_TOP + plotH).toFixed(1) + " " + PAD_LEFT.toFixed(1) + "," + (PAD_TOP + plotH).toFixed(1);
314
+
315
+ // grid: 3 horizontal lines at 0, 50%, 100% of max.
316
+ const gridLines = [0, 0.5, 1].map((f) => {
317
+ const gy = PAD_TOP + plotH - f * plotH;
318
+ return jsx("line", { x1: PAD_LEFT, x2: PAD_LEFT + plotW, y1: gy, y2: gy, stroke: "var(--dsw-alias-border-l1)", strokeWidth: "1", strokeDasharray: "2 3" });
319
+ });
320
+ // y-axis labels (0, mid, max) at left.
321
+ const yLabels = [0, 0.5, 1].map((f) => {
322
+ const val = (tMax * f);
323
+ const gy = PAD_TOP + plotH - f * plotH;
324
+ const lbl = (metric === "cost" ? ("¥" + (val >= 1 ? val.toFixed(0) : val.toFixed(1))) : (val >= 1000000 ? (val / 1000000).toFixed(0) + "M" : (val >= 1000 ? (val / 1000).toFixed(0) + "K" : val.toFixed(0))));
325
+ return jsx("text", { x: PAD_LEFT - 4, y: gy + 3, fill: "var(--dsw-alias-label-tertiary)", fontSize: "8", textAnchor: "end", children: lbl });
326
+ });
327
+ // x-axis dates: first, middle, last (and every ~quarter).
328
+ const dateIdx = [0, Math.floor((trendValues.length - 1) / 3), Math.floor(((trendValues.length - 1) * 2) / 3), trendValues.length - 1];
329
+ const xLabels = dateIdx.map((idx, di) => {
330
+ const c = cells[idx];
331
+ const label = c && c.key ? String(c.key).slice(5) : "";
332
+ const lx = xAt(idx);
333
+ const ly = TH - PAD_BOTTOM + 15;
334
+ // clear backdrop so date text reads against the dark card
335
+ return jsxs("g", { key: "x" + di, children: [
336
+ jsx("rect", { x: lx - 15, y: ly - 10, width: 30, height: 14, rx: 3, fill: "var(--dsw-alias-bg-overlay, #2a2a2a)", opacity: "0.9" }),
337
+ jsx("text", { x: lx, y: ly, fill: "var(--dsw-alias-label-primary)", fontSize: "9", fontWeight: 500, textAnchor: "middle", children: label })
338
+ ] });
339
+ });
340
+ // data points (small circles) + highlight last point.
341
+ const dotPts = trendValues.map((v, i) => jsx("circle", { key: "d" + i, cx: xAt(i), cy: yAt(v), r: "1.8", fill: "var(--dsw-alias-label-primary)", opacity: "0.5" }));
342
+ const lastIdx = trendValues.length - 1;
343
+ const lastV = trendValues[lastIdx];
344
+
345
+ // hover: transparent overlay computes nearest day from mouse x.
346
+ const overlay = jsx("rect", {
347
+ x: PAD_LEFT, y: PAD_TOP, width: plotW, height: plotH, fill: "transparent",
348
+ onMouseMove: (ev) => {
349
+ const rect = ev.currentTarget.getBoundingClientRect();
350
+ const relX = (((ev.clientX - rect.left) / rect.width) * plotW);
351
+ const idx = Math.round(relX / step);
352
+ const clamped = Math.max(0, Math.min(trendValues.length - 1, idx));
353
+ if (clamped !== hoverIdx) setHoverIdx(clamped);
354
+ },
355
+ onMouseLeave: () => setHoverIdx(null),
356
+ style: { cursor: "crosshair" }
357
+ });
358
+
359
+ // hover indicator: vertical line + highlighted point + tooltip.
360
+ let hoverMarkers = null;
361
+ if (hoverIdx !== null && cells[hoverIdx]) {
362
+ const hx = xAt(hoverIdx);
363
+ const hy = yAt(trendValues[hoverIdx]);
364
+ const hv = trendValues[hoverIdx];
365
+ const hc = cells[hoverIdx];
366
+ const tooltipLabel = (metric === "cost" ? ("¥" + fmtMoney(hv)) : fmt(hv));
367
+ // Place the tooltip box above the point when there is room, else below it,
368
+ // clamping within the plot so tall points never push it off the chart.
369
+ const boxW = 64, boxH = 18;
370
+ let boxTop = hy - (boxH + 8);
371
+ if (boxTop < PAD_TOP) boxTop = hy + 10; // too high place below
372
+ const boxLeft = Math.max(PAD_LEFT, Math.min(PAD_LEFT + plotW - boxW, hx - boxW / 2));
373
+ const dateLbl = String(hc.key).slice(5);
374
+ hoverMarkers = jsxs("g", { children: [
375
+ jsx("line", { x1: hx, x2: hx, y1: PAD_TOP, y2: PAD_TOP + plotH, stroke: "var(--dsw-alias-label-secondary)", strokeWidth: "1", strokeDasharray: "3 3" }),
376
+ jsx("circle", { cx: hx, cy: hy, r: "4", fill: "var(--dsw-alias-label-primary)", stroke: "var(--dsw-alias-bg-overlay, #222)", strokeWidth: "1" }),
377
+ // tooltip box (auto-placed, clear backdrop for readability)
378
+ jsx("g", { children: [
379
+ jsx("rect", { x: boxLeft, y: boxTop, width: boxW, height: boxH, rx: 4, fill: "var(--dsw-alias-bg-overlay, #2a2a2a)", stroke: "var(--dsw-alias-border-l2)", strokeWidth: "1" }),
380
+ jsx("text", { x: boxLeft + boxW / 2, y: boxTop + boxH / 2 + 3, fill: "var(--dsw-alias-label-primary)", fontSize: "9", fontWeight: 600, textAnchor: "middle", children: tooltipLabel }),
381
+ jsx("text", { x: boxLeft + boxW / 2, y: boxTop + boxH + 12, fill: "var(--dsw-alias-label-accent, #4dd0e1)", fontSize: "9", fontWeight: 600, textAnchor: "middle", children: dateLbl })
382
+ ] })
383
+ ] });
384
+ }
385
+
386
+ trendSvg = jsxs("svg", { width: "100%", viewBox: "0 0 " + TW + " " + TH, style: { display: "block", maxHeight: "150px", width: "100%" }, children: [
387
+ jsx("defs", { children: jsx("linearGradient", { id: "tdfill", x1: "0", y1: "0", x2: "0", y2: "1", children: [
388
+ jsx("stop", { offset: "0%", stopColor: "var(--dsw-alias-label-accent, #4caf50)", stopOpacity: "0.35" }),
389
+ jsx("stop", { offset: "100%", stopColor: "var(--dsw-alias-label-accent, #4caf50)", stopOpacity: "0.02" })
390
+ ] })
391
+ }),
392
+ gridLines,
393
+ jsx("polygon", { points: areaPts, fill: "url(#tdfill)" }),
394
+ jsx("polyline", { points: linePts, fill: "none", stroke: "var(--dsw-alias-label-primary)", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round" }),
395
+ dotPts,
396
+ jsx("circle", { cx: xAt(lastIdx), cy: yAt(lastV), r: "3", fill: "var(--dsw-alias-label-primary)" }),
397
+ yLabels,
398
+ xLabels,
399
+ hoverMarkers,
400
+ overlay,
401
+ jsx("text", { x: PAD_LEFT, y: PAD_TOP - 2, fill: "var(--dsw-alias-label-tertiary)", fontSize: "9", children: (metric === "cost" ? ("¥" + fmtMoney(lastV)) : fmt(lastV)) + " 最新" })
402
+ ] });
403
+ }
404
+
405
+ const modelTable = jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: "13px" }, children: [
406
+ jsxs("thead", { children: [jsxs("tr", { children: [
407
+ jsx("th", { style: thStyle, children: "模型" }), jsx("th", { style: thR, children: "Req" }),
408
+ jsx("th", { style: thR, children: "Input" }), jsx("th", { style: thR, children: "Cache" }),
409
+ jsx("th", { style: thR, children: "Output" }), jsx("th", { style: thR, children: "Cost" })
410
+ ] })] }),
411
+ jsxs("tbody", { children: modelRows.map((r) => jsxs("tr", { style: { borderTop: "1px solid var(--dsw-alias-border-l1)" }, children: [
412
+ jsx("td", { style: tdStyle, children: r.key }),
413
+ jsx("td", { style: tdR, children: r.req }),
414
+ jsx("td", { style: tdR, children: fmt(r.input) }),
415
+ jsx("td", { style: tdR, children: fmt(r.cacheRead) }),
416
+ jsx("td", { style: tdR, children: fmt(r.output) }),
417
+ jsx("td", { style: { ...tdR, color: "var(--dsw-alias-label-primary)", fontWeight: 600 }, children: "¥" + fmtMoney(r.cost || 0) })
418
+ ] }, r.key)) })
419
+ ] });
420
+
421
+ return jsxs("div", { children: [
422
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", marginBottom: "6px", flexWrap: "wrap" }, children: [
423
+ jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "0" }, children: "用量看板" }),
424
+ jsx(Seg, { options: [{ label: "近7天", value: 7 }, { label: "近30天", value: 30 }, { label: "近90天", value: 90 }, { label: "全部", value: 0 }], value: days, onChange: (v) => { setDays(v); setLoading(true); } })
425
+ ] }),
426
+ jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: "8px", marginBottom: "4px" }, children: [
427
+ jsx("button", { onClick: () => exportCsv(stats, metric), style: { ...btnStyle }, children: "导出 CSV" }),
428
+ jsx("button", { onClick: () => exportJson(stats), style: { ...btnStyle }, children: "导出 JSON" })
429
+ ] }),
430
+ (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
431
+
432
+ // Balance
433
+ jsxs("div", { style: CARD, children: [
434
+ jsx("div", { style: SEC_TITLE, children: "💰 账户余额(DeepSeek 官方)" }), balanceInner
435
+ ] }),
436
+
437
+ // Token usage
438
+ jsxs("div", { style: CARD, children: [
439
+ jsx("div", { style: SEC_TITLE, children: "📊 Token 使用量" }),
440
+ statBar,
441
+ jsxs("div", { style: { ...ROW, marginTop: "10px" }, children: [
442
+ jsx("span", { children: "今日花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.todayCost) })
443
+ ] }),
444
+ jsxs("div", { style: ROW, children: [
445
+ jsx("span", { children: "本月花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.thisMonthCost) })
446
+ ] }),
447
+ jsxs("div", { style: ROW, children: [
448
+ jsx("span", { children: "总花费" }), jsx("span", { ...MONO, children: "¥" + fmtMoney(stats.cost) })
449
+ ] }),
450
+ jsxs("div", { style: { ...ROW, marginTop: "6px", borderTop: "1px solid var(--dsw-alias-border-l1)", paddingTop: "6px" }, children: [
451
+ jsx("span", { children: "请求数" }), jsx("span", { ...MONO, children: fmt(req) })
452
+ ] }),
453
+ jsxs("div", { style: ROW, children: [
454
+ jsx("span", { children: "缓存命中率" }), jsx("span", { ...MONO, children: cacheRate + "%" })
455
+ ] }),
456
+ jsxs("div", { style: ROW, children: [
457
+ jsx("span", { children: "会话数" }), jsx("span", { ...MONO, children: Number(stats.sessions) || 0 })
458
+ ] })
459
+ ] }),
460
+
461
+ // Grouped share
462
+ jsxs("div", { style: CARD, children: [
463
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", marginBottom: "8px" }, children: [
464
+ jsx("div", { style: SEC_TITLE, children: "📈 用量分布" }),
465
+ jsx(Seg, { options: [{ label: "按模型", value: "model" }, { label: "按工作区", value: "workspace" }], value: groupBy, onChange: setGroupBy })
466
+ ] }),
467
+ jsxs("div", { style: { display: "flex", justifyContent: "flex-end", marginBottom: "8px" }, children: [
468
+ jsx(Seg, { options: [{ label: "按 Token", value: "total" }, { label: "按成本", value: "cost" }], value: shareMetric, onChange: setShareMetric })
469
+ ] }),
470
+ shareBar
471
+ ] }),
472
+
473
+ // Activity heatmap
474
+ jsxs("div", { style: CARD, children: [
475
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }, children: [
476
+ jsx("div", { style: SEC_TITLE, children: "🗓 活跃度" }),
477
+ jsx(Seg, { options: [{ label: "30天", value: 30 }, { label: "90天", value: 90 }, { label: "180天", value: 180 }], value: range, onChange: setRange })
478
+ ] }),
479
+ heatGrid
480
+ ] }),
481
+
482
+ // Trend chart
483
+ jsxs("div", { style: CARD, children: [
484
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }, children: [
485
+ jsx("div", { style: SEC_TITLE, children: "📉 趋势" }),
486
+ jsx(Seg, { options: [{ label: "成本", value: "cost" }, { label: "Token", value: "token" }], value: metric, onChange: setMetric })
487
+ ] }),
488
+ trendSvg
489
+ ] }),
490
+
491
+ // Models table
492
+ jsxs("div", { style: CARD, children: [
493
+ jsx("div", { style: SEC_TITLE, children: "🤖 按模型" }),
494
+ modelTable
495
+ ] }),
496
+
497
+ // Recent sessions
498
+ jsxs("div", { style: CARD, children: [
499
+ jsx("div", { style: SEC_TITLE, children: "🗂 最近会话" }),
500
+ sessionRows.length === 0
501
+ ? jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", fontSize: "12px", padding: "6px 0" }, children: "暂无数据" })
502
+ : jsxs("div", { style: { display: "flex", flexDirection: "column" }, children: sessionRows.map((r) =>
503
+ jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 0", borderTop: "1px solid var(--dsw-alias-border-l1)" }, children: [
504
+ jsxs("div", { style: { display: "flex", flexDirection: "column", minWidth: "0", flex: "1" }, children: [
505
+ jsx("span", { style: { color: "var(--dsw-alias-label-primary)", fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: (r.model || "会话").slice(0, 40) + " · " + fmtDate(r.last) }),
506
+ jsx("span", { style: { color: "var(--dsw-alias-label-tertiary)", fontSize: "11px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: r.id })
507
+ ] }),
508
+ jsxs("div", { style: { display: "flex", gap: "14px", color: "var(--dsw-alias-label-secondary)", ...MONO, fontSize: "12px", flexShrink: "0" }, children: [
509
+ jsx("span", { children: fmt(r.total) }),
510
+ jsx("span", { style: { color: "var(--dsw-alias-label-primary)", fontWeight: 600 }, children: "¥" + fmtMoney(r.cost) })
511
+ ] })
512
+ ] }, r.id)
513
+ ) })
514
+ ] })
515
+ ] });
516
+ }
517
+
518
+ // loading / no data -> simple spinner-ish text
519
+ return jsxs("div", { children: [
520
+ jsx("h3", { style: { fontSize: "18px", fontWeight: 700, color: "var(--dsw-alias-label-primary)", marginBottom: "12px" }, children: "用量看板" }),
521
+ (error) ? jsx("div", { style: { color: "var(--dsw-alias-danger, #e5484d)", fontSize: "12px", margin: "6px 0" }, children: "加载出错:" + error }) : null,
522
+ jsx("div", { style: { color: "var(--dsw-alias-label-secondary)", padding: "8px 0" }, children: "加载中…" })
523
+ ] });
524
+ }
525
+
526
+ const thStyle = { textAlign: "left", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px" };
527
+ const thR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-tertiary)", fontWeight: 500, fontSize: "12px", ...MONO };
528
+ const tdStyle = { padding: "8px 6px", color: "var(--dsw-alias-label-primary)" };
529
+ const tdR = { textAlign: "right", padding: "8px 6px", color: "var(--dsw-alias-label-secondary)", ...MONO };
530
+
531
+ function apply(ctx) {
532
+ ctx.slots.inject("settings.section", () => ctx.slots.register(
533
+ {
534
+ name: "settings.section",
535
+ id: "token-dashboard",
536
+ order: 30,
537
+ label: () => {
538
+ try {
539
+ const bound = ctx.locale && typeof ctx.locale.bind === "function" && ctx.locale.bind("token-dashboard");
540
+ const v = bound && typeof bound === "function" ? bound("title") : null;
541
+ return (typeof v === "string" && v !== "title") ? v : "用量看板";
542
+ } catch {
543
+ return "用量看板";
544
+ }
545
+ },
546
+ inject: () => ({})
547
+ },
548
+ (props) => jsx(Boundary, { children: jsx(TokenDashboardPanel, props) })
549
+ ));
550
+ }
551
+
552
+ exports.apply = apply;
553
+ exports.inject = ["slots", "locale"];
554
+ exports.TokenDashboardPanel = TokenDashboardPanel;
555
+ exports.Boundary = Boundary;
556
+ exports.fmt = fmt;
557
+ exports.fmtMoney = fmtMoney;
558
+
559
+ return module.exports;
560
+ }
561
+ });