fogact 1.2.4 → 1.2.6
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/bin/web-server.js +481 -22
- package/frontend/admin/admin-panel-v2.js +115 -7
- package/frontend/admin/index.html +3 -1
- package/frontend/user/assets/CardBind-CsCxihhP-fogact-live.js +1 -1
- package/frontend/user/assets/CardBind-CsCxihhP.js +1 -1
- package/lib/commands/restore.js +136 -47
- package/lib/commands/test.js +53 -18
- package/lib/services/activation-orchestrator.js +1 -0
- package/lib/services/database.js +58 -0
- package/lib/services/node-service.js +36 -18
- package/package.json +1 -1
package/lib/commands/restore.js
CHANGED
|
@@ -3,49 +3,72 @@
|
|
|
3
3
|
const prompts = require("prompts");
|
|
4
4
|
const { listBackups, restoreBackup, clearBackups } = require("../services/backup-service");
|
|
5
5
|
|
|
6
|
+
function getServiceLabel(service) {
|
|
7
|
+
if (service === "codex") return "Codex";
|
|
8
|
+
if (service === "claude") return "Claude Code";
|
|
9
|
+
return service || "未知服务";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getBackupFiles(backup) {
|
|
13
|
+
if (Array.isArray(backup.files)) {
|
|
14
|
+
return backup.files.map((file) => file.originalPath).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
return [backup.originalPath].filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatBackupTime(backup) {
|
|
20
|
+
const date = backup.timestamp ? new Date(backup.timestamp) : null;
|
|
21
|
+
if (!date || Number.isNaN(date.getTime())) return "未知时间";
|
|
22
|
+
return date.toLocaleString("zh-CN");
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
function formatBackupTitle(backup) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const count = Array.isArray(backup.files) ? ` · ${backup.files.length} 个文件` : "";
|
|
10
|
-
return `${service || "未知服务"} · ${time}${count}`;
|
|
26
|
+
const fileCount = getBackupFiles(backup).length;
|
|
27
|
+
return `${getServiceLabel(backup.service)} · ${formatBackupTime(backup)} · ${fileCount} 个文件`;
|
|
11
28
|
}
|
|
12
29
|
|
|
13
|
-
|
|
30
|
+
function printRestoreHeader() {
|
|
14
31
|
console.log("");
|
|
15
32
|
console.log(" 恢复备份");
|
|
16
33
|
console.log(" ─────────────────────────────────────");
|
|
34
|
+
}
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (response.service === undefined) {
|
|
33
|
-
console.log("");
|
|
34
|
-
console.log(" 已取消");
|
|
35
|
-
console.log("");
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
service = response.service;
|
|
36
|
+
function printBackupDetail(backup) {
|
|
37
|
+
console.log("");
|
|
38
|
+
console.log(" 备份详情");
|
|
39
|
+
console.log(" ─────────────────────────────────────");
|
|
40
|
+
console.log(` 服务类型: ${getServiceLabel(backup.service)}`);
|
|
41
|
+
console.log(` 备份时间: ${formatBackupTime(backup)}`);
|
|
42
|
+
console.log(` 备份类型: ${backup.kind === "manifest" ? "激活配置备份" : "单文件备份"}`);
|
|
43
|
+
console.log(` 文件数量: ${getBackupFiles(backup).length}`);
|
|
44
|
+
if (backup.path) console.log(` 备份位置: ${backup.path}`);
|
|
45
|
+
console.log("");
|
|
46
|
+
console.log(" 将恢复文件:");
|
|
47
|
+
for (const filePath of getBackupFiles(backup)) {
|
|
48
|
+
console.log(` ${filePath}`);
|
|
39
49
|
}
|
|
50
|
+
console.log("");
|
|
51
|
+
}
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
async function promptBackupService(defaultService) {
|
|
54
|
+
if (defaultService) return defaultService;
|
|
55
|
+
|
|
56
|
+
const response = await prompts({
|
|
57
|
+
type: "select",
|
|
58
|
+
name: "service",
|
|
59
|
+
message: "请选择要查看的备份",
|
|
60
|
+
choices: [
|
|
61
|
+
{ title: "Claude Code", value: "claude" },
|
|
62
|
+
{ title: "Codex", value: "codex" },
|
|
63
|
+
{ title: "全部备份", value: null },
|
|
64
|
+
],
|
|
65
|
+
initial: 2,
|
|
66
|
+
}, { onCancel: () => false });
|
|
67
|
+
|
|
68
|
+
return response.service;
|
|
69
|
+
}
|
|
48
70
|
|
|
71
|
+
async function promptBackup(backups) {
|
|
49
72
|
const response = await prompts({
|
|
50
73
|
type: "select",
|
|
51
74
|
name: "backup",
|
|
@@ -56,14 +79,71 @@ async function runRestoreCommand(options = {}) {
|
|
|
56
79
|
],
|
|
57
80
|
}, { onCancel: () => false });
|
|
58
81
|
|
|
59
|
-
|
|
82
|
+
return response.backup;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function confirmRestore(backup) {
|
|
86
|
+
printBackupDetail(backup);
|
|
87
|
+
const response = await prompts({
|
|
88
|
+
type: "confirm",
|
|
89
|
+
name: "confirmed",
|
|
90
|
+
message: "确认恢复此备份?",
|
|
91
|
+
initial: true,
|
|
92
|
+
}, { onCancel: () => false });
|
|
93
|
+
return Boolean(response.confirmed);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printRestoreResult(success, restoredPaths, service, error = null) {
|
|
97
|
+
console.log("");
|
|
98
|
+
if (success) {
|
|
99
|
+
console.log(" ✓ 备份已恢复");
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log(" 已恢复文件:");
|
|
102
|
+
for (const restoredPath of restoredPaths) {
|
|
103
|
+
console.log(` ${restoredPath}`);
|
|
104
|
+
}
|
|
105
|
+
console.log("");
|
|
106
|
+
const tip = service === "claude"
|
|
107
|
+
? "请重启 Claude Code 以使配置生效"
|
|
108
|
+
: "请重启 Codex / VSCode / Cursor / OpenCode 以使配置生效";
|
|
109
|
+
console.log(` ${tip}`);
|
|
110
|
+
console.log("");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(" ✗ 恢复失败");
|
|
115
|
+
if (error) console.log(` ${error}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runRestoreCommand(options = {}) {
|
|
120
|
+
printRestoreHeader();
|
|
121
|
+
|
|
122
|
+
const service = await promptBackupService(options.service);
|
|
123
|
+
if (service === undefined) {
|
|
60
124
|
console.log("");
|
|
61
125
|
console.log(" 已取消");
|
|
62
126
|
console.log("");
|
|
63
127
|
return;
|
|
64
128
|
}
|
|
65
129
|
|
|
66
|
-
|
|
130
|
+
const backups = listBackups(service);
|
|
131
|
+
if (backups.length === 0) {
|
|
132
|
+
console.log("");
|
|
133
|
+
console.log(" ℹ 暂无可恢复备份");
|
|
134
|
+
console.log("");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const backupPath = await promptBackup(backups);
|
|
139
|
+
if (!backupPath) {
|
|
140
|
+
console.log("");
|
|
141
|
+
console.log(" 已取消");
|
|
142
|
+
console.log("");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (backupPath === "__clear__") {
|
|
67
147
|
const confirm = await prompts({
|
|
68
148
|
type: "confirm",
|
|
69
149
|
name: "value",
|
|
@@ -85,21 +165,30 @@ async function runRestoreCommand(options = {}) {
|
|
|
85
165
|
return;
|
|
86
166
|
}
|
|
87
167
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const restoredPaths = restoreBackup(response.backup);
|
|
92
|
-
console.log(" ✓ 备份已恢复");
|
|
93
|
-
for (const restoredPath of restoredPaths) {
|
|
94
|
-
console.log(` ${restoredPath}`);
|
|
95
|
-
}
|
|
168
|
+
const backup = backups.find((item) => item.path === backupPath);
|
|
169
|
+
if (!backup) {
|
|
96
170
|
console.log("");
|
|
97
|
-
console.log("
|
|
171
|
+
console.log(" ✗ 备份不存在");
|
|
98
172
|
console.log("");
|
|
99
|
-
|
|
100
|
-
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!await confirmRestore(backup)) {
|
|
177
|
+
console.log(" 已取消恢复");
|
|
101
178
|
console.log("");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const restoredPaths = restoreBackup(backupPath);
|
|
184
|
+
printRestoreResult(true, restoredPaths, backup.service);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
printRestoreResult(false, [], backup.service, err.message);
|
|
102
187
|
}
|
|
103
188
|
}
|
|
104
189
|
|
|
105
|
-
module.exports = {
|
|
190
|
+
module.exports = {
|
|
191
|
+
formatBackupTitle,
|
|
192
|
+
printBackupDetail,
|
|
193
|
+
runRestoreCommand,
|
|
194
|
+
};
|
package/lib/commands/test.js
CHANGED
|
@@ -3,32 +3,67 @@
|
|
|
3
3
|
const { getNodes } = require("../services/fogact-api");
|
|
4
4
|
const { testNodes, formatNodeResults } = require("../services/node-service");
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
function updateProgress(message) {
|
|
7
|
+
if (process.stdout.isTTY) {
|
|
8
|
+
process.stdout.write(`\r ${message}${" ".repeat(20)}`);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
console.log(` ${message}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeNodeUrl(value) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.origin;
|
|
18
|
+
} catch (_error) {
|
|
19
|
+
return String(value || "").replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
10
22
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
];
|
|
23
|
+
async function collectUniqueNodes() {
|
|
24
|
+
const services = ["claude", "codex"];
|
|
25
|
+
const byUrl = new Map();
|
|
15
26
|
|
|
16
27
|
for (const service of services) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
const nodes = await getNodes(service);
|
|
29
|
+
for (const node of nodes) {
|
|
30
|
+
const url = normalizeNodeUrl(node.url);
|
|
31
|
+
if (!url || byUrl.has(url)) continue;
|
|
32
|
+
byUrl.set(url, {
|
|
33
|
+
...node,
|
|
34
|
+
url,
|
|
35
|
+
name: node.name || new URL(url).hostname,
|
|
36
|
+
});
|
|
24
37
|
}
|
|
38
|
+
}
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
return [...byUrl.values()];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runTestCommand() {
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(" 正在测试所有节点...");
|
|
46
|
+
|
|
47
|
+
const nodes = await collectUniqueNodes();
|
|
48
|
+
|
|
49
|
+
if (nodes.length === 0) {
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log(" ℹ 暂无可用节点");
|
|
27
52
|
console.log("");
|
|
28
|
-
|
|
53
|
+
return;
|
|
29
54
|
}
|
|
30
55
|
|
|
56
|
+
updateProgress(`测试中... (0/${nodes.length} 完成)`);
|
|
57
|
+
const results = await testNodes(nodes, (_node, done, total) => {
|
|
58
|
+
updateProgress(`测试中... (${done}/${total} 完成)`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (process.stdout.isTTY) {
|
|
62
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
63
|
+
}
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(formatNodeResults(results));
|
|
31
66
|
console.log("");
|
|
32
67
|
}
|
|
33
68
|
|
|
34
|
-
module.exports = { runTestCommand };
|
|
69
|
+
module.exports = { collectUniqueNodes, runTestCommand };
|
package/lib/services/database.js
CHANGED
|
@@ -7,6 +7,7 @@ const DATA_DIR = path.join(__dirname, "..", "..", "data");
|
|
|
7
7
|
const USERS_FILE = path.join(DATA_DIR, "users.json");
|
|
8
8
|
const CODES_FILE = path.join(DATA_DIR, "codes.json");
|
|
9
9
|
const USAGE_FILE = path.join(DATA_DIR, "usage.json");
|
|
10
|
+
const CARD_MERGES_FILE = path.join(DATA_DIR, "card-merges.json");
|
|
10
11
|
|
|
11
12
|
// 确保数据目录存在
|
|
12
13
|
function ensureDataDir() {
|
|
@@ -136,6 +137,11 @@ const codeDb = {
|
|
|
136
137
|
name: codeData.name || `Code-${Date.now()}`,
|
|
137
138
|
service: codeData.service || "Claude Code",
|
|
138
139
|
category: codeData.category || "标准运营",
|
|
140
|
+
subServiceType: codeData.subServiceType || codeData.category || "标准运营",
|
|
141
|
+
billingType: codeData.billingType || codeData.quota?.billingType || codeData.quota?.type || "quota",
|
|
142
|
+
cycleType: codeData.cycleType || codeData.quota?.cycleType || codeData.quota?.type || "fixed",
|
|
143
|
+
quotaUnit: codeData.quotaUnit || codeData.quota?.unit || "tokens",
|
|
144
|
+
resetTimezone: codeData.resetTimezone || codeData.quota?.resetTimezone || "Asia/Shanghai",
|
|
139
145
|
status: codeData.status || "未使用",
|
|
140
146
|
enabled: codeData.enabled !== undefined ? codeData.enabled : true,
|
|
141
147
|
usedBy: codeData.usedBy || null,
|
|
@@ -148,9 +154,14 @@ const codeDb = {
|
|
|
148
154
|
total: codeData.quota?.total || 100000,
|
|
149
155
|
used: codeData.quota?.used || 0,
|
|
150
156
|
dailyLimit: codeData.quota?.dailyLimit || 5000,
|
|
157
|
+
dailyQuota: codeData.quota?.dailyQuota || codeData.quota?.dailyLimit || 5000,
|
|
151
158
|
dailyUsed: codeData.quota?.dailyUsed || 0,
|
|
152
159
|
periodDays: codeData.quota?.periodDays || 30,
|
|
153
160
|
periodLimit: codeData.quota?.periodLimit || 50000,
|
|
161
|
+
billingType: codeData.billingType || codeData.quota?.billingType || codeData.quota?.type || "quota",
|
|
162
|
+
cycleType: codeData.cycleType || codeData.quota?.cycleType || codeData.quota?.type || "fixed",
|
|
163
|
+
unit: codeData.quotaUnit || codeData.quota?.unit || "tokens",
|
|
164
|
+
resetTimezone: codeData.resetTimezone || codeData.quota?.resetTimezone || "Asia/Shanghai",
|
|
154
165
|
},
|
|
155
166
|
// 计费设置
|
|
156
167
|
billing: {
|
|
@@ -303,6 +314,52 @@ const usageDb = {
|
|
|
303
314
|
},
|
|
304
315
|
};
|
|
305
316
|
|
|
317
|
+
// 叠卡合并记录数据库操作
|
|
318
|
+
const cardMergeDb = {
|
|
319
|
+
getAll() {
|
|
320
|
+
return readJsonFile(CARD_MERGES_FILE, []);
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
create(mergeData) {
|
|
324
|
+
const items = this.getAll();
|
|
325
|
+
const now = new Date().toISOString();
|
|
326
|
+
const newItem = {
|
|
327
|
+
id: mergeData.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
328
|
+
parentCodeId: mergeData.parentCodeId,
|
|
329
|
+
parentCode: mergeData.parentCode,
|
|
330
|
+
childCodeId: mergeData.childCodeId,
|
|
331
|
+
childCode: mergeData.childCode,
|
|
332
|
+
serviceType: mergeData.serviceType,
|
|
333
|
+
subServiceType: mergeData.subServiceType || "",
|
|
334
|
+
billingType: mergeData.billingType,
|
|
335
|
+
cycleType: mergeData.cycleType,
|
|
336
|
+
quotaUnit: mergeData.quotaUnit,
|
|
337
|
+
timezone: mergeData.timezone || "Asia/Shanghai",
|
|
338
|
+
mergeMode: mergeData.mergeMode,
|
|
339
|
+
parentDailyQuota: Number(mergeData.parentDailyQuota || 0),
|
|
340
|
+
childDailyQuota: Number(mergeData.childDailyQuota || 0),
|
|
341
|
+
childDays: Number(mergeData.childDays || 0),
|
|
342
|
+
childTotalValue: Number(mergeData.childTotalValue || 0),
|
|
343
|
+
addedDays: Number(mergeData.addedDays || 0),
|
|
344
|
+
addedQuota: Number(mergeData.addedQuota || 0),
|
|
345
|
+
oldExpiresAt: mergeData.oldExpiresAt || null,
|
|
346
|
+
newExpiresAt: mergeData.newExpiresAt || null,
|
|
347
|
+
createdAt: mergeData.createdAt || now,
|
|
348
|
+
};
|
|
349
|
+
items.push(newItem);
|
|
350
|
+
writeJsonFile(CARD_MERGES_FILE, items);
|
|
351
|
+
return newItem;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
getByParent(parentCodeId) {
|
|
355
|
+
return this.getAll().filter((item) => item.parentCodeId === parentCodeId);
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
getByChild(childCodeId) {
|
|
359
|
+
return this.getAll().filter((item) => item.childCodeId === childCodeId);
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
306
363
|
// 初始化示例数据
|
|
307
364
|
function initializeSampleData() {
|
|
308
365
|
ensureDataDir();
|
|
@@ -504,5 +561,6 @@ module.exports = {
|
|
|
504
561
|
userDb,
|
|
505
562
|
codeDb,
|
|
506
563
|
usageDb,
|
|
564
|
+
cardMergeDb,
|
|
507
565
|
initializeSampleData,
|
|
508
566
|
};
|
|
@@ -6,7 +6,6 @@ async function testNodes(nodes, onProgress = null) {
|
|
|
6
6
|
const results = [];
|
|
7
7
|
|
|
8
8
|
for (const node of nodes) {
|
|
9
|
-
if (onProgress) onProgress(node, results.length, nodes.length);
|
|
10
9
|
const probes = [];
|
|
11
10
|
for (let index = 0; index < 3; index += 1) {
|
|
12
11
|
const result = await testNode(node.url);
|
|
@@ -35,6 +34,8 @@ async function testNodes(nodes, onProgress = null) {
|
|
|
35
34
|
score: scoreNode({ available, avgLatency, latencyStdDev, successRate }),
|
|
36
35
|
error: available ? undefined : probes.find((probe) => probe.error)?.error || "节点不可达",
|
|
37
36
|
});
|
|
37
|
+
|
|
38
|
+
if (onProgress) onProgress(node, results.length, nodes.length);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
return results;
|
|
@@ -72,11 +73,15 @@ function selectBestNode(testResults) {
|
|
|
72
73
|
return available[0];
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
function sortNodeResults(results) {
|
|
77
|
+
return [...results].sort((left, right) => getResultScore(right) - getResultScore(left) || getResultLatency(left) - getResultLatency(right));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function latencyLevel(latency) {
|
|
81
|
+
if (latency <= 50) return "优秀";
|
|
82
|
+
if (latency <= 100) return "良好";
|
|
83
|
+
if (latency <= 300) return "一般";
|
|
84
|
+
return "较慢";
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
function stabilityLabel(stdDev) {
|
|
@@ -86,27 +91,39 @@ function stabilityLabel(stdDev) {
|
|
|
86
91
|
return "波动";
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
function
|
|
90
|
-
const
|
|
94
|
+
function padCell(value, width) {
|
|
95
|
+
const text = String(value || "");
|
|
96
|
+
const displayWidth = Array.from(text).reduce((sum, char) => sum + (char.charCodeAt(0) > 0xff ? 2 : 1), 0);
|
|
97
|
+
return `${text}${" ".repeat(Math.max(0, width - displayWidth))}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatNodeLine(result, best) {
|
|
101
|
+
const mark = result.available ? "✓" : "✗";
|
|
102
|
+
const name = padCell(result.name || result.url || "FogAct", 12);
|
|
103
|
+
|
|
104
|
+
if (!result.available) {
|
|
105
|
+
return ` ${mark} ${name} 不可达`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const bestMark = best && best === result ? " ★ 最优" : "";
|
|
109
|
+
return ` ${mark} ${name} ${result.avgLatency}ms (±${result.latencyStdDev}ms) ${stabilityLabel(result.latencyStdDev)} ${getResultScore(result)}分${bestMark}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatNodeResults(results, options = {}) {
|
|
113
|
+
const sorted = sortNodeResults(results);
|
|
91
114
|
const best = sorted.find((result) => result.available);
|
|
115
|
+
const availableCount = results.filter((result) => result.available).length;
|
|
92
116
|
const lines = [];
|
|
117
|
+
const title = options.title || "节点测试结果";
|
|
93
118
|
|
|
94
|
-
lines.push(
|
|
119
|
+
lines.push(` ${title}`);
|
|
95
120
|
lines.push(" ───────────────────────────────────────────────────");
|
|
96
121
|
lines.push("");
|
|
97
122
|
|
|
98
123
|
for (const result of sorted) {
|
|
99
|
-
|
|
100
|
-
const name = String(result.name || result.url).padEnd(10);
|
|
101
|
-
if (!result.available) {
|
|
102
|
-
lines.push(` ${mark} ${name} 不可达`);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const bestMark = best && best.url === result.url ? " ★ 最优" : "";
|
|
106
|
-
lines.push(` ${mark} ${name} ${latencyLabel(result.avgLatency)} (±${result.latencyStdDev}ms) ${stabilityLabel(result.latencyStdDev)} ${result.score}分${bestMark}`);
|
|
124
|
+
lines.push(formatNodeLine(result, best));
|
|
107
125
|
}
|
|
108
126
|
|
|
109
|
-
const availableCount = results.filter((result) => result.available).length;
|
|
110
127
|
lines.push("");
|
|
111
128
|
lines.push(" ───────────────────────────────────────────────────");
|
|
112
129
|
lines.push(` 测试完成,共 ${results.length} 个节点,${availableCount} 个可用`);
|
|
@@ -117,4 +134,5 @@ module.exports = {
|
|
|
117
134
|
testNodes,
|
|
118
135
|
selectBestNode,
|
|
119
136
|
formatNodeResults,
|
|
137
|
+
sortNodeResults,
|
|
120
138
|
};
|