coding-proxy 0.2.3a2__py3-none-any.whl → 0.2.3a4__py3-none-any.whl

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.
@@ -58,7 +58,7 @@ vendors:
58
58
  probe_interval_seconds: 300
59
59
  weekly_quota_guard:
60
60
  enabled: true
61
- token_budget: 250000000 # 一周 token 预算(根据订阅计划调整)
61
+ token_budget: 500000000 # 一周 token 预算(根据订阅计划调整)
62
62
  window_hours: 168.0 # 7 天滑动窗口
63
63
  threshold_percent: 99.0
64
64
  probe_interval_seconds: 1800 # 每 30 分钟探测一次
@@ -497,7 +497,7 @@ class TokenLogger:
497
497
  return 0
498
498
  cutoff_iso = _hours_ago_utc_iso(window_hours)
499
499
  cursor = await self._db.execute(
500
- """SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
500
+ """SELECT COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total
501
501
  FROM usage_log
502
502
  WHERE vendor = ? AND success = 1
503
503
  AND ts >= ?""",
@@ -276,7 +276,12 @@ class _RouteExecutor:
276
276
  tier.name,
277
277
  usage,
278
278
  )
279
- tier.record_success(info.input_tokens + info.output_tokens)
279
+ tier.record_success(
280
+ info.input_tokens
281
+ + info.output_tokens
282
+ + info.cache_creation_tokens
283
+ + info.cache_read_tokens
284
+ )
280
285
  duration = int((time.monotonic() - start) * 1000)
281
286
  model = body.get("model", "unknown")
282
287
  model_served = usage.get("model_served") or tier.vendor.map_model(model)
@@ -197,6 +197,7 @@ class QuotaGuard:
197
197
  if self._budget > 0
198
198
  else 0,
199
199
  "threshold_percent": self._threshold * 100,
200
+ "window_hours": self.window_hours,
200
201
  }
201
202
 
202
203
  def _expire(self) -> None:
@@ -19,14 +19,20 @@ def _build_favicon() -> bytes:
19
19
 
20
20
  width, height = 16, 16
21
21
  pixel_rows: list[bytes] = []
22
+ cx, cy = width / 2.0, height / 2.0
22
23
  for y in range(height - 1, -1, -1): # BMP bottom-up
23
24
  row = bytearray()
24
25
  for x in range(width):
25
- t = (x + (height - 1 - y)) / (width + height - 2)
26
- r = int(88 + (188 - 88) * t)
27
- g = int(166 + (140 - 166) * t)
28
- b = 255
29
- row.extend([b, g, r, 255]) # BGRA
26
+ dx = x - cx + 0.5
27
+ dy = y - cy + 0.5
28
+ if dx * dx + dy * dy > (width / 2.0) ** 2:
29
+ row.extend([0, 0, 0, 0]) # 圆外透明
30
+ else:
31
+ t = (x + (height - 1 - y)) / (width + height - 2)
32
+ r = int(88 + (188 - 88) * t)
33
+ g = int(166 + (140 - 166) * t)
34
+ b = 255
35
+ row.extend([b, g, r, 255]) # BGRA
30
36
  pixel_rows.append(bytes(row))
31
37
 
32
38
  bmp_hdr = struct.pack(
@@ -56,6 +62,9 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
56
62
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
57
63
  <title>Coding Proxy Dashboard</title>
58
64
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
65
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
66
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
67
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
59
68
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
60
69
  <style>
61
70
  :root {
@@ -63,16 +72,26 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
63
72
  --bg-card: #161b22;
64
73
  --bg-card-hover: #1c2128;
65
74
  --border: #30363d;
75
+ --border-subtle: rgba(48,54,61,.6);
66
76
  --text-primary: #e6edf3;
67
77
  --text-secondary: #8b949e;
78
+ --text-tertiary: #6e7681;
68
79
  --accent-blue: #58a6ff;
69
80
  --accent-green: #3fb950;
70
81
  --accent-yellow: #d29922;
71
82
  --accent-red: #f85149;
72
83
  --accent-purple: #bc8cff;
73
84
  --accent-orange: #ffa657;
74
- --radius: 8px;
75
- --shadow: 0 1px 3px rgba(0,0,0,.4);
85
+ --accent-teal: #39d353;
86
+ --radius: 10px;
87
+ --radius-sm: 6px;
88
+ --shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
89
+ --shadow-md: 0 4px 12px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.3);
90
+ --glow-blue: 0 0 0 1px rgba(88,166,255,.15), 0 4px 16px rgba(88,166,255,.06);
91
+ }
92
+ @keyframes fadeInUp {
93
+ from { opacity: 0; transform: translateY(10px); }
94
+ to { opacity: 1; transform: translateY(0); }
76
95
  }
77
96
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
78
97
  body {
@@ -85,9 +104,11 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
85
104
  }
86
105
  /* ── 头部 ── */
87
106
  header {
88
- background: var(--bg-card);
107
+ background: rgba(22,27,34,.85);
108
+ backdrop-filter: blur(12px);
109
+ -webkit-backdrop-filter: blur(12px);
89
110
  border-bottom: 1px solid var(--border);
90
- padding: 14px 24px;
111
+ padding: 13px 24px;
91
112
  display: flex;
92
113
  align-items: center;
93
114
  justify-content: space-between;
@@ -97,75 +118,105 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
97
118
  }
98
119
  .header-left { display: flex; align-items: center; gap: 12px; }
99
120
  .logo {
100
- width: 28px; height: 28px;
121
+ width: 30px; height: 30px;
101
122
  background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
102
- border-radius: 6px;
123
+ border-radius: 50%;
103
124
  display: flex; align-items: center; justify-content: center;
104
- font-size: 16px; font-weight: bold; color: #fff;
125
+ font-size: 15px; font-weight: 700; color: #fff;
126
+ box-shadow: 0 2px 8px rgba(88,166,255,.3);
105
127
  }
106
- h1 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
128
+ h1 { font-size: 15px; font-weight: 600; color: var(--text-primary); letter-spacing: -.2px; }
107
129
  .header-right { display: flex; align-items: center; gap: 12px; }
108
130
  .badge {
109
131
  font-size: 11px; padding: 2px 8px;
110
132
  border-radius: 12px;
111
- background: rgba(88,166,255,.15);
133
+ background: rgba(88,166,255,.1);
112
134
  color: var(--accent-blue);
113
- border: 1px solid rgba(88,166,255,.25);
135
+ border: 1px solid rgba(88,166,255,.2);
136
+ font-family: 'JetBrains Mono', monospace;
114
137
  }
115
- .refresh-time { font-size: 12px; color: var(--text-secondary); }
138
+ .refresh-time { font-size: 11px; color: var(--text-tertiary); }
116
139
  .btn-refresh {
117
- padding: 5px 12px; border-radius: var(--radius);
118
- background: rgba(48,54,61,.6);
140
+ padding: 5px 12px; border-radius: var(--radius-sm);
141
+ background: rgba(48,54,61,.5);
119
142
  border: 1px solid var(--border);
120
- color: var(--text-primary);
143
+ color: var(--text-secondary);
121
144
  font-size: 12px; cursor: pointer;
122
- transition: background .15s;
145
+ transition: all .2s ease;
146
+ }
147
+ .btn-refresh:hover {
148
+ background: var(--bg-card-hover);
149
+ color: var(--text-primary);
150
+ border-color: rgba(88,166,255,.4);
123
151
  }
124
- .btn-refresh:hover { background: var(--bg-card-hover); }
125
152
  /* ── 主内容 ── */
126
- main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; }
153
+ main { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
127
154
  /* ── KPI 卡片 ── */
128
155
  .kpi-grid {
129
156
  display: grid;
130
157
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
131
- gap: 14px;
132
- margin-bottom: 20px;
158
+ gap: 12px;
159
+ margin-bottom: 18px;
133
160
  }
134
161
  .kpi-card {
135
162
  background: var(--bg-card);
136
163
  border: 1px solid var(--border);
137
164
  border-radius: var(--radius);
138
- padding: 16px 20px;
165
+ padding: 16px 18px 14px;
139
166
  box-shadow: var(--shadow);
140
- transition: background .15s;
167
+ transition: all .2s ease;
168
+ animation: fadeInUp .4s ease both;
169
+ position: relative;
170
+ overflow: hidden;
171
+ }
172
+ .kpi-card::before {
173
+ content: '';
174
+ position: absolute;
175
+ top: 0; left: 0; right: 0;
176
+ height: 2px;
177
+ border-radius: var(--radius) var(--radius) 0 0;
178
+ }
179
+ .kpi-card:nth-child(1)::before { background: var(--accent-blue); }
180
+ .kpi-card:nth-child(2)::before { background: var(--accent-purple); }
181
+ .kpi-card:nth-child(3)::before { background: var(--accent-green); }
182
+ .kpi-card:nth-child(4)::before { background: var(--accent-yellow); }
183
+ .kpi-card:nth-child(5)::before { background: var(--accent-red); }
184
+ .kpi-card:nth-child(6)::before { background: var(--accent-orange); }
185
+ .kpi-card:hover {
186
+ background: var(--bg-card-hover);
187
+ box-shadow: var(--glow-blue);
188
+ transform: translateY(-1px);
189
+ }
190
+ .kpi-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
191
+ .kpi-icon { font-size: 13px; opacity: .8; }
192
+ .kpi-label { font-size: 11px; color: var(--text-secondary); font-weight: 500; letter-spacing: .2px; }
193
+ .kpi-value {
194
+ font-size: 24px; font-weight: 700; line-height: 1.2;
195
+ font-family: 'JetBrains Mono', monospace;
196
+ letter-spacing: -0.5px;
141
197
  }
142
- .kpi-card:hover { background: var(--bg-card-hover); }
143
- .kpi-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
144
- .kpi-value { font-size: 26px; font-weight: 700; line-height: 1.2; }
145
- .kpi-sub { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
146
- .kpi-delta { font-size: 12px; margin-top: 4px; }
147
- .kpi-delta.up { color: var(--accent-green); }
148
- .kpi-delta.down { color: var(--accent-red); }
198
+ .kpi-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
149
199
  .color-blue { color: var(--accent-blue); }
150
200
  .color-green { color: var(--accent-green); }
151
201
  .color-yellow { color: var(--accent-yellow); }
152
202
  .color-red { color: var(--accent-red); }
153
203
  .color-purple { color: var(--accent-purple); }
204
+ .color-orange { color: var(--accent-orange); }
154
205
  /* ── 图表网格 ── */
155
206
  .charts-grid {
156
207
  display: grid;
157
208
  grid-template-columns: 1fr 2fr;
158
- gap: 14px;
159
- margin-bottom: 14px;
209
+ gap: 12px;
210
+ margin-bottom: 12px;
160
211
  }
161
- .charts-grid-3 {
212
+ .charts-grid-2 {
162
213
  display: grid;
163
214
  grid-template-columns: 1fr 2fr;
164
- gap: 14px;
165
- margin-bottom: 14px;
215
+ gap: 12px;
216
+ margin-bottom: 12px;
166
217
  }
167
- @media (max-width: 900px) {
168
- .charts-grid, .charts-grid-3 { grid-template-columns: 1fr; }
218
+ @media (max-width: 960px) {
219
+ .charts-grid, .charts-grid-2 { grid-template-columns: 1fr; }
169
220
  }
170
221
  .card {
171
222
  background: var(--bg-card);
@@ -173,101 +224,131 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
173
224
  border-radius: var(--radius);
174
225
  padding: 16px 20px;
175
226
  box-shadow: var(--shadow);
227
+ transition: box-shadow .2s ease;
228
+ animation: fadeInUp .4s ease both;
176
229
  }
230
+ .card:hover { box-shadow: var(--shadow-md); }
177
231
  .card-title {
178
- font-size: 13px; font-weight: 600;
179
- color: var(--text-secondary);
232
+ font-size: 11px; font-weight: 600;
233
+ color: var(--text-tertiary);
180
234
  text-transform: uppercase;
181
- letter-spacing: .5px;
235
+ letter-spacing: .8px;
182
236
  margin-bottom: 14px;
183
237
  display: flex; align-items: center; justify-content: space-between;
184
238
  }
185
239
  .chart-wrap { position: relative; height: 220px; }
186
240
  .chart-wrap-lg { position: relative; height: 240px; }
241
+ .chart-wrap-xl { position: relative; height: 260px; }
187
242
  /* ── 供应商状态 ── */
188
- .vendor-list { display: flex; flex-direction: column; gap: 10px; }
243
+ .vendor-list { display: flex; flex-direction: column; gap: 8px; }
189
244
  .vendor-item {
190
245
  display: flex; align-items: center; justify-content: space-between;
191
246
  padding: 10px 12px;
192
- background: rgba(255,255,255,.03);
193
- border: 1px solid var(--border);
194
- border-radius: 6px;
247
+ background: rgba(255,255,255,.02);
248
+ border: 1px solid var(--border-subtle);
249
+ border-radius: var(--radius-sm);
250
+ transition: background .15s;
251
+ }
252
+ .vendor-item:hover { background: rgba(255,255,255,.04); }
253
+ .vendor-info { display: flex; align-items: center; gap: 10px; }
254
+ .vendor-avatar {
255
+ width: 28px; height: 28px; border-radius: 50%;
256
+ background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
257
+ display: flex; align-items: center; justify-content: center;
258
+ font-size: 11px; font-weight: 700; color: #fff;
259
+ flex-shrink: 0;
195
260
  }
196
- .vendor-name { font-weight: 600; font-size: 13px; min-width: 80px; }
197
- .vendor-badges { display: flex; gap: 6px; flex-wrap: wrap; }
261
+ .vendor-name { font-weight: 600; font-size: 12px; }
262
+ .vendor-badges { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }
198
263
  .status-badge {
199
- font-size: 11px; padding: 2px 7px;
264
+ font-size: 10px; padding: 2px 7px;
200
265
  border-radius: 10px;
201
266
  font-weight: 500;
202
267
  }
203
- .sb-ok { background: rgba(63,185,80,.15); color: var(--accent-green); border: 1px solid rgba(63,185,80,.25); }
204
- .sb-warn { background: rgba(210,153,34,.15); color: var(--accent-yellow); border: 1px solid rgba(210,153,34,.25); }
205
- .sb-err { background: rgba(248,81,73,.15); color: var(--accent-red); border: 1px solid rgba(248,81,73,.25); }
206
- .sb-info { background: rgba(88,166,255,.15); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.25); }
207
- .quota-bar-wrap { flex: 1; margin: 0 12px; max-width: 120px; }
268
+ .sb-ok { background: rgba(63,185,80,.12); color: var(--accent-green); border: 1px solid rgba(63,185,80,.2); }
269
+ .sb-warn { background: rgba(210,153,34,.12); color: var(--accent-yellow); border: 1px solid rgba(210,153,34,.2); }
270
+ .sb-err { background: rgba(248,81,73,.12); color: var(--accent-red); border: 1px solid rgba(248,81,73,.2); }
271
+ .sb-info { background: rgba(88,166,255,.12); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.2); }
272
+ .quota-bar-wrap { flex: 1; margin: 0 10px; max-width: 100px; }
208
273
  .quota-bar-bg {
209
- height: 5px; border-radius: 3px;
210
- background: rgba(255,255,255,.08);
274
+ height: 4px; border-radius: 2px;
275
+ background: rgba(255,255,255,.06);
211
276
  overflow: hidden;
212
277
  }
213
- .quota-bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
214
- .quota-pct { font-size: 11px; color: var(--text-secondary); margin-top: 2px; text-align: right; }
278
+ .quota-bar-fill {
279
+ height: 100%; border-radius: 2px;
280
+ transition: width .6s cubic-bezier(.4,0,.2,1);
281
+ }
215
282
  /* ── 故障转移表 ── */
216
283
  .ft-table-wrap { overflow-x: auto; }
217
284
  table { width: 100%; border-collapse: collapse; }
285
+ thead tr { position: sticky; top: 0; background: var(--bg-card); z-index: 1; }
218
286
  th {
219
- text-align: left; font-size: 12px; color: var(--text-secondary);
220
- font-weight: 500; padding: 6px 10px;
287
+ text-align: left; font-size: 11px; color: var(--text-tertiary);
288
+ font-weight: 600; padding: 6px 10px;
221
289
  border-bottom: 1px solid var(--border);
290
+ letter-spacing: .4px; text-transform: uppercase;
222
291
  }
223
- td { padding: 8px 10px; font-size: 13px; border-bottom: 1px solid rgba(48,54,61,.5); }
292
+ td { padding: 8px 10px; font-size: 13px; border-bottom: 1px solid var(--border-subtle); }
224
293
  tr:last-child td { border-bottom: none; }
225
294
  tr:hover td { background: rgba(255,255,255,.02); }
226
295
  .tag-vendor {
227
296
  display: inline-block;
228
- font-size: 11px; padding: 1px 7px;
297
+ font-size: 11px; padding: 2px 8px;
229
298
  border-radius: 10px;
230
- background: rgba(188,140,255,.15);
299
+ background: rgba(188,140,255,.1);
231
300
  color: var(--accent-purple);
232
- border: 1px solid rgba(188,140,255,.25);
301
+ border: 1px solid rgba(188,140,255,.2);
302
+ font-weight: 500;
233
303
  }
234
- .arrow { color: var(--text-secondary); margin: 0 4px; }
304
+ .arrow { color: var(--text-tertiary); margin: 0 4px; }
235
305
  /* ── 时间区间选择栏 ── */
236
306
  .time-range-bar {
237
307
  display: flex; align-items: center; gap: 8px;
238
- margin-bottom: 16px; flex-wrap: wrap;
308
+ margin-bottom: 18px; flex-wrap: wrap;
309
+ padding: 10px 14px;
310
+ background: rgba(22,27,34,.6);
311
+ border: 1px solid var(--border-subtle);
312
+ border-radius: var(--radius);
313
+ backdrop-filter: blur(8px);
239
314
  }
240
- .time-range-label { font-size: 13px; color: var(--text-secondary); }
315
+ .time-range-label { font-size: 12px; color: var(--text-tertiary); font-weight: 500; }
241
316
  .range-btn {
242
317
  padding: 4px 14px; border-radius: 14px;
243
- background: rgba(48,54,61,.6);
244
- border: 1px solid var(--border);
318
+ background: transparent;
319
+ border: 1px solid transparent;
245
320
  color: var(--text-secondary);
246
321
  font-size: 12px; cursor: pointer;
247
- transition: background .15s, color .15s, border-color .15s;
322
+ transition: all .2s ease;
248
323
  }
249
- .range-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); }
324
+ .range-btn:hover { background: rgba(255,255,255,.05); color: var(--text-primary); }
250
325
  .range-btn.active {
251
- background: rgba(88,166,255,.15);
252
- border-color: rgba(88,166,255,.5);
326
+ background: rgba(88,166,255,.12);
327
+ border-color: rgba(88,166,255,.35);
253
328
  color: var(--accent-blue);
329
+ font-weight: 500;
254
330
  }
255
331
  .range-custom { display: none; align-items: center; gap: 6px; }
256
332
  .range-custom.visible { display: flex; }
257
333
  .range-date {
258
- padding: 3px 8px; border-radius: var(--radius);
259
- background: var(--bg-card); border: 1px solid var(--border);
334
+ padding: 3px 10px; border-radius: var(--radius-sm);
335
+ background: rgba(48,54,61,.4); border: 1px solid var(--border);
260
336
  color: var(--text-primary); font-size: 12px;
261
337
  color-scheme: dark;
338
+ transition: border-color .2s;
262
339
  }
263
- .range-sep { font-size: 12px; color: var(--text-secondary); }
340
+ .range-date:focus { outline: none; border-color: rgba(88,166,255,.5); }
341
+ .range-sep { font-size: 12px; color: var(--text-tertiary); }
264
342
  /* ── 空态 ── */
265
343
  .empty {
266
344
  text-align: center; padding: 32px;
267
- color: var(--text-secondary); font-size: 13px;
345
+ color: var(--text-tertiary); font-size: 13px;
268
346
  }
347
+ .empty-icon { font-size: 28px; margin-bottom: 8px; opacity: .5; }
269
348
  /* ── 加载态 ── */
270
349
  .loading { opacity: .4; pointer-events: none; }
350
+ /* ── 图表标签截断 ── */
351
+ .chart-legend-note { font-size: 11px; color: var(--text-tertiary); margin-top: 4px; text-align: center; }
271
352
  </style>
272
353
  </head>
273
354
  <body>
@@ -286,28 +367,52 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
286
367
  <main>
287
368
  <!-- 时间区间选择器 -->
288
369
  <div class="time-range-bar">
289
- <span class="time-range-label">数据时间区间:</span>
290
- <button class="range-btn active" onclick="setTimeRange(7, this)">最近一周</button>
291
- <button class="range-btn" onclick="setTimeRange(30, this)">最近一月</button>
370
+ <span class="time-range-label">时间区间</span>
371
+ <button class="range-btn active" onclick="setTimeRange(7, this)">近 7 天</button>
372
+ <button class="range-btn" onclick="setTimeRange(30, this)">近 30 天</button>
292
373
  <button class="range-btn" onclick="setTimeRange(0, this)">自选区间</button>
293
374
  <div class="range-custom" id="range-custom">
294
375
  <input type="date" id="range-start" class="range-date" onchange="applyCustomRange()" />
295
- <span class="range-sep">–</span>
376
+ <span class="range-sep">→</span>
296
377
  <input type="date" id="range-end" class="range-date" onchange="applyCustomRange()" />
297
378
  </div>
298
379
  </div>
299
380
 
300
381
  <!-- KPI 卡片 -->
301
382
  <div class="kpi-grid" id="kpi-grid">
302
- <div class="kpi-card"><div class="kpi-label">今日请求数</div><div class="kpi-value color-blue" id="kpi-req-today">–</div><div class="kpi-sub" id="kpi-req-week">本周 –</div></div>
303
- <div class="kpi-card"><div class="kpi-label">今日 Token 总量</div><div class="kpi-value color-purple" id="kpi-tok-today">–</div><div class="kpi-sub" id="kpi-tok-week">本周 –</div></div>
304
- <div class="kpi-card"><div class="kpi-label">今日输出 Token</div><div class="kpi-value color-green" id="kpi-out-today">–</div><div class="kpi-sub" id="kpi-out-week">本周 –</div></div>
305
- <div class="kpi-card"><div class="kpi-label">今日费用估算</div><div class="kpi-value color-yellow" id="kpi-cost-today">–</div><div class="kpi-sub" id="kpi-cost-week">本周 –</div></div>
306
- <div class="kpi-card"><div class="kpi-label">故障转移(今日)</div><div class="kpi-value color-red" id="kpi-fo-today">–</div><div class="kpi-sub" id="kpi-fo-week">本周 –</div></div>
307
- <div class="kpi-card"><div class="kpi-label">平均延迟(今日)</div><div class="kpi-value" id="kpi-lat-today">–</div><div class="kpi-sub" id="kpi-lat-week">本周 –</div></div>
383
+ <div class="kpi-card">
384
+ <div class="kpi-header"><span class="kpi-icon">📊</span><span class="kpi-label">今日请求数</span></div>
385
+ <div class="kpi-value color-blue" id="kpi-req-today">–</div>
386
+ <div class="kpi-sub" id="kpi-req-week">本周 –</div>
387
+ </div>
388
+ <div class="kpi-card">
389
+ <div class="kpi-header"><span class="kpi-icon">🔢</span><span class="kpi-label">今日 Token 总量</span></div>
390
+ <div class="kpi-value color-purple" id="kpi-tok-today">–</div>
391
+ <div class="kpi-sub" id="kpi-tok-week">本周 –</div>
392
+ </div>
393
+ <div class="kpi-card">
394
+ <div class="kpi-header"><span class="kpi-icon">💬</span><span class="kpi-label">今日输出 Token</span></div>
395
+ <div class="kpi-value color-green" id="kpi-out-today">–</div>
396
+ <div class="kpi-sub" id="kpi-out-week">本周 –</div>
397
+ </div>
398
+ <div class="kpi-card">
399
+ <div class="kpi-header"><span class="kpi-icon">💰</span><span class="kpi-label">今日费用估算</span></div>
400
+ <div class="kpi-value color-yellow" id="kpi-cost-today">–</div>
401
+ <div class="kpi-sub" id="kpi-cost-week">本周 –</div>
402
+ </div>
403
+ <div class="kpi-card">
404
+ <div class="kpi-header"><span class="kpi-icon">🔄</span><span class="kpi-label">故障转移(今日)</span></div>
405
+ <div class="kpi-value color-red" id="kpi-fo-today">–</div>
406
+ <div class="kpi-sub" id="kpi-fo-week">本周 –</div>
407
+ </div>
408
+ <div class="kpi-card">
409
+ <div class="kpi-header"><span class="kpi-icon">⚡</span><span class="kpi-label">平均延迟(今日)</span></div>
410
+ <div class="kpi-value color-orange" id="kpi-lat-today">–</div>
411
+ <div class="kpi-sub" id="kpi-lat-week">本周 –</div>
412
+ </div>
308
413
  </div>
309
414
 
310
- <!-- 供应商状态 + 趋势折线图 -->
415
+ <!-- 供应商状态 + 请求量趋势折线图 -->
311
416
  <div class="charts-grid">
312
417
  <div class="card">
313
418
  <div class="card-title">供应商状态</div>
@@ -323,8 +428,8 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
323
428
  </div>
324
429
  </div>
325
430
 
326
- <!-- 供应商分布 + Token 量趋势 -->
327
- <div class="charts-grid-3">
431
+ <!-- 供应商分布 + Token 量趋势(按 vendor) -->
432
+ <div class="charts-grid-2">
328
433
  <div class="card">
329
434
  <div class="card-title" id="title-vendor-dist">供应商请求分布(近 7 天)</div>
330
435
  <div class="chart-wrap">
@@ -332,45 +437,48 @@ _DASHBOARD_HTML = """<!DOCTYPE html>
332
437
  </div>
333
438
  </div>
334
439
  <div class="card">
335
- <div class="card-title" id="title-token-timeline">近 7 天 Token 量趋势</div>
440
+ <div class="card-title" id="title-token-timeline">近 7 天 Token 量趋势(按供应商)</div>
336
441
  <div class="chart-wrap-lg">
337
442
  <canvas id="chart-token-timeline"></canvas>
338
443
  </div>
339
444
  </div>
340
445
  </div>
341
446
 
342
- <!-- 故障转移明细表 -->
343
- <div class="card">
344
- <div class="card-title">故障转移明细</div>
345
- <div class="ft-table-wrap">
346
- <table>
347
- <thead>
348
- <tr>
349
- <th>来源供应商</th>
350
- <th>目标供应商</th>
351
- <th>次数</th>
352
- </tr>
353
- </thead>
354
- <tbody id="ft-tbody">
355
- <tr><td colspan="3" class="empty">加载中…</td></tr>
356
- </tbody>
357
- </table>
447
+ <!-- Token 用量(按 Vendor / 模型)堆叠图 -->
448
+ <div class="card" style="margin-bottom:12px">
449
+ <div class="card-title" id="title-model-token-timeline">近 7 天 Token 用量(按 Vendor / 模型)</div>
450
+ <div class="chart-wrap-xl">
451
+ <canvas id="chart-model-token-timeline"></canvas>
358
452
  </div>
359
453
  </div>
454
+
360
455
  </main>
361
456
 
362
457
  <script>
363
458
  // ── 颜色配置 ──────────────────────────────────────────────
459
+ // 调色盘参考 Tailwind CSS 400-level,深色背景高区分度最佳实践
364
460
  const VENDOR_COLORS = [
365
- '#58a6ff','#bc8cff','#3fb950','#ffa657','#f85149',
366
- '#79c0ff','#d2a8ff','#56d364','#ffb77c','#ff7b72',
461
+ '#60A5FA', // blue-400
462
+ '#FB923C', // orange-400
463
+ '#34D399', // emerald-400
464
+ '#A78BFA', // violet-400
465
+ '#F87171', // red-400
466
+ '#38BDF8', // sky-400
467
+ '#FBBF24', // amber-400
468
+ '#F472B6', // pink-400
469
+ '#4ADE80', // green-400
470
+ '#E879F9', // fuchsia-400
471
+ '#818CF8', // indigo-400
472
+ '#2DD4BF', // teal-400
473
+ '#FB7185', // rose-400
474
+ '#FCD34D', // yellow-300
475
+ '#6EE7B7', // emerald-300
476
+ '#C4B5FD', // violet-300
477
+ '#7DD3FC', // sky-300
478
+ '#FED7AA', // orange-200
479
+ '#FECDD3', // rose-200
480
+ '#BBF7D0', // green-200
367
481
  ];
368
- const TOKEN_COLORS = {
369
- input: '#58a6ff',
370
- output: '#3fb950',
371
- cache_creation: '#d29922',
372
- cache_read: '#bc8cff',
373
- };
374
482
 
375
483
  // ── 工具函数 ──────────────────────────────────────────────
376
484
  function fmtTokens(n) {
@@ -385,20 +493,95 @@ function now() {
385
493
  return new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
386
494
  }
387
495
 
496
+ // ── 渐变填充工具 ──────────────────────────────────────────
497
+ function makeGradient(ctx, color) {
498
+ const h = ctx.canvas.height;
499
+ const grad = ctx.createLinearGradient(0, 0, 0, h);
500
+ grad.addColorStop(0, color + '44');
501
+ grad.addColorStop(1, color + '04');
502
+ return grad;
503
+ }
504
+
388
505
  // ── Chart.js 全局默认 ─────────────────────────────────────
389
506
  Chart.defaults.color = '#8b949e';
390
- Chart.defaults.borderColor = '#30363d';
507
+ Chart.defaults.borderColor = 'rgba(255,255,255,.04)';
391
508
  Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
392
- Chart.defaults.font.size = 12;
509
+ Chart.defaults.font.size = 11;
510
+
511
+ const COMMON_SCALE_X = { grid: { display: false }, ticks: { maxTicksLimit: 10 } };
512
+ const COMMON_SCALE_Y = { grid: { color: 'rgba(255,255,255,.04)' }, beginAtZero: true };
513
+ const COMMON_LEGEND = {
514
+ position: 'bottom',
515
+ labels: {
516
+ boxWidth: 8,
517
+ padding: 14,
518
+ usePointStyle: true,
519
+ pointStyle: 'circle',
520
+ pointStyleWidth: 8,
521
+ font: { size: 11 },
522
+ generateLabels: chart => {
523
+ const items = Chart.defaults.plugins.legend.labels.generateLabels(chart);
524
+ items.forEach(item => { item.pointStyle = 'circle'; item.lineWidth = 0; });
525
+ return items;
526
+ },
527
+ },
528
+ };
529
+ const COMMON_LINE_DATASET = { tension: .35, pointRadius: 0, pointHoverRadius: 5, borderWidth: 2 };
530
+
531
+ // ── Legend 点击交互:单击=仅选该项,Ctrl/Meta+单击=多选追加,Shift+单击=排除 ──
532
+ function legendOnClick(e, legendItem, legend) {
533
+ const chart = legend.chart;
534
+ const isShift = e.native.shiftKey;
535
+ const isCtrl = e.native.ctrlKey || e.native.metaKey;
536
+ if (chart.config.type === 'doughnut' || chart.config.type === 'pie') {
537
+ const idx = legendItem.index;
538
+ const dataLen = chart.data.labels.length;
539
+ if (isShift) {
540
+ chart.toggleDataVisibility(idx);
541
+ } else if (isCtrl) {
542
+ if (!chart.getDataVisibility(idx)) chart.toggleDataVisibility(idx);
543
+ } else {
544
+ const allOthersHidden = [...Array(dataLen).keys()].filter(i => i !== idx).every(i => !chart.getDataVisibility(i));
545
+ if (allOthersHidden) {
546
+ for (let i = 0; i < dataLen; i++) { if (!chart.getDataVisibility(i)) chart.toggleDataVisibility(i); }
547
+ } else {
548
+ for (let i = 0; i < dataLen; i++) {
549
+ const vis = chart.getDataVisibility(i);
550
+ if (i === idx && !vis) chart.toggleDataVisibility(i);
551
+ if (i !== idx && vis) chart.toggleDataVisibility(i);
552
+ }
553
+ }
554
+ }
555
+ } else {
556
+ const idx = legendItem.datasetIndex;
557
+ const datasets = chart.data.datasets;
558
+ if (isShift) {
559
+ const meta = chart.getDatasetMeta(idx);
560
+ meta.hidden = !meta.hidden;
561
+ } else if (isCtrl) {
562
+ chart.getDatasetMeta(idx).hidden = false;
563
+ } else {
564
+ const allOthersHidden = datasets.every((_, i) => i === idx || !!chart.getDatasetMeta(i).hidden);
565
+ if (allOthersHidden) {
566
+ datasets.forEach((_, i) => { chart.getDatasetMeta(i).hidden = false; });
567
+ } else {
568
+ datasets.forEach((_, i) => { chart.getDatasetMeta(i).hidden = (i !== idx); });
569
+ }
570
+ }
571
+ }
572
+ chart.update();
573
+ }
393
574
 
394
575
  // ── 图表实例 ──────────────────────────────────────────────
395
576
  let chartTimeline = null;
396
577
  let chartVendorDist = null;
397
578
  let chartTokenTimeline = null;
579
+ let chartModelTokenTimeline = null;
398
580
 
399
581
  function destroyCharts() {
400
- [chartTimeline, chartVendorDist, chartTokenTimeline].forEach(c => c && c.destroy());
401
- chartTimeline = chartVendorDist = chartTokenTimeline = null;
582
+ [chartTimeline, chartVendorDist, chartTokenTimeline, chartModelTokenTimeline]
583
+ .forEach(c => c && c.destroy());
584
+ chartTimeline = chartVendorDist = chartTokenTimeline = chartModelTokenTimeline = null;
402
585
  }
403
586
 
404
587
  // ── 数据拉取 ──────────────────────────────────────────────
@@ -410,28 +593,29 @@ async function fetchJSON(url) {
410
593
 
411
594
  // ── KPI 更新 ──────────────────────────────────────────────
412
595
  function updateKPI(summary) {
413
- const t = summary.today, w = summary.week, m = summary.month;
596
+ const t = summary.today, r = summary.range;
597
+ const lbl = currentRangeLabel;
414
598
 
415
599
  document.getElementById('kpi-req-today').textContent = fmtNum(t.requests);
416
- document.getElementById('kpi-req-week').textContent = '本周 ' + fmtNum(w.requests);
600
+ document.getElementById('kpi-req-week').textContent = lbl + ' ' + fmtNum(r.requests);
417
601
 
418
- const tokT = t.tokens, tokW = w.tokens;
602
+ const tokT = t.tokens, tokR = r.tokens;
419
603
  const totalT = tokT.input + tokT.output + tokT.cache_creation + tokT.cache_read;
420
- const totalW = tokW.input + tokW.output + tokW.cache_creation + tokW.cache_read;
604
+ const totalR = tokR.input + tokR.output + tokR.cache_creation + tokR.cache_read;
421
605
  document.getElementById('kpi-tok-today').textContent = fmtTokens(totalT);
422
- document.getElementById('kpi-tok-week').textContent = '本周 ' + fmtTokens(totalW);
606
+ document.getElementById('kpi-tok-week').textContent = lbl + ' ' + fmtTokens(totalR);
423
607
 
424
608
  document.getElementById('kpi-out-today').textContent = fmtTokens(tokT.output);
425
- document.getElementById('kpi-out-week').textContent = '本周 ' + fmtTokens(tokW.output);
609
+ document.getElementById('kpi-out-week').textContent = lbl + ' ' + fmtTokens(tokR.output);
426
610
 
427
611
  document.getElementById('kpi-cost-today').textContent = t.cost || '–';
428
- document.getElementById('kpi-cost-week').textContent = '本周 ' + (w.cost || '–');
612
+ document.getElementById('kpi-cost-week').textContent = lbl + ' ' + (r.cost || '–');
429
613
 
430
614
  document.getElementById('kpi-fo-today').textContent = fmtNum(t.failovers);
431
- document.getElementById('kpi-fo-week').textContent = '本周 ' + fmtNum(w.failovers);
615
+ document.getElementById('kpi-fo-week').textContent = lbl + ' ' + fmtNum(r.failovers);
432
616
 
433
617
  document.getElementById('kpi-lat-today').textContent = t.avg_duration_ms ? t.avg_duration_ms + 'ms' : '–';
434
- document.getElementById('kpi-lat-week').textContent = '本周 ' + (w.avg_duration_ms ? w.avg_duration_ms + 'ms' : '–');
618
+ document.getElementById('kpi-lat-week').textContent = lbl + ' ' + (r.avg_duration_ms ? r.avg_duration_ms + 'ms' : '–');
435
619
  }
436
620
 
437
621
  // ── 供应商状态 ────────────────────────────────────────────
@@ -459,41 +643,47 @@ function quotaBarColor(pct) {
459
643
  if (pct >= 70) return 'var(--accent-yellow)';
460
644
  return 'var(--accent-green)';
461
645
  }
646
+ function quotaWindowLabel(wh) {
647
+ if (!wh) return '配额';
648
+ const h = parseFloat(wh);
649
+ if (h >= 24) return Math.round(h / 24) + 'd配额';
650
+ return Math.round(h) + 'h配额';
651
+ }
652
+ function renderQuotaBar(qg) {
653
+ if (!qg || qg.usage_percent == null) return '';
654
+ const pct = Math.round(qg.usage_percent);
655
+ const label = quotaWindowLabel(qg.window_hours);
656
+ return `<span class="status-badge ${quotaClass(pct)}">${label} ${pct}%</span>` +
657
+ `<div class="quota-bar-wrap"><div class="quota-bar-bg">` +
658
+ `<div class="quota-bar-fill" style="width:${Math.min(pct,100)}%;background:${quotaBarColor(pct)}"></div>` +
659
+ `</div></div>`;
660
+ }
462
661
 
463
662
  function updateVendorStatus(status) {
464
663
  const tiers = status.tiers || [];
465
664
  const list = document.getElementById('vendor-list');
466
665
  if (!tiers.length) {
467
- list.innerHTML = '<div class="empty">无供应商数据</div>';
666
+ list.innerHTML = '<div class="empty"><div class="empty-icon">🔌</div>无供应商数据</div>';
468
667
  return;
469
668
  }
470
669
  list.innerHTML = tiers.map(tier => {
471
670
  const cb = tier.circuit_breaker || {};
472
- const qg = tier.quota_guard || {};
473
- const wqg = tier.weekly_quota_guard || {};
474
671
  const cbClass = cbStateClass(cb.state);
475
672
  const cbLabel = cbStateLabel(cb.state);
476
- const pct = qg.usage_percent != null ? Math.round(qg.usage_percent) : null;
477
- const wpct = wqg.usage_percent != null ? Math.round(wqg.usage_percent) : null;
673
+ const initial = (tier.name || '?').charAt(0).toUpperCase();
478
674
 
479
675
  let quotaHTML = '';
480
- if (pct != null) {
481
- quotaHTML += `
482
- <span class="status-badge ${quotaClass(pct)}">日配额 ${pct}%</span>
483
- <div class="quota-bar-wrap">
484
- <div class="quota-bar-bg"><div class="quota-bar-fill" style="width:${Math.min(pct,100)}%;background:${quotaBarColor(pct)}"></div></div>
485
- </div>`;
486
- }
487
- if (wpct != null) {
488
- quotaHTML += `<span class="status-badge ${quotaClass(wpct)}">周配额 ${wpct}%</span>`;
489
- }
676
+ if (tier.quota_guard) quotaHTML += renderQuotaBar(tier.quota_guard);
677
+ if (tier.weekly_quota_guard) quotaHTML += renderQuotaBar(tier.weekly_quota_guard);
490
678
 
491
679
  const rlInfo = tier.rate_limit || {};
492
- const rlHtml = rlInfo.limited
493
- ? `<span class="status-badge sb-warn">限速中</span>` : '';
680
+ const rlHtml = rlInfo.limited ? `<span class="status-badge sb-warn">限速中</span>` : '';
494
681
 
495
682
  return `<div class="vendor-item">
496
- <span class="vendor-name">${tier.name}</span>
683
+ <div class="vendor-info">
684
+ <div class="vendor-avatar">${initial}</div>
685
+ <span class="vendor-name">${tier.name}</span>
686
+ </div>
497
687
  <div class="vendor-badges">
498
688
  <span class="status-badge ${cbClass}">${cbLabel}${cb.failure_count ? ' ×'+cb.failure_count : ''}</span>
499
689
  ${quotaHTML}
@@ -503,10 +693,9 @@ function updateVendorStatus(status) {
503
693
  }).join('');
504
694
  }
505
695
 
506
- // ── 时序折线图 ────────────────────────────────────────────
696
+ // ── 时序折线图(请求量,按 vendor)────────────────────────
507
697
  function buildTimeline(rows) {
508
- // vendor 分组,按 date 汇总
509
- const vendorDateMap = {}; // vendor → {date → count}
698
+ const vendorDateMap = {};
510
699
  const allDates = new Set();
511
700
  for (const r of rows) {
512
701
  const v = r.vendor, d = r.date;
@@ -518,29 +707,33 @@ function buildTimeline(rows) {
518
707
  const dates = [...allDates].sort();
519
708
  const vendors = Object.keys(vendorDateMap).sort();
520
709
 
521
- const datasets = vendors.map((v, i) => ({
522
- label: v,
523
- data: dates.map(d => vendorDateMap[v][d] || 0),
524
- borderColor: VENDOR_COLORS[i % VENDOR_COLORS.length],
525
- backgroundColor: VENDOR_COLORS[i % VENDOR_COLORS.length] + '22',
526
- fill: true,
527
- tension: .3,
528
- pointRadius: 3,
529
- pointHoverRadius: 5,
530
- }));
531
-
532
710
  if (chartTimeline) chartTimeline.destroy();
533
711
  const ctx = document.getElementById('chart-timeline').getContext('2d');
712
+ const datasets = vendors.map((v, i) => {
713
+ const color = VENDOR_COLORS[i % VENDOR_COLORS.length];
714
+ return {
715
+ ...COMMON_LINE_DATASET,
716
+ label: v,
717
+ data: dates.map(d => vendorDateMap[v][d] || 0),
718
+ borderColor: color,
719
+ backgroundColor: makeGradient(ctx, color),
720
+ fill: true,
721
+ };
722
+ });
723
+
534
724
  chartTimeline = new Chart(ctx, {
535
725
  type: 'line',
536
726
  data: { labels: dates, datasets },
537
727
  options: {
538
728
  responsive: true, maintainAspectRatio: false,
539
729
  interaction: { mode: 'index', intersect: false },
540
- plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12 } } },
730
+ plugins: {
731
+ legend: { ...COMMON_LEGEND, onClick: legendOnClick },
732
+ tooltip: { itemSort: (a, b) => (b.raw || 0) - (a.raw || 0) },
733
+ },
541
734
  scales: {
542
- x: { grid: { color: '#30363d' } },
543
- y: { grid: { color: '#30363d' }, beginAtZero: true, ticks: { precision: 0 } },
735
+ x: COMMON_SCALE_X,
736
+ y: { ...COMMON_SCALE_Y, ticks: { precision: 0 } },
544
737
  },
545
738
  },
546
739
  });
@@ -560,7 +753,7 @@ function buildVendorDist(rows) {
560
753
  if (chartVendorDist) chartVendorDist.destroy();
561
754
  const ctx = document.getElementById('chart-vendor-dist').getContext('2d');
562
755
  if (!labels.length) {
563
- ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>';
756
+ ctx.canvas.parentElement.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>暂无数据</div>';
564
757
  return;
565
758
  }
566
759
  chartVendorDist = new Chart(ctx, {
@@ -571,23 +764,22 @@ function buildVendorDist(rows) {
571
764
  data,
572
765
  backgroundColor: labels.map((_,i) => VENDOR_COLORS[i % VENDOR_COLORS.length]),
573
766
  borderWidth: 0,
574
- hoverOffset: 6,
767
+ hoverOffset: 8,
575
768
  }],
576
769
  },
577
770
  options: {
578
771
  responsive: true, maintainAspectRatio: false,
579
772
  plugins: {
580
- legend: { position: 'bottom', labels: { boxWidth: 10, padding: 10 } },
581
- tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${ctx.raw} 次` } },
773
+ legend: { ...COMMON_LEGEND, onClick: legendOnClick },
774
+ tooltip: { callbacks: { label: c => ` ${c.label}: ${c.raw.toLocaleString()} 次` } },
582
775
  },
583
776
  },
584
777
  });
