mslxdff 0.1.67 → 0.1.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bench/probe.js +68 -0
- package/src/bench/report.js +56 -0
- package/src/bench/runner.js +74 -0
- package/src/cli/commands/provider/bench.js +140 -0
- package/src/cli/commands/provider/index.js +2 -0
- package/src/cli/commands/system.js +3 -2
- package/src/cli/commands/timezone.js +47 -0
- package/src/cli/index.js +2 -0
- package/src/logs.js +2 -1
- package/src/metrics.js +63 -0
- package/src/providers/workbuddy/rotation-log.js +2 -1
- package/src/routes/groups.js +2 -1
- package/src/state/facade.js +1 -0
- package/src/state/schemas/timezone.js +70 -0
- package/src/state/schemas/token.js +3 -2
- package/src/time.js +49 -16
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
|
|
3
|
+
function normalizeModelsPayload(json) {
|
|
4
|
+
if (!json) return [];
|
|
5
|
+
if (Array.isArray(json)) return json;
|
|
6
|
+
if (Array.isArray(json.data)) return json.data;
|
|
7
|
+
if (Array.isArray(json.models)) return json.models;
|
|
8
|
+
if (json.data && typeof json.data === "object" && Array.isArray(json.data.data)) return json.data.data;
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toModelId(m) {
|
|
13
|
+
if (!m) return "";
|
|
14
|
+
if (typeof m === "string") return m;
|
|
15
|
+
if (typeof m.id === "string") return m.id;
|
|
16
|
+
if (typeof m.model === "string") return m.model;
|
|
17
|
+
if (typeof m.name === "string") return m.name;
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeModels({
|
|
22
|
+
baseUrl,
|
|
23
|
+
modelsPath,
|
|
24
|
+
chatPath,
|
|
25
|
+
headers = {},
|
|
26
|
+
fetchImpl = globalThis.fetch,
|
|
27
|
+
timeoutMs = 8000,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const base = String(baseUrl || "").replace(/\/+$/, "");
|
|
30
|
+
if (!base) return { ok: false, error: "missing baseUrl", tried: [], data: [] };
|
|
31
|
+
const candidates = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const push = (p) => {
|
|
34
|
+
const s = String(p || "").trim();
|
|
35
|
+
if (!s) return;
|
|
36
|
+
const norm = s.startsWith("/") ? s : `/${s}`;
|
|
37
|
+
if (!seen.has(norm)) { seen.add(norm); candidates.push(norm); }
|
|
38
|
+
};
|
|
39
|
+
if (modelsPath) push(modelsPath);
|
|
40
|
+
// 通用回退:/v1/models 与 /models
|
|
41
|
+
push("/v1/models");
|
|
42
|
+
push("/models");
|
|
43
|
+
// workbuddy 异形已在 defaultModelsPath 注入,若仍未命中则 candidates 已含
|
|
44
|
+
const tried = [];
|
|
45
|
+
let lastError = "";
|
|
46
|
+
for (const p of candidates) {
|
|
47
|
+
const url = joinUrl(base, p);
|
|
48
|
+
tried.push(url);
|
|
49
|
+
try {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const t = setTimeout(() => controller.abort(new Error(`probe timeout ${timeoutMs}ms`)), timeoutMs);
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetchImpl(url, { headers, signal: controller.signal });
|
|
55
|
+
} finally { clearTimeout(t); }
|
|
56
|
+
if (res instanceof Error) { lastError = res.message || String(res); continue; }
|
|
57
|
+
if (!res.ok) { lastError = `HTTP ${res.status}`; continue; }
|
|
58
|
+
let json = {};
|
|
59
|
+
try { json = await res.json(); } catch { json = {}; }
|
|
60
|
+
const raw = normalizeModelsPayload(json);
|
|
61
|
+
const data = raw.map((m) => ({ id: toModelId(m), raw: m })).filter((x) => x.id).map((x) => ({ id: x.id, ...x.raw }));
|
|
62
|
+
return { ok: true, data, tried, url, rawCount: raw.length };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
lastError = e?.message || String(e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { ok: false, error: lastError || "all probes failed", tried, data: [] };
|
|
68
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function sortResults(list) {
|
|
2
|
+
const arr = [...(list || [])];
|
|
3
|
+
arr.sort((a, b) => {
|
|
4
|
+
if (a.ok !== b.ok) return a.ok ? -1 : 1;
|
|
5
|
+
if (a.ok && b.ok) {
|
|
6
|
+
const at = a.ttfbMs ?? a.totalMs ?? 1e9;
|
|
7
|
+
const bt = b.ttfbMs ?? b.totalMs ?? 1e9;
|
|
8
|
+
if (at !== bt) return at - bt;
|
|
9
|
+
const ap = a.tps ?? a.charsPerSec ?? -1;
|
|
10
|
+
const bp = b.tps ?? b.charsPerSec ?? -1;
|
|
11
|
+
return bp - ap;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
});
|
|
15
|
+
return arr;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pad(s, n, align = "left") {
|
|
19
|
+
const str = String(s ?? "");
|
|
20
|
+
if (str.length >= n) return str.slice(0, n);
|
|
21
|
+
const d = n - str.length;
|
|
22
|
+
if (align === "right") return " ".repeat(d) + str;
|
|
23
|
+
return str + " ".repeat(d);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatReport(results, { json = false } = {}) {
|
|
27
|
+
const sorted = sortResults(results);
|
|
28
|
+
const winner = sorted.find((r) => r.ok) || null;
|
|
29
|
+
if (json) {
|
|
30
|
+
return { text: JSON.stringify(sorted, null, 2), json: sorted, winner, sorted };
|
|
31
|
+
}
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push("模型 状态 TTFB 总耗时 速度 tokens 备注");
|
|
34
|
+
lines.push("─".repeat(84));
|
|
35
|
+
for (const r of sorted) {
|
|
36
|
+
const isWin = winner && r.id === winner.id;
|
|
37
|
+
const mark = isWin ? "*" : " ";
|
|
38
|
+
const id = pad((isWin ? "* " : " ") + r.id, 28);
|
|
39
|
+
const label = pad(r.label || (r.ok ? "成功" : "失败"), 8);
|
|
40
|
+
const ttfb = pad(r.ttfbMs != null ? `${r.ttfbMs}ms` : "—", 6, "right");
|
|
41
|
+
const total = pad(r.totalMs != null ? `${r.totalMs}ms` : "—", 7, "right");
|
|
42
|
+
let speed = "—";
|
|
43
|
+
if (r.tps != null) speed = `${r.tps} t/s`;
|
|
44
|
+
else if (r.charsPerSec != null) speed = `${r.charsPerSec} 字/秒`;
|
|
45
|
+
speed = pad(speed, 11, "right");
|
|
46
|
+
const tok = r.tokens?.completion != null ? String(r.tokens.completion) : r.chars != null ? String(r.chars) : "—";
|
|
47
|
+
const note = r.ok ? "" : (r.error || "").slice(0, 28);
|
|
48
|
+
lines.push(`${mark}${id} ${label} ${ttfb} ${total} ${speed} ${pad(tok, 6, "right")} ${note}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("─".repeat(84));
|
|
51
|
+
if (winner) lines.push(`最快:${winner.id} TTFB ${winner.ttfbMs}ms 总 ${winner.totalMs}ms ${winner.tps != null ? `${winner.tps} t/s` : `${winner.charsPerSec ?? "—"} 字/秒`}`);
|
|
52
|
+
else lines.push("无可用模型(均失败)");
|
|
53
|
+
const failed = sorted.filter((r) => !r.ok).length;
|
|
54
|
+
lines.push(`完成:${sorted.length} 个,成功 ${sorted.length - failed},失败 ${failed}`);
|
|
55
|
+
return { text: lines.join("\n"), json: sorted, winner, sorted };
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { joinUrl } from "../providers/base.js";
|
|
2
|
+
import { computeMetrics, extractUsageFromJson } from "../metrics.js";
|
|
3
|
+
|
|
4
|
+
function classifyError(status, bodyText) {
|
|
5
|
+
const t = String(bodyText || "").slice(0, 300);
|
|
6
|
+
if (status === 401) return { label: "鉴权失败", retryable: false };
|
|
7
|
+
if (status === 402 || /insufficient balance/i.test(t)) return { label: "余额不足", retryable: false };
|
|
8
|
+
if (status === 403) return { label: /insufficient/i.test(t) ? "余额不足" : "鉴权失败", retryable: false };
|
|
9
|
+
if (status === 429) return { label: "限流", retryable: true };
|
|
10
|
+
if (status >= 500) return { label: `上游错误 ${status}`, retryable: true };
|
|
11
|
+
if (status === 404) return { label: "模型不存在", retryable: false };
|
|
12
|
+
return { label: `HTTP ${status}`, retryable: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runOne({
|
|
16
|
+
baseUrl,
|
|
17
|
+
chatPath = "/v1/chat/completions",
|
|
18
|
+
model,
|
|
19
|
+
providerId,
|
|
20
|
+
apiKey,
|
|
21
|
+
headers = {},
|
|
22
|
+
prompt = "hi",
|
|
23
|
+
maxTokens = 32,
|
|
24
|
+
timeoutMs = 30000,
|
|
25
|
+
fetchImpl = globalThis.fetch,
|
|
26
|
+
clock = Date.now,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const started = clock();
|
|
29
|
+
const t0 = typeof performance !== "undefined" && performance.now ? performance.now() : started;
|
|
30
|
+
if (!baseUrl) return { id: model, ok: false, error: "missing baseUrl", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
31
|
+
if (!model) return { id: model, ok: false, error: "missing model", label: "配置错误", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
32
|
+
if (!apiKey) return { id: model, ok: false, error: "missing apiKey", label: "未配置 Key", ttfbMs: null, totalMs: 0, tps: null, charsPerSec: null, tokens: null };
|
|
33
|
+
const url = joinUrl(String(baseUrl).replace(/\/+$/, ""), chatPath);
|
|
34
|
+
const body = { model: String(model).split("/").pop(), stream: false, messages: [{ role: "user", content: prompt }], max_tokens: maxTokens };
|
|
35
|
+
// workbuddy 需额外 workbuddy 透传头由调用方 headers 注入;此处直接透传
|
|
36
|
+
const finalHeaders = { "Content-Type": "application/json", Accept: "application/json", ...headers };
|
|
37
|
+
if (apiKey && !finalHeaders.Authorization) finalHeaders.Authorization = `Bearer ${apiKey}`;
|
|
38
|
+
|
|
39
|
+
let ttfbMs = null;
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
|
|
44
|
+
const fetchStart = typeof performance !== "undefined" && performance.now ? performance.now() : clock();
|
|
45
|
+
try {
|
|
46
|
+
res = await fetchImpl(url, { method: "POST", headers: finalHeaders, body: JSON.stringify(body), signal: controller.signal });
|
|
47
|
+
} finally { clearTimeout(timer); }
|
|
48
|
+
ttfbMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - fetchStart);
|
|
49
|
+
if (res instanceof Error) throw res;
|
|
50
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
let txt = "";
|
|
53
|
+
try { txt = await res.text(); } catch {}
|
|
54
|
+
const cls = classifyError(res.status, txt);
|
|
55
|
+
return { id: model, providerId, ok: false, status: res.status, error: txt.slice(0, 300) || `HTTP ${res.status}`, label: cls.label, ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
56
|
+
}
|
|
57
|
+
let json = {};
|
|
58
|
+
let txt = "";
|
|
59
|
+
try { txt = await res.text(); json = JSON.parse(txt); } catch { json = {}; }
|
|
60
|
+
const usage = extractUsageFromJson(json);
|
|
61
|
+
const content = json?.choices?.[0]?.message?.content || json?.choices?.[0]?.text || txt || "";
|
|
62
|
+
const chars = typeof content === "string" ? content.length : 0;
|
|
63
|
+
const promptTokens = usage?.prompt_tokens ?? null;
|
|
64
|
+
const completionTokens = usage?.completion_tokens ?? null;
|
|
65
|
+
const { tps, charsPerSec } = computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars });
|
|
66
|
+
const totalTokens = usage?.total_tokens ?? (promptTokens !== null && completionTokens !== null ? promptTokens + completionTokens : null);
|
|
67
|
+
return { id: model, providerId, ok: true, status: res.status, ttfbMs, totalMs, tps, charsPerSec, tokens: { prompt: promptTokens, completion: completionTokens, total: totalTokens }, chars, label: "成功", raw: json };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const totalMs = Math.round((typeof performance !== "undefined" && performance.now ? performance.now() : clock()) - t0);
|
|
70
|
+
const msg = e?.message || String(e);
|
|
71
|
+
const isTimeout = /timeout|abort/i.test(msg);
|
|
72
|
+
return { id: model, providerId, ok: false, error: msg.slice(0, 300), label: isTimeout ? "超时" : "网络错误", ttfbMs, totalMs, tps: null, charsPerSec: null, tokens: null };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { probeModels } from "../../../bench/probe.js";
|
|
2
|
+
import { runOne } from "../../../bench/runner.js";
|
|
3
|
+
import { formatReport } from "../../../bench/report.js";
|
|
4
|
+
import { defaultModelsPath, defaultChatPath } from "../../../state/provider-config.js";
|
|
5
|
+
|
|
6
|
+
function parseBenchArgs(rest) {
|
|
7
|
+
const opts = { json: false, prompt: "hi", maxTokens: 32, timeoutMs: 30000 };
|
|
8
|
+
for (let i = 0; i < rest.length; i++) {
|
|
9
|
+
const a = rest[i];
|
|
10
|
+
if (a === "--json" || a === "-json") opts.json = true;
|
|
11
|
+
else if (a === "--prompt" && rest[i + 1]) opts.prompt = rest[++i];
|
|
12
|
+
else if (a.startsWith("--prompt=")) opts.prompt = a.slice(9);
|
|
13
|
+
else if (a === "--max-tokens" && rest[i + 1]) opts.maxTokens = Number(rest[++i]) || 32;
|
|
14
|
+
else if (a.startsWith("--max-tokens=")) opts.maxTokens = Number(a.split("=")[1]) || 32;
|
|
15
|
+
else if (a === "--timeout" && rest[i + 1]) opts.timeoutMs = Number(rest[++i]) || 30000;
|
|
16
|
+
else if (a.startsWith("--timeout=")) opts.timeoutMs = Number(a.split("=")[1]) || 30000;
|
|
17
|
+
}
|
|
18
|
+
return opts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildHeadersForProvider(providerId, apiKey, auth) {
|
|
22
|
+
const h = {};
|
|
23
|
+
if (String(providerId).toLowerCase() === "workbuddy") {
|
|
24
|
+
h["Content-Type"] = "application/json";
|
|
25
|
+
h["Accept"] = "text/event-stream";
|
|
26
|
+
h["User-Agent"] = "CLI/2.115.0 WorkBuddy/2.115.0";
|
|
27
|
+
h["Origin"] = "https://www.codebuddy.cn";
|
|
28
|
+
h["Referer"] = "https://www.codebuddy.cn/";
|
|
29
|
+
h["X-Product"] = "SaaS";
|
|
30
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
31
|
+
if (auth?.uid) h["X-User-Id"] = auth.uid;
|
|
32
|
+
h["X-Domain"] = auth?.domain || "www.codebuddy.cn";
|
|
33
|
+
if (auth?.enterpriseId) {
|
|
34
|
+
h["X-Enterprise-Id"] = auth.enterpriseId;
|
|
35
|
+
h["X-Tenant-Id"] = auth.enterpriseId;
|
|
36
|
+
}
|
|
37
|
+
return h;
|
|
38
|
+
}
|
|
39
|
+
if (apiKey) h["Authorization"] = `Bearer ${apiKey}`;
|
|
40
|
+
return h;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleProviderBench(id, sub, rest, args, deps = {}) {
|
|
44
|
+
const isBench = sub === "bench" || sub === "benchmark" || sub === "eval" || sub === "test";
|
|
45
|
+
if (!isBench) return false;
|
|
46
|
+
const fetchImpl = deps.fetchImpl || globalThis.fetch;
|
|
47
|
+
const loadConfigs = deps.loadProviderConfigs || (await import("../../../state.js")).loadProviderConfigs;
|
|
48
|
+
const loadKeys = deps.loadProviderKeys || (await import("../../../state.js")).loadProviderKeys;
|
|
49
|
+
const loadAllowed = deps.loadProviderAllowedModels || (await import("../../../state.js")).loadProviderAllowedModels;
|
|
50
|
+
const loadAllowAny = deps.loadProviderAllowAnyModels || (await import("../../../state.js")).loadProviderAllowAnyModels;
|
|
51
|
+
const loadBaseUrl = deps.loadProviderBaseUrl || (await import("../../../state.js")).loadProviderBaseUrl;
|
|
52
|
+
const loadAuths = deps.loadProviderAuths || (async () => { try { const m = await import("../../../state.js"); return m.loadProviderAuths ? m.loadProviderAuths(id) : []; } catch { return []; } }) && (await import("../../../state.js")).loadProviderAuths;
|
|
53
|
+
|
|
54
|
+
const providerId = String(id || "").trim();
|
|
55
|
+
if (!providerId) {
|
|
56
|
+
console.error("usage: mslxdff -provider <id> bench [--json] [--prompt hi] [--max-tokens 32]");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const opts = parseBenchArgs(rest || []);
|
|
60
|
+
const configs = loadConfigs();
|
|
61
|
+
const cfg = configs[providerId] || {};
|
|
62
|
+
const keys = loadKeys(providerId) || [];
|
|
63
|
+
const allowed = loadAllowed(providerId) || [];
|
|
64
|
+
const allowAny = loadAllowAny(providerId);
|
|
65
|
+
const baseUrl = (loadBaseUrl(providerId) || cfg.baseUrl || "").trim();
|
|
66
|
+
let auths = [];
|
|
67
|
+
try { const m = await import("../../../state.js"); auths = m.loadProviderAuths ? m.loadProviderAuths(providerId) : []; } catch {}
|
|
68
|
+
if (!baseUrl) {
|
|
69
|
+
console.error(`provider ${providerId}: missing baseUrl — 先设置: mslxdff -provider ${providerId} set-url https://api.example.com/v1`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (!keys.length) {
|
|
73
|
+
console.error(`provider ${providerId}: 未配置 Key — 先设置: mslxdff -provider ${providerId} <key>`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
// 空勾选 → 不发 chat,仅探活模型列表并提示
|
|
77
|
+
if (!allowed.length) {
|
|
78
|
+
const modelsPath = cfg.modelsPath || defaultModelsPath(providerId);
|
|
79
|
+
console.log(`provider ${providerId}: 未设置 allowlist(allowAny=${allowAny ? "ON" : "OFF"}),不发起测速,仅探活模型列表...`);
|
|
80
|
+
console.log(`尝试:GET ${baseUrl}${modelsPath} → GET ${baseUrl}/v1/models → GET ${baseUrl}/models`);
|
|
81
|
+
const headers = buildHeadersForProvider(providerId, keys[0], auths[0]);
|
|
82
|
+
const probed = await probeModels({ baseUrl, modelsPath, headers, fetchImpl, timeoutMs: 8000 });
|
|
83
|
+
if (!probed.ok) {
|
|
84
|
+
console.error(`探活失败:${probed.error}`);
|
|
85
|
+
console.error(`已尝试:${probed.tried.join(", ")}`);
|
|
86
|
+
console.error(`请手动设置 allowlist:mslxdff -provider ${providerId} allowlist set <model>`);
|
|
87
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, error: probed.error, tried: probed.tried }, null, 2));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const list = probed.data || [];
|
|
91
|
+
if (!list.length) {
|
|
92
|
+
console.log("探活成功但返回空列表,请确认上游是否暴露 /v1/models");
|
|
93
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, data: [] }, null, 2));
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
console.log(`\n发现 ${list.length} 个模型:`);
|
|
97
|
+
for (const m of list.slice(0, 30)) {
|
|
98
|
+
console.log(` - ${m.id || m.model || m.name || JSON.stringify(m).slice(0, 80)}`);
|
|
99
|
+
}
|
|
100
|
+
if (list.length > 30) console.log(` ... 还有 ${list.length - 30} 个未展示`);
|
|
101
|
+
console.log(`\n下一步:勾选后再测(只测勾选,避免扣费)`);
|
|
102
|
+
console.log(` mslxdff -provider ${providerId} allowlist set ${list.slice(0, 2).map((m) => m.id).join(" ")}`);
|
|
103
|
+
console.log(` mslxdff -provider ${providerId} bench${opts.json ? " --json" : ""}`);
|
|
104
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, data: list, hint: `pick then bench` }, null, 2));
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 有勾选 → 逐个测
|
|
109
|
+
console.log(`bench ${providerId}: 共 ${allowed.length} 个已勾选模型,逐个测速(串行,${opts.timeoutMs}ms 超时)...`);
|
|
110
|
+
if (!opts.json) console.log(`prompt="${opts.prompt}" maxTokens=${opts.maxTokens}\n`);
|
|
111
|
+
const chatPath = cfg.chatPath || defaultChatPath(providerId);
|
|
112
|
+
const results = [];
|
|
113
|
+
for (let i = 0; i < allowed.length; i++) {
|
|
114
|
+
const raw = allowed[i];
|
|
115
|
+
const model = String(raw || "").trim();
|
|
116
|
+
if (!opts.json) process.stdout.write(` [${i + 1}/${allowed.length}] ${model} ... `);
|
|
117
|
+
// 轮询 key/auth:按索引取,超长循环
|
|
118
|
+
const kIdx = i % keys.length;
|
|
119
|
+
const aIdx = Math.min(kIdx, auths.length - 1);
|
|
120
|
+
const key = keys[kIdx];
|
|
121
|
+
const auth = auths[aIdx] || auths[0] || null;
|
|
122
|
+
const headers = buildHeadersForProvider(providerId, key, auth);
|
|
123
|
+
const r = await runOne({ baseUrl, chatPath, model, providerId, apiKey: key, headers, prompt: opts.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs, fetchImpl });
|
|
124
|
+
results.push(r);
|
|
125
|
+
if (!opts.json) {
|
|
126
|
+
if (r.ok) console.log(`OK TTFB ${r.ttfbMs}ms 总 ${r.totalMs}ms ${r.tps != null ? `${r.tps} t/s` : r.charsPerSec != null ? `${r.charsPerSec} 字/秒` : "—"}`);
|
|
127
|
+
else console.log(`FAIL ${r.label} ${r.error ? `(${r.error.slice(0, 60)})` : ""}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const report = formatReport(results, { json: opts.json });
|
|
131
|
+
console.log("\n" + report.text);
|
|
132
|
+
if (opts.json) {
|
|
133
|
+
// json 已在 text 中输出一次(report.text 是 JSON),无需重复
|
|
134
|
+
}
|
|
135
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
136
|
+
if (failed && !opts.json) {
|
|
137
|
+
console.log(`\n提示:失败 ${failed} 个多为 402余额不足/429限流/超时,可清冷却或换 Key 后重试`);
|
|
138
|
+
}
|
|
139
|
+
process.exit(failed ? 2 : 0);
|
|
140
|
+
}
|
|
@@ -187,6 +187,8 @@ export async function handleProvider(args) {
|
|
|
187
187
|
if (await handleProviderAllowlist(id, sub, rest)) return true;
|
|
188
188
|
const { handleProviderModels } = await import("./models.js");
|
|
189
189
|
if (await handleProviderModels(id, sub, args, rest)) return true;
|
|
190
|
+
const { handleProviderBench } = await import("./bench.js");
|
|
191
|
+
if (await handleProviderBench(id, sub, rest, args)) return true;
|
|
190
192
|
const { handleProviderKeys } = await import("./keys.js");
|
|
191
193
|
await handleProviderKeys(id, sub, rest, args);
|
|
192
194
|
return true;
|
|
@@ -6,6 +6,7 @@ import { loadToken, refreshToken } from "../../state.js";
|
|
|
6
6
|
import { stopDaemon, pidFile, logFile } from "../../daemon.js";
|
|
7
7
|
import { logDir, eventsFile, callsFile, errorsFile, recentEvents } from "../../logs.js";
|
|
8
8
|
import { fmtEvent } from "../format.js";
|
|
9
|
+
import { fmtShanghaiYMDHMS, fmtShanghaiHMS } from "../../time.js";
|
|
9
10
|
import { printHelp } from "../help.js";
|
|
10
11
|
import { printStatus } from "../status.js";
|
|
11
12
|
import { loadPlugins, resolvePluginDirs } from "../../plugins.js";
|
|
@@ -178,7 +179,7 @@ export async function handleFree(args) {
|
|
|
178
179
|
const { fetchV2exFree } = await import("../../free-watcher.js");
|
|
179
180
|
const show = async () => {
|
|
180
181
|
const hits = await fetchV2exFree({ timeoutMs: 6000 });
|
|
181
|
-
const ts = new Date()
|
|
182
|
+
const ts = fmtShanghaiYMDHMS(new Date());
|
|
182
183
|
console.log(`[V2EX] free check @ ${ts} — ${hits.length} hit(s)`);
|
|
183
184
|
if (!hits.length) {
|
|
184
185
|
console.log("(暂无命中 — 关键词:白嫖|限免|免费额度|注册送|羊毛,来源:/api/topics/latest.json + hot.json)");
|
|
@@ -192,7 +193,7 @@ export async function handleFree(args) {
|
|
|
192
193
|
}
|
|
193
194
|
console.log("V2EX 白嫖雷达 watch 模式 — 每 5 分钟拉一次 Ctrl+C 退出");
|
|
194
195
|
const run = async () => {
|
|
195
|
-
try { await show(); } catch (err) { console.error(`[${new Date()
|
|
196
|
+
try { await show(); } catch (err) { console.error(`[${fmtShanghaiHMS(new Date())}] 拉取失败: ${err?.message || err}`); }
|
|
196
197
|
console.log("---");
|
|
197
198
|
};
|
|
198
199
|
await run();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadTimezone, loadTimezoneState, saveTimezone, clearTimezone, getTimezoneEnv, isValidTimezone, DEFAULT_TZ } from "../../state/schemas/timezone.js";
|
|
2
|
+
import { getTimezone } from "../../time.js";
|
|
3
|
+
|
|
4
|
+
export async function handleTimezone(args) {
|
|
5
|
+
if (!(args.includes("-timezone") || args.includes("--timezone") || args.includes("-tz") || args.includes("--tz") || args.includes("-time") || args.includes("--time"))) return false;
|
|
6
|
+
const idx = args.findIndex((x) => ["-timezone","--timezone","-tz","--tz","-time","--time"].includes(x));
|
|
7
|
+
const sub = args[idx + 1];
|
|
8
|
+
const rest = args.slice(idx + 2);
|
|
9
|
+
const env = getTimezoneEnv();
|
|
10
|
+
const current = loadTimezoneState();
|
|
11
|
+
const effective = getTimezone();
|
|
12
|
+
|
|
13
|
+
if (!sub || sub === "status" || sub === "list" || sub === "show") {
|
|
14
|
+
console.log(`timezone: ${effective} ${env ? `(env ${env} 覆盖)` : ""}`.trim());
|
|
15
|
+
console.log(` state: ${current} ${current === DEFAULT_TZ ? "(默认 Asia/Shanghai)" : ""}`);
|
|
16
|
+
if (env) console.log(` env : ${env} (MSLXDFF_TZ 覆盖 state)`);
|
|
17
|
+
else console.log(` env : (未设 MSLXDFF_TZ)`);
|
|
18
|
+
console.log(`\n可用示例: Asia/Shanghai, UTC, America/New_York, Europe/London, Asia/Tokyo`);
|
|
19
|
+
console.log(`用法:`);
|
|
20
|
+
console.log(` mslxdff -timezone set Asia/Shanghai 设为上海时间(默认)`);
|
|
21
|
+
console.log(` mslxdff -timezone set UTC 设为 UTC`);
|
|
22
|
+
console.log(` mslxdff -timezone clear 恢复默认 (${DEFAULT_TZ})`);
|
|
23
|
+
console.log(` MSLXDFF_TZ=UTC mslxdff -status 临时用 UTC(env 覆盖,不落盘)`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
if (sub === "clear" || sub === "reset") {
|
|
27
|
+
clearTimezone();
|
|
28
|
+
console.log(`timezone 已清除,恢复默认: ${DEFAULT_TZ}`);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
let target = "";
|
|
32
|
+
if (sub === "set") target = rest[0];
|
|
33
|
+
else target = sub;
|
|
34
|
+
if (!target) {
|
|
35
|
+
console.error("usage: mslxdff -timezone set <Timezone> e.g. Asia/Shanghai, UTC");
|
|
36
|
+
console.error(" mslxdff -timezone <Timezone> 直接设置");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (!isValidTimezone(target)) {
|
|
40
|
+
console.error(`无效时区: ${target}`);
|
|
41
|
+
console.error(`示例: Asia/Shanghai, UTC, America/New_York`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
saveTimezone(target);
|
|
45
|
+
console.log(`timezone 已设为: ${target}(已写入 state.json,${env ? "但当前 env MSLXDFF_TZ 仍覆盖,需 unset 后生效" : "立即生效"})`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,8 @@ export async function run(args = process.argv.slice(2)) {
|
|
|
12
12
|
if (await handleUpdate(args, VERSION)) return;
|
|
13
13
|
if (await handleRefreshToken(args)) return;
|
|
14
14
|
if (await handleShowToken(args)) return;
|
|
15
|
+
const { handleTimezone } = await import("./commands/timezone.js");
|
|
16
|
+
if (await handleTimezone(args)) return;
|
|
15
17
|
|
|
16
18
|
const { handleStop, handleRestart } = await import("./commands/daemon.js");
|
|
17
19
|
if (await handleStop(args)) return;
|
package/src/logs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { appendFile, stat, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { defaultStateFile } from "./state.js";
|
|
6
|
+
import { fmtShanghaiYMDHMS } from "./time.js";
|
|
6
7
|
|
|
7
8
|
const MAX_CALLS = 500;
|
|
8
9
|
const MAX_ERRORS = 200;
|
|
@@ -73,7 +74,7 @@ function shouldSync(file) {
|
|
|
73
74
|
|
|
74
75
|
function appendLine(file, entry) {
|
|
75
76
|
ensureDir(dirname(file));
|
|
76
|
-
const line = JSON.stringify({ ts: new Date()
|
|
77
|
+
const line = JSON.stringify({ ts: fmtShanghaiYMDHMS(new Date()), ...entry }) + "\n";
|
|
77
78
|
if (shouldSync(file)) {
|
|
78
79
|
appendFileSync(file, line);
|
|
79
80
|
trimIfOversized(file);
|
package/src/metrics.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { normalizeFullId, toFullId } from "./providers/model-id.js";
|
|
2
|
+
|
|
3
|
+
export { normalizeFullId, toFullId };
|
|
4
|
+
|
|
5
|
+
// 从上游 usage 或文本兜底算 tps / charsPerSec
|
|
6
|
+
export function computeMetrics({ ttfbMs, totalMs, promptTokens, completionTokens, chars }) {
|
|
7
|
+
const ttfb = Number(ttfbMs);
|
|
8
|
+
const total = Number(totalMs);
|
|
9
|
+
const hasTtfb = Number.isFinite(ttfb) && ttfb >= 0;
|
|
10
|
+
const hasTotal = Number.isFinite(total) && total >= 0;
|
|
11
|
+
const completionMs = hasTtfb && hasTotal ? Math.max(0, total - ttfb) : hasTotal ? total : null;
|
|
12
|
+
const comp = Number(completionTokens);
|
|
13
|
+
const hasComp = Number.isFinite(comp) && comp > 0 && Number.isFinite(completionMs) && completionMs > 0;
|
|
14
|
+
const tps = hasComp ? Number((comp / (completionMs / 1000)).toFixed(1)) : null;
|
|
15
|
+
const c = Number(chars);
|
|
16
|
+
const hasChars = Number.isFinite(c) && c > 0 && Number.isFinite(completionMs) && completionMs > 0;
|
|
17
|
+
const charsPerSec = !hasComp && hasChars ? Math.round(c / (completionMs / 1000)) : null;
|
|
18
|
+
return { completionMs, tps, charsPerSec };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 从 Response 文本或 SSE 末帧提取 usage
|
|
22
|
+
export function extractUsageFromJson(parsed) {
|
|
23
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
24
|
+
const u = parsed.usage;
|
|
25
|
+
if (!u || typeof u !== "object") return null;
|
|
26
|
+
const prompt = Number(u.prompt_tokens ?? u.promptTokens ?? u.input_tokens);
|
|
27
|
+
const comp = Number(u.completion_tokens ?? u.completionTokens ?? u.output_tokens);
|
|
28
|
+
const total = Number(u.total_tokens ?? u.totalTokens);
|
|
29
|
+
const out = {};
|
|
30
|
+
if (Number.isFinite(prompt)) out.prompt_tokens = prompt;
|
|
31
|
+
if (Number.isFinite(comp)) out.completion_tokens = comp;
|
|
32
|
+
if (Number.isFinite(total)) out.total_tokens = total;
|
|
33
|
+
// reasoning_tokens 透传
|
|
34
|
+
const rt = u.completion_tokens_details?.reasoning_tokens ?? u.reasoning_tokens;
|
|
35
|
+
if (Number.isFinite(Number(rt))) out.reasoning_tokens = Number(rt);
|
|
36
|
+
return Object.keys(out).length ? out : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractUsageFromSseText(sseText) {
|
|
40
|
+
if (!sseText) return null;
|
|
41
|
+
const lines = String(sseText).split("\n");
|
|
42
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
43
|
+
const line = lines[i].trim();
|
|
44
|
+
if (!line.startsWith("data:")) continue;
|
|
45
|
+
const data = line.slice(5).trim();
|
|
46
|
+
if (data === "[DONE]") continue;
|
|
47
|
+
try {
|
|
48
|
+
const j = JSON.parse(data);
|
|
49
|
+
if (j.usage) return extractUsageFromJson(j);
|
|
50
|
+
if (j.choices?.[0]?.finish_reason && j.usage) return extractUsageFromJson(j);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 归一模型全称:provider + raw -> opencode/xxx
|
|
57
|
+
export function resolveFullId(rawModel, providerHint) {
|
|
58
|
+
const raw = String(rawModel || "").trim();
|
|
59
|
+
if (!raw) return "";
|
|
60
|
+
if (raw.includes("/")) return normalizeFullId(raw);
|
|
61
|
+
const prov = String(providerHint || "opencode").trim() || "opencode";
|
|
62
|
+
return toFullId(prov, raw);
|
|
63
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { fmtShanghaiYMDHMS } from "../../time.js";
|
|
3
4
|
|
|
4
5
|
function defaultDirs() {
|
|
5
6
|
const dirs = new Set();
|
|
@@ -22,7 +23,7 @@ export function appendRotationLog({ uid, model, totalMs, balanceHit, error, cloc
|
|
|
22
23
|
const useFs = fsOverride || { appendFileSync, mkdirSync, readFileSync, writeFileSync, statSync, join };
|
|
23
24
|
const useDirs = dirsOverride || defaultDirs();
|
|
24
25
|
try {
|
|
25
|
-
const line = `${new Date(clock())
|
|
26
|
+
const line = `${fmtShanghaiYMDHMS(new Date(clock()))} uid=${uid} model=${model || "-"} totalMs=${totalMs} balanceHit=${balanceHit ? 1 : 0}${error ? ` error=${String(error).slice(0, 120)}` : ""}\n`;
|
|
26
27
|
for (const dir of useDirs) {
|
|
27
28
|
try {
|
|
28
29
|
useFs.mkdirSync(dir, { recursive: true });
|
package/src/routes/groups.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { clientIp, json, readBody, errMsg } from "./helpers.js";
|
|
2
|
+
import { fmtShanghaiYMDHMS } from "../time.js";
|
|
2
3
|
|
|
3
4
|
export async function joinHandler({ req, res, groups, token, bans }) {
|
|
4
5
|
if (!groups) return json(res, 501, { error: "Groups service not configured" });
|
|
5
6
|
const ip = clientIp(req);
|
|
6
7
|
const banned = bans?.isBanned(ip);
|
|
7
8
|
if (banned) {
|
|
8
|
-
return json(res, 403, { error: `banned until ${
|
|
9
|
+
return json(res, 403, { error: `banned until ${fmtShanghaiYMDHMS(banned.until)}` });
|
|
9
10
|
}
|
|
10
11
|
let body;
|
|
11
12
|
try {
|
package/src/state/facade.js
CHANGED
|
@@ -55,3 +55,4 @@ export {
|
|
|
55
55
|
} from "./schemas/model.js";
|
|
56
56
|
export { loadPeers, savePeers, loadPeerErrors, savePeerErrors, loadPeerStats, savePeerStats } from "./schemas/peer.js";
|
|
57
57
|
export { loadGroups, loadGroupsJoined, saveGroupsJoined, loadBans, saveBans, saveGroups } from "./schemas/group.js";
|
|
58
|
+
export { loadTimezone, loadTimezoneState, saveTimezone, clearTimezone, getTimezoneEnv, DEFAULT_TZ, isValidTimezone } from "./schemas/timezone.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readState, writeStateImmediate, defaultStateFile } from "../store.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TZ = "Asia/Shanghai";
|
|
4
|
+
const ENV_KEYS = ["MSLXDFF_TZ", "MSLXDFF_TIMEZONE", "TZ"];
|
|
5
|
+
|
|
6
|
+
function isValidTimezone(tz) {
|
|
7
|
+
try {
|
|
8
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: tz });
|
|
9
|
+
return true;
|
|
10
|
+
} catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getTimezoneEnv() {
|
|
14
|
+
for (const k of ENV_KEYS) {
|
|
15
|
+
const v = String(process.env[k] || "").trim();
|
|
16
|
+
if (v && isValidTimezone(v)) return v;
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadTimezone({ file } = {}) {
|
|
22
|
+
const env = getTimezoneEnv();
|
|
23
|
+
if (env) return env;
|
|
24
|
+
try {
|
|
25
|
+
const f = file || defaultStateFile();
|
|
26
|
+
const s = readState(f);
|
|
27
|
+
const v = String(s?.timezone || s?.tz || "").trim();
|
|
28
|
+
if (v && isValidTimezone(v)) return v;
|
|
29
|
+
} catch {}
|
|
30
|
+
return DEFAULT_TZ;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function loadTimezoneState({ file } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
const f = file || defaultStateFile();
|
|
36
|
+
const s = readState(f);
|
|
37
|
+
const v = String(s?.timezone || s?.tz || "").trim();
|
|
38
|
+
if (v && isValidTimezone(v)) return v;
|
|
39
|
+
} catch {}
|
|
40
|
+
return DEFAULT_TZ;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveTimezone(tz, { file } = {}) {
|
|
44
|
+
const v = String(tz || "").trim();
|
|
45
|
+
if (!v) throw new Error("timezone 不能为空");
|
|
46
|
+
if (!isValidTimezone(v)) throw new Error(`无效时区: ${v}(示例: Asia/Shanghai, UTC, America/New_York)`);
|
|
47
|
+
const f = file || defaultStateFile();
|
|
48
|
+
const patch = { timezone: v };
|
|
49
|
+
// 兼容旧字段 tz
|
|
50
|
+
const cur = readState(f);
|
|
51
|
+
if (cur?.tz !== undefined) patch.tz = undefined;
|
|
52
|
+
return writeStateImmediate(f, patch);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clearTimezone({ file } = {}) {
|
|
56
|
+
const f = file || defaultStateFile();
|
|
57
|
+
const cur = readState(f);
|
|
58
|
+
const patch = {};
|
|
59
|
+
if (cur?.timezone !== undefined) patch.timezone = undefined;
|
|
60
|
+
if (cur?.tz !== undefined) patch.tz = undefined;
|
|
61
|
+
if (!Object.keys(patch).length) return cur;
|
|
62
|
+
// 通过写 undefined 触发 merge 覆盖?用直接删后写回
|
|
63
|
+
const next = { ...cur };
|
|
64
|
+
delete next.timezone;
|
|
65
|
+
delete next.tz;
|
|
66
|
+
writeStateImmediate(f, next);
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { DEFAULT_TZ, isValidTimezone };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { defaultStateFile, tokenFile, generateToken, readState, writeStateImmediate, getEntry } from "../store.js";
|
|
4
|
+
import { fmtShanghaiYMDHMS } from "../../time.js";
|
|
4
5
|
|
|
5
6
|
function syncTokenFile(token, file) {
|
|
6
7
|
try {
|
|
@@ -29,13 +30,13 @@ export async function loadToken({ file = defaultStateFile() } = {}) {
|
|
|
29
30
|
} catch {}
|
|
30
31
|
}
|
|
31
32
|
const tok = generateToken();
|
|
32
|
-
const saved = writeStateImmediate(file, { token: tok, createdAt: new Date()
|
|
33
|
+
const saved = writeStateImmediate(file, { token: tok, createdAt: fmtShanghaiYMDHMS(new Date()) }).token;
|
|
33
34
|
syncTokenFile(saved, file);
|
|
34
35
|
return { token: saved, created: true };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export async function refreshToken({ file = defaultStateFile() } = {}) {
|
|
38
|
-
const tok = writeStateImmediate(file, { token: generateToken(), createdAt: new Date()
|
|
39
|
+
const tok = writeStateImmediate(file, { token: generateToken(), createdAt: fmtShanghaiYMDHMS(new Date()) }).token;
|
|
39
40
|
syncTokenFile(tok, file);
|
|
40
41
|
return tok;
|
|
41
42
|
}
|
package/src/time.js
CHANGED
|
@@ -1,23 +1,50 @@
|
|
|
1
|
-
|
|
1
|
+
import { loadTimezone } from "./state/schemas/timezone.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const DEFAULT_TZ = "Asia/Shanghai";
|
|
4
|
+
|
|
5
|
+
function getTimezone() {
|
|
6
|
+
try { return loadTimezone() || DEFAULT_TZ; } catch { return DEFAULT_TZ; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function tzParts(d, tz) {
|
|
10
|
+
const zone = tz || getTimezone();
|
|
4
11
|
const date = d instanceof Date ? d : new Date(d);
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
try {
|
|
13
|
+
const fmt = new Intl.DateTimeFormat("en-GB", {
|
|
14
|
+
timeZone: zone,
|
|
15
|
+
year: "numeric",
|
|
16
|
+
month: "2-digit",
|
|
17
|
+
day: "2-digit",
|
|
18
|
+
hour: "2-digit",
|
|
19
|
+
minute: "2-digit",
|
|
20
|
+
second: "2-digit",
|
|
21
|
+
hour12: false,
|
|
22
|
+
});
|
|
23
|
+
const parts = fmt.formatToParts(date);
|
|
24
|
+
const m = {};
|
|
25
|
+
for (const p of parts) m[p.type] = p.value;
|
|
26
|
+
return m; // {year, month, day, hour, minute, second}
|
|
27
|
+
} catch {
|
|
28
|
+
// 回退上海
|
|
29
|
+
const fmt = new Intl.DateTimeFormat("en-GB", {
|
|
30
|
+
timeZone: DEFAULT_TZ,
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
hour12: false,
|
|
38
|
+
});
|
|
39
|
+
const parts = fmt.formatToParts(date);
|
|
40
|
+
const m = {};
|
|
41
|
+
for (const p of parts) m[p.type] = p.value;
|
|
42
|
+
return m;
|
|
43
|
+
}
|
|
19
44
|
}
|
|
20
45
|
|
|
46
|
+
function shanghaiParts(d) { return tzParts(d, getTimezone()); }
|
|
47
|
+
|
|
21
48
|
// "MM-DD HH:mm:ss" e.g. "08-27 15:07:14"
|
|
22
49
|
export function fmtShanghai(isoOrTs) {
|
|
23
50
|
if (isoOrTs == null) return "-";
|
|
@@ -71,3 +98,9 @@ export function nowShanghaiYMDHM() {
|
|
|
71
98
|
export function fmtTsShanghai(iso) {
|
|
72
99
|
return fmtShanghai(iso);
|
|
73
100
|
}
|
|
101
|
+
|
|
102
|
+
export { getTimezone };
|
|
103
|
+
// 通用别名(实际已可配置,不再仅限上海)
|
|
104
|
+
export const fmtYMDHMS = fmtShanghaiYMDHMS;
|
|
105
|
+
export const fmtYMDHM = fmtShanghaiYMDHM;
|
|
106
|
+
export const fmtHMS = fmtShanghaiHMS;
|