Skip to content

Commit 30434d2

Browse files
committed
固定成本渠道类型:不访问接口,按「每次付费 ÷ 覆盖天数」日均摊销 (v1.6.0)
- 新站点类型「固定成本(不访问)」:填每次付费金额(¥)+ 覆盖天数, 日均成本 = 金额 ÷ 天数;不做任何接口访问、不刷新、不进余额监控与聚合 - 利润计算:固定成本渠道无论是否匹配到渠道都计入期内成本 (匹配的显示对应渠道名,未匹配标注通用成本);站点地址可选,填了可与渠道匹配 - 站点卡片专属样式:日均摊销 + 每次付费信息,无刷新按钮; 用量统计页下拉排除固定成本类型 - 表单按类型联动:固定成本类型隐藏凭证/阈值/汇率字段 - v1.5.0 的 fixedMonthlyCny 自动迁移为 金额 + 30 天 - 「我的站点」页移除「数据来源」提示
1 parent b75b5ef commit 30434d2

7 files changed

Lines changed: 108 additions & 37 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
启用周末/工作日模式识别)
2727
- **利润分析**:读取自有站的渠道列表,按 base_url 与监控中的上游站点匹配(忽略协议 /
2828
末尾斜杠 / `/api` 后缀,同 URL 渠道合并)——利润 = 下游收入(消费 × 你的售价汇率)−
29-
各匹配上游的期内成本(按用量 × 充值汇率;配置了「每月固定成本」的按天摊销,
30-
月费 ÷ 30 × 窗口天数;用量接口不可用时退回余额下降推算)。
31-
未匹配的渠道单独列出,加入监控即可参与计算
29+
各匹配上游的期内成本(按用量 × 充值汇率;「固定成本」渠道按天摊销:
30+
每次付费金额 ÷ 覆盖天数 × 窗口天数,且无论是否匹配到渠道都计入;
31+
用量接口不可用时退回余额下降推算)。未匹配的渠道单独列出,加入监控即可参与计算
3232
- **人民币折算**:每个站点可配置充值汇率(站点 $1 折合 ¥ 多少,如 1:2 充值);
3333
余额、消耗、图表等金额主显人民币(未配置按 1:1),站点原始余额作为次要信息展示;
3434
**余额告警阈值仍按站点余额判断**,不受汇率影响
@@ -43,6 +43,7 @@
4343
| **New API(sk 密钥)** | OpenAI 兼容 `/dashboard/billing/subscription` + `/usage` | 站点地址、`sk-` 密钥 |
4444
| **Sub2API(登录令牌)** | `GET /api/v1/auth/me``Bearer JWT` | 站点地址、登录 JWT(过期需手动更换) |
4545
| **Sub2API(账号密码)** | 自动 `POST /api/v1/auth/login` / `refresh` / `me` | 站点地址、登录邮箱、密码(开启 2FA 的账号不支持) |
46+
| **固定成本(不访问)** | 不访问任何接口 | 每次付费金额(¥)+ 覆盖天数;日均摊销计入利润成本,地址可选(用于渠道匹配) |
4647