585
778
  }
586
779
 
587
- // ── Token 量趋势折线图 ────────────────────────────────────
780
+ // ── Token 量趋势折线图(按 vendor)───────────────────────
588
781
  function buildTokenTimeline(rows) {
589
- // vendor 分组,按 date 汇总 token 总量
590
- const vendorDateMap = {}; // vendor → {date → total_tokens}
782
+ const vendorDateMap = {};
591
783
  const allDates = new Set();
592
784
  for (const r of rows) {
593
785
  const v = r.vendor, d = r.date;
@@ -604,20 +796,21 @@ function buildTokenTimeline(rows) {
604
796
  if (chartTokenTimeline) chartTokenTimeline.destroy();
605
797
  const ctx = document.getElementById('chart-token-timeline').getContext('2d');
606
798
  if (!dates.length) {
607
- ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>';
799
+ ctx.canvas.parentElement.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>暂无数据</div>';
608
800
  return;
609
801
  }
610
802
 
611
- const datasets = vendors.map((v, i) => ({
612
- label: v,
613
- data: dates.map(d => vendorDateMap[v][d] || 0),
614
- borderColor: VENDOR_COLORS[i % VENDOR_COLORS.length],
615
- backgroundColor: VENDOR_COLORS[i % VENDOR_COLORS.length] + '22',
616
- fill: true,
617
- tension: .3,
618
- pointRadius: 3,
619
- pointHoverRadius: 5,
620
- }));
803
+ const datasets = vendors.map((v, i) => {
804
+ const color = VENDOR_COLORS[i % VENDOR_COLORS.length];
805
+ return {
806
+ ...COMMON_LINE_DATASET,
807
+ label: v,
808
+ data: dates.map(d => vendorDateMap[v][d] || 0),
809
+ borderColor: color,
810
+ backgroundColor: makeGradient(ctx, color),
811
+ fill: true,
812
+ };
813
+ });
621
814
 
622
815
  chartTokenTimeline = new Chart(ctx, {
623
816
  type: 'line',
@@ -625,44 +818,122 @@ function buildTokenTimeline(rows) {
625
818
  options: {
626
819
  responsive: true, maintainAspectRatio: false,
627
820
  interaction: { mode: 'index', intersect: false },
628
- plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12 } } },
629
- scales: {
630
- x: { grid: { color: '#30363d' } },
631
- y: {
632
- grid: { color: '#30363d' }, beginAtZero: true,
633
- ticks: { callback: v => fmtTokens(v) },
821
+ plugins: {
822
+ legend: { ...COMMON_LEGEND, onClick: legendOnClick },
823
+ tooltip: {
824
+ itemSort: (a, b) => (b.raw || 0) - (a.raw || 0),
825
+ callbacks: { label: c => ` ${c.dataset.label}: ${fmtTokens(c.raw)}` },
634
826
  },
635
827
  },
828
+ scales: {
829
+ x: COMMON_SCALE_X,
830
+ y: { ...COMMON_SCALE_Y, ticks: { callback: v => fmtTokens(v) } },
831
+ },
636
832
  },
637
833
  });
638
834
  }
639
835
 
640
- // ── 故障转移明细表 ────────────────────────────────────────
641
- function updateFtTable(failoverStats) {
642
- const tbody = document.getElementById('ft-tbody');
643
- if (!failoverStats || !failoverStats.length) {
644
- tbody.innerHTML = '<tr><td colspan="3" class="empty">暂无故障转移记录</td></tr>';
836
+ // ── Token 用量趋势(按 Vendor / 模型,堆叠面积图)────────
837
+ function buildModelTokenTimeline(rows) {
838
+ const modelDateMap = {};
839
+ const allDates = new Set();
840
+ for (const r of rows) {
841
+ const key = (r.vendor || '?') + ' / ' + (r.model_served || '?');
842
+ const d = r.date;
843
+ if (!d) continue;
844
+ if (!modelDateMap[key]) modelDateMap[key] = {};
845
+ const total = (r.total_input || 0) + (r.total_output || 0)
846
+ + (r.total_cache_creation || 0) + (r.total_cache_read || 0);
847
+ modelDateMap[key][d] = (modelDateMap[key][d] || 0) + total;
848
+ allDates.add(d);
849
+ }
850
+ const dates = [...allDates].sort();
851
+ // 按总量降序排列 key
852
+ const keys = Object.keys(modelDateMap).sort((a, b) => {
853
+ const sumA = Object.values(modelDateMap[a]).reduce((s, v) => s + v, 0);
854
+ const sumB = Object.values(modelDateMap[b]).reduce((s, v) => s + v, 0);
855
+ return sumB - sumA;
856
+ });
857
+
858
+ if (chartModelTokenTimeline) chartModelTokenTimeline.destroy();
859
+ const canvasEl = document.getElementById('chart-model-token-timeline');
860
+ if (!canvasEl) return;
861
+ const ctx = canvasEl.getContext('2d');
862
+ if (!dates.length || !keys.length) {
863
+ ctx.canvas.parentElement.innerHTML = '<div class="empty"><div class="empty-icon">📭</div>暂无数据</div>';
645
864
  return;
646
865
  }
647
- tbody.innerHTML = failoverStats.map(r => `
648
- <tr>
649
- <td><span class="tag-vendor">${r.failover_from || 'unknown'}</span></td>
650
- <td><span class="tag-vendor">${r.vendor || ''}</span></td>
651
- <td>${fmtNum(r.count)}</td>
652
- </tr>`).join('');
866
+
867
+ const datasets = keys.map((key, i) => {
868
+ const color = VENDOR_COLORS[i % VENDOR_COLORS.length];
869
+ return {
870
+ ...COMMON_LINE_DATASET,
871
+ label: key,
872
+ data: dates.map(d => modelDateMap[key][d] || 0),
873
+ borderColor: color,
874
+ backgroundColor: color + '30',
875
+ fill: true,
876
+ };
877
+ });
878
+
879
+ chartModelTokenTimeline = new Chart(ctx, {
880
+ type: 'line',
881
+ data: { labels: dates, datasets },
882
+ options: {
883
+ responsive: true, maintainAspectRatio: false,
884
+ interaction: { mode: 'index', intersect: false },
885
+ plugins: {
886
+ legend: {
887
+ position: keys.length > 8 ? 'right' : 'bottom',
888
+ onClick: legendOnClick,
889
+ labels: {
890
+ ...COMMON_LEGEND.labels,
891
+ generateLabels: chart => {
892
+ const items = COMMON_LEGEND.labels.generateLabels(chart);
893
+ const maxLen = 32;
894
+ items.forEach(item => {
895
+ if (item.text.length > maxLen) item.text = item.text.slice(0, maxLen) + '…';
896
+ });
897
+ return items;
898
+ },
899
+ },
900
+ },
901
+ tooltip: {
902
+ itemSort: (a, b) => (b.raw || 0) - (a.raw || 0),
903
+ callbacks: {
904
+ label: c => ` ${c.dataset.label}: ${fmtTokens(c.raw)}`,
905
+ footer: items => {
906
+ const total = items.reduce((s, i) => s + (i.raw || 0), 0);
907
+ return total > 0 ? '合计: ' + fmtTokens(total) : '';
908
+ },
909
+ },
910
+ },
911
+ },
912
+ scales: {
913
+ x: COMMON_SCALE_X,
914
+ y: {
915
+ ...COMMON_SCALE_Y,
916
+ stacked: true,
917
+ ticks: { callback: v => fmtTokens(v) },
918
+ },
919
+ },
920
+ },
921
+ });
653
922
  }
654
923
 
655
924
  // ── 时间区间控制 ──────────────────────────────────────────
656
925
  let currentDays = 7;
926
+ let currentRangeLabel = '本周';
657
927
 
658
928
  function setTimeRange(days, btn) {
659
929
  currentDays = days;
930
+ if (days === 7) currentRangeLabel = '本周';
931
+ else if (days === 30) currentRangeLabel = '本月';
660
932
  document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
661
933
  if (btn) btn.classList.add('active');
662
934
  const customEl = document.getElementById('range-custom');
663
935
  if (days === 0) {
664
936
  customEl.classList.add('visible');
665
- // 初始化日期:默认今天往前 7 天
666
937
  const today = new Date();
667
938
  const weekAgo = new Date(today);
668
939
  weekAgo.setDate(weekAgo.getDate() - 6);
@@ -683,23 +954,20 @@ function applyCustomRange() {
683
954
  const endMs = new Date(e).getTime();
684
955
  if (endMs < startMs) return;
685
956
  currentDays = Math.ceil((endMs - startMs) / 86400000) + 1;
957
+ currentRangeLabel = s + '—' + e;
686
958
  refresh();
687
959
  }
688
960
 
689
- function rangeLabel() {
690
- if (currentDays <= 7) return '近 7 天';
691
- if (currentDays <= 30) return '近 30 天';
692
- return '近 ' + currentDays + ' 天';
693
- }
694
-
695
961
  function updateChartTitles(days) {
696
962
  const label = days <= 7 ? '近 7 天' : (days <= 30 ? '近 30 天' : '近 ' + days + ' 天');
697
963
  const tl = document.getElementById('title-timeline');
698
964
  const tt = document.getElementById('title-token-timeline');
699
965
  const vd = document.getElementById('title-vendor-dist');
966
+ const mt = document.getElementById('title-model-token-timeline');
700
967
  if (tl) tl.textContent = label + ' 请求量趋势';
701
- if (tt) tt.textContent = label + ' Token 量趋势';
968
+ if (tt) tt.textContent = label + ' Token 量趋势(按供应商)';
702
969
  if (vd) vd.textContent = '供应商请求分布(' + label + ')';
970
+ if (mt) mt.textContent = label + ' Token 用量(按 Vendor / 模型)';
703
971
  }
704
972
 
705
973
  // ── 主刷新逻辑 ────────────────────────────────────────────
@@ -711,12 +979,11 @@ async function refresh() {
711
979
  try {
712
980
  const days = currentDays > 0 ? currentDays : 7;
713
981
  const [summary, timeline, status] = await Promise.all([
714
- fetchJSON('/api/dashboard/summary'),
982
+ fetchJSON('/api/dashboard/summary?days=' + days),
715
983
  fetchJSON('/api/dashboard/timeline?days=' + days),
716
984
  fetchJSON('/api/status'),
717
985
  ]);
718
986
 
719
- // 版本号
720
987
  if (summary.version) {
721
988
  document.getElementById('version-badge').textContent = 'v' + summary.version;
722
989
  }
@@ -729,8 +996,7 @@ async function refresh() {
729
996
  buildTimeline(rows);
730
997
  buildVendorDist(rows);
731
998
  buildTokenTimeline(rows);
732
-
733
- updateFtTable(summary.failover_stats || []);
999
+ buildModelTokenTimeline(rows);
734
1000
 
735
1001
  document.getElementById('refresh-time').textContent = '上次刷新: ' + now();
736
1002
  } catch (e) {
@@ -830,8 +1096,8 @@ def register_dashboard_routes(app: Any) -> None:
830
1096
  return HTMLResponse(content=_DASHBOARD_HTML)
831
1097
 
832
1098
  @app.get("/api/dashboard/summary")
833
- async def dashboard_summary(request: Request) -> Response:
834
- """返回 Dashboard 汇总数据(今日 / 本周 / 本月)."""
1099
+ async def dashboard_summary(request: Request, days: int = 7) -> Response:
1100
+ """返回 Dashboard 汇总数据(今日 / 所选区间)."""
835
1101
  token_logger = getattr(request.app.state, "token_logger", None)
836
1102
  pricing_table = getattr(request.app.state, "pricing_table", None)
837
1103
 
@@ -842,15 +1108,17 @@ def register_dashboard_routes(app: Any) -> None:
842
1108
  media_type="application/json",
843
1109
  )
844
1110
 
1111
+ days = max(1, min(days, 90)) # 限制范围 1~90 天
1112
+
845
1113
  try:
846
1114
  # 今日(最近 1 天)
847
1115
  today_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=1)
848
- # 本周(最近 7 天)
849
- week_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=7)
850
- # 本月(最近 30 天)
851
- month_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=30)
852
- # 故障转移(最近 7 天)
853
- failover_stats = await token_logger.query_failover_stats(days=7)
1116
+ # 所选区间
1117
+ range_rows = await token_logger.query_usage(
1118
+ period=TimePeriod.DAY, count=days
1119
+ )
1120
+ # 故障转移(所选区间)
1121
+ failover_stats = await token_logger.query_failover_stats(days=days)
854
1122
  except Exception as exc:
855
1123
  logger.error("dashboard_summary query error: %s", exc, exc_info=True)
856
1124
  return Response(
@@ -860,18 +1128,15 @@ def register_dashboard_routes(app: Any) -> None:
860
1128
  )
861
1129
 
862
1130
  today = _sum_rows(today_rows)
863
- week = _sum_rows(week_rows)
864
- month = _sum_rows(month_rows)
1131
+ range_stat = _sum_rows(range_rows)
865
1132
 
866
1133
  today["cost"] = _compute_cost_str(today_rows, pricing_table)
867
- week["cost"] = _compute_cost_str(week_rows, pricing_table)
868
- month["cost"] = _compute_cost_str(month_rows, pricing_table)
1134
+ range_stat["cost"] = _compute_cost_str(range_rows, pricing_table)
869
1135
 
870
1136
  result = {
871
1137
  "version": __version__,
872
1138
  "today": today,
873
- "week": week,
874
- "month": month,
1139
+ "range": range_stat,
875
1140
  "failover_stats": failover_stats,
876
1141
  }
877
1142
  return Response(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.2.3a2
3
+ Version: 0.2.3a4
4
4
  Summary: A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao...
5
5
  Project-URL: Source Code, https://github.com/ThreeFish-AI/coding-proxy
6
6
  Project-URL: User Guide, https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md
@@ -17,7 +17,7 @@ coding/proxy/compat/canonical.py,sha256=-zcuEwZ402xeH3C545RmuYRkT2HDuvFloyFydDv8
17
17
  coding/proxy/compat/session_store.py,sha256=B9IFjjQJnHMg1244m__jG9gnqGWi26-JyEtwopEri6Q,5244
18
18
  coding/proxy/config/__init__.py,sha256=hzgU5noJGecjj13UY38cC_p6jpWO3GO7okSW-A2XkJ0,127
19
19
  coding/proxy/config/auth_schema.py,sha256=LYrJQU_fgW-6AoQdjXt4-MgPJjXjv9HrghlCcZwotnA,696
20
- coding/proxy/config/config.default.yaml,sha256=WT5wK3T9h3ODyz7ydicieC4PGZTHq-419FV__dxdj4M,16575
20
+ coding/proxy/config/config.default.yaml,sha256=NIVvRD_mzMvsuDtyRnF5SGCB1OviT73DUQIVHuz8ni4,16575
21
21
  coding/proxy/config/loader.py,sha256=1J_RBJgjuC8RwB2mwewLMS7vy9WEhUqttZG2igeDm-w,8984
22
22
  coding/proxy/config/resiliency.py,sha256=GnzY-LoyfFqXFM1l6xEru418v-cKlv97-HM0noGPdks,1308
23
23
  coding/proxy/config/routing.py,sha256=aJMhfCRyoZIvemM3Q2_KV9rpWxUsFnoY0ZdCr4TwSs8,11765
@@ -31,7 +31,7 @@ coding/proxy/convert/gemini_sse_adapter.py,sha256=b7zQ9wBBn1bbG46WpLrAYOl0ExpyMT
31
31
  coding/proxy/convert/gemini_to_anthropic.py,sha256=EE_rUsLmWTsF2QSYWqIYzsjyk1mKXFp2sziqj_S2W5U,3446
32
32
  coding/proxy/convert/openai_to_anthropic.py,sha256=8rg8NZs_1up27MixrXRJAfmSiYrOkh_lkAJuytDGZow,3694
33
33
  coding/proxy/logging/__init__.py,sha256=6zEh2CELMJ5aZvM6PNCOFlZ55De8xoC6QNyMAa9WeCA,6755
34
- coding/proxy/logging/db.py,sha256=uARNaaAXWxemwfyybp660ujpi6UikNtFokG0pzXYwR8,19058
34
+ coding/proxy/logging/db.py,sha256=MwpWxS9Rf4QhTj0_VuBdnqNjY3oJtEvdFq_T2AvPSCQ,19102
35
35
  coding/proxy/logging/formatters.py,sha256=8LDqiNAxGcfn9fsHqZzdK9TKGqOGNAOls75B1byBjnM,823
36
36
  coding/proxy/logging/stats.py,sha256=JlhJC4a2RmuCjbaseSUONScbcgn41KNR_sNsZ0OScbw,9203
37
37
  coding/proxy/model/__init__.py,sha256=E8E59yabP-CF5GIwHE4OEGPLiJj3yTLjFWmCorkQTxc,3947
@@ -44,9 +44,9 @@ coding/proxy/model/vendor.py,sha256=Yg4AYN7tgjHaGWRGEccCs3AdO8vBc-KxIelnqRowpyo,
44
44
  coding/proxy/routing/__init__.py,sha256=2Tpc6MQ5Oy_w-YlEWQVDSgcSgiHp3OwKOABIjj5kfHA,1595
45
45
  coding/proxy/routing/circuit_breaker.py,sha256=hOyoY_RWB5dTVbipyuiWfAelnva3cZRLbXrSmSn8KTs,8096
46
46
  coding/proxy/routing/error_classifier.py,sha256=oDxp69sCb-pdfe1hWce29A9tsN3jAbhqfl2fRECBtCo,3747
47
- coding/proxy/routing/executor.py,sha256=vnXmmaWw0w49HhKsNine37Hnye04NSZ0vc9ghMzqLdQ,30268
47
+ coding/proxy/routing/executor.py,sha256=mE-vfLCxxGMfW2HQSQ49aXgoTfDZPsKY914cni-PjQs,30420
48
48
  coding/proxy/routing/model_mapper.py,sha256=b72CGRdusLAwoq7p8-8TWAv9-Zv8BozssiZC8XnNTx4,3574
49
- coding/proxy/routing/quota_guard.py,sha256=EX4ROzBxKeK_o850mGNY1c21n96f-UxifZeUJD_yJQs,7740
49
+ coding/proxy/routing/quota_guard.py,sha256=uoQSr2siv6aIBVylvXG_PsfdpY_i4QJxc-WHz4MGDa4,7791
50
50
  coding/proxy/routing/rate_limit.py,sha256=i2cCqtbmXrP6wjRUTWXLP4DdYwVj0C4o59QdYGkPQj0,5122
51
51
  coding/proxy/routing/retry.py,sha256=KEYVToBkBOtXdqY7He70RrtVWFqIpnOQf2VVksdLrlk,2386
52
52
  coding/proxy/routing/router.py,sha256=x5x6cjL5BOnRXZZuzK3GCnNtaMVjw75xw2OQ-DoO9_s,5830
@@ -56,7 +56,7 @@ coding/proxy/routing/usage_parser.py,sha256=j4G0ArFduQ2D4Yeuad94DVlwdc-JvSh7SJLV
56
56
  coding/proxy/routing/usage_recorder.py,sha256=pObOrX2yIITTiyojl1fJcqO0yWWpbP4KqsJvFdmlt04,6273
57
57
  coding/proxy/server/__init__.py,sha256=KeH7mEu36v9v27m3VgeSxSeFz9sLTJrxS_EVATJ7Vks,20
58
58
  coding/proxy/server/app.py,sha256=kRGgb772dZu8200LPnn7Nt0IU5oagcbBf_Vc5ynxxzE,5599
59
- coding/proxy/server/dashboard.py,sha256=r2_4-7Jn4hpYhfU7G-3W8FGOgTaRKWQj3PsN8SFajcE,34859
59
+ coding/proxy/server/dashboard.py,sha256=V3gxYNRZz5S7-hi137VolpKNVlvyrbgZQfRjOS5phD4,44712
60
60
  coding/proxy/server/factory.py,sha256=w8VFvxoogw9K9sO8MlT6bIP7xM7mR6sCohrZle9y_Gg,9985
61
61
  coding/proxy/server/request_normalizer.py,sha256=XUqpZP42_DJmsoX9aMFVk7oLWDeG3UgXdmCv0A76FN4,12334
62
62
  coding/proxy/server/responses.py,sha256=i0ugnLRNOdRYGHEWxkwsxR35ChmdMQsSaD8AjRluTn4,2167
@@ -80,8 +80,8 @@ coding/proxy/vendors/native_anthropic.py,sha256=SxtM71PDci0gqLqiwCrFnT410SnSoD7F
80
80
  coding/proxy/vendors/token_manager.py,sha256=s10t4Com0jNnKGkPyJ_HpG5SjHrCEJvfArEOAaPKA_k,4189
81
81
  coding/proxy/vendors/xiaomi.py,sha256=E-GcmJBZh7GOtDFonxZmlf0hKRhrlrXzL0IxHFRYcRo,860
82
82
  coding/proxy/vendors/zhipu.py,sha256=3j_rqNFu1CX-B5ugtrL6Y1OeWSy9yiqsVa9Bi1ssaAA,1062
83
- coding_proxy-0.2.3a2.dist-info/METADATA,sha256=YINKvqEA_wFWpR0rK9CHjf96doxPiIDI9WMeS_URVSY,10819
84
- coding_proxy-0.2.3a2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
85
- coding_proxy-0.2.3a2.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
86
- coding_proxy-0.2.3a2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
87
- coding_proxy-0.2.3a2.dist-info/RECORD,,
83
+ coding_proxy-0.2.3a4.dist-info/METADATA,sha256=-lTOQrW0TP9A8dyPrsSmZFds6_f0qW4zGzTPyKlqero,10819
84
+ coding_proxy-0.2.3a4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
85
+ coding_proxy-0.2.3a4.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
86
+ coding_proxy-0.2.3a4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
87
+ coding_proxy-0.2.3a4.dist-info/RECORD,,