Skip to content

Commit 0a1c89b

Browse files
committed
每日日报:定时汇总昨日经营情况并推送 (v1.10.0)
- 通知页新增「每日日报」配置:启用开关、发送时间(服务器时区)、 渠道多选(不选=全部启用渠道),支持预览弹窗与立即发送 - 报告内容:昨日消费(环比 + 对近7天日均)、收入(不含管理员)/管理员消耗/ 成本/利润/利润率、请求与 Tokens 与活跃用户、Top5 模型与用户(标管理员)、 昨日成本明细(按用量/固定摊销 + 未纳入渠道数)、上游余额与耗尽预警、 用户余额预收合计、今日与未来 7 天预测(含区间与回测偏差) - 调度器每 30 秒检查 HH:MM 命中,lastSent 持久化防重发(重启安全) - 邮件渠道(Resend/SMTP)正文上限放宽至 2 万字符,IM 渠道保持 1500 截断
1 parent ccaf356 commit 0a1c89b

8 files changed

Lines changed: 301 additions & 3 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
各匹配上游的期内成本(按用量 × 充值汇率;「固定成本」渠道按天摊销:
3131
每次付费金额 ÷ 覆盖天数 × 窗口天数,且无论是否匹配到渠道都计入;
3232
用量接口不可用时退回余额下降推算)。未匹配的渠道单独列出,加入监控即可参与计算
33+
- **每日日报**:可设定每天固定时间(服务器时区)自动汇总昨日经营情况——消费环比、
34+
收入/成本/利润、Top 模型与用户、成本明细、上游余额与耗尽预警、用户余额预收、
35+
今日与未来 7 天预测——通过通知渠道推送(邮件渠道发全文,IM 渠道自动截断);
36+
支持预览与立即发送
3337
- **人民币折算**:每个站点可配置充值汇率(站点 $1 折合 ¥ 多少,如 1:2 充值);
3438
余额、消耗、图表等金额主显人民币(未配置按 1:1),站点原始余额作为次要信息展示;
3539
**余额告警阈值仍按站点余额判断**,不受汇率影响