4748
> new-api / one-api 的额度按 500000 = $1 换算;sk 密钥模式 `total_usage` 单位为美分。
4849
> Sub2API 契约提取自 [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 服务端源码:登录字段为 `email`(须是邮箱格式)+ `password`

lib/providers.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,4 +600,13 @@ export const STATION_TYPES = [
600600
{ value: "newapi-key", label: "New API(sk 密钥)", needs: ["apiKey"] },
601601
{ value: "sub2api", label: "Sub2API(登录令牌)", needs: ["accessToken"] },
602602
{ value: "sub2api-password", label: "Sub2API(账号密码,自动续期)", needs: ["email", "password"] },
603+
{ value: "fixed", label: "固定成本(包月/包年,不访问)", needs: [] },
603604
];
605+
606+
// 固定成本渠道的日均摊销(¥/天)= 每次付费金额 ÷ 覆盖天数;未配置返回 null
607+
export function dailyFixedCny(station) {
608+
const amt = station?.fixedCostCny;
609+
const days = station?.fixedDays;
610+
if (amt == null || amt <= 0 || days == null || days <= 0) return null;
611+
return amt / days;
612+
}

lib/store.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ export class Store {
4747
} catch {
4848
// 文件不存在或损坏:使用默认值并写入
4949
}
50+
// 迁移:v1.5.0 的 fixedMonthlyCny(月费)→ 金额 + 30 天
51+
for (const s of this.data.stations) {
52+
if (s.fixedMonthlyCny != null && s.fixedCostCny == null) {
53+
s.fixedCostCny = s.fixedMonthlyCny;
54+
s.fixedDays = 30;
55+
}
56+
delete s.fixedMonthlyCny;
57+
delete s.fixedPeriod;
58+
}
5059
// 初始化面板账号(默认 admin / admin123,登录后请在设置里修改)
5160
if (!this.data.auth) {
5261
const { salt, hash } = hashPassword("admin123");
@@ -181,8 +190,9 @@ export class Store {
181190
cnyPerUsd: numOrNull(input.cnyPerUsd),
182191
// 我自己的中转站:启用「我的站点」下游用量分析(需管理员令牌)
183192
isOwn: !!input.isOwn,
184-
// 每月固定成本(¥):包月/定期投入的上游,利润计算按天摊销并忽略其按用量成本
185-
fixedMonthlyCny: numOrNull(input.fixedMonthlyCny),
193+
// 固定成本:每次付费金额(¥)+ 覆盖天数,日均摊销 = 金额 ÷ 天数
194+
fixedCostCny: numOrNull(input.fixedCostCny),
195+
fixedDays: numOrNull(input.fixedDays),
186196
demo: !!input.demo,
187197
createdAt: new Date().toISOString(),
188198
s2Tokens: null, // Sub2API 密码模式的令牌缓存 {accessToken, refreshToken, expiresAt}
@@ -207,7 +217,8 @@ export class Store {
207217
if ("lowBalanceUsd" in patch) s.lowBalanceUsd = numOrNull(patch.lowBalanceUsd);
208218
if ("cnyPerUsd" in patch) s.cnyPerUsd = numOrNull(patch.cnyPerUsd);
209219
if ("isOwn" in patch) s.isOwn = !!patch.isOwn;
210-
if ("fixedMonthlyCny" in patch) s.fixedMonthlyCny = numOrNull(patch.fixedMonthlyCny);
220+
if ("fixedCostCny" in patch) s.fixedCostCny = numOrNull(patch.fixedCostCny);
221+
if ("fixedDays" in patch) s.fixedDays = numOrNull(patch.fixedDays);
211222
// 凭证或站点实际变化才作废令牌缓存(前端编辑总会带上 type/email 原值,
212223
// 无脑作废会导致每次改名都触发一次完整重登录)
213224
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.5.1",
3+
"version": "1.6.0",
44
"private": true,
55
"description": "监控 sub2api / new-api 中转站余额的网页面板",
66
"type": "module",

public/app.js

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,27 @@ function sparkSvg(pts) {
187187
}
188188

189189
function stationRow(s) {
190+
// 固定成本渠道:不访问接口,只展示摊销信息
191+
if (s.type === "fixed") {
192+
const daily = s.fixedCostCny > 0 && s.fixedDays > 0 ? s.fixedCostCny / s.fixedDays : 0;
193+
return `
194+
<div class="st-row" data-id="${s.id}">
195+
<div class="st-plate">¥</div>
196+
<div class="st-main" style="cursor:default">
197+
<div class="st-name">${esc(s.name)}<span class="demo-tag">固定成本</span></div>
198+
<div class="st-meta">${s.baseUrl ? esc(s.baseUrl) + " · " : ""}不访问接口 · 仅计入利润成本</div>
199+
<div class="st-predict"><span>日均摊销 ${cny(daily)}</span><span>·</span><span>每次 ${cny(s.fixedCostCny || 0)}${s.fixedDays || 0} 天</span></div>
200+
</div>
201+
<div class="st-balance">
202+
<div class="amt">${cny(daily)}</div>
203+
<div class="sub">每天</div>
204+
</div>
205+
<div class="st-actions">
206+
<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>
207+
<button class="icon-btn" data-act="delete" title="删除"><svg viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg></button>
208+
</div>
209+
</div>`;
210+
}
190211
const st = statusOf(s);
191212
const b = s.balance;
192213
const rate = rateOf(s);
@@ -596,7 +617,7 @@ function renderUsage() {
596617
`<button data-range="${v}" class="${state.usageRange === v ? "active" : ""}">${l}</button>`).join("")}</div>
597618
<select class="select usage-station" id="usageStation">
598619
<option value="all">全部上游站点</option>
599-
${state.stations.map((s) => `<option value="${s.id}"${state.usageStation === s.id ? " selected" : ""}>${esc(s.name)}${s.isOwn ? "(我的站)" : ""}</option>`).join("")}
620+
${state.stations.filter((s) => s.type !== "fixed").map((s) => `<option value="${s.id}"${state.usageStation === s.id ? " selected" : ""}>${esc(s.name)}${s.isOwn ? "(我的站)" : ""}</option>`).join("")}
600621
</select>
601622
</div>
602623
<div id="usageBody"><div class="chart-empty">加载中…</div></div>`;
@@ -845,7 +866,6 @@ function renderOwn() {
845866
<div class="usage-filters">
846867
<div class="seg" id="ownRange">${OWN_RANGES.map(([v, l]) =>
847868
`<button data-range="${v}" class="${state.ownRange === v ? "active" : ""}">${l}</button>`).join("")}</div>
848-
<span class="muted" id="ownStationName"></span>
849869
</div>
850870
<div id="ownBody"><div class="chart-empty">加载中…</div></div>`;
851871
loadOwn();
@@ -875,9 +895,6 @@ function renderOwnBody() {
875895
if (!el || !state.ownData) return;
876896
const d = state.ownData;
877897
const rate = rateOf(d.station);
878-
const nameEl = $("#ownStationName");
879-
if (nameEl) nameEl.textContent = `数据来源:${d.station.name}`;
880-
881898
const totCost = d.byModel.reduce((a, m) => a + m.cost, 0) * rate;
882899
const totTokens = d.byModel.reduce((a, m) => a + m.tokens, 0);
883900
const totReqs = d.byModel.reduce((a, m) => a + m.requests, 0);
@@ -1211,7 +1228,8 @@ function openModal(station) {
12111228
$("#f-password").value = "";
12121229
$("#f-lowBalance").value = station?.lowBalanceUsd ?? "";
12131230
$("#f-cnyRate").value = station?.cnyPerUsd ?? "";
1214-
$("#f-fixedCny").value = station?.fixedMonthlyCny ?? "";
1231+
$("#f-fixedCost").value = station?.fixedCostCny ?? "";
1232+
$("#f-fixedDays").value = station?.fixedDays ?? "";
12151233
$("#f-own").checked = !!station?.isOwn;
12161234
if (station) {
12171235
$("#f-accessToken").placeholder = station.hasAccessToken ? "已配置,留空保持不变" : "令牌 / JWT";
@@ -1239,10 +1257,16 @@ function syncCredFields() {
12391257
"newapi-key": "任意可用的 sk- 密钥;通过 OpenAI 兼容计费接口查询额度。",
12401258
sub2api: "Sub2API 登录后的访问令牌(JWT);过期需手动更换,推荐用账号密码模式。",
12411259
"sub2api-password": "填 Sub2API 的登录邮箱和密码,面板会自动登录并在令牌过期时自动续期。开启 2FA 的账号不支持。",
1260+
fixed: "包月 / 包年等定期投入的上游:不访问任何接口,只按天摊销计入利润成本。",
12421261
};
12431262
$("#f-type-hint").textContent = hints[type] || "";
12441263
$("#l-accessToken").textContent = type.startsWith("sub2api") ? "登录令牌(JWT)" : "访问令牌";
12451264
$("#f-own-wrap").style.display = type === "newapi" ? "" : "none"; // 下游分析只支持 new-api
1265+
// 固定成本类型:只留名称/地址/固定成本,隐藏阈值与汇率;其他类型隐藏固定成本
1266+
const isFixed = type === "fixed";
1267+
$("#f-fixedWrap").style.display = isFixed ? "" : "none";
1268+
$("#f-lowBalance").closest(".form-field").style.display = isFixed ? "none" : "";
1269+
$("#f-cnyRate").closest(".form-field").style.display = isFixed ? "none" : "";
12461270
}
12471271
$("#f-type").onchange = syncCredFields;
12481272

@@ -1255,7 +1279,8 @@ $("#modalSave").onclick = async () => {
12551279
email: $("#f-email").value.trim(),
12561280
lowBalanceUsd: $("#f-lowBalance").value.trim(),
12571281
cnyPerUsd: $("#f-cnyRate").value.trim(),
1258-
fixedMonthlyCny: $("#f-fixedCny").value.trim(),
1282+
fixedCostCny: $("#f-fixedCost").value.trim(),
1283+
fixedDays: $("#f-fixedDays").value.trim(),
12591284
isOwn: $("#f-type").value === "newapi" && $("#f-own").checked,
12601285
};
12611286
const at = $("#f-accessToken").value.trim();
@@ -1264,7 +1289,12 @@ $("#modalSave").onclick = async () => {
12641289
if (at) payload.accessToken = at;
12651290
if (ak) payload.apiKey = ak;
12661291
if (pw) payload.password = pw;
1267-
if (!payload.baseUrl) return toast("请填写站点地址", "err");
1292+
if (payload.type === "fixed") {
1293+
if (!(Number(payload.fixedCostCny) > 0)) return toast("请填写每次付费金额", "err");
1294+
if (!(Number(payload.fixedDays) > 0)) return toast("请填写覆盖天数", "err");
1295+
} else if (!payload.baseUrl) {
1296+
return toast("请填写站点地址", "err");
1297+
}
12681298
try {
12691299
if (editingId) { await api.update(editingId, payload); toast("已更新"); }
12701300
else { await api.create(payload); toast("已添加,正在查询余额…"); }

public/index.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,14 @@ <h2 id="modalTitle">添加中转站</h2>
153153
<input class="input" id="f-cnyRate" placeholder="如 2 表示 $1 = ¥2,留空按 1:1" />
154154
<div class="hint">面板金额将按此汇率折算成人民币展示;余额告警仍按站点余额判断。</div>
155155
</div>
156-
<div class="form-field">
157-
<label>每月固定成本(¥,可留空)</label>
158-
<input class="input" id="f-fixedCny" placeholder="包月/定期投入的上游填月费,如 199" />
159-
<div class="hint">利润计算按天摊销(月费 ÷ 30 × 天数),并忽略该站的按用量成本。</div>
156+
<div class="form-field" id="f-fixedWrap">
157+
<label>固定成本(每次付费金额 + 覆盖天数)</label>
158+
<div class="field-inline">
159+
<input class="input" id="f-fixedCost" placeholder="每次付多少(¥),如 199" style="flex:1" />
160+
<input class="input" id="f-fixedDays" placeholder="管多少天,如 30" style="width:110px" />
161+
</div>
162+
<div class="hint">不访问任何接口;日均成本 = 金额 ÷ 天数,均摊计入利润。
163+
站点地址可留空;填上游地址可与「我的站点」渠道自动匹配。</div>
160164
</div>
161165
<div class="form-field" id="f-own-wrap">
162166
<label class="chk-line"><input type="checkbox" id="f-own" /> 这是我自己的中转站</label>

server.js

Lines changed: 34 additions & 18 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,
9-
dateStrInTz, parseDateLabel, STATION_TYPES,
9+
dateStrInTz, parseDateLabel, dailyFixedCny, STATION_TYPES,
1010
} from "./lib/providers.js";
1111
import { forecastDaily } from "./lib/forecast.js";
1212
import { SessionManager, verifyPassword } from "./lib/auth.js";
@@ -250,6 +250,7 @@ async function seedDemo() {
250250
// 并发会重复发告警、并让 Sub2API 轮换的 refresh_token 相互作废
251251
const inflightRefresh = new Map();
252252
function refreshOne(station) {
253+
if (station.type === "fixed") return Promise.resolve(null); // 固定成本渠道不访问任何接口
253254
const running = inflightRefresh.get(station.id);
254255
if (running) return running;
255256
const p = doRefreshOne(station).finally(() => inflightRefresh.delete(station.id));
@@ -351,7 +352,7 @@ app.post("/api/stations", async (req, res) => {
351352
const b = req.body || {};
352353
if (!b.type || !STATION_TYPES.some((t) => t.value === b.type))
353354
return res.status(400).json({ error: "无效的中转站类型" });
354-
if (!b.baseUrl) return res.status(400).json({ error: "请填写站点地址" });
355+
if (!b.baseUrl && b.type !== "fixed") return res.status(400).json({ error: "请填写站点地址" });
355356
const s = await store.add(b);
356357
refreshOne(s).catch(() => {});
357358
res.json({ station: redact(s) });
@@ -443,7 +444,7 @@ app.get("/api/usage", async (req, res) => {
443444
}
444445
const endMs = now;
445446

446-
const stations = await Promise.all(store.list().map(async (s) => {
447+
const stations = await Promise.all(store.list().filter((s) => s.type !== "fixed").map(async (s) => {
447448
const meta = { id: s.id, name: s.name, type: s.type, cnyPerUsd: s.cnyPerUsd ?? null, isOwn: !!s.isOwn };
448449
try {
449450
const u = await queryStationUsage(s, {
@@ -597,21 +598,36 @@ async function computeProfit(own, incomeUsd, { startMs, now, tz, range }) {
597598

598599
const windowDays = (now - startMs) / 86400000;
599600
const rateOf = (s) => (s.cnyPerUsd != null && s.cnyPerUsd > 0 ? s.cnyPerUsd : 1);
600-
const costs = await Promise.all([...matched.values()].map(async ({ station, channels: chNames }) => {
601-
const item = { stationId: station.id, name: station.name, channels: chNames };
602-
if (station.fixedMonthlyCny != null && station.fixedMonthlyCny > 0) {
603-
return { ...item, mode: "fixed", cny: r2((station.fixedMonthlyCny / 30) * windowDays) };
604-
}
605-
try {
606-
const u = await queryStationUsage(station, {
607-
startMs, endMs: now, granularity: "day", tz, wantToday: range === "today",
608-
});
609-
const usd = range === "today" && u.summary ? u.summary.cost : u.models.reduce((a, m) => a + m.cost, 0);
610-
return { ...item, mode: "usage", cny: r2(usd * rateOf(station)) };
611-
} catch {
612-
return { ...item, mode: "history", cny: r2(history.usedSince(station.id, startMs) * rateOf(station)) };
613-
}
614-
}));
601+
602+
// 固定成本:无论是否匹配到渠道都计入(服务器租金这类可以完全不填地址)
603+
const costs = [];
604+
for (const s of upstreams) {
605+
const df = dailyFixedCny(s);
606+
if (df == null) continue;
607+
costs.push({
608+
stationId: s.id, name: s.name, mode: "fixed",
609+
channels: matched.get(s.id)?.channels || ["未匹配渠道 · 通用成本"],
610+
cny: r2(df * windowDays),
611+
});
612+
}
613+
// 按用量:匹配到渠道且未配置固定成本的上游
614+
const usageCosts = await Promise.all(
615+
[...matched.values()]
616+
.filter(({ station }) => dailyFixedCny(station) == null && station.type !== "fixed")
617+
.map(async ({ station, channels: chNames }) => {
618+
const item = { stationId: station.id, name: station.name, channels: chNames };
619+
try {
620+
const u = await queryStationUsage(station, {
621+
startMs, endMs: now, granularity: "day", tz, wantToday: range === "today",
622+
});
623+
const usd = range === "today" && u.summary ? u.summary.cost : u.models.reduce((a, m) => a + m.cost, 0);
624+
return { ...item, mode: "usage", cny: r2(usd * rateOf(station)) };
625+
} catch {
626+
return { ...item, mode: "history", cny: r2(history.usedSince(station.id, startMs) * rateOf(station)) };
627+
}
628+
})
629+
);
630+
costs.push(...usageCosts);
615631

616632
const ownRate = own.cnyPerUsd != null && own.cnyPerUsd > 0 ? own.cnyPerUsd : 1;
617633
const incomeCny = r2(incomeUsd * ownRate);

0 commit comments

Comments
 (0)