Skip to content

Commit d7778e8

Browse files
committed
目录移出内容区不再遮挡数据;固定成本支持多笔付费叠加 (v1.8.0)
- 「我的站点」页内容区右侧让出专属通道(padding-right 122px), 目录立在通道内不压任何数据;窄屏隐藏时通道自动收回 - 固定成本改为付费记录数组 fixedPurchases:每笔 {金额, 天数, 购买日期}, 弹窗内可增删多笔(在现有套餐上加购/续费就追加一笔) - 成本 = 各笔在窗口内的摊销之和:每笔独立按生效区间计算、到期归零、 多笔重叠期间叠加;利润明细标注「M/N 笔生效中 / 已全部到期」 - 站点卡片汇总展示:当前日均摊销合计、生效/待生效笔数、最近一笔到期时间 - 历代字段(fixedMonthlyCny / fixedCostCny+fixedDays+fixedStartDate)自动迁移
1 parent a6a6618 commit d7778e8

7 files changed

Lines changed: 142 additions & 75 deletions

File tree

lib/providers.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -640,10 +640,7 @@ export const STATION_TYPES = [
640640
{ value: "fixed", label: "固定成本(包月/包年,不访问)", needs: [] },
641641
];
642642

643-
// 固定成本渠道的日均摊销(¥/天)= 每次付费金额 ÷ 覆盖天数;未配置返回 null
644-
export function dailyFixedCny(station) {
645-
const amt = station?.fixedCostCny;
646-
const days = station?.fixedDays;
647-
if (amt == null || amt <= 0 || days == null || days <= 0) return null;
648-
return amt / days;
643+
// 固定成本付费记录列表;每笔 {amount, days, startDate|null}
644+
export function fixedPurchases(station) {
645+
return Array.isArray(station?.fixedPurchases) ? station.fixedPurchases : [];
649646
}

lib/store.js

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ function numOrNull(v) {
2020
return Number.isFinite(n) ? n : null;
2121
}
2222

23+
// 固定成本付费记录:[{amount(¥), days, startDate|null}],无效行直接丢弃
24+
function sanitizePurchases(input) {
25+
if (!Array.isArray(input)) return null;
26+
const out = [];
27+
for (const p of input) {
28+
const amount = numOrNull(p?.amount);
29+
const days = numOrNull(p?.days);
30+
if (amount == null || amount <= 0 || days == null || days <= 0) continue;
31+
out.push({
32+
amount, days,
33+
startDate: /^\d{4}-\d{2}-\d{2}$/.test(p?.startDate || "") ? p.startDate : null,
34+
});
35+
}
36+
return out;
37+
}
38+
2339
export class Store {
2440
constructor(file) {
2541
this.file = file;
@@ -47,14 +63,22 @@ export class Store {
4763
} catch {
4864
// 文件不存在或损坏:使用默认值并写入
4965
}
50-
// 迁移:v1.5.0 的 fixedMonthlyCny(月费)→ 金额 + 30 天
66+
// 迁移:历代固定成本字段(fixedMonthlyCny / fixedCostCny+fixedDays+fixedStartDate)
67+
// 统一为付费记录数组 fixedPurchases
5168
for (const s of this.data.stations) {
52-
if (s.fixedMonthlyCny != null && s.fixedCostCny == null) {
53-
s.fixedCostCny = s.fixedMonthlyCny;
54-
s.fixedDays = 30;
69+
if (!Array.isArray(s.fixedPurchases)) {
70+
s.fixedPurchases = [];
71+
const amount = s.fixedCostCny ?? s.fixedMonthlyCny;
72+
if (amount > 0) {
73+
s.fixedPurchases.push({
74+
amount,
75+
days: s.fixedDays > 0 ? s.fixedDays : 30,
76+
startDate: /^\d{4}-\d{2}-\d{2}$/.test(s.fixedStartDate || "") ? s.fixedStartDate : null,
77+
});
78+
}
5579
}
56-
delete s.fixedMonthlyCny;
57-
delete s.fixedPeriod;
80+
delete s.fixedMonthlyCny; delete s.fixedPeriod;
81+
delete s.fixedCostCny; delete s.fixedDays; delete s.fixedStartDate;
5882
}
5983
// 初始化面板账号(默认 admin / admin123,登录后请在设置里修改)
6084
if (!this.data.auth) {
@@ -190,11 +214,8 @@ export class Store {
190214
cnyPerUsd: numOrNull(input.cnyPerUsd),
191215
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
192216
isOwn: !!input.isOwn,
193-
// 固定成本:每次付费金额(¥)+ 覆盖天数 + 购买日期,日均摊销 = 金额 ÷ 天数,
194-
// 只在 [购买日, 购买日+天数] 区间内计成本,到期归零
195-
fixedCostCny: numOrNull(input.fixedCostCny),
196-
fixedDays: numOrNull(input.fixedDays),
197-
fixedStartDate: /^\d{4}-\d{2}-\d{2}$/.test(input.fixedStartDate || "") ? input.fixedStartDate : null,
217+
// 固定成本付费记录(可多笔叠加):每笔按 金额÷天数 在生效区间内摊销
218+
fixedPurchases: sanitizePurchases(input.fixedPurchases) || [],
198219
demo: !!input.demo,
199220
createdAt: new Date().toISOString(),
200221
s2Tokens: null, // Sub2API 密码模式的令牌缓存 {accessToken, refreshToken, expiresAt}
@@ -219,11 +240,7 @@ export class Store {
219240
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
220241
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
221242
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
222-
if ("fixedCostCny" in patch) s.fixedCostCny = numOrNull(patch.fixedCostCny);
223-
if ("fixedDays" in patch) s.fixedDays = numOrNull(patch.fixedDays);
224-
if ("fixedStartDate" in patch) {
225-
s.fixedStartDate = /^\d{4}-\d{2}-\d{2}$/.test(patch.fixedStartDate || "") ? patch.fixedStartDate : null;
226-
}
243+
if ("fixedPurchases" in patch) s.fixedPurchases = sanitizePurchases(patch.fixedPurchases) || [];
227244
// 凭证或站点实际变化才作废令牌缓存(前端编辑总会带上 type/email 原值,
228245
// 无脑作废会导致每次改名都触发一次完整重登录)
229246
const credsChanged =

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

public/app.js

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -187,20 +187,30 @@ function sparkSvg(pts) {
187187
}
188188

189189
function stationRow(s) {
190-
// 固定成本渠道:不访问接口,只展示摊销信息
190+
// 固定成本渠道:不访问接口,展示当前生效各笔的摊销汇总
191191
if (s.type === "fixed") {
192-
const daily = s.fixedCostCny > 0 && s.fixedDays > 0 ? s.fixedCostCny / s.fixedDays : 0;
193-
const pieces = [`<span>日均摊销 ${cny(daily)}</span>`, `<span>每次 ${cny(s.fixedCostCny || 0)}${s.fixedDays || 0} 天</span>`];
194-
let expired = false;
195-
if (s.fixedStartDate && s.fixedDays > 0) {
196-
const start = new Date(s.fixedStartDate + "T00:00:00");
197-
const end = start.getTime() + s.fixedDays * 86400000;
198-
const remain = Math.ceil((end - Date.now()) / 86400000);
199-
expired = remain <= 0;
200-
pieces.push(expired
201-
? `<span class="danger">已于 ${fmtClock(end).split(" ")[0]} 到期,续费请更新日期</span>`
202-
: `<span${remain <= 3 ? ' class="warn"' : ""}>${esc(s.fixedStartDate)} 起 · 剩 ${remain} 天</span>`);
192+
const ps = Array.isArray(s.fixedPurchases) ? s.fixedPurchases : [];
193+
const nowMs = Date.now();
194+
let daily = 0, active = 0, pendingStart = 0, nextEnd = null;
195+
for (const p of ps) {
196+
const d = p.amount > 0 && p.days > 0 ? p.amount / p.days : 0;
197+
if (!p.startDate) { daily += d; active++; continue; }
198+
const st = Date.parse(p.startDate + "T00:00:00");
199+
const end = st + p.days * 86400000;
200+
if (st > nowMs) { pendingStart++; continue; }
201+
if (end > nowMs) {
202+
daily += d; active++;
203+
if (nextEnd == null || end < nextEnd) nextEnd = end;
204+
}
205+
}
206+
const expiredAll = ps.length > 0 && active === 0 && pendingStart === 0;
207+
const pieces = [`<span>日均摊销 ${cny(daily)}</span>`, `<span>生效 ${active}/${ps.length} 笔</span>`];
208+
if (pendingStart) pieces.push(`<span>待生效 ${pendingStart} 笔</span>`);
209+
if (nextEnd != null) {
210+
const remain = Math.ceil((nextEnd - nowMs) / 86400000);
211+
pieces.push(`<span${remain <= 3 ? ' class="warn"' : ""}>最近一笔 ${fmtClock(nextEnd).split(" ")[0]} 到期(剩 ${remain} 天)</span>`);
203212
}
213+
if (expiredAll) pieces.push('<span class="danger">已全部到期,续费请追加付费记录</span>');
204214
return `
205215
<div class="st-row" data-id="${s.id}">
206216
<div class="st-plate">¥</div>
@@ -210,8 +220,8 @@ function stationRow(s) {
210220
<div class="st-predict">${pieces.join("<span>·</span>")}</div>
211221
</div>
212222
<div class="st-balance">
213-
<div class="amt${expired ? " danger" : ""}">${cny(expired ? 0 : daily)}</div>
214-
<div class="sub">${expired ? "已到期" : "每天"}</div>
223+
<div class="amt${expiredAll ? " danger" : ""}">${cny(daily)}</div>
224+
<div class="sub">${expiredAll ? "已到期" : "每天"}</div>
215225
</div>
216226
<div class="st-actions">
217227
<button class="icon-btn" data-act="edit" title="编辑"><svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg></button>
@@ -1277,6 +1287,7 @@ function render() {
12771287
$("#pageTitle").textContent = titles[state.view][0];
12781288
$("#pageSub").textContent = titles[state.view][1];
12791289
if (state.view !== "own") document.querySelector(".own-toc")?.remove();
1290+
$("#content").classList.toggle("has-toc", state.view === "own");
12801291
document.querySelectorAll(".nav-item").forEach((n) => n.classList.toggle("active", n.dataset.view === state.view));
12811292
if (state.view === "dashboard") renderDashboard();
12821293
else if (state.view === "stations") renderStations();
@@ -1305,10 +1316,7 @@ function openModal(station) {
13051316
$("#f-password").value = "";
13061317
$("#f-lowBalance").value = station?.lowBalanceUsd ?? "";
13071318
$("#f-cnyRate").value = station?.cnyPerUsd ?? "";
1308-
$("#f-fixedCost").value = station?.fixedCostCny ?? "";
1309-
$("#f-fixedDays").value = station?.fixedDays ?? "";
1310-
// 新建默认今天;编辑回显已存日期(本地时区的 YYYY-MM-DD)
1311-
$("#f-fixedStart").value = station ? (station.fixedStartDate || "") : new Date().toLocaleDateString("en-CA");
1319+
seedPurchaseRows(station?.fixedPurchases);
13121320
$("#f-own").checked = !!station?.isOwn;
13131321
if (station) {
13141322
$("#f-accessToken").placeholder = station.hasAccessToken ? "已配置,留空保持不变" : "令牌 / JWT";
@@ -1349,6 +1357,34 @@ function syncCredFields() {
13491357
}
13501358
$("#f-type").onchange = syncCredFields;
13511359

1360+
// ---- 固定成本付费记录编辑器 ---------------------------------------------------
1361+
function purchaseRowEl(p = {}) {
1362+
const row = document.createElement("div");
1363+
row.className = "purchase-row";
1364+
row.innerHTML = `
1365+
<input class="input" data-p="amount" placeholder="金额(¥)" value="${p.amount ?? ""}" />
1366+
<input class="input" data-p="days" placeholder="天数" value="${p.days ?? ""}" style="width:76px" />
1367+
<input class="input" type="date" data-p="startDate" value="${p.startDate ?? ""}" style="width:148px" />
1368+
<button type="button" class="icon-btn" data-p="del" title="删除这笔"><svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg></button>`;
1369+
row.querySelector('[data-p="del"]').onclick = () => row.remove();
1370+
return row;
1371+
}
1372+
function seedPurchaseRows(list) {
1373+
const box = $("#f-purchases");
1374+
box.innerHTML = "";
1375+
const rows = list && list.length ? list : [{ startDate: new Date().toLocaleDateString("en-CA") }];
1376+
rows.forEach((p) => box.appendChild(purchaseRowEl(p)));
1377+
}
1378+
$("#f-addPurchase").onclick = () =>
1379+
$("#f-purchases").appendChild(purchaseRowEl({ startDate: new Date().toLocaleDateString("en-CA") }));
1380+
function collectPurchases() {
1381+
return [...document.querySelectorAll("#f-purchases .purchase-row")].map((r) => ({
1382+
amount: r.querySelector('[data-p="amount"]').value.trim(),
1383+
days: r.querySelector('[data-p="days"]').value.trim(),
1384+
startDate: r.querySelector('[data-p="startDate"]').value,
1385+
})).filter((p) => p.amount !== "" || p.days !== "");
1386+
}
1387+
13521388
$("#modalSave").onclick = async () => {
13531389
const payload = {
13541390
name: $("#f-name").value.trim(),
@@ -1358,9 +1394,7 @@ $("#modalSave").onclick = async () => {
13581394
email: $("#f-email").value.trim(),
13591395
lowBalanceUsd: $("#f-lowBalance").value.trim(),
13601396
cnyPerUsd: $("#f-cnyRate").value.trim(),
1361-
fixedCostCny: $("#f-fixedCost").value.trim(),
1362-
fixedDays: $("#f-fixedDays").value.trim(),
1363-
fixedStartDate: $("#f-fixedStart").value,
1397+
fixedPurchases: collectPurchases(),
13641398
isOwn: $("#f-type").value === "newapi" && $("#f-own").checked,
13651399
};
13661400
const at = $("#f-accessToken").value.trim();
@@ -1370,8 +1404,9 @@ $("#modalSave").onclick = async () => {
13701404
if (ak) payload.apiKey = ak;
13711405
if (pw) payload.password = pw;
13721406
if (payload.type === "fixed") {
1373-
if (!(Number(payload.fixedCostCny) > 0)) return toast("请填写每次付费金额", "err");
1374-
if (!(Number(payload.fixedDays) > 0)) return toast("请填写覆盖天数", "err");
1407+
const bad = payload.fixedPurchases.find((p) => !(Number(p.amount) > 0) || !(Number(p.days) > 0));
1408+
if (bad) return toast("每笔付费需填写金额与天数(均大于 0)", "err");
1409+
if (!payload.fixedPurchases.length) return toast("请至少填写一笔付费记录", "err");
13751410
} else if (!payload.baseUrl) {
13761411
return toast("请填写站点地址", "err");
13771412
}

public/index.html

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,12 @@ <h2 id="modalTitle">添加中转站</h2>
154154
<div class="hint">面板金额将按此汇率折算成人民币展示;余额告警仍按站点余额判断。</div>
155155
</div>
156156
<div class="form-field" id="f-fixedWrap">
157-
<label>固定成本(每次付费金额 + 覆盖天数 + 购买日期)</label>
158-
<div class="field-inline">
159-
<input class="input" id="f-fixedCost" placeholder="每次付多少(¥)" style="flex:1" />
160-
<input class="input" id="f-fixedDays" placeholder="管多少天" style="width:96px" />
161-
<input class="input" type="date" id="f-fixedStart" style="width:150px" />
162-
</div>
163-
<div class="hint">日均成本 = 金额 ÷ 天数,从购买日起摊销,到期后不再计成本(续费改日期即可)。
164-
不访问任何接口;站点地址可留空。填上游地址可与「我的站点」渠道自动匹配——
165-
只填主机(如 1.2.3.4,不带端口)可匹配该主机所有端口的渠道。</div>
157+
<label>固定成本付费记录(可叠加多笔)</label>
158+
<div id="f-purchases"></div>
159+
<button type="button" class="btn btn-ghost" id="f-addPurchase" style="margin-top:2px">+ 追加一笔</button>
160+
<div class="hint">每笔 = 金额 ÷ 天数 按天摊销,从购买日起生效、到期归零;多笔重叠期间成本叠加
161+
(在现有套餐上加购/续费就追加一笔)。不访问任何接口;站点地址可留空,
162+
填主机(不带端口)可匹配该主机所有端口的渠道。</div>
166163
</div>
167164
<div class="form-field" id="f-own-wrap">
168165
<label class="chk-line"><input type="checkbox" id="f-own" /> 这是我自己的中转站</label>

public/styles.css

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,21 +89,25 @@ button { font-family: inherit; cursor: pointer; }
8989

9090
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; position: relative; }
9191

92-
/* 我的站点页右侧悬浮目录 */
92+
/* 我的站点页右侧目录:内容区让出右侧通道,目录不遮挡数据 */
93+
.content.has-toc { padding-right: 122px; }
9394
.own-toc {
94-
position: absolute; right: 12px; top: 116px; z-index: 12;
95+
position: absolute; right: 12px; top: 116px; z-index: 12; width: 98px;
9596
display: flex; flex-direction: column; gap: 2px; padding: 6px;
96-
background: color-mix(in srgb, var(--bg-surface) 86%, transparent);
97-
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
97+
background: var(--bg-surface);
9898
border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-card);
9999
}
100100
.own-toc a {
101101
font-size: 11.5px; color: var(--text-tertiary); padding: 4px 10px;
102102
border-radius: 6px; cursor: pointer; white-space: nowrap;
103+
overflow: hidden; text-overflow: ellipsis;
103104
}
104105
.own-toc a:hover { background: var(--bg-hover); color: var(--text-primary); }
105106
.own-toc a.active { background: var(--bg-selected); color: var(--primary); font-weight: 500; }
106-
@media (max-width: 1080px) { .own-toc { display: none; } }
107+
@media (max-width: 1080px) {
108+
.own-toc { display: none; }
109+
.content.has-toc { padding-right: var(--content-padding); }
110+
}
107111
.page-header {
108112
padding: 18px var(--content-padding) 0; display: flex; align-items: flex-start;
109113
justify-content: space-between; gap: 12px; flex-shrink: 0;
@@ -335,6 +339,12 @@ button { font-family: inherit; cursor: pointer; }
335339
.u-table td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
336340
.u-table td.u-empty { text-align: center; color: var(--text-tertiary); padding: 28px; }
337341

342+
/* ---- 固定成本付费记录编辑器 ---- */
343+
.purchase-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
344+
.purchase-row .input { height: 32px; }
345+
.purchase-row [data-p="amount"] { flex: 1; }
346+
.purchase-row .icon-btn { flex-shrink: 0; }
347+
338348
/* ---- 我的站点:预测图与勾选框 ---- */
339349
.chk-line { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-primary); cursor: pointer; }
340350
.chk-line input { width: 15px; height: 15px; accent-color: var(--primary); }

server.js

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { readFile } from "node:fs/promises";
66
import { Store } from "./lib/store.js";
77
import {
88
queryStation, queryStationUsage, queryOwnData, queryOwnChannels, queryOwnUsers,
9-
dateStrInTz, parseDateLabel, dailyFixedCny, STATION_TYPES,
9+
dateStrInTz, parseDateLabel, fixedPurchases, STATION_TYPES,
1010
} from "./lib/providers.js";
1111
import { forecastDaily } from "./lib/forecast.js";
1212
import { SessionManager, verifyPassword } from "./lib/auth.js";
@@ -649,32 +649,43 @@ async function computeProfit(own, incomeUsd, adminUsageUsd, { startMs, now, tz,
649649
const rateOf = (s) => (s.cnyPerUsd != null && s.cnyPerUsd > 0 ? s.cnyPerUsd : 1);
650650

651651
// 固定成本:无论是否匹配到渠道都计入(服务器租金这类可以完全不填地址)。
652-
// 配置了购买日期的,只对 [购买日, 购买日+天数] 与展示窗口的重叠部分摊销,到期归零
652+
// 每笔付费按 [购买日, 购买日+天数] 与展示窗口的重叠部分摊销,多笔叠加求和;
653+
// 没填日期的按全窗口摊销(自动续费的常驻成本)
653654
const costs = [];
654655
for (const s of upstreams) {
655-
const df = dailyFixedCny(s);
656-
if (df == null) continue;
657-
let activeDays = windowDays;
658-
let note = null;
659-
if (s.fixedStartDate) {
660-
const st = parseDateLabel(s.fixedStartDate, tz);
661-
if (st != null) {
662-
const end = st + s.fixedDays * 86400000;
663-
activeDays = Math.max(0, Math.min(now, end) - Math.max(startMs, st)) / 86400000;
664-
if (end <= now) note = "已到期";
665-
else if (st > startMs) note = `${s.fixedStartDate} 起`;
656+
const purchases = fixedPurchases(s);
657+
if (!purchases.length) continue;
658+
let cnySum = 0, active = 0, expired = 0;
659+
for (const p of purchases) {
660+
const daily = p.amount / p.days;
661+
const st = p.startDate ? parseDateLabel(p.startDate, tz) : null;
662+
if (st == null) {
663+
cnySum += daily * windowDays;
664+
active++;
665+
continue;
666666
}
667+
const end = st + p.days * 86400000;
668+
cnySum += daily * (Math.max(0, Math.min(now, end) - Math.max(startMs, st)) / 86400000);
669+
if (end <= now) expired++;
670+
else if (st <= now) active++;
671+
}
672+
let note = null;
673+
if (expired === purchases.length) note = purchases.length > 1 ? "已全部到期" : "已到期";
674+
else if (purchases.length > 1) note = `${active}/${purchases.length} 笔生效中`;
675+
else if (purchases[0].startDate) {
676+
const st0 = parseDateLabel(purchases[0].startDate, tz);
677+
if (st0 != null && st0 > startMs) note = `${purchases[0].startDate} 起`;
667678
}
668679
costs.push({
669680
stationId: s.id, name: s.name, mode: "fixed", note,
670681
channels: matched.get(s.id)?.channels || ["未匹配渠道 · 通用成本"],
671-
cny: r2(df * activeDays),
682+
cny: r2(cnySum),
672683
});
673684
}
674685
// 按用量:匹配到渠道且未配置固定成本的上游
675686
const usageCosts = await Promise.all(
676687
[...matched.values()]
677-
.filter(({ station }) => dailyFixedCny(station) == null && station.type !== "fixed")
688+
.filter(({ station }) => fixedPurchases(station).length === 0 && station.type !== "fixed")
678689
.map(async ({ station, channels: chNames }) => {
679690
const item = { stationId: station.id, name: station.name, channels: chNames };
680691
try {

0 commit comments

Comments
 (0)