Skip to content

Commit 16f33d9

Browse files
committed
今日消耗与站点对齐;耗尽预警阈值支持小时单位
- Sub2API 站点的今日消耗直接读取站点用户仪表盘同款接口 (/api/v1/usage/dashboard/stats 的 today_actual_cost),与站点显示完全一致; 老版本站点或其他类型按余额历史下降推算,界面以 ≈ 标注为估算值 - 总览新增「今日总消耗」统计卡,「日均总消耗」更名为「日均消耗(估算)」以免混淆; 站点行与趋势弹窗同步展示今日消耗 - 耗尽预警阈值支持按天或小时设置:内部统一按天存储(下限 1 小时), 界面提供单位切换并自动换算,非法输入保留原值; 告警文案不足一天时按小时表述(如「预计 12 小时内耗尽」) - 内置演示站补充同款 stats 接口
1 parent 57e1c04 commit 16f33d9

8 files changed

Lines changed: 134 additions & 22 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88
## 功能
99

10-
- **总览面板**:总剩余余额、日均总消耗、低余额 / 耗尽、查询异常统计;**总余额趋势图**(全站合计、24 小时~30 天切换、悬停查看分站明细)与**日均消耗对比图**;每站余额、用量进度条、状态标签、查询延迟
10+
- **总览面板**:总剩余余额、**今日总消耗**、日均消耗(估算)、低余额 / 耗尽、查询异常统计;**总余额趋势图**(全站合计、24 小时~30 天切换、悬停查看分站明细)与**日均消耗对比图**;每站余额、今日消耗、用量进度条、状态标签、查询延迟
11+
- **今日消耗与站点一致**:Sub2API 站点直接读取站点用户仪表盘同款接口(`/api/v1/usage/dashboard/stats``today_actual_cost`,即今日实际扣费),与站点页面显示的数值完全一致;其他类型按余额历史推算并以 ≈ 标注
1112
- **Sub2API 账号密码模式**:只填邮箱 + 密码,面板自动登录换取令牌;令牌过期时自动用 refresh_token 刷新(支持轮换),刷新失败自动用密码重新登录——**全程无需人工干预**
1213
- **余额预测**:记录余额历史(30 天),线性回归估算日均消耗与预计耗尽时间;点击任意站点查看趋势图(历史折线 + 虚线耗尽投影 + 悬停查看);识别充值,只按最近一段消耗回归
13-
- **通知告警**:余额偏低 / 耗尽 / 查询失败 / 恢复正常 / 预计 N 天内耗尽,状态迁移触发、自动去重、可配重复提醒间隔;支持 10 种渠道:
14+
- **通知告警**:余额偏低 / 耗尽 / 查询失败 / 恢复正常 / 预计即将耗尽(**阈值可按天或小时设置**,状态迁移触发、自动去重、可配重复提醒间隔;支持 10 种渠道:
1415
Telegram、钉钉(含加签)、企业微信、飞书(含签名)、Bark、ntfy、Server酱(含 sctp 新版)、Resend 邮件、SMTP 邮件(内置零依赖客户端,465 SSL / 587 STARTTLS)、自定义 Webhook,每个渠道可单独测试
1516
- **面板登录**:网页需要账号密码登录(scrypt 哈希 + HMAC 签名会话 Cookie,7 天有效;登录失败限流);默认账号 `admin / admin123`,登录后请在「设置」中修改
1617
- **内置演示**:首次启动自动创建演示中转站(数据来自内置 mock,含完整的登录→过期→自动续期链路演示),可直接删除

lib/alerts.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@ export const DEFAULT_RULES = {
77
onError: true, // 查询失败
88
onRecover: true, // 恢复正常
99
onEta: true, // 预计耗尽天数过近
10-
etaDays: 3, // 预计 N 天内耗尽则告警
10+
etaDays: 3, // 预计 N 天内耗尽则告警(内部统一按天存储,支持小数)
11+
etaUnit: "days", // 界面展示单位:days | hours
1112
renotifyHours: 24, // 同一异常状态的重复提醒间隔
1213
};
1314

15+
// 不足一天用小时表述,避免出现「预计 0.3 天内耗尽」
16+
export function fmtEta(days) {
17+
return days >= 1 ? `${days} 天` : `${Math.max(1, Math.round(days * 24))} 小时`;
18+
}
19+
1420
const STATE_LABEL = {
1521
ok: "正常",
1622
warn: "余额偏低",
@@ -44,7 +50,7 @@ function buildMessage(station, state, prediction) {
4450
lines.push(`错误:${b.error}`);
4551
}
4652
if (prediction?.etaDays != null) {
47-
lines.push(`日均消耗:${fmtUsd(prediction.burnPerDay)}/天,预计 ${prediction.etaDays} 天后耗尽`);
53+
lines.push(`日均消耗:${fmtUsd(prediction.burnPerDay)}/天,预计 ${fmtEta(prediction.etaDays)}后耗尽`);
4854
}
4955
lines.push(`时间:${new Date().toLocaleString("zh-CN", { hour12: false })}`);
5056
return lines.join("\n");
@@ -84,7 +90,7 @@ export async function evaluateStation(station, prediction, rules, channels, glob
8490
prediction.etaDays <= r.etaDays &&
8591
(!prev.etaNotifiedAt ||
8692
(r.renotifyHours > 0 && now - prev.etaNotifiedAt > r.renotifyHours * 3600000))) {
87-
notify = { title: `【耗尽预警】${station.name} 预计 ${prediction.etaDays} 天内耗尽`, isEta: true };
93+
notify = { title: `【耗尽预警】${station.name} 预计 ${fmtEta(prediction.etaDays)}内耗尽`, isEta: true };
8894
}
8995

9096
if (notify) {

lib/history.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ export class History {
5555
this.scheduleSave();
5656
}
5757

58+
/**
59+
* 自某时刻起的实际消耗:累加相邻快照间的余额下降(上升视为充值,忽略)。
60+
* 基线取 since 之前的最后一个快照;面板离线期间的消耗会在下一个快照补上。
61+
*/
62+
usedSince(stationId, sinceTs) {
63+
const arr = this.data[stationId] || [];
64+
if (arr.length < 2) return 0;
65+
let start = 0;
66+
for (let i = 0; i < arr.length; i++) if (arr[i][0] <= sinceTs) start = i;
67+
let used = 0;
68+
for (let i = start + 1; i < arr.length; i++) {
69+
const drop = arr[i - 1][1] - arr[i][1];
70+
if (drop > 0) used += drop;
71+
}
72+
return Math.round(used * 100) / 100;
73+
}
74+
5875
points(stationId, hours = 72) {
5976
const arr = this.data[stationId] || [];
6077
const cutoff = Date.now() - hours * 3600 * 1000;

lib/providers.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,24 @@ async function querySub2Api(station) {
261261
if (r.status >= 300) throw new Error(httpErrorMessage(r));
262262

263263
const data = unwrapEnvelope(r, "查询");
264-
return { ...parseSub2ApiMe(data), tokensChanged };
264+
265+
// 今日消耗:读取站点用户仪表盘同款接口(today_actual_cost = 今日实际扣费),
266+
// 与站点页面显示完全一致;老版本没有该接口时静默降级为历史推算
267+
let todayUsed = null, todayRequests = null;
268+
try {
269+
const st = await request(`${base}/api/v1/usage/dashboard/stats`, {
270+
headers: { Authorization: `Bearer ${bearerOf()}` },
271+
});
272+
if (st.status < 300 && st.body?.code === 0 && st.body.data) {
273+
const d = st.body.data;
274+
if (Number.isFinite(Number(d.today_actual_cost))) {
275+
todayUsed = round2(Number(d.today_actual_cost));
276+
todayRequests = Number.isFinite(Number(d.today_requests)) ? Number(d.today_requests) : null;
277+
}
278+
}
279+
} catch { /* 不影响余额查询 */ }
280+
281+
return { ...parseSub2ApiMe(data), todayUsed, todayRequests, tokensChanged };
265282
}
266283

267284
const HANDLERS = {

lib/store.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ export class Store {
145145
for (const k of ["onLow", "onExhaust", "onError", "onRecover", "onEta"]) {
146146
if (k in patch) r[k] = !!patch[k];
147147
}
148-
if ("etaDays" in patch) r.etaDays = Math.max(0.5, Number(patch.etaDays) || 3);
148+
if ("etaDays" in patch) {
149+
const v = Number(patch.etaDays);
150+
// 非法输入保留原值;下限 1 小时(阈值支持按小时配置)
151+
if (Number.isFinite(v) && v > 0) r.etaDays = Math.max(1 / 24, Math.round(v * 10000) / 10000);
152+
}
153+
if ("etaUnit" in patch) r.etaUnit = patch.etaUnit === "hours" ? "hours" : "days";
149154
if ("renotifyHours" in patch) r.renotifyHours = Math.max(0, Number(patch.renotifyHours) || 0);
150155
await this.save();
151156
return r;

public/app.js

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,15 @@ function fmtClock(ts) {
108108
const p = (n) => String(n).padStart(2, "0");
109109
return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}`;
110110
}
111+
function fmtEtaText(days) {
112+
return days >= 1 ? `${days} 天` : `${Math.max(1, Math.round(days * 24))} 小时`;
113+
}
111114
function etaText(p) {
112115
if (!p) return null;
113116
if (p.burnPerDay === 0) return { text: "近期无消耗", cls: "" };
114117
if (p.etaDays == null) return null;
115118
const cls = p.etaDays <= (state.rules.etaDays ?? 3) ? "danger" : p.etaDays <= 7 ? "warn" : "";
116-
const t = p.etaDays >= 1 ? `${p.etaDays} 天` : `${Math.max(1, Math.round(p.etaDays * 24))} 小时`;
117-
return { text: `≈ ${usd(p.burnPerDay)}/天 · 预计 ${t}后耗尽`, cls };
119+
return { text: `≈ ${usd(p.burnPerDay)}/天 · 预计 ${fmtEtaText(p.etaDays)}后耗尽`, cls };
118120
}
119121

120122
// ---- 登录 -------------------------------------------------------------------
@@ -175,7 +177,13 @@ function stationRow(s) {
175177
? `<div class="st-bar"><div class="progress"><i class="${cls}" style="width:${usedPct}%"></i></div><span class="st-usage">已用 ${usd(b.used)} / ${usd(b.total)}</span></div>`
176178
: "";
177179
const eta = etaText(s.prediction);
178-
const predict = eta ? `<div class="st-predict"><span class="${eta.cls}">${eta.text}</span><span>· 点击查看趋势</span></div>` : "";
180+
const pieces = [];
181+
if (b && b.ok && s.todayUsed != null) {
182+
pieces.push(`<span>今日消耗 ${s.todayIsEstimate ? "≈" : ""}${usd(s.todayUsed)}</span>`);
183+
}
184+
if (eta) pieces.push(`<span class="${eta.cls}">${eta.text}</span>`);
185+
if (pieces.length) pieces.push("<span>点击查看趋势</span>");
186+
const predict = pieces.length ? `<div class="st-predict">${pieces.join("<span>·</span>")}</div>` : "";
179187
return `
180188
<div class="st-row" data-id="${s.id}">
181189
<div class="st-plate">${PLATE[s.type] || "?"}</div>
@@ -207,14 +215,18 @@ function renderDashboard() {
207215
const totalRemaining = okList.reduce((a, s) => a + s.balance.remaining, 0);
208216
const totalUsed = okList.reduce((a, s) => a + s.balance.used, 0);
209217
const totalBurn = list.reduce((a, s) => a + (s.prediction?.burnPerDay || 0), 0);
218+
const todayTotal = list.reduce((a, s) => a + (s.todayUsed || 0), 0);
219+
// 任一站点的今日消耗是历史推算值时,合计也只能算约数
220+
const todayApprox = list.some((s) => (s.todayUsed || 0) > 0 && s.todayIsEstimate);
210221
const lowCount = list.filter((s) => ["warn", "danger"].includes(statusOf(s))).length;
211222
const errCount = list.filter((s) => statusOf(s) === "error").length;
212223

213224
$("#headerActions").innerHTML = HDR_BTNS;
214225
const stats = `
215-
<div class="stats">
226+
<div class="stats stats-5">
216227
<div class="stat-card"><div class="label">总剩余余额</div><div class="value">${usd(totalRemaining)}</div></div>
217-
<div class="stat-card"><div class="label">日均总消耗</div><div class="value">${totalBurn > 0 ? usd(totalBurn) : "—"}</div></div>
228+
<div class="stat-card"><div class="label">今日总消耗</div><div class="value">${todayApprox ? "≈ " : ""}${usd(todayTotal)}</div></div>
229+
<div class="stat-card"><div class="label">日均消耗(估算)</div><div class="value">${totalBurn > 0 ? usd(totalBurn) : "—"}</div></div>
218230
<div class="stat-card"><div class="label">低余额 / 耗尽</div><div class="value ${lowCount ? "warn" : ""}">${lowCount}<small>个</small></div></div>
219231
<div class="stat-card"><div class="label">查询异常</div><div class="value ${errCount ? "danger" : ""}">${errCount}<small>个</small></div></div>
220232
</div>`;
@@ -484,8 +496,14 @@ function renderNotify() {
484496
${tg("onRecover", "恢复正常", "从异常状态恢复后通知")}
485497
${tg("onEta", "耗尽预警", "按消耗速度预计即将耗尽时通知")}
486498
<div class="set-row">
487-
<div><div class="set-title">耗尽预警阈值</div><div class="set-desc">预计 N 天内耗尽则触发「耗尽预警」</div></div>
488-
<div class="field-inline"><input class="input small" id="rule-etaDays" value="${r.etaDays ?? 3}"><span class="set-desc">天</span></div>
499+
<div><div class="set-title">耗尽预警阈值</div><div class="set-desc">预计在该时间内耗尽则触发「耗尽预警」,可按天或小时设置</div></div>
500+
<div class="field-inline">
501+
<input class="input small" id="rule-etaVal" value="${etaRuleDisplay(r)}">
502+
<select class="select small" id="rule-etaUnit">
503+
<option value="days"${r.etaUnit === "hours" ? "" : " selected"}>天</option>
504+
<option value="hours"${r.etaUnit === "hours" ? " selected" : ""}>小时</option>
505+
</select>
506+
</div>
489507
</div>
490508
<div class="set-row">
491509
<div><div class="set-title">重复提醒间隔</div><div class="set-desc">同一异常持续存在时,每隔 N 小时再次提醒(0 = 只提醒一次)</div></div>
@@ -496,6 +514,20 @@ function renderNotify() {
496514
<button class="btn btn-primary" id="rulesSave">保存</button>
497515
</div>
498516
</div>`;
517+
518+
// 切换单位时把输入值换算过去(两个单位间必然是互换)
519+
$("#rule-etaUnit").onchange = () => {
520+
const inp = $("#rule-etaVal");
521+
const v = Number(inp.value);
522+
if (!Number.isFinite(v) || v <= 0) return;
523+
inp.value = $("#rule-etaUnit").value === "hours" ? +(v * 24).toFixed(2) : +(v / 24).toFixed(2);
524+
};
525+
}
526+
527+
// 阈值内部按天存储;界面按所选单位展示
528+
function etaRuleDisplay(r) {
529+
const days = Number(r.etaDays ?? 3);
530+
return r.etaUnit === "hours" ? +(days * 24).toFixed(2) : +days.toFixed(2);
499531
}
500532

501533
// ---- 设置页 -----------------------------------------------------------------
@@ -736,8 +768,9 @@ async function openTrend(station) {
736768
const eta = etaText(prediction);
737769
$("#trendStats").innerHTML = `
738770
<div class="stat-card"><div class="label">当前余额</div><div class="value">${b?.ok ? usd(b.remaining) : "—"}</div></div>
739-
<div class="stat-card"><div class="label">日均消耗</div><div class="value">${prediction?.burnPerDay > 0 ? usd(prediction.burnPerDay) : "—"}</div></div>
740-
<div class="stat-card"><div class="label">预计耗尽</div><div class="value ${eta?.cls || ""}">${prediction?.etaDays != null ? prediction.etaDays + " 天" : "—"}</div></div>`;
771+
<div class="stat-card"><div class="label">今日消耗</div><div class="value">${station.todayUsed != null ? (station.todayIsEstimate ? "≈ " : "") + usd(station.todayUsed) : "—"}</div></div>
772+
<div class="stat-card"><div class="label">日均消耗(估算)</div><div class="value">${prediction?.burnPerDay > 0 ? usd(prediction.burnPerDay) : "—"}</div></div>
773+
<div class="stat-card"><div class="label">预计耗尽</div><div class="value ${eta?.cls || ""}">${prediction?.etaDays != null ? fmtEtaText(prediction.etaDays) : "—"}</div></div>`;
741774
drawChart($("#trendChart"), points, prediction);
742775
} catch (e) {
743776
if (seq !== trendSeq) return;
@@ -884,13 +917,16 @@ $(".main").addEventListener("click", async (e) => {
884917
}
885918
if (e.target.closest("#rulesSave")) {
886919
try {
920+
const unit = $("#rule-etaUnit").value;
921+
const val = Number($("#rule-etaVal").value);
887922
const r = await api.saveRules({
888-
etaDays: Number($("#rule-etaDays").value),
923+
etaDays: unit === "hours" ? val / 24 : val, // 内部统一按天
924+
etaUnit: unit,
889925
renotifyHours: Number($("#rule-renotify").value),
890926
});
891927
state.rules = r.rules;
892-
// 回显服务端钳制后的值(比如 0 天会被修正为 3),不然界面显示的是没生效的输入
893-
$("#rule-etaDays").value = state.rules.etaDays;
928+
// 回显服务端钳制后的值(如非法输入被忽略、下限 1 小时),不然界面显示的是没生效的输入
929+
$("#rule-etaVal").value = etaRuleDisplay(state.rules);
894930
$("#rule-renotify").value = state.rules.renotifyHours;
895931
toast("规则已保存");
896932
} catch (err) { toast(err.message, "err"); }

public/styles.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ button { font-family: inherit; cursor: pointer; }
112112
.content { flex: 1; overflow: auto; padding: 18px var(--content-padding) 24px; }
113113

114114
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
115+
.stats.stats-5 { grid-template-columns: repeat(5, 1fr); }
115116
.stat-card {
116117
background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-md);
117118
padding: 13px 15px; box-shadow: var(--shadow-card);
@@ -197,6 +198,7 @@ button { font-family: inherit; cursor: pointer; }
197198
.input:focus, .select:focus { outline: none; border-color: var(--primary); }
198199
.input.small { height: 32px; width: 96px; text-align: right; }
199200
.select { appearance: none; background-image: none; }
201+
.select.small { height: 32px; width: 72px; padding: 0 8px; }
200202

201203
/* 开关 */
202204
.toggle { width: 44px; height: 25px; border-radius: 999px; background: var(--toggle-off); position: relative; border: none; flex-shrink: 0; transition: background .18s; }
@@ -232,9 +234,13 @@ button { font-family: inherit; cursor: pointer; }
232234
.toast.err { color: var(--text-danger); }
233235
.toast.ok .dot, .toast.err .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
234236

237+
@media (max-width: 1000px) {
238+
.stats.stats-5 { grid-template-columns: repeat(3, 1fr); }
239+
}
235240
@media (max-width: 720px) {
236-
.stats { grid-template-columns: repeat(2, 1fr); }
241+
.stats, .stats.stats-5 { grid-template-columns: repeat(2, 1fr); }
237242
.st-bar { display: none; }
243+
.trend-stats { grid-template-columns: repeat(2, 1fr); }
238244
}
239245

240246
/* ---- 登录屏 ---- */
@@ -292,7 +298,7 @@ button { font-family: inherit; cursor: pointer; }
292298

293299
/* ---- 弹窗宽版 + 趋势图 ---- */
294300
.modal-wide { width: min(680px, 100%); }
295-
.trend-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
301+
.trend-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
296302
.trend-stats .stat-card { padding: 10px 12px; }
297303
.trend-stats .stat-card .value { font-size: 17px; }
298304
.chart-wrap { position: relative; margin-top: 4px; }

server.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function mockState(acc) {
4343
const a = MOCK_ACCOUNTS[acc] || MOCK_ACCOUNTS["np-pro"];
4444
const minutes = (Date.now() - BOOT) / 60000;
4545
const used = Math.min(a.baseUsedUsd + minutes * a.drain, a.grantUsd);
46-
return { name: a.name, grantUsd: a.grantUsd, usedUsd: used };
46+
return { name: a.name, grantUsd: a.grantUsd, usedUsd: used, drain: a.drain };
4747
}
4848
const mock = express.Router();
4949
const needAuth = (req, res) => {
@@ -136,6 +136,24 @@ mock.get("/sub2api/:acc/api/v1/auth/me", (req, res) => {
136136
});
137137
}
138138
});
139+
// 用户仪表盘统计(与真实 Sub2API 的 /usage/dashboard/stats 契约一致)
140+
mock.get("/sub2api/:acc/api/v1/usage/dashboard/stats", (req, res) => {
141+
if (!needAuth(req, res)) return;
142+
const s = mockState(req.params.acc);
143+
const midnight = new Date();
144+
midnight.setHours(0, 0, 0, 0);
145+
const minToday = (Date.now() - midnight.getTime()) / 60000;
146+
const todayCost = Math.min(minToday * s.drain, s.usedUsd);
147+
res.json({
148+
code: 0, message: "success",
149+
data: {
150+
today_actual_cost: Number(todayCost.toFixed(4)),
151+
today_cost: Number((todayCost * 1.15).toFixed(4)),
152+
today_requests: Math.round(todayCost * 40),
153+
total_actual_cost: Number(s.usedUsd.toFixed(4)),
154+
},
155+
});
156+
});
139157
app.use("/mock", mock);
140158

141159
async function seedDemo() {
@@ -384,6 +402,10 @@ app.put("/api/settings", async (req, res) => {
384402
// 隐藏敏感凭证,仅返回是否已配置
385403
function redact(s) {
386404
const { accessToken, apiKey, password, s2Tokens, ...rest } = s;
405+
// 今日消耗:sub2api 直接用站点仪表盘接口的值;拿不到就按余额历史推算(前端标 ≈)
406+
const midnight = new Date();
407+
midnight.setHours(0, 0, 0, 0);
408+
const fromSite = s.balance?.todayUsed;
387409
return {
388410
...rest,
389411
hasAccessToken: !!accessToken,
@@ -393,6 +415,8 @@ function redact(s) {
393415
? { expiresAt: s2Tokens.expiresAt || null, lastLoginAt: s2Tokens.lastLoginAt || null }
394416
: null,
395417
prediction: history.predict(s.id),
418+
todayUsed: fromSite ?? history.usedSince(s.id, midnight.getTime()),
419+
todayIsEstimate: fromSite == null,
396420
};
397421
}
398422

0 commit comments

Comments
 (0)