lib/notify.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ export async function sendToChannel(channel, title, body, extra = {}) {
211211
const sender = SENDERS[channel.type];
212212
if (!sender) return { ok: false, error: `未知渠道类型:${channel.type}` };
213213
try {
214-
await sender(channel.config || {}, clamp(title, 200), clamp(body), extra);
214+
// 邮件渠道放宽正文长度(日报等长文),IM 类渠道按平台上限截断
215+
const limit = channel.type === "resend" || channel.type === "smtp" ? 20000 : 1500;
216+
await sender(channel.config || {}, clamp(title, 200), clamp(body, limit), extra);
215217
return { ok: true };
216218
} catch (err) {
217219
return { ok: false, error: err?.message || String(err) };

lib/store.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { DEFAULT_RULES } from "./alerts.js";
77
const DEFAULT_SETTINGS = {
88
refreshIntervalSec: 60, // 后台自动刷新间隔
99
lowBalanceUsd: 5, // 全局低余额告警阈值(美元)
10+
// 每日日报:按服务器时区定时汇总昨日「我的站点」经营情况并推送
11+
dailyReport: { enabled: false, time: "09:00", channelIds: [], lastSent: null },
1012
};
1113

1214
function uid(prefix = "st") {
@@ -111,6 +113,20 @@ export class Store {
111113
}
112114

113115
async updateSettings(patch) {
116+
// dailyReport 做字段级合并与校验,保留 lastSent
117+
if (patch.dailyReport && typeof patch.dailyReport === "object") {
118+
const cur = this.data.settings.dailyReport || DEFAULT_SETTINGS.dailyReport;
119+
const p = patch.dailyReport;
120+
patch = {
121+
...patch,
122+
dailyReport: {
123+
enabled: "enabled" in p ? !!p.enabled : cur.enabled,
124+
time: /^\d{2}:\d{2}$/.test(p.time || "") ? p.time : cur.time,
125+
channelIds: Array.isArray(p.channelIds) ? p.channelIds.map(String) : cur.channelIds,
126+
lastSent: cur.lastSent ?? null,
127+
},
128+
};
129+
}
114130
this.data.settings = { ...this.data.settings, ...patch };
115131
await this.save();
116132
return this.data.settings;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "relay-monitor",
3-
"version": "1.9.0",
3+
"version": "1.10.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const api = {
6363
removeChannel: (id) => call("/api/notifications/channels/" + id, { method: "DELETE" }),
6464
testChannel: (b) => call("/api/notifications/test", { body: b }),
6565
saveRules: (b) => call("/api/notifications/rules", { method: "PUT", body: b }),
66+
reportPreview: () => call("/api/report/preview", { method: "POST", body: {} }),
67+
reportSend: () => call("/api/report/send", { method: "POST", body: {} }),
6668
login: (b) => call("/api/auth/login", { body: b }),
6769
logout: () => call("/api/auth/logout", { method: "POST", body: {} }),
6870
changePassword: (b) => call("/api/auth/password", { body: b }),
@@ -609,7 +611,33 @@ function renderNotify() {
609611
<div><div class="set-title">保存规则</div><div class="set-desc">应用阈值与间隔修改</div></div>
610612
<button class="btn btn-primary" id="rulesSave">保存</button>
611613
</div>
614+
</div>
615+
<div class="section-head" style="margin-top:20px"><h2>每日日报</h2>
616+
<span class="muted">定时汇总昨日「我的站点」经营情况并推送(时间按服务器时区)</span></div>
617+
<div class="panel settings-list">
618+
<div class="set-row">
619+
<div><div class="set-title">启用日报</div><div class="set-desc">每天在设定时间生成并发送昨日报告</div></div>
620+
<button class="toggle ${state.settings.dailyReport?.enabled ? "on" : ""}" id="dr-enabled"></button>
621+
</div>
622+
<div class="set-row">
623+
<div><div class="set-title">发送时间</div><div class="set-desc">服务器时区的每日时刻</div></div>
624+
<div class="field-inline"><input type="time" class="input small" style="width:112px;text-align:center" id="dr-time" value="${esc(state.settings.dailyReport?.time || "09:00")}"></div>
625+
</div>
626+
<div class="set-row">
627+
<div><div class="set-title">发送渠道</div><div class="set-desc">不勾选 = 所有启用的渠道;日报较长,建议勾选邮件渠道</div></div>
628+
<div class="dr-channels">${state.channels.map((c) =>
629+
`<label class="chk-line"><input type="checkbox" data-drch="${c.id}"${(state.settings.dailyReport?.channelIds || []).includes(c.id) ? " checked" : ""}> ${esc(c.name)}</label>`).join("") || '<span class="muted">先添加通知渠道</span>'}</div>
630+
</div>
631+
<div class="set-row">
632+
<div><div class="set-title">保存与测试</div><div class="set-desc">预览按当前数据生成的报告,或立即发送一次</div></div>
633+
<div class="field-inline">
634+
<button class="btn btn-ghost" id="dr-preview">预览</button>
635+
<button class="btn btn-ghost" id="dr-send">立即发送</button>
636+
<button class="btn btn-primary" id="dr-save">保存</button>
637+
</div>
638+
</div>
612639
</div>`;
640+
$("#dr-enabled").onclick = () => $("#dr-enabled").classList.toggle("on");
613641

614642
// 切换单位时把输入值换算过去(两个单位间必然是互换)
615643
$("#rule-etaUnit").onchange = () => {
@@ -1588,6 +1616,8 @@ async function openTrend(station) {
15881616
}
15891617
$("#trendClose").onclick = () => $("#trendModal").classList.remove("open");
15901618
backdropClose("trendModal", () => $("#trendModal").classList.remove("open"));
1619+
$("#reportClose").onclick = () => $("#reportModal").classList.remove("open");
1620+
backdropClose("reportModal", () => $("#reportModal").classList.remove("open"));
15911621

15921622
function niceStep(rough) {
15931623
const pow = Math.pow(10, Math.floor(Math.log10(rough || 1)));
@@ -1776,6 +1806,45 @@ $(".main").addEventListener("click", async (e) => {
17761806
return;
17771807
}
17781808

1809+
// 日报:保存 / 预览 / 立即发送
1810+
if (e.target.closest("#dr-save")) {
1811+
try {
1812+
const channelIds = [...document.querySelectorAll("[data-drch]:checked")].map((el) => el.dataset.drch);
1813+
const r = await api.saveSettings({
1814+
dailyReport: {
1815+
enabled: $("#dr-enabled").classList.contains("on"),
1816+
time: $("#dr-time").value || "09:00",
1817+
channelIds,
1818+
},
1819+
});
1820+
state.settings = r.settings;
1821+
toast("日报设置已保存");
1822+
} catch (err) { toast(err.message, "err"); }
1823+
return;
1824+
}
1825+
const drPreview = e.target.closest("#dr-preview");
1826+
if (drPreview) {
1827+
drPreview.disabled = true;
1828+
try {
1829+
const r = await api.reportPreview();
1830+
$("#reportText").textContent = r.text;
1831+
$("#reportModal").classList.add("open");
1832+
} catch (err) { toast(err.message, "err"); }
1833+
finally { drPreview.disabled = false; }
1834+
return;
1835+
}
1836+
const drSend = e.target.closest("#dr-send");
1837+
if (drSend) {
1838+
drSend.disabled = true;
1839+
try {
1840+
const r = await api.reportSend();
1841+
const ok = r.results.filter((x) => x.ok).length;
1842+
toast(`日报已发送:${ok}/${r.results.length} 个渠道成功`, ok ? "ok" : "err");
1843+
} catch (err) { toast(err.message, "err"); }
1844+
finally { drSend.disabled = false; }
1845+
return;
1846+
}
1847+
17791848
// 渠道操作
17801849
const chBtn = e.target.closest("[data-chact]");
17811850
if (chBtn) {

public/index.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,22 @@ <h2 id="chModalTitle">添加通知渠道</h2>
200200
</div>
201201
</div>
202202

203+
<!-- 日报预览弹窗 -->
204+
<div class="overlay" id="reportModal">
205+
<div class="modal modal-wide">
206+
<div class="modal-head">
207+
<h2>日报预览</h2>
208+
<p>按当前数据生成的昨日报告(实际发送时按设定时间的数据)</p>
209+
</div>
210+
<div class="modal-body">
211+
<pre class="report-pre" id="reportText"></pre>
212+
</div>
213+
<div class="modal-foot">
214+
<button class="btn btn-secondary" id="reportClose">关闭</button>
215+
</div>
216+
</div>
217+
</div>
218+
203219
<!-- 趋势弹窗 -->
204220
<div class="overlay" id="trendModal">
205221
<div class="modal modal-wide">

public/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,15 @@ button { font-family: inherit; cursor: pointer; }
340340
.u-table td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
341341
.u-table td.u-empty { text-align: center; color: var(--text-tertiary); padding: 28px; }
342342

343+
/* ---- 日报 ---- */
344+
.dr-channels { display: flex; flex-wrap: wrap; gap: 6px 16px; max-width: 460px; justify-content: flex-end; }
345+
.report-pre {
346+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
347+
line-height: 1.7; white-space: pre-wrap; word-break: break-all;
348+
background: var(--bg-muted); border: 1px solid var(--border); border-radius: 8px; padding: 14px;
349+
max-height: 60vh; overflow: auto;
350+
}
351+
343352
/* ---- 固定成本付费记录编辑器 ---- */
344353
.purchase-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
345354
.purchase-row .input { height: 32px; }

0 commit comments

Comments
 (0)