Skip to content

Commit ae531cf

Browse files
committed
日报 HTML 版本:邮件渠道发送带图表的富文本报告 (v1.11.0)
- SMTP 客户端支持 multipart/alternative(纯文本 + HTML 双部分, 客户端优先渲染 HTML),mock 服务器验证结构与解码正确 - Resend 渠道带 html 字段;webhook 渠道剥离 html 避免载荷膨胀 - HTML 日报全内联样式 + 表格排版(Gmail 剥离 SVG、屏蔽 data:URI 图片, 色块/条形是全客户端可靠做法):KPI 卡片(利润盈亏着色)、 近 14 天消费趋势柱状图(昨日高亮)、Top 模型/用户比例条形榜、 成本明细、上游余额(耗尽预警标红)、展望 - 预览弹窗双视图:HTML(邮件效果)/ 纯文本(IM 渠道),iframe 沙箱渲染
1 parent 0a1c89b commit ae531cf

7 files changed

Lines changed: 193 additions & 20 deletions

File tree

lib/notify.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,34 +105,38 @@ async function sendServerChan(cfg, title, body) {
105105
}
106106
}
107107

108-
async function sendResend(cfg, title, body) {
108+
async function sendResend(cfg, title, body, extra) {
109109
const to = splitRecipients(cfg.to);
110110
if (!to.length) throw new Error("缺少收件人");
111-
const r = await postJson("https://api.resend.com/emails", {
111+
const payload = {
112112
from: String(cfg.from || "").trim(),
113113
to,
114114
subject: title,
115115
text: body,
116-
}, { Authorization: `Bearer ${String(cfg.apiKey || "").trim()}` });
116+
};
117+
if (extra?.html) payload.html = extra.html; // 日报等富文本
118+
const r = await postJson("https://api.resend.com/emails", payload,
119+
{ Authorization: `Bearer ${String(cfg.apiKey || "").trim()}` });
117120
if (r.status >= 300 || !r.json?.id) {
118121
throw new Error(r.json?.message || r.json?.error?.message || `HTTP ${r.status}`);
119122
}
120123
}
121124

122-
async function sendSmtp(cfg, title, body) {
123-
await sendSmtpMail(cfg, title, body);
125+
async function sendSmtp(cfg, title, body, extra) {
126+
await sendSmtpMail(cfg, title, body, extra?.html);
124127
}
125128

126129
async function sendWebhook(cfg, title, body, extra) {
127130
let headers = {};
128131
if (cfg.headersJson) {
129132
try { headers = JSON.parse(cfg.headersJson); } catch {}
130133
}
134+
const { html, ...rest } = extra || {}; // HTML 只给邮件渠道,避免撑爆 webhook 载荷
131135
const r = await postJson(cfg.url, {
132136
source: "relay-monitor",
133137
title,
134138
body,
135-
...extra,
139+
...rest,
136140
timestamp: new Date().toISOString(),
137141
}, headers);
138142
if (r.status >= 300) throw new Error(`HTTP ${r.status}`);

lib/smtp.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,43 @@ function encodeAddress(s) {
2929
return name ? `${encodeHeader(name)} <${m[2].trim()}>` : `<${m[2].trim()}>`;
3030
}
3131

32-
function buildMessage(from, toList, subject, text) {
33-
const body = b64(text).replace(/(.{76})/g, `$1${CRLF}`);
34-
return [
32+
const b64wrap = (s) => b64(s).replace(/(.{76})/g, `$1${CRLF}`);
33+
34+
function buildMessage(from, toList, subject, text, html) {
35+
const common = [
3536
`From: ${encodeAddress(from)}`,
3637
`To: ${toList.map(encodeAddress).join(", ")}`,
3738
`Subject: ${encodeHeader(subject)}`,
3839
`Date: ${new Date().toUTCString()}`,
3940
`Message-ID: <${Date.now()}.${Math.random().toString(36).slice(2)}@relay-monitor>`,
4041
"MIME-Version: 1.0",
42+
];
43+
if (!html) {
44+
return [
45+
...common,
46+
'Content-Type: text/plain; charset=utf-8',
47+
"Content-Transfer-Encoding: base64",
48+
"",
49+
b64wrap(text),
50+
].join(CRLF);
51+
}
52+
// multipart/alternative:纯文本 + HTML,客户端优先渲染 HTML
53+
const boundary = "rmb-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
54+
return [
55+
...common,
56+
`Content-Type: multipart/alternative; boundary="${boundary}"`,
57+
"",
58+
`--${boundary}`,
4159
'Content-Type: text/plain; charset=utf-8',
4260
"Content-Transfer-Encoding: base64",
4361
"",
44-
body,
62+
b64wrap(text),
63+
`--${boundary}`,
64+
'Content-Type: text/html; charset=utf-8',
65+
"Content-Transfer-Encoding: base64",
66+
"",
67+
b64wrap(html),
68+
`--${boundary}--`,
4569
].join(CRLF);
4670
}
4771

@@ -50,10 +74,10 @@ export function splitRecipients(s) {
5074
}
5175

5276
/**
53-
* 发送一封纯文本邮件
77+
* 发送邮件;提供 html 时按 multipart/alternative 同时携带纯文本与 HTML
5478
* cfg: { host, port?, username?, password?, from, to }
5579
*/
56-
export async function sendSmtpMail(cfg, subject, text) {
80+
export async function sendSmtpMail(cfg, subject, text, html) {
5781
const host = String(cfg.host || "").trim();
5882
if (!host) throw new Error("缺少 SMTP 服务器地址");
5983
const port = Number(cfg.port) || 465;
@@ -147,7 +171,7 @@ export async function sendSmtpMail(cfg, subject, text) {
147171
await cmd(`MAIL FROM:<${addrOf(from)}>`, [250]);
148172
for (const t of toList) await cmd(`RCPT TO:<${addrOf(t)}>`, [250, 251], "RCPT");
149173
await cmd("DATA", [354]);
150-
await cmd(buildMessage(from, toList, subject, text) + CRLF + ".", [250], "发送");
174+
await cmd(buildMessage(from, toList, subject, text, html) + CRLF + ".", [250], "发送");
151175
sock.write("QUIT" + CRLF);
152176
} finally {
153177
sock.destroy();

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.10.0",
3+
"version": "1.11.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,11 @@ $("#trendClose").onclick = () => $("#trendModal").classList.remove("open");
16181618
backdropClose("trendModal", () => $("#trendModal").classList.remove("open"));
16191619
$("#reportClose").onclick = () => $("#reportModal").classList.remove("open");
16201620
backdropClose("reportModal", () => $("#reportModal").classList.remove("open"));
1621+
document.querySelectorAll(".rpt-tab").forEach((b) => (b.onclick = () => {
1622+
document.querySelectorAll(".rpt-tab").forEach((x) => x.classList.toggle("active", x === b));
1623+
$("#reportHtml").style.display = b.dataset.rt === "html" ? "" : "none";
1624+
$("#reportText").style.display = b.dataset.rt === "text" ? "" : "none";
1625+
}));
16211626

16221627
function niceStep(rough) {
16231628
const pow = Math.pow(10, Math.floor(Math.log10(rough || 1)));
@@ -1828,6 +1833,7 @@ $(".main").addEventListener("click", async (e) => {
18281833
try {
18291834
const r = await api.reportPreview();
18301835
$("#reportText").textContent = r.text;
1836+
$("#reportHtml").srcdoc = r.html || "<p>无 HTML 版本</p>";
18311837
$("#reportModal").classList.add("open");
18321838
} catch (err) { toast(err.message, "err"); }
18331839
finally { drPreview.disabled = false; }

public/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,12 @@ <h2>日报预览</h2>
208208
<p>按当前数据生成的昨日报告(实际发送时按设定时间的数据)</p>
209209
</div>
210210
<div class="modal-body">
211-
<pre class="report-pre" id="reportText"></pre>
211+
<div class="report-tabs">
212+
<button class="btn btn-ghost rpt-tab active" data-rt="html">HTML(邮件效果)</button>
213+
<button class="btn btn-ghost rpt-tab" data-rt="text">纯文本(IM 渠道)</button>
214+
</div>
215+
<iframe class="report-frame" id="reportHtml" sandbox=""></iframe>
216+
<pre class="report-pre" id="reportText" style="display:none"></pre>
212217
</div>
213218
<div class="modal-foot">
214219
<button class="btn btn-secondary" id="reportClose">关闭</button>

public/styles.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,12 @@ button { font-family: inherit; cursor: pointer; }
342342

343343
/* ---- 日报 ---- */
344344
.dr-channels { display: flex; flex-wrap: wrap; gap: 6px 16px; max-width: 460px; justify-content: flex-end; }
345+
.report-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
346+
.rpt-tab.active { background: var(--bg-selected); color: var(--primary); border-color: transparent; }
347+
.report-frame {
348+
width: 100%; height: 62vh; border: 1px solid var(--border); border-radius: 8px;
349+
background: #f5f6f8; display: block;
350+
}
345351
.report-pre {
346352
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
347353
line-height: 1.7; white-space: pre-wrap; word-break: break-all;

server.js

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,135 @@ async function buildDailyReport() {
884884
L.push(`未来 7 天预计:≈${rptCny(fc.nextTotal * ownRate)}(区间 ${rptCny((fc.nextLo ?? 0) * ownRate)} ~ ${rptCny((fc.nextHi ?? 0) * ownRate)})`);
885885
L.push(`(${fc.method} · 基于 ${fc.sampleDays} 天 · 回测日均偏差 ±${fc.backtestWapePct ?? "?"}%)`);
886886
}
887-
return { title: `【日报】${own.name} ${dateLabel}`, text: L.join("\n") };
887+
const html = buildReportHtml({
888+
own, dateLabel, dow, ownRate,
889+
totalCost, prev, avg7, incomeUsd, adminUsd, profit,
890+
totalReqs, totalTokens, activeUsers: byUser.length,
891+
byModel, byUser, daily, fc, upstreams, balanceTotal,
892+
userCount: (ownUsers || []).filter((u) => u.role < 10).length, hasUsers: !!ownUsers,
893+
});
894+
return { title: `【日报】${own.name} ${dateLabel}`, text: L.join("\n"), html };
895+
}
896+
897+
// 邮件安全的 HTML 日报:全内联样式 + 表格排版,图表用色块/条形实现
898+
//(Gmail 会剥离 SVG、屏蔽 data:URI 图片,这是全客户端可靠的做法)
899+
function buildReportHtml(d) {
900+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
901+
const C = { ink: "#1c1c1e", sub: "#6e6e73", line: "#e5e5ea", blue: "#0a84ff", blueSoft: "#b9d6f8", green: "#1f9d4d", red: "#d03b3b", track: "#eef1f5", bg: "#f5f6f8" };
902+
const font = "font-family:-apple-system,'PingFang SC','Segoe UI',sans-serif;";
903+
const money = (v) => rptCny(v);
904+
905+
const kpi = (label, value, sub, color) => `
906+
<td width="25%" style="padding:6px"><div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:12px 14px">
907+
<div style="${font}font-size:11px;color:${C.sub}">${esc(label)}</div>
908+
<div style="${font}font-size:20px;font-weight:700;color:${color || C.ink};margin-top:2px">${esc(value)}</div>
909+
${sub ? `<div style="${font}font-size:11px;color:${C.sub};margin-top:2px">${esc(sub)}</div>` : ""}
910+
</div></td>`;
911+
912+
const section = (title, inner) => `
913+
<tr><td style="padding:18px 12px 6px;${font}font-size:14px;font-weight:700;color:${C.ink}">${esc(title)}</td></tr>
914+
<tr><td style="padding:0 12px">${inner}</td></tr>`;
915+
916+
// 14 天消费柱状图(表格 + 色块;昨日高亮)
917+
const days = d.daily.slice(-14);
918+
const maxDay = Math.max(...days.map((x) => x.cost), 0.01);
919+
const trendCols = days.map((x, i) => {
920+
const h = Math.max(3, Math.round((x.cost / maxDay) * 72));
921+
const last = i === days.length - 1;
922+
return `<td align="center" valign="bottom" style="padding:0 2px">
923+
<div title="${esc(money(x.cost * d.ownRate))}" style="height:${h}px;background:${last ? C.blue : C.blueSoft};border-radius:3px 3px 0 0"></div>
924+
</td>`;
925+
}).join("");
926+
const fmtMD = (t) => { const dd = new Date(t); return `${dd.getMonth() + 1}/${dd.getDate()}`; };
927+
const trend = days.length ? `
928+
<div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:14px">
929+
<table width="100%" cellpadding="0" cellspacing="0" style="height:76px"><tr>${trendCols}</tr></table>
930+
<table width="100%" cellpadding="0" cellspacing="0"><tr>
931+
<td style="${font}font-size:10px;color:${C.sub}">${fmtMD(days[0].t)}</td>
932+
<td align="right" style="${font}font-size:10px;color:${C.sub}">昨日 ${fmtMD(days[days.length - 1].t)} · ${esc(money(days[days.length - 1].cost * d.ownRate))}</td>
933+
</tr></table>
934+
</div>` : "";
935+
936+
// 条形榜单(名称 + 比例条 + 数值)
937+
const barList = (rows, nameOf, valOf, valText) => {
938+
const max = Math.max(...rows.map(valOf), 1e-9);
939+
return `<div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:6px 14px">
940+
${rows.map((r) => `
941+
<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0"><tr>
942+
<td width="34%" style="${font}font-size:12px;color:${C.ink};white-space:nowrap;overflow:hidden">${nameOf(r)}</td>
943+
<td style="padding:0 10px"><div style="background:${C.track};border-radius:4px"><div style="width:${Math.max(2, Math.round(valOf(r) / max * 100))}%;height:8px;background:${C.blue};border-radius:4px"></div></div></td>
944+
<td width="20%" align="right" style="${font}font-size:12px;color:${C.ink};font-weight:600;white-space:nowrap">${valText(r)}</td>
945+
</tr></table>`).join("")}
946+
</div>`;
947+
};
948+
949+
const modelBars = d.byModel.length
950+
? barList(d.byModel.slice(0, 5), (m) => esc(m.key), (m) => m.cost, (m) => esc(money(m.cost * d.ownRate)))
951+
: `<div style="${font}font-size:12px;color:${C.sub}">昨日无消费</div>`;
952+
const userBars = d.byUser.length
953+
? barList(d.byUser.slice(0, 5),
954+
(u) => `${esc(u.key)}${u.isAdmin ? ` <span style="color:${C.red};font-size:10px">管理员</span>` : ""}`,
955+
(u) => u.cost, (u) => esc(money(u.cost * d.ownRate)))
956+
: `<div style="${font}font-size:12px;color:${C.sub}">昨日无消费</div>`;
957+
958+
const MODE = { usage: "按用量", fixed: "固定摊销", history: "余额推算≈" };
959+
const costRows = (d.profit && !d.profit.error && d.profit.costs.length)
960+
? `<div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:6px 14px">
961+
${d.profit.costs.map((c) => `
962+
<table width="100%" cellpadding="0" cellspacing="0" style="margin:7px 0"><tr>
963+
<td style="${font}font-size:12px;color:${C.ink}">${esc(c.name)} <span style="color:${C.sub};font-size:11px">${esc(MODE[c.mode] || c.mode)}${c.note ? " · " + esc(c.note) : ""}</span></td>
964+
<td align="right" style="${font}font-size:12px;font-weight:600;color:${C.ink}">${esc(money(c.cny))}</td>
965+
</tr></table>`).join("")}
966+
${d.profit.unmatched.length ? `<div style="${font}font-size:11px;color:${C.sub};margin:6px 0">另有 ${d.profit.unmatched.length} 组渠道未纳入成本计算</div>` : ""}
967+
</div>` : "";
968+
969+
const upRows = `<div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:6px 14px">
970+
${d.upstreams.map((s) => {
971+
const rate = s.cnyPerUsd != null && s.cnyPerUsd > 0 ? s.cnyPerUsd : 1;
972+
const b = s.balance;
973+
let status = "尚未查询", warn = false;
974+
if (b && !b.ok) { status = `查询失败(${b.error || "未知"})`; warn = true; }
975+
else if (b) {
976+
const p = history.predict(s.id);
977+
status = p && p.etaDays != null ? `≈${money(p.burnPerDay * rate)}/天 · 预计 ${fmtEta(p.etaDays)}后耗尽` : "近期无消耗";
978+
warn = !!(p && p.etaDays != null && p.etaDays <= 3);
979+
}
980+
return `<table width="100%" cellpadding="0" cellspacing="0" style="margin:7px 0"><tr>
981+
<td style="${font}font-size:12px;color:${C.ink}">${esc(s.name)} <span style="color:${warn ? C.red : C.sub};font-size:11px">${warn ? "⚠ " : ""}${esc(status)}</span></td>
982+
<td align="right" style="${font}font-size:12px;font-weight:600;color:${C.ink}">${b && b.ok ? esc(money(b.remaining * rate)) : "—"}</td>
983+
</tr></table>`;
984+
}).join("") || `<div style="${font}font-size:12px;color:${C.sub}">暂无上游站点</div>`}
985+
</div>`;
986+
987+
const outlook = d.fc ? `
988+
<div style="background:#fff;border:1px solid ${C.line};border-radius:10px;padding:12px 14px;${font}font-size:12px;color:${C.ink};line-height:1.9">
989+
今天预计 <b>≈${esc(money(d.fc.points[0].cost * d.ownRate))}</b>(区间 ${esc(money(d.fc.points[0].lo * d.ownRate))} ~ ${esc(money(d.fc.points[0].hi * d.ownRate))})<br>
990+
未来 7 天预计 <b>≈${esc(money(d.fc.nextTotal * d.ownRate))}</b>(区间 ${esc(money((d.fc.nextLo ?? 0) * d.ownRate))} ~ ${esc(money((d.fc.nextHi ?? 0) * d.ownRate))})<br>
991+
<span style="color:${C.sub};font-size:11px">${esc(d.fc.method)} · 基于 ${d.fc.sampleDays} 天 · 回测日均偏差 ±${d.fc.backtestWapePct ?? "?"}%</span>
992+
</div>` : "";
993+
994+
const profitOk = d.profit && !d.profit.error;
995+
return `<!DOCTYPE html><html><body style="margin:0;padding:0;background:${C.bg}">
996+
<table width="100%" cellpadding="0" cellspacing="0" style="background:${C.bg};padding:18px 0"><tr><td align="center">
997+
<table width="640" cellpadding="0" cellspacing="0" style="max-width:640px;width:100%">
998+
<tr><td style="padding:6px 12px 2px;${font}font-size:18px;font-weight:700;color:${C.ink}">${esc(d.own.name)} 日报</td></tr>
999+
<tr><td style="padding:0 12px 8px;${font}font-size:12px;color:${C.sub}">${esc(d.dateLabel)}(周${esc(d.dow)})· 由 relay-monitor 生成</td></tr>
1000+
<tr><td><table width="100%" cellpadding="0" cellspacing="0"><tr>
1001+
${kpi("昨日消费", money(d.totalCost * d.ownRate), d.prev != null ? `环比 ${pctDelta(d.totalCost, d.prev)} · 7日均 ${pctDelta(d.totalCost, d.avg7)}` : "", null)}
1002+
${kpi("收入(不含管理员)", money(d.incomeUsd * d.ownRate), d.adminUsd > 0 ? `管理员另耗 ${money(d.adminUsd * d.ownRate)}` : "", null)}
1003+
${kpi("成本", profitOk ? money(d.profit.totalCostCny) : "—", "", null)}
1004+
${kpi("利润", profitOk ? money(d.profit.profitCny) : "—", profitOk && d.profit.marginPct != null ? `利润率 ${d.profit.marginPct}%` : "", profitOk && d.profit.profitCny >= 0 ? C.green : C.red)}
1005+
</tr></table></td></tr>
1006+
${section("近 14 天消费趋势", trend)}
1007+
${section(`用量:请求 ${d.totalReqs.toLocaleString("en-US")} 次 · Tokens ${rptTok(d.totalTokens)} · 活跃用户 ${d.activeUsers} 个`, "")}
1008+
${section("Top 模型(按消费)", modelBars)}
1009+
${section("Top 用户(按消费)", userBars)}
1010+
${costRows ? section("成本明细(昨日)", costRows) : ""}
1011+
${section("上游余额", upRows)}
1012+
${d.hasUsers ? section(`用户余额合计:${money(d.balanceTotal)}(预收 · ${d.userCount} 个用户)`, "") : ""}
1013+
${outlook ? section("展望", outlook) : ""}
1014+
<tr><td style="padding:16px 12px;${font}font-size:11px;color:${C.sub}">relay-monitor 每日日报 · 数据截至发送时刻</td></tr>
1015+
</table></td></tr></table></body></html>`;
8881016
}
8891017

8901018
function reportChannels() {
@@ -903,8 +1031,8 @@ app.post("/api/report/preview", async (req, res) => {
9031031

9041032
app.post("/api/report/send", async (req, res) => {
9051033
try {
906-
const { title, text } = await buildDailyReport();
907-
const results = await broadcast(reportChannels(), title, text, { event: "daily-report" });
1034+
const { title, text, html } = await buildDailyReport();
1035+
const results = await broadcast(reportChannels(), title, text, { event: "daily-report", html });
9081036
res.json({ ok: true, results });
9091037
} catch (err) {
9101038
res.status(400).json({ error: err?.message || String(err) });
@@ -922,8 +1050,8 @@ setInterval(async () => {
9221050
cfg.lastSent = today; // 先占位,避免同一分钟重复发送
9231051
await store.save();
9241052
try {
925-
const { title, text } = await buildDailyReport();
926-
await broadcast(reportChannels(), title, text, { event: "daily-report" });
1053+
const { title, text, html } = await buildDailyReport();
1054+
await broadcast(reportChannels(), title, text, { event: "daily-report", html });
9271055
console.log(`日报已发送(${today} ${cfg.time})`);
9281056
} catch (err) {
9291057
console.error("日报发送失败:", err?.message);

0 commit comments

Comments
 (0)