protocol-proxy 2.6.0 → 2.8.0
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/lib/config-store.js +83 -2
- package/package.json +1 -1
- package/public/app.js +337 -4
- package/public/index.html +58 -0
- package/public/style.css +411 -65
- package/server.js +180 -0
package/lib/config-store.js
CHANGED
|
@@ -154,20 +154,35 @@ function normalizeConfig(config) {
|
|
|
154
154
|
function loadConfig() {
|
|
155
155
|
try {
|
|
156
156
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
157
|
-
configCache = { providers: [], proxies: [] };
|
|
157
|
+
configCache = { providers: [], proxies: [], settings: {} };
|
|
158
158
|
return configCache;
|
|
159
159
|
}
|
|
160
160
|
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
161
161
|
let config = normalizeConfig(JSON.parse(raw));
|
|
162
162
|
config = migrateTargetToProvider(config);
|
|
163
|
+
if (!config.settings) config.settings = {};
|
|
163
164
|
configCache = config;
|
|
164
165
|
return configCache;
|
|
165
166
|
} catch (err) {
|
|
166
167
|
console.error('加载配置失败:', err.message);
|
|
167
|
-
return configCache || { providers: [], proxies: [] };
|
|
168
|
+
return configCache || { providers: [], proxies: [], settings: {} };
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
function getSettings() {
|
|
173
|
+
return loadConfig().settings || {};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function setSetting(key, value) {
|
|
177
|
+
const config = loadConfig();
|
|
178
|
+
if (!config.settings) config.settings = {};
|
|
179
|
+
config.settings[key] = value;
|
|
180
|
+
saveConfig(config);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const SNAPSHOT_DIR = path.join(os.homedir(), '.protocol-proxy', 'snapshots');
|
|
184
|
+
const MAX_SNAPSHOTS = 30;
|
|
185
|
+
|
|
171
186
|
function saveConfig(config) {
|
|
172
187
|
try {
|
|
173
188
|
const normalizedConfig = normalizeConfig(config);
|
|
@@ -186,6 +201,67 @@ function saveConfig(config) {
|
|
|
186
201
|
}
|
|
187
202
|
}
|
|
188
203
|
|
|
204
|
+
function saveSnapshot(reason) {
|
|
205
|
+
try {
|
|
206
|
+
if (!fs.existsSync(CONFIG_PATH)) return;
|
|
207
|
+
if (!fs.existsSync(SNAPSHOT_DIR)) fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
208
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
209
|
+
const name = `${ts}_${reason || 'save'}.json`;
|
|
210
|
+
fs.copyFileSync(CONFIG_PATH, path.join(SNAPSHOT_DIR, name));
|
|
211
|
+
// 清理旧快照
|
|
212
|
+
const files = fs.readdirSync(SNAPSHOT_DIR)
|
|
213
|
+
.filter(f => f.endsWith('.json'))
|
|
214
|
+
.sort();
|
|
215
|
+
while (files.length > MAX_SNAPSHOTS) {
|
|
216
|
+
const oldest = files.shift();
|
|
217
|
+
fs.unlinkSync(path.join(SNAPSHOT_DIR, oldest));
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error('[Snapshot] 保存快照失败:', err.message);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getSnapshots() {
|
|
225
|
+
try {
|
|
226
|
+
if (!fs.existsSync(SNAPSHOT_DIR)) return [];
|
|
227
|
+
return fs.readdirSync(SNAPSHOT_DIR)
|
|
228
|
+
.filter(f => f.endsWith('.json'))
|
|
229
|
+
.sort().reverse()
|
|
230
|
+
.map(f => {
|
|
231
|
+
const fullPath = path.join(SNAPSHOT_DIR, f);
|
|
232
|
+
const stat = fs.statSync(fullPath);
|
|
233
|
+
const name = f.replace('.json', '');
|
|
234
|
+
const [ts, ...reasonParts] = name.split('_');
|
|
235
|
+
return {
|
|
236
|
+
file: f,
|
|
237
|
+
timestamp: stat.mtime.toISOString(),
|
|
238
|
+
reason: reasonParts.join('_') || 'save',
|
|
239
|
+
size: stat.size,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error('[Snapshot] 读取快照列表失败:', err.message);
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function restoreSnapshot(file) {
|
|
249
|
+
try {
|
|
250
|
+
if (!/^[\w\-]+\.json$/.test(file)) return { error: '非法文件名' };
|
|
251
|
+
const snapshotPath = path.join(SNAPSHOT_DIR, file);
|
|
252
|
+
if (!fs.existsSync(snapshotPath)) return { error: '快照不存在' };
|
|
253
|
+
const content = fs.readFileSync(snapshotPath, 'utf-8');
|
|
254
|
+
const config = JSON.parse(content);
|
|
255
|
+
// 先对当前配置做快照,以便回滚本次操作
|
|
256
|
+
saveSnapshot('before-rollback');
|
|
257
|
+
saveConfig(config);
|
|
258
|
+
return { success: true };
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error('[Snapshot] 恢复快照失败:', err.message);
|
|
261
|
+
return { error: err.message };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
189
265
|
// ==================== 供应商 CRUD ====================
|
|
190
266
|
|
|
191
267
|
function getProviders() {
|
|
@@ -280,6 +356,11 @@ function removeProxy(id) {
|
|
|
280
356
|
module.exports = {
|
|
281
357
|
loadConfig,
|
|
282
358
|
saveConfig,
|
|
359
|
+
saveSnapshot,
|
|
360
|
+
getSnapshots,
|
|
361
|
+
restoreSnapshot,
|
|
362
|
+
getSettings,
|
|
363
|
+
setSetting,
|
|
283
364
|
getProviders,
|
|
284
365
|
getProviderById,
|
|
285
366
|
addProvider,
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -6,6 +6,51 @@ let importData = null;
|
|
|
6
6
|
let statsRange = 'daily';
|
|
7
7
|
let statsProxyId = '';
|
|
8
8
|
let providerPoolItems = [];
|
|
9
|
+
let keyHealth = {};
|
|
10
|
+
let statsAutoRefreshTimer = null;
|
|
11
|
+
|
|
12
|
+
// ==================== 主题切换 ====================
|
|
13
|
+
|
|
14
|
+
const THEMES = [
|
|
15
|
+
{ id: 'dark', icon: '☾', label: '深色' },
|
|
16
|
+
{ id: 'light', icon: '☀', label: '浅色' },
|
|
17
|
+
{ id: 'pure-black', icon: '●', label: '纯黑' },
|
|
18
|
+
{ id: 'neon', icon: '⚡', label: '霓虹' },
|
|
19
|
+
{ id: 'amber', icon: '◈', label: '琥珀' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function applyTheme(theme) {
|
|
23
|
+
const t = THEMES.find(t => t.id === theme) || THEMES[0];
|
|
24
|
+
document.documentElement.setAttribute('data-theme', t.id);
|
|
25
|
+
const icon = document.getElementById('theme-icon');
|
|
26
|
+
const label = document.getElementById('theme-label');
|
|
27
|
+
if (icon) icon.textContent = t.icon;
|
|
28
|
+
if (label) label.textContent = t.label;
|
|
29
|
+
localStorage.setItem('theme', t.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toggleTheme() {
|
|
33
|
+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
34
|
+
const idx = THEMES.findIndex(t => t.id === current);
|
|
35
|
+
const next = THEMES[(idx + 1) % THEMES.length];
|
|
36
|
+
applyTheme(next.id);
|
|
37
|
+
fetch('/api/settings', {
|
|
38
|
+
method: 'PUT',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ theme: next.id }),
|
|
41
|
+
}).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 初始化主题:优先服务端,fallback 到 localStorage
|
|
45
|
+
(async () => {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch('/api/settings');
|
|
48
|
+
const settings = await res.json();
|
|
49
|
+
applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
|
|
50
|
+
} catch {
|
|
51
|
+
applyTheme(localStorage.getItem('theme') || 'dark');
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
9
54
|
|
|
10
55
|
// ==================== 数据加载 ====================
|
|
11
56
|
|
|
@@ -38,6 +83,30 @@ function updateStats() {
|
|
|
38
83
|
proxies.filter(p => p.running).length;
|
|
39
84
|
}
|
|
40
85
|
|
|
86
|
+
async function loadKeyHealth() {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/key-health');
|
|
89
|
+
keyHealth = await res.json();
|
|
90
|
+
refreshHealthUI();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error('加载 Key 健康状态失败:', err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshHealthUI() {
|
|
97
|
+
// 更新每个代理卡片上的健康点
|
|
98
|
+
document.querySelectorAll('.health-dot[data-provider]').forEach(dot => {
|
|
99
|
+
const h = keyHealth[dot.dataset.provider];
|
|
100
|
+
dot.className = 'health-dot';
|
|
101
|
+
if (!h || h.status === 'unknown') { dot.classList.add('health-unknown'); dot.title = '未检测'; }
|
|
102
|
+
else if (h.status === 'healthy') { dot.classList.add('health-ok'); dot.title = 'Key 正常'; }
|
|
103
|
+
else if (h.status === 'partial') { dot.classList.add('health-warn'); dot.title = '部分 Key 异常'; }
|
|
104
|
+
else { dot.classList.add('health-error'); dot.title = 'Key 全部异常'; }
|
|
105
|
+
});
|
|
106
|
+
// 更新汇总卡片
|
|
107
|
+
renderProviderHealthSummary();
|
|
108
|
+
}
|
|
109
|
+
|
|
41
110
|
function parseProviderPool(value) {
|
|
42
111
|
const text = (value || '').trim();
|
|
43
112
|
if (!text) return [];
|
|
@@ -948,9 +1017,18 @@ function initStatsRangeBtns() {
|
|
|
948
1017
|
document.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active'));
|
|
949
1018
|
btn.classList.add('active');
|
|
950
1019
|
statsRange = btn.dataset.range;
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
|
|
1020
|
+
// 清除自动刷新
|
|
1021
|
+
if (statsAutoRefreshTimer) { clearInterval(statsAutoRefreshTimer); statsAutoRefreshTimer = null; }
|
|
1022
|
+
if (statsRange === 'hourly') {
|
|
1023
|
+
// 实时模式:设为今天 + 30 秒自动刷新
|
|
1024
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1025
|
+
document.getElementById('stats-start-date').value = today;
|
|
1026
|
+
document.getElementById('stats-end-date').value = today;
|
|
1027
|
+
statsAutoRefreshTimer = setInterval(loadStats, 30000);
|
|
1028
|
+
} else {
|
|
1029
|
+
document.getElementById('stats-start-date').value = '';
|
|
1030
|
+
document.getElementById('stats-end-date').value = '';
|
|
1031
|
+
}
|
|
954
1032
|
loadStats();
|
|
955
1033
|
});
|
|
956
1034
|
});
|
|
@@ -1011,7 +1089,16 @@ function initProviderPoolDropdown() {
|
|
|
1011
1089
|
}
|
|
1012
1090
|
|
|
1013
1091
|
async function init() {
|
|
1092
|
+
// 默认统计范围:当天(HTML 内联脚本已优先设置,此处兜底)
|
|
1093
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1094
|
+
const sd = document.getElementById('stats-start-date');
|
|
1095
|
+
const ed = document.getElementById('stats-end-date');
|
|
1096
|
+
if (!sd.value) sd.value = today;
|
|
1097
|
+
if (!ed.value) ed.value = today;
|
|
1014
1098
|
await Promise.all([loadProxies(), loadProviders(), loadStats()]);
|
|
1099
|
+
// 延迟加载 health(等后端启动检测完成),之后每 5 分钟刷新
|
|
1100
|
+
setTimeout(() => loadKeyHealth(), 6000);
|
|
1101
|
+
setInterval(() => loadKeyHealth(), 5 * 60 * 1000);
|
|
1015
1102
|
renderProxies();
|
|
1016
1103
|
initProviderDropdown();
|
|
1017
1104
|
initModelDropdown();
|
|
@@ -1036,6 +1123,33 @@ async function init() {
|
|
|
1036
1123
|
// 初始状态:根据当前协议值决定 Azure 字段显示
|
|
1037
1124
|
const initProto = document.getElementById('target-protocol').value;
|
|
1038
1125
|
document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
|
|
1126
|
+
|
|
1127
|
+
// 快捷键
|
|
1128
|
+
document.addEventListener('keydown', (e) => {
|
|
1129
|
+
// Esc 关闭最上层弹窗
|
|
1130
|
+
if (e.key === 'Escape') {
|
|
1131
|
+
if (document.getElementById('confirm-modal').classList.contains('active')) return;
|
|
1132
|
+
if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
|
|
1133
|
+
if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
|
|
1134
|
+
if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
|
|
1135
|
+
if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
|
|
1136
|
+
if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
|
|
1137
|
+
}
|
|
1138
|
+
// Ctrl+S 保存表单
|
|
1139
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
1140
|
+
if (document.getElementById('modal').classList.contains('active')) {
|
|
1141
|
+
e.preventDefault();
|
|
1142
|
+
document.getElementById('proxy-form').requestSubmit();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// Ctrl+N 新建代理
|
|
1146
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
|
1147
|
+
if (!document.getElementById('modal').classList.contains('active')) {
|
|
1148
|
+
e.preventDefault();
|
|
1149
|
+
openModal();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1039
1153
|
}
|
|
1040
1154
|
|
|
1041
1155
|
// ==================== 代理地址复制 ====================
|
|
@@ -1090,6 +1204,149 @@ function showToast(msg, isError) {
|
|
|
1090
1204
|
setTimeout(() => toast.remove(), 2000);
|
|
1091
1205
|
}
|
|
1092
1206
|
|
|
1207
|
+
// ==================== 批量操作 ====================
|
|
1208
|
+
|
|
1209
|
+
async function startAllProxies() {
|
|
1210
|
+
try {
|
|
1211
|
+
const res = await fetch('/api/proxies/start-all', { method: 'POST' });
|
|
1212
|
+
const data = await res.json();
|
|
1213
|
+
await loadProxies();
|
|
1214
|
+
const started = data.results.filter(r => r.success).length;
|
|
1215
|
+
const skipped = data.results.filter(r => r.skipped).length;
|
|
1216
|
+
const failed = data.results.filter(r => !r.success && !r.skipped).length;
|
|
1217
|
+
let msg = `启动完成:${started} 个启动`;
|
|
1218
|
+
if (skipped > 0) msg += `,${skipped} 个已在运行`;
|
|
1219
|
+
if (failed > 0) msg += `,${failed} 个失败`;
|
|
1220
|
+
showToast(msg, failed > 0);
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
showToast('批量启动失败: ' + err.message, true);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async function stopAllProxies() {
|
|
1227
|
+
const ok = await showConfirm('确定要停止所有运行中的代理吗?', '全部停止');
|
|
1228
|
+
if (!ok) return;
|
|
1229
|
+
try {
|
|
1230
|
+
const res = await fetch('/api/proxies/stop-all', { method: 'POST' });
|
|
1231
|
+
const data = await res.json();
|
|
1232
|
+
await loadProxies();
|
|
1233
|
+
showToast(`已停止 ${data.results.length} 个代理`);
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
showToast('批量停止失败: ' + err.message, true);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ==================== 日志查看 ====================
|
|
1240
|
+
|
|
1241
|
+
async function openLogViewer() {
|
|
1242
|
+
document.getElementById('log-modal').classList.add('active');
|
|
1243
|
+
await loadLogs();
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function closeLogViewer() {
|
|
1247
|
+
document.getElementById('log-modal').classList.remove('active');
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function loadLogs() {
|
|
1251
|
+
const container = document.getElementById('log-content');
|
|
1252
|
+
const lines = document.getElementById('log-lines-select').value;
|
|
1253
|
+
container.textContent = '加载中...';
|
|
1254
|
+
try {
|
|
1255
|
+
const res = await fetch(`/api/logs?lines=${lines}`);
|
|
1256
|
+
const data = await res.json();
|
|
1257
|
+
document.getElementById('log-total').textContent = data.total ? `(共 ${data.total} 行)` : '';
|
|
1258
|
+
if (!data.lines || data.lines.length === 0) {
|
|
1259
|
+
container.textContent = '暂无日志';
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
container.innerHTML = data.lines.map(line => {
|
|
1263
|
+
let cls = 'log-line';
|
|
1264
|
+
if (/error|fail|失败/i.test(line)) cls += ' log-error';
|
|
1265
|
+
else if (/warn|警告/i.test(line)) cls += ' log-warn';
|
|
1266
|
+
return `<div class="${cls}">${escapeHtml(line)}</div>`;
|
|
1267
|
+
}).join('');
|
|
1268
|
+
container.scrollTop = container.scrollHeight;
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
container.textContent = '加载失败: ' + err.message;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ==================== 版本历史 ====================
|
|
1275
|
+
|
|
1276
|
+
async function openHistoryViewer() {
|
|
1277
|
+
document.getElementById('history-modal').classList.add('active');
|
|
1278
|
+
await loadHistory();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function closeHistoryViewer() {
|
|
1282
|
+
document.getElementById('history-modal').classList.remove('active');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function loadHistory() {
|
|
1286
|
+
const container = document.getElementById('history-content');
|
|
1287
|
+
container.textContent = '加载中...';
|
|
1288
|
+
try {
|
|
1289
|
+
const res = await fetch('/api/config/history');
|
|
1290
|
+
const data = await res.json();
|
|
1291
|
+
if (!data.snapshots || data.snapshots.length === 0) {
|
|
1292
|
+
container.innerHTML = '<div class="empty">暂无历史版本</div>';
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const REASON_LABELS = {
|
|
1296
|
+
'create-proxy': '创建代理',
|
|
1297
|
+
'update-proxy': '更新代理',
|
|
1298
|
+
'delete-proxy': '删除代理',
|
|
1299
|
+
'import-merge': '导入配置(合并)',
|
|
1300
|
+
'import-overwrite': '导入配置(覆盖)',
|
|
1301
|
+
'before-rollback': '回滚前备份',
|
|
1302
|
+
'save': '保存',
|
|
1303
|
+
};
|
|
1304
|
+
container.innerHTML = `<div class="history-list">` + data.snapshots.map(s => {
|
|
1305
|
+
const date = new Date(s.timestamp);
|
|
1306
|
+
const timeStr = date.toLocaleString('zh-CN', { hour12: false });
|
|
1307
|
+
const label = REASON_LABELS[s.reason] || s.reason;
|
|
1308
|
+
const sizeStr = s.size > 1024 ? `${(s.size / 1024).toFixed(1)} KB` : `${s.size} B`;
|
|
1309
|
+
return `
|
|
1310
|
+
<div class="history-item">
|
|
1311
|
+
<div class="history-info">
|
|
1312
|
+
<span class="history-time">${timeStr}</span>
|
|
1313
|
+
<span class="history-reason">${escapeHtml(label)}</span>
|
|
1314
|
+
<span class="history-size">${sizeStr}</span>
|
|
1315
|
+
</div>
|
|
1316
|
+
<button class="btn btn-sm history-rollback-btn" data-file="${escapeHtml(s.file)}">恢复</button>
|
|
1317
|
+
</div>
|
|
1318
|
+
`;
|
|
1319
|
+
}).join('') + '</div>';
|
|
1320
|
+
container.querySelectorAll('.history-rollback-btn').forEach(btn => {
|
|
1321
|
+
btn.addEventListener('click', () => rollbackToSnapshot(btn.dataset.file));
|
|
1322
|
+
});
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
container.textContent = '加载失败: ' + err.message;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function rollbackToSnapshot(file) {
|
|
1329
|
+
const ok = await showConfirm('确认恢复到此版本?<br>当前配置会先自动备份。', '确认恢复');
|
|
1330
|
+
if (!ok) return;
|
|
1331
|
+
try {
|
|
1332
|
+
const res = await fetch('/api/config/rollback', {
|
|
1333
|
+
method: 'POST',
|
|
1334
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1335
|
+
body: JSON.stringify({ file }),
|
|
1336
|
+
});
|
|
1337
|
+
const data = await res.json();
|
|
1338
|
+
if (!res.ok) {
|
|
1339
|
+
showToast(data.error || '恢复失败', true);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
closeHistoryViewer();
|
|
1343
|
+
await Promise.all([loadProxies(), loadProviders()]);
|
|
1344
|
+
showToast('已恢复到历史版本');
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
showToast('恢复失败: ' + err.message, true);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1093
1350
|
// ==================== 渲染代理列表 ====================
|
|
1094
1351
|
|
|
1095
1352
|
const ROUTING_LABELS = {
|
|
@@ -1099,14 +1356,89 @@ const ROUTING_LABELS = {
|
|
|
1099
1356
|
fastest: '最快优先',
|
|
1100
1357
|
};
|
|
1101
1358
|
|
|
1359
|
+
function getFilteredProxies() {
|
|
1360
|
+
const q = (document.getElementById('proxy-search-input')?.value || '').trim().toLowerCase();
|
|
1361
|
+
if (!q) return proxies;
|
|
1362
|
+
return proxies.filter(p => {
|
|
1363
|
+
const name = (p.name || '').toLowerCase();
|
|
1364
|
+
const port = String(p.port || '');
|
|
1365
|
+
const provider = (p.providerName || '').toLowerCase();
|
|
1366
|
+
return name.includes(q) || port.includes(q) || provider.includes(q);
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function filterProxies() {
|
|
1371
|
+
renderProxies();
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function healthDot(providerId) {
|
|
1375
|
+
const h = keyHealth[providerId];
|
|
1376
|
+
const cls = !h || h.status === 'unknown' ? 'health-unknown'
|
|
1377
|
+
: h.status === 'healthy' ? 'health-ok'
|
|
1378
|
+
: h.status === 'partial' ? 'health-warn' : 'health-error';
|
|
1379
|
+
const title = !h || h.status === 'unknown' ? '未检测'
|
|
1380
|
+
: h.status === 'healthy' ? 'Key 正常'
|
|
1381
|
+
: h.status === 'partial' ? '部分 Key 异常' : 'Key 全部异常';
|
|
1382
|
+
return `<span class="health-dot ${cls}" data-provider="${escapeHtml(providerId)}" title="${title}"></span>`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function renderProviderHealthSummary() {
|
|
1386
|
+
const el = document.getElementById('provider-health-summary');
|
|
1387
|
+
if (!el) return;
|
|
1388
|
+
const allProviders = proxies.map(p => p.providerId).filter(Boolean);
|
|
1389
|
+
const unique = [...new Set(allProviders)];
|
|
1390
|
+
if (unique.length === 0) { el.style.display = 'none'; return; }
|
|
1391
|
+
let healthy = 0, partial = 0, unhealthy = 0, unknown = 0;
|
|
1392
|
+
for (const id of unique) {
|
|
1393
|
+
const h = keyHealth[id];
|
|
1394
|
+
if (!h || h.status === 'unknown') unknown++;
|
|
1395
|
+
else if (h.status === 'healthy') healthy++;
|
|
1396
|
+
else if (h.status === 'partial') partial++;
|
|
1397
|
+
else unhealthy++;
|
|
1398
|
+
}
|
|
1399
|
+
el.style.display = '';
|
|
1400
|
+
el.innerHTML = `
|
|
1401
|
+
<div class="health-stat"><span class="health-dot health-ok"></span><span>正常 ${healthy}</span></div>
|
|
1402
|
+
<div class="health-stat"><span class="health-dot health-warn"></span><span>部分异常 ${partial}</span></div>
|
|
1403
|
+
<div class="health-stat"><span class="health-dot health-error"></span><span>异常 ${unhealthy}</span></div>
|
|
1404
|
+
<div class="health-stat"><span class="health-dot health-unknown"></span><span>未检测 ${unknown}</span></div>
|
|
1405
|
+
<button class="btn btn-sm" onclick="recheckKeys()">重新检测</button>
|
|
1406
|
+
`;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
let rechecking = false;
|
|
1410
|
+
async function recheckKeys() {
|
|
1411
|
+
if (rechecking) return;
|
|
1412
|
+
rechecking = true;
|
|
1413
|
+
const btn = document.querySelector('.provider-health-summary .btn');
|
|
1414
|
+
if (btn) { btn.disabled = true; btn.textContent = '检测中...'; }
|
|
1415
|
+
showToast('正在检测...');
|
|
1416
|
+
try {
|
|
1417
|
+
await fetch('/api/key-health/check', { method: 'POST' });
|
|
1418
|
+
await loadKeyHealth();
|
|
1419
|
+
showToast('检测完成');
|
|
1420
|
+
} catch (err) {
|
|
1421
|
+
showToast('检测失败: ' + err.message, true);
|
|
1422
|
+
} finally {
|
|
1423
|
+
rechecking = false;
|
|
1424
|
+
if (btn) { btn.disabled = false; btn.textContent = '重新检测'; }
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1102
1428
|
function renderProxies() {
|
|
1103
1429
|
const container = document.getElementById('proxy-list');
|
|
1430
|
+
const list = getFilteredProxies();
|
|
1431
|
+
renderProviderHealthSummary();
|
|
1104
1432
|
if (proxies.length === 0) {
|
|
1105
1433
|
container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
|
|
1106
1434
|
return;
|
|
1107
1435
|
}
|
|
1436
|
+
if (list.length === 0) {
|
|
1437
|
+
container.innerHTML = '<div class="empty">没有匹配的代理</div>';
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1108
1440
|
|
|
1109
|
-
container.innerHTML =
|
|
1441
|
+
container.innerHTML = list.map(p => {
|
|
1110
1442
|
// Build unified provider rows: primary first, then pool entries
|
|
1111
1443
|
const primaryRow = {
|
|
1112
1444
|
name: p.providerName || p.providerUrl || '-',
|
|
@@ -1141,6 +1473,7 @@ function renderProxies() {
|
|
|
1141
1473
|
</div>
|
|
1142
1474
|
<div class="proxy-meta">
|
|
1143
1475
|
<span>端口: <strong>${p.port}</strong></span>
|
|
1476
|
+
<span>供应商: ${healthDot(p.providerId)} ${escapeHtml(p.providerName || '-')}</span>
|
|
1144
1477
|
<span>认证: ${p.requireAuth ? '已启用' : '未启用'}</span>
|
|
1145
1478
|
</div>
|
|
1146
1479
|
<div class="proxy-address">
|
package/public/index.html
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
7
7
|
<link rel="stylesheet" href="style.css">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
|
+
<script>
|
|
11
|
+
// 尽早设置统计日期默认值,避免 init() 时序问题
|
|
12
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
13
|
+
var today = new Date().toISOString().slice(0, 10);
|
|
14
|
+
var sd = document.getElementById('stats-start-date');
|
|
15
|
+
var ed = document.getElementById('stats-end-date');
|
|
16
|
+
if (sd && !sd.value) sd.value = today;
|
|
17
|
+
if (ed && !ed.value) ed.value = today;
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
10
20
|
<div class="container">
|
|
21
|
+
<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">
|
|
22
|
+
<span id="theme-icon">☾</span>
|
|
23
|
+
<span id="theme-label" class="theme-label">深色</span>
|
|
24
|
+
</button>
|
|
11
25
|
<header>
|
|
12
26
|
<h1>Protocol Proxy</h1>
|
|
13
27
|
<p>OpenAI / Anthropic 协议转换透明代理</p>
|
|
@@ -39,6 +53,7 @@
|
|
|
39
53
|
</div>
|
|
40
54
|
</div>
|
|
41
55
|
<div class="stats-range-btns">
|
|
56
|
+
<button class="btn btn-sm stats-range-btn" data-range="hourly">实时</button>
|
|
42
57
|
<button class="btn btn-sm stats-range-btn active" data-range="daily">每日</button>
|
|
43
58
|
<button class="btn btn-sm stats-range-btn" data-range="monthly">每月</button>
|
|
44
59
|
<button class="btn btn-sm stats-range-btn" data-range="yearly">每年</button>
|
|
@@ -79,9 +94,21 @@
|
|
|
79
94
|
<button class="btn" onclick="exportConfig()">导出配置</button>
|
|
80
95
|
<button class="btn" onclick="document.getElementById('import-file').click()">导入配置</button>
|
|
81
96
|
<input type="file" id="import-file" accept=".json" style="display:none" onchange="handleImportFile(event)">
|
|
97
|
+
<button class="btn" onclick="openHistoryViewer()">版本历史</button>
|
|
82
98
|
<button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
|
|
83
99
|
</div>
|
|
84
100
|
</div>
|
|
101
|
+
<div class="proxy-toolbar">
|
|
102
|
+
<div class="proxy-search">
|
|
103
|
+
<input type="text" id="proxy-search-input" placeholder="搜索代理名称、端口、供应商..." oninput="filterProxies()">
|
|
104
|
+
</div>
|
|
105
|
+
<div class="proxy-toolbar-actions">
|
|
106
|
+
<button class="btn btn-sm" onclick="startAllProxies()">全部启动</button>
|
|
107
|
+
<button class="btn btn-sm" onclick="stopAllProxies()">全部停止</button>
|
|
108
|
+
<button class="btn btn-sm" onclick="openLogViewer()">日志</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="provider-health-summary" id="provider-health-summary" style="display:none"></div>
|
|
85
112
|
<div id="proxy-list" class="proxy-list">
|
|
86
113
|
<div class="empty">加载中...</div>
|
|
87
114
|
</div>
|
|
@@ -285,6 +312,37 @@
|
|
|
285
312
|
</div>
|
|
286
313
|
</div>
|
|
287
314
|
|
|
315
|
+
<!-- 日志查看弹窗 -->
|
|
316
|
+
<div class="modal" id="log-modal">
|
|
317
|
+
<div class="modal-content" style="max-width:800px">
|
|
318
|
+
<div class="modal-header">
|
|
319
|
+
<h3>运行日志 <span id="log-total" style="color:#64748b;font-size:0.8rem;font-weight:400"></span></h3>
|
|
320
|
+
<button class="btn-close" onclick="closeLogViewer()">×</button>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="log-toolbar">
|
|
323
|
+
<select id="log-lines-select" onchange="loadLogs()">
|
|
324
|
+
<option value="100">最近 100 行</option>
|
|
325
|
+
<option value="200" selected>最近 200 行</option>
|
|
326
|
+
<option value="500">最近 500 行</option>
|
|
327
|
+
<option value="1000">最近 1000 行</option>
|
|
328
|
+
</select>
|
|
329
|
+
<button class="btn btn-sm" onclick="loadLogs()">刷新</button>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="log-content" id="log-content">加载中...</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<!-- 版本历史弹窗 -->
|
|
336
|
+
<div class="modal" id="history-modal">
|
|
337
|
+
<div class="modal-content" style="max-width:600px">
|
|
338
|
+
<div class="modal-header">
|
|
339
|
+
<h3>配置版本历史</h3>
|
|
340
|
+
<button class="btn-close" onclick="closeHistoryViewer()">×</button>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="history-content" id="history-content">加载中...</div>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
288
346
|
<!-- 导入预览弹窗 -->
|
|
289
347
|
<div class="modal" id="import-modal">
|
|
290
348
|
<div class="modal-content" style="max-width:500px">
|