protocol-proxy 2.6.0 → 2.7.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocol-proxy",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "OpenAI / Anthropic 协议转换透明代理",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -7,6 +7,49 @@ let statsRange = 'daily';
7
7
  let statsProxyId = '';
8
8
  let providerPoolItems = [];
9
9
 
10
+ // ==================== 主题切换 ====================
11
+
12
+ const THEMES = [
13
+ { id: 'dark', icon: '☾', label: '深色' },
14
+ { id: 'light', icon: '☀', label: '浅色' },
15
+ { id: 'pure-black', icon: '●', label: '纯黑' },
16
+ { id: 'neon', icon: '⚡', label: '霓虹' },
17
+ { id: 'amber', icon: '◈', label: '琥珀' },
18
+ ];
19
+
20
+ function applyTheme(theme) {
21
+ const t = THEMES.find(t => t.id === theme) || THEMES[0];
22
+ document.documentElement.setAttribute('data-theme', t.id);
23
+ const icon = document.getElementById('theme-icon');
24
+ const label = document.getElementById('theme-label');
25
+ if (icon) icon.textContent = t.icon;
26
+ if (label) label.textContent = t.label;
27
+ localStorage.setItem('theme', t.id);
28
+ }
29
+
30
+ function toggleTheme() {
31
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
32
+ const idx = THEMES.findIndex(t => t.id === current);
33
+ const next = THEMES[(idx + 1) % THEMES.length];
34
+ applyTheme(next.id);
35
+ fetch('/api/settings', {
36
+ method: 'PUT',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ theme: next.id }),
39
+ }).catch(() => {});
40
+ }
41
+
42
+ // 初始化主题:优先服务端,fallback 到 localStorage
43
+ (async () => {
44
+ try {
45
+ const res = await fetch('/api/settings');
46
+ const settings = await res.json();
47
+ applyTheme(settings.theme || localStorage.getItem('theme') || 'dark');
48
+ } catch {
49
+ applyTheme(localStorage.getItem('theme') || 'dark');
50
+ }
51
+ })();
52
+
10
53
  // ==================== 数据加载 ====================
11
54
 
12
55
  async function loadProxies() {
@@ -1036,6 +1079,33 @@ async function init() {
1036
1079
  // 初始状态:根据当前协议值决定 Azure 字段显示
1037
1080
  const initProto = document.getElementById('target-protocol').value;
1038
1081
  document.getElementById('azure-fields').style.display = initProto === 'openai' ? '' : 'none';
1082
+
1083
+ // 快捷键
1084
+ document.addEventListener('keydown', (e) => {
1085
+ // Esc 关闭最上层弹窗
1086
+ if (e.key === 'Escape') {
1087
+ if (document.getElementById('confirm-modal').classList.contains('active')) return;
1088
+ if (document.getElementById('log-modal').classList.contains('active')) { closeLogViewer(); return; }
1089
+ if (document.getElementById('history-modal').classList.contains('active')) { closeHistoryViewer(); return; }
1090
+ if (document.getElementById('test-result-modal').classList.contains('active')) { document.getElementById('test-result-modal').classList.remove('active'); return; }
1091
+ if (document.getElementById('import-modal').classList.contains('active')) { closeImportModal(); return; }
1092
+ if (document.getElementById('modal').classList.contains('active')) { closeModal(); return; }
1093
+ }
1094
+ // Ctrl+S 保存表单
1095
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
1096
+ if (document.getElementById('modal').classList.contains('active')) {
1097
+ e.preventDefault();
1098
+ document.getElementById('proxy-form').requestSubmit();
1099
+ }
1100
+ }
1101
+ // Ctrl+N 新建代理
1102
+ if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
1103
+ if (!document.getElementById('modal').classList.contains('active')) {
1104
+ e.preventDefault();
1105
+ openModal();
1106
+ }
1107
+ }
1108
+ });
1039
1109
  }
1040
1110
 
1041
1111
  // ==================== 代理地址复制 ====================
@@ -1090,6 +1160,149 @@ function showToast(msg, isError) {
1090
1160
  setTimeout(() => toast.remove(), 2000);
1091
1161
  }
1092
1162
 
1163
+ // ==================== 批量操作 ====================
1164
+
1165
+ async function startAllProxies() {
1166
+ try {
1167
+ const res = await fetch('/api/proxies/start-all', { method: 'POST' });
1168
+ const data = await res.json();
1169
+ await loadProxies();
1170
+ const started = data.results.filter(r => r.success).length;
1171
+ const skipped = data.results.filter(r => r.skipped).length;
1172
+ const failed = data.results.filter(r => !r.success && !r.skipped).length;
1173
+ let msg = `启动完成:${started} 个启动`;
1174
+ if (skipped > 0) msg += `,${skipped} 个已在运行`;
1175
+ if (failed > 0) msg += `,${failed} 个失败`;
1176
+ showToast(msg, failed > 0);
1177
+ } catch (err) {
1178
+ showToast('批量启动失败: ' + err.message, true);
1179
+ }
1180
+ }
1181
+
1182
+ async function stopAllProxies() {
1183
+ const ok = await showConfirm('确定要停止所有运行中的代理吗?', '全部停止');
1184
+ if (!ok) return;
1185
+ try {
1186
+ const res = await fetch('/api/proxies/stop-all', { method: 'POST' });
1187
+ const data = await res.json();
1188
+ await loadProxies();
1189
+ showToast(`已停止 ${data.results.length} 个代理`);
1190
+ } catch (err) {
1191
+ showToast('批量停止失败: ' + err.message, true);
1192
+ }
1193
+ }
1194
+
1195
+ // ==================== 日志查看 ====================
1196
+
1197
+ async function openLogViewer() {
1198
+ document.getElementById('log-modal').classList.add('active');
1199
+ await loadLogs();
1200
+ }
1201
+
1202
+ function closeLogViewer() {
1203
+ document.getElementById('log-modal').classList.remove('active');
1204
+ }
1205
+
1206
+ async function loadLogs() {
1207
+ const container = document.getElementById('log-content');
1208
+ const lines = document.getElementById('log-lines-select').value;
1209
+ container.textContent = '加载中...';
1210
+ try {
1211
+ const res = await fetch(`/api/logs?lines=${lines}`);
1212
+ const data = await res.json();
1213
+ document.getElementById('log-total').textContent = data.total ? `(共 ${data.total} 行)` : '';
1214
+ if (!data.lines || data.lines.length === 0) {
1215
+ container.textContent = '暂无日志';
1216
+ return;
1217
+ }
1218
+ container.innerHTML = data.lines.map(line => {
1219
+ let cls = 'log-line';
1220
+ if (/error|fail|失败/i.test(line)) cls += ' log-error';
1221
+ else if (/warn|警告/i.test(line)) cls += ' log-warn';
1222
+ return `<div class="${cls}">${escapeHtml(line)}</div>`;
1223
+ }).join('');
1224
+ container.scrollTop = container.scrollHeight;
1225
+ } catch (err) {
1226
+ container.textContent = '加载失败: ' + err.message;
1227
+ }
1228
+ }
1229
+
1230
+ // ==================== 版本历史 ====================
1231
+
1232
+ async function openHistoryViewer() {
1233
+ document.getElementById('history-modal').classList.add('active');
1234
+ await loadHistory();
1235
+ }
1236
+
1237
+ function closeHistoryViewer() {
1238
+ document.getElementById('history-modal').classList.remove('active');
1239
+ }
1240
+
1241
+ async function loadHistory() {
1242
+ const container = document.getElementById('history-content');
1243
+ container.textContent = '加载中...';
1244
+ try {
1245
+ const res = await fetch('/api/config/history');
1246
+ const data = await res.json();
1247
+ if (!data.snapshots || data.snapshots.length === 0) {
1248
+ container.innerHTML = '<div class="empty">暂无历史版本</div>';
1249
+ return;
1250
+ }
1251
+ const REASON_LABELS = {
1252
+ 'create-proxy': '创建代理',
1253
+ 'update-proxy': '更新代理',
1254
+ 'delete-proxy': '删除代理',
1255
+ 'import-merge': '导入配置(合并)',
1256
+ 'import-overwrite': '导入配置(覆盖)',
1257
+ 'before-rollback': '回滚前备份',
1258
+ 'save': '保存',
1259
+ };
1260
+ container.innerHTML = `<div class="history-list">` + data.snapshots.map(s => {
1261
+ const date = new Date(s.timestamp);
1262
+ const timeStr = date.toLocaleString('zh-CN', { hour12: false });
1263
+ const label = REASON_LABELS[s.reason] || s.reason;
1264
+ const sizeStr = s.size > 1024 ? `${(s.size / 1024).toFixed(1)} KB` : `${s.size} B`;
1265
+ return `
1266
+ <div class="history-item">
1267
+ <div class="history-info">
1268
+ <span class="history-time">${timeStr}</span>
1269
+ <span class="history-reason">${escapeHtml(label)}</span>
1270
+ <span class="history-size">${sizeStr}</span>
1271
+ </div>
1272
+ <button class="btn btn-sm history-rollback-btn" data-file="${escapeHtml(s.file)}">恢复</button>
1273
+ </div>
1274
+ `;
1275
+ }).join('') + '</div>';
1276
+ container.querySelectorAll('.history-rollback-btn').forEach(btn => {
1277
+ btn.addEventListener('click', () => rollbackToSnapshot(btn.dataset.file));
1278
+ });
1279
+ } catch (err) {
1280
+ container.textContent = '加载失败: ' + err.message;
1281
+ }
1282
+ }
1283
+
1284
+ async function rollbackToSnapshot(file) {
1285
+ const ok = await showConfirm('确认恢复到此版本?<br>当前配置会先自动备份。', '确认恢复');
1286
+ if (!ok) return;
1287
+ try {
1288
+ const res = await fetch('/api/config/rollback', {
1289
+ method: 'POST',
1290
+ headers: { 'Content-Type': 'application/json' },
1291
+ body: JSON.stringify({ file }),
1292
+ });
1293
+ const data = await res.json();
1294
+ if (!res.ok) {
1295
+ showToast(data.error || '恢复失败', true);
1296
+ return;
1297
+ }
1298
+ closeHistoryViewer();
1299
+ await Promise.all([loadProxies(), loadProviders()]);
1300
+ showToast('已恢复到历史版本');
1301
+ } catch (err) {
1302
+ showToast('恢复失败: ' + err.message, true);
1303
+ }
1304
+ }
1305
+
1093
1306
  // ==================== 渲染代理列表 ====================
1094
1307
 
1095
1308
  const ROUTING_LABELS = {
@@ -1099,14 +1312,34 @@ const ROUTING_LABELS = {
1099
1312
  fastest: '最快优先',
1100
1313
  };
1101
1314
 
1315
+ function getFilteredProxies() {
1316
+ const q = (document.getElementById('proxy-search-input')?.value || '').trim().toLowerCase();
1317
+ if (!q) return proxies;
1318
+ return proxies.filter(p => {
1319
+ const name = (p.name || '').toLowerCase();
1320
+ const port = String(p.port || '');
1321
+ const provider = (p.providerName || '').toLowerCase();
1322
+ return name.includes(q) || port.includes(q) || provider.includes(q);
1323
+ });
1324
+ }
1325
+
1326
+ function filterProxies() {
1327
+ renderProxies();
1328
+ }
1329
+
1102
1330
  function renderProxies() {
1103
1331
  const container = document.getElementById('proxy-list');
1332
+ const list = getFilteredProxies();
1104
1333
  if (proxies.length === 0) {
1105
1334
  container.innerHTML = '<div class="empty">暂无代理配置,点击右上角创建</div>';
1106
1335
  return;
1107
1336
  }
1337
+ if (list.length === 0) {
1338
+ container.innerHTML = '<div class="empty">没有匹配的代理</div>';
1339
+ return;
1340
+ }
1108
1341
 
1109
- container.innerHTML = proxies.map(p => {
1342
+ container.innerHTML = list.map(p => {
1110
1343
  // Build unified provider rows: primary first, then pool entries
1111
1344
  const primaryRow = {
1112
1345
  name: p.providerName || p.providerUrl || '-',
package/public/index.html CHANGED
@@ -8,6 +8,10 @@
8
8
  </head>
9
9
  <body>
10
10
  <div class="container">
11
+ <button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="切换主题">
12
+ <span id="theme-icon">&#9790;</span>
13
+ <span id="theme-label" class="theme-label">深色</span>
14
+ </button>
11
15
  <header>
12
16
  <h1>Protocol Proxy</h1>
13
17
  <p>OpenAI / Anthropic 协议转换透明代理</p>
@@ -79,9 +83,20 @@
79
83
  <button class="btn" onclick="exportConfig()">导出配置</button>
80
84
  <button class="btn" onclick="document.getElementById('import-file').click()">导入配置</button>
81
85
  <input type="file" id="import-file" accept=".json" style="display:none" onchange="handleImportFile(event)">
86
+ <button class="btn" onclick="openHistoryViewer()">版本历史</button>
82
87
  <button class="btn btn-primary" onclick="openModal()">+ 新建代理</button>
83
88
  </div>
84
89
  </div>
90
+ <div class="proxy-toolbar">
91
+ <div class="proxy-search">
92
+ <input type="text" id="proxy-search-input" placeholder="搜索代理名称、端口、供应商..." oninput="filterProxies()">
93
+ </div>
94
+ <div class="proxy-toolbar-actions">
95
+ <button class="btn btn-sm" onclick="startAllProxies()">全部启动</button>
96
+ <button class="btn btn-sm" onclick="stopAllProxies()">全部停止</button>
97
+ <button class="btn btn-sm" onclick="openLogViewer()">日志</button>
98
+ </div>
99
+ </div>
85
100
  <div id="proxy-list" class="proxy-list">
86
101
  <div class="empty">加载中...</div>
87
102
  </div>
@@ -285,6 +300,37 @@
285
300
  </div>
286
301
  </div>
287
302
 
303
+ <!-- 日志查看弹窗 -->
304
+ <div class="modal" id="log-modal">
305
+ <div class="modal-content" style="max-width:800px">
306
+ <div class="modal-header">
307
+ <h3>运行日志 <span id="log-total" style="color:#64748b;font-size:0.8rem;font-weight:400"></span></h3>
308
+ <button class="btn-close" onclick="closeLogViewer()">&times;</button>
309
+ </div>
310
+ <div class="log-toolbar">
311
+ <select id="log-lines-select" onchange="loadLogs()">
312
+ <option value="100">最近 100 行</option>
313
+ <option value="200" selected>最近 200 行</option>
314
+ <option value="500">最近 500 行</option>
315
+ <option value="1000">最近 1000 行</option>
316
+ </select>
317
+ <button class="btn btn-sm" onclick="loadLogs()">刷新</button>
318
+ </div>
319
+ <div class="log-content" id="log-content">加载中...</div>
320
+ </div>
321
+ </div>
322
+
323
+ <!-- 版本历史弹窗 -->
324
+ <div class="modal" id="history-modal">
325
+ <div class="modal-content" style="max-width:600px">
326
+ <div class="modal-header">
327
+ <h3>配置版本历史</h3>
328
+ <button class="btn-close" onclick="closeHistoryViewer()">&times;</button>
329
+ </div>
330
+ <div class="history-content" id="history-content">加载中...</div>
331
+ </div>
332
+ </div>
333
+
288
334
  <!-- 导入预览弹窗 -->
289
335
  <div class="modal" id="import-modal">
290
336
  <div class="modal-content" style="max-width:500px">
package/public/style.css CHANGED
@@ -1,9 +1,114 @@
1
+ :root {
2
+ --bg-body: #030712;
3
+ --bg-card: rgba(15, 23, 42, 0.5);
4
+ --bg-surface: rgba(6, 8, 15, 0.6);
5
+ --bg-surface-alt: rgba(15, 23, 42, 0.4);
6
+ --bg-elevated: rgba(15, 23, 42, 0.7);
7
+ --bg-dropdown: rgba(15, 23, 42, 0.95);
8
+ --text-primary: #f1f5f9;
9
+ --text-secondary: #e2e8f0;
10
+ --text-muted: #94a3b8;
11
+ --text-dim: #64748b;
12
+ --text-faint: #475569;
13
+ --border-main: rgba(51, 65, 85, 0.4);
14
+ --border-light: rgba(51, 65, 85, 0.35);
15
+ --border-input: rgba(51, 65, 85, 0.5);
16
+ --grid-opacity: 0.03;
17
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
18
+ --accent: #60a5fa;
19
+ --accent-dim: #3b82f6;
20
+ }
21
+
22
+ [data-theme="light"] {
23
+ --bg-body: #f1f5f9;
24
+ --bg-card: rgba(255, 255, 255, 0.85);
25
+ --bg-surface: rgba(241, 245, 249, 0.8);
26
+ --bg-surface-alt: rgba(241, 245, 249, 0.6);
27
+ --bg-elevated: rgba(255, 255, 255, 0.92);
28
+ --bg-dropdown: rgba(255, 255, 255, 0.97);
29
+ --text-primary: #0f172a;
30
+ --text-secondary: #1e293b;
31
+ --text-muted: #475569;
32
+ --text-dim: #64748b;
33
+ --text-faint: #94a3b8;
34
+ --border-main: rgba(203, 213, 225, 0.6);
35
+ --border-light: rgba(203, 213, 225, 0.5);
36
+ --border-input: rgba(203, 213, 225, 0.7);
37
+ --grid-opacity: 0.4;
38
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
39
+ --accent: #2563eb;
40
+ --accent-dim: #1d4ed8;
41
+ }
42
+
43
+ [data-theme="pure-black"] {
44
+ --bg-body: #000000;
45
+ --bg-card: rgba(10, 10, 10, 0.7);
46
+ --bg-surface: rgba(5, 5, 5, 0.8);
47
+ --bg-surface-alt: rgba(10, 10, 10, 0.5);
48
+ --bg-elevated: rgba(10, 10, 10, 0.85);
49
+ --bg-dropdown: rgba(10, 10, 10, 0.97);
50
+ --text-primary: #e5e5e5;
51
+ --text-secondary: #d4d4d4;
52
+ --text-muted: #a3a3a3;
53
+ --text-dim: #737373;
54
+ --text-faint: #525252;
55
+ --border-main: rgba(64, 64, 64, 0.4);
56
+ --border-light: rgba(64, 64, 64, 0.3);
57
+ --border-input: rgba(64, 64, 64, 0.5);
58
+ --grid-opacity: 0.02;
59
+ --card-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
60
+ --accent: #a3a3a3;
61
+ --accent-dim: #737373;
62
+ }
63
+
64
+ [data-theme="neon"] {
65
+ --bg-body: #0a0a1a;
66
+ --bg-card: rgba(10, 10, 30, 0.6);
67
+ --bg-surface: rgba(10, 10, 30, 0.7);
68
+ --bg-surface-alt: rgba(15, 15, 40, 0.4);
69
+ --bg-elevated: rgba(10, 10, 30, 0.8);
70
+ --bg-dropdown: rgba(10, 10, 30, 0.95);
71
+ --text-primary: #e0f7ff;
72
+ --text-secondary: #c5e8f5;
73
+ --text-muted: #7ec8e8;
74
+ --text-dim: #4a9ab5;
75
+ --text-faint: #2d6a80;
76
+ --border-main: rgba(0, 200, 255, 0.2);
77
+ --border-light: rgba(0, 200, 255, 0.15);
78
+ --border-input: rgba(0, 200, 255, 0.25);
79
+ --grid-opacity: 0.06;
80
+ --card-shadow: 0 4px 24px rgba(0, 200, 255, 0.08);
81
+ --accent: #00d4ff;
82
+ --accent-dim: #0099cc;
83
+ }
84
+
85
+ [data-theme="amber"] {
86
+ --bg-body: #0f0a05;
87
+ --bg-card: rgba(20, 15, 8, 0.6);
88
+ --bg-surface: rgba(15, 10, 5, 0.7);
89
+ --bg-surface-alt: rgba(20, 15, 8, 0.45);
90
+ --bg-elevated: rgba(20, 15, 8, 0.8);
91
+ --bg-dropdown: rgba(20, 15, 8, 0.95);
92
+ --text-primary: #fef3c7;
93
+ --text-secondary: #fde68a;
94
+ --text-muted: #c8a55a;
95
+ --text-dim: #92703a;
96
+ --text-faint: #6b5025;
97
+ --border-main: rgba(180, 130, 50, 0.25);
98
+ --border-light: rgba(180, 130, 50, 0.18);
99
+ --border-input: rgba(180, 130, 50, 0.3);
100
+ --grid-opacity: 0.04;
101
+ --card-shadow: 0 4px 24px rgba(180, 130, 50, 0.06);
102
+ --accent: #f59e0b;
103
+ --accent-dim: #d97706;
104
+ }
105
+
1
106
  * { box-sizing: border-box; margin: 0; padding: 0; }
2
107
 
3
108
  body {
4
109
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
5
- background: #030712;
6
- color: #e2e8f0;
110
+ background: var(--bg-body);
111
+ color: var(--text-secondary);
7
112
  line-height: 1.6;
8
113
  min-height: 100vh;
9
114
  }
@@ -14,8 +119,8 @@ body::before {
14
119
  position: fixed;
15
120
  top: 0; left: 0; right: 0; bottom: 0;
16
121
  background-image:
17
- linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
18
- linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px);
122
+ linear-gradient(rgba(59, 130, 246, var(--grid-opacity)) 1px, transparent 1px),
123
+ linear-gradient(90deg, rgba(59, 130, 246, var(--grid-opacity)) 1px, transparent 1px);
19
124
  background-size: 60px 60px;
20
125
  pointer-events: none;
21
126
  z-index: 0;
@@ -29,6 +134,39 @@ body::before {
29
134
  padding: 40px 20px;
30
135
  }
31
136
 
137
+ /* Theme toggle */
138
+ .theme-toggle {
139
+ position: fixed;
140
+ top: 20px;
141
+ right: 20px;
142
+ z-index: 100;
143
+ height: 36px;
144
+ padding: 0 14px 0 10px;
145
+ border-radius: 10px;
146
+ border: 1px solid var(--border-input);
147
+ background: var(--bg-card);
148
+ backdrop-filter: blur(12px);
149
+ color: var(--text-muted);
150
+ font-size: 1rem;
151
+ cursor: pointer;
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 6px;
155
+ transition: all 0.25s;
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .theme-label {
160
+ font-size: 0.78rem;
161
+ font-weight: 500;
162
+ }
163
+
164
+ .theme-toggle:hover {
165
+ border-color: var(--accent);
166
+ color: var(--accent);
167
+ box-shadow: 0 0 12px rgba(59, 130, 246, 0.15);
168
+ }
169
+
32
170
  /* Header */
33
171
  header {
34
172
  text-align: center;
@@ -62,9 +200,9 @@ header p {
62
200
  }
63
201
 
64
202
  .stat-item {
65
- background: rgba(15, 23, 42, 0.6);
203
+ background: var(--bg-surface);
66
204
  backdrop-filter: blur(12px);
67
- border: 1px solid rgba(51, 65, 85, 0.5);
205
+ border: 1px solid var(--border-input);
68
206
  border-radius: 16px;
69
207
  padding: 24px 48px;
70
208
  text-align: center;
@@ -93,7 +231,7 @@ header p {
93
231
  display: block;
94
232
  font-size: 2.2rem;
95
233
  font-weight: 700;
96
- color: #60a5fa;
234
+ color: var(--accent);
97
235
  font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
98
236
  }
99
237
 
@@ -107,12 +245,12 @@ header p {
107
245
 
108
246
  /* Card */
109
247
  .card {
110
- background: rgba(15, 23, 42, 0.5);
248
+ background: var(--bg-card);
111
249
  backdrop-filter: blur(16px);
112
- border: 1px solid rgba(51, 65, 85, 0.4);
250
+ border: 1px solid var(--border-main);
113
251
  border-radius: 20px;
114
252
  overflow: hidden;
115
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
253
+ box-shadow: var(--card-shadow);
116
254
  }
117
255
 
118
256
  .card-header {
@@ -120,7 +258,7 @@ header p {
120
258
  justify-content: space-between;
121
259
  align-items: center;
122
260
  padding: 24px 28px;
123
- border-bottom: 1px solid rgba(51, 65, 85, 0.4);
261
+ border-bottom: 1px solid var(--border-main);
124
262
  }
125
263
 
126
264
  .card-header h2 {
@@ -152,14 +290,13 @@ header p {
152
290
  }
153
291
 
154
292
  .btn-primary {
155
- background: linear-gradient(135deg, #3b82f6, #2563eb);
293
+ background: linear-gradient(135deg, var(--accent), var(--accent-dim));
156
294
  border-color: transparent;
157
295
  color: white;
158
296
  box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
159
297
  }
160
298
 
161
299
  .btn-primary:hover {
162
- background: linear-gradient(135deg, #4b92ff, #3573fb);
163
300
  box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
164
301
  transform: translateY(-1px);
165
302
  }
@@ -193,6 +330,47 @@ header p {
193
330
  box-shadow: 0 4px 16px rgba(34, 197, 94, 0.35);
194
331
  }
195
332
 
333
+ /* Proxy toolbar */
334
+ .proxy-toolbar {
335
+ display: flex;
336
+ align-items: center;
337
+ gap: 12px;
338
+ padding: 14px 28px;
339
+ border-bottom: 1px solid var(--border-light);
340
+ background: var(--bg-surface-alt);
341
+ }
342
+
343
+ .proxy-search {
344
+ flex: 1;
345
+ }
346
+
347
+ .proxy-search input {
348
+ width: 100%;
349
+ padding: 8px 14px;
350
+ background: var(--bg-surface);
351
+ border: 1px solid var(--border-input);
352
+ border-radius: 8px;
353
+ color: var(--text-secondary);
354
+ font-size: 0.85rem;
355
+ transition: all 0.2s;
356
+ }
357
+
358
+ .proxy-search input::placeholder {
359
+ color: #475569;
360
+ }
361
+
362
+ .proxy-search input:focus {
363
+ outline: none;
364
+ border-color: rgba(59, 130, 246, 0.5);
365
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
366
+ }
367
+
368
+ .proxy-toolbar-actions {
369
+ display: flex;
370
+ gap: 6px;
371
+ flex-shrink: 0;
372
+ }
373
+
196
374
  /* Proxy list */
197
375
  .proxy-list {
198
376
  padding: 20px;
@@ -201,7 +379,7 @@ header p {
201
379
  .empty {
202
380
  text-align: center;
203
381
  padding: 80px 20px;
204
- color: #475569;
382
+ color: var(--text-faint);
205
383
  font-size: 0.95rem;
206
384
  }
207
385
 
@@ -209,15 +387,15 @@ header p {
209
387
  content: '◈';
210
388
  display: block;
211
389
  font-size: 3rem;
212
- color: #334155;
390
+ color: var(--text-faint);
213
391
  margin-bottom: 16px;
214
392
  }
215
393
 
216
394
  /* Proxy Item */
217
395
  .proxy-item {
218
- background: rgba(6, 8, 15, 0.6);
396
+ background: var(--bg-surface);
219
397
  backdrop-filter: blur(12px);
220
- border: 1px solid rgba(51, 65, 85, 0.35);
398
+ border: 1px solid var(--border-light);
221
399
  border-radius: 16px;
222
400
  padding: 24px;
223
401
  margin-bottom: 16px;
@@ -355,7 +533,7 @@ header p {
355
533
  gap: 10px;
356
534
  margin-top: 18px;
357
535
  padding-top: 18px;
358
- border-top: 1px solid rgba(30, 41, 59, 0.5);
536
+ border-top: 1px solid var(--border-light);
359
537
  }
360
538
 
361
539
  /* Modal */
@@ -380,9 +558,9 @@ header p {
380
558
  }
381
559
 
382
560
  .modal-content {
383
- background: rgba(15, 23, 42, 0.7);
561
+ background: var(--bg-elevated);
384
562
  backdrop-filter: blur(24px);
385
- border: 1px solid rgba(71, 85, 105, 0.4);
563
+ border: 1px solid var(--border-main);
386
564
  border-radius: 20px;
387
565
  width: 100%;
388
566
  max-width: 700px;
@@ -401,7 +579,7 @@ header p {
401
579
  justify-content: space-between;
402
580
  align-items: center;
403
581
  padding: 22px 28px;
404
- border-bottom: 1px solid rgba(51, 65, 85, 0.4);
582
+ border-bottom: 1px solid var(--border-main);
405
583
  }
406
584
 
407
585
  .modal-header h3 {
@@ -454,10 +632,10 @@ form {
454
632
  .form-group textarea {
455
633
  width: 100%;
456
634
  padding: 11px 16px;
457
- background: rgba(6, 8, 15, 0.6);
458
- border: 1px solid rgba(51, 65, 85, 0.5);
635
+ background: var(--bg-surface);
636
+ border: 1px solid var(--border-input);
459
637
  border-radius: 10px;
460
- color: #f1f5f9;
638
+ color: var(--text-primary);
461
639
  font-size: 0.95rem;
462
640
  transition: all 0.25s ease;
463
641
  appearance: none;
@@ -507,7 +685,7 @@ form {
507
685
  .target-section {
508
686
  margin-top: 24px;
509
687
  padding-top: 24px;
510
- border-top: 1px solid rgba(51, 65, 85, 0.4);
688
+ border-top: 1px solid var(--border-main);
511
689
  }
512
690
 
513
691
  .target-section h4 {
@@ -520,8 +698,8 @@ form {
520
698
  }
521
699
 
522
700
  .target-item {
523
- background: rgba(6, 8, 15, 0.5);
524
- border: 1px solid rgba(51, 65, 85, 0.35);
701
+ background: var(--bg-surface-alt);
702
+ border: 1px solid var(--border-light);
525
703
  border-radius: 14px;
526
704
  padding: 20px;
527
705
  margin-bottom: 12px;
@@ -551,7 +729,7 @@ form {
551
729
  gap: 12px;
552
730
  padding-top: 20px;
553
731
  margin-top: 20px;
554
- border-top: 1px solid rgba(51, 65, 85, 0.4);
732
+ border-top: 1px solid var(--border-main);
555
733
  }
556
734
 
557
735
  /* Proxy address */
@@ -561,8 +739,8 @@ form {
561
739
  gap: 10px;
562
740
  margin-top: 14px;
563
741
  padding: 12px 16px;
564
- background: rgba(15, 23, 42, 0.5);
565
- border: 1px solid rgba(51, 65, 85, 0.35);
742
+ background: var(--bg-surface-alt);
743
+ border: 1px solid var(--border-light);
566
744
  border-radius: 12px;
567
745
  font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
568
746
  transition: all 0.25s;
@@ -576,7 +754,7 @@ form {
576
754
  .proxy-address code {
577
755
  flex: 1;
578
756
  font-size: 0.85rem;
579
- color: #7dd3fc;
757
+ color: var(--accent);
580
758
  word-break: break-all;
581
759
  }
582
760
 
@@ -648,10 +826,10 @@ form {
648
826
  justify-content: space-between;
649
827
  width: 100%;
650
828
  padding: 11px 16px;
651
- background: rgba(6, 8, 15, 0.6);
652
- border: 1px solid rgba(51, 65, 85, 0.5);
829
+ background: var(--bg-surface);
830
+ border: 1px solid var(--border-input);
653
831
  border-radius: 10px;
654
- color: #f1f5f9;
832
+ color: var(--text-primary);
655
833
  font-size: 0.95rem;
656
834
  cursor: pointer;
657
835
  transition: all 0.25s ease;
@@ -683,9 +861,9 @@ form {
683
861
  top: calc(100% + 6px);
684
862
  left: 0;
685
863
  right: 0;
686
- background: rgba(15, 23, 42, 0.95);
864
+ background: var(--bg-dropdown);
687
865
  backdrop-filter: blur(24px);
688
- border: 1px solid rgba(71, 85, 105, 0.5);
866
+ border: 1px solid var(--border-input);
689
867
  border-radius: 12px;
690
868
  z-index: 100;
691
869
  overflow: hidden;
@@ -772,17 +950,17 @@ form {
772
950
  display: flex;
773
951
  gap: 8px;
774
952
  padding: 10px 16px;
775
- border-top: 1px solid rgba(51, 65, 85, 0.4);
776
- background: rgba(6, 8, 15, 0.3);
953
+ border-top: 1px solid var(--border-main);
954
+ background: var(--bg-surface-alt);
777
955
  }
778
956
 
779
957
  .model-add-input {
780
958
  flex: 1;
781
959
  padding: 7px 12px;
782
- background: rgba(6, 8, 15, 0.5);
783
- border: 1px solid rgba(51, 65, 85, 0.5);
960
+ background: var(--bg-surface);
961
+ border: 1px solid var(--border-input);
784
962
  border-radius: 8px;
785
- color: #e2e8f0;
963
+ color: var(--text-secondary);
786
964
  font-size: 0.85rem;
787
965
  transition: all 0.2s;
788
966
  }
@@ -799,9 +977,9 @@ form {
799
977
  }
800
978
 
801
979
  .confirm-modal .confirm-box {
802
- background: rgba(15, 23, 42, 0.8);
980
+ background: var(--bg-elevated);
803
981
  backdrop-filter: blur(24px);
804
- border: 1px solid rgba(71, 85, 105, 0.4);
982
+ border: 1px solid var(--border-main);
805
983
  border-radius: 20px;
806
984
  padding: 32px 36px;
807
985
  max-width: 380px;
@@ -872,8 +1050,8 @@ form {
872
1050
 
873
1051
  .import-stat {
874
1052
  flex: 1;
875
- background: rgba(6, 8, 15, 0.4);
876
- border: 1px solid rgba(51, 65, 85, 0.3);
1053
+ background: var(--bg-surface-alt);
1054
+ border: 1px solid var(--border-light);
877
1055
  border-radius: 12px;
878
1056
  padding: 16px;
879
1057
  text-align: center;
@@ -883,7 +1061,7 @@ form {
883
1061
  display: block;
884
1062
  font-size: 1.8rem;
885
1063
  font-weight: 700;
886
- color: #60a5fa;
1064
+ color: var(--accent);
887
1065
  font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
888
1066
  }
889
1067
 
@@ -913,8 +1091,8 @@ form {
913
1091
  align-items: flex-start;
914
1092
  gap: 12px;
915
1093
  padding: 14px 16px;
916
- background: rgba(6, 8, 15, 0.4);
917
- border: 1px solid rgba(51, 65, 85, 0.3);
1094
+ background: var(--bg-surface-alt);
1095
+ border: 1px solid var(--border-light);
918
1096
  border-radius: 10px;
919
1097
  cursor: pointer;
920
1098
  transition: all 0.2s;
@@ -974,17 +1152,17 @@ form {
974
1152
  .stats-filter-trigger {
975
1153
  padding: 7px 14px !important;
976
1154
  font-size: 0.85rem !important;
977
- background: rgba(51, 65, 85, 0.3) !important;
1155
+ background: var(--bg-surface-alt) !important;
978
1156
  }
979
1157
 
980
1158
  .stats-range-btns {
981
1159
  display: flex;
982
1160
  gap: 4px;
983
1161
  align-items: center;
984
- background: rgba(6, 8, 15, 0.4);
1162
+ background: var(--bg-surface-alt);
985
1163
  border-radius: 10px;
986
1164
  padding: 3px;
987
- border: 1px solid rgba(51, 65, 85, 0.3);
1165
+ border: 1px solid var(--border-light);
988
1166
  }
989
1167
 
990
1168
  .stats-range-btn {
@@ -1015,8 +1193,8 @@ form {
1015
1193
  }
1016
1194
 
1017
1195
  .stats-date-input {
1018
- background: rgba(15, 23, 42, 0.6);
1019
- border: 1px solid rgba(51, 65, 85, 0.5);
1196
+ background: var(--bg-surface);
1197
+ border: 1px solid var(--border-input);
1020
1198
  border-radius: 6px;
1021
1199
  color: #e2e8f0;
1022
1200
  padding: 6px 8px;
@@ -1049,8 +1227,8 @@ form {
1049
1227
  }
1050
1228
 
1051
1229
  .stats-summary-item {
1052
- background: rgba(6, 8, 15, 0.4);
1053
- border: 1px solid rgba(51, 65, 85, 0.3);
1230
+ background: var(--bg-surface-alt);
1231
+ border: 1px solid var(--border-light);
1054
1232
  border-radius: 12px;
1055
1233
  padding: 18px 16px;
1056
1234
  text-align: center;
@@ -1066,7 +1244,7 @@ form {
1066
1244
  display: block;
1067
1245
  font-size: 1.5rem;
1068
1246
  font-weight: 700;
1069
- color: #60a5fa;
1247
+ color: var(--accent);
1070
1248
  font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1071
1249
  margin-bottom: 4px;
1072
1250
  }
@@ -1128,7 +1306,7 @@ form {
1128
1306
  text-align: right;
1129
1307
  font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1130
1308
  font-size: 0.82rem;
1131
- color: #7dd3fc;
1309
+ color: var(--accent);
1132
1310
  }
1133
1311
 
1134
1312
  .stats-table tfoot td {
@@ -1276,10 +1454,10 @@ form {
1276
1454
  .api-key-display {
1277
1455
  display: block;
1278
1456
  padding: 11px 16px;
1279
- background: rgba(6, 8, 15, 0.6);
1280
- border: 1px solid rgba(51, 65, 85, 0.5);
1457
+ background: var(--bg-surface);
1458
+ border: 1px solid var(--border-input);
1281
1459
  border-radius: 10px;
1282
- color: #64748b;
1460
+ color: var(--text-dim);
1283
1461
  font-size: 0.9rem;
1284
1462
  letter-spacing: 2px;
1285
1463
  cursor: pointer;
@@ -1343,11 +1521,11 @@ form {
1343
1521
 
1344
1522
  .provider-pool-empty {
1345
1523
  padding: 12px 14px;
1346
- border: 1px dashed rgba(71, 85, 105, 0.6);
1524
+ border: 1px dashed var(--border-input);
1347
1525
  border-radius: 10px;
1348
- color: #64748b;
1526
+ color: var(--text-dim);
1349
1527
  font-size: 0.9rem;
1350
- background: rgba(6, 8, 15, 0.35);
1528
+ background: var(--bg-surface-alt);
1351
1529
  }
1352
1530
 
1353
1531
  .provider-pool-item {
@@ -1356,9 +1534,9 @@ form {
1356
1534
  gap: 12px;
1357
1535
  align-items: center;
1358
1536
  padding: 12px 14px;
1359
- border: 1px solid rgba(51, 65, 85, 0.5);
1537
+ border: 1px solid var(--border-input);
1360
1538
  border-radius: 12px;
1361
- background: rgba(6, 8, 15, 0.45);
1539
+ background: var(--bg-surface);
1362
1540
  }
1363
1541
 
1364
1542
  .provider-pool-main {
@@ -1558,3 +1736,138 @@ form {
1558
1736
  background: rgba(71, 85, 105, 0.5);
1559
1737
  border-radius: 3px;
1560
1738
  }
1739
+
1740
+ /* Log viewer */
1741
+ .log-toolbar {
1742
+ display: flex;
1743
+ gap: 10px;
1744
+ align-items: center;
1745
+ padding: 14px 28px;
1746
+ border-bottom: 1px solid var(--border-light);
1747
+ background: var(--bg-surface-alt);
1748
+ }
1749
+
1750
+ .log-toolbar select {
1751
+ padding: 6px 10px;
1752
+ background: var(--bg-surface);
1753
+ border: 1px solid var(--border-input);
1754
+ border-radius: 8px;
1755
+ color: #e2e8f0;
1756
+ font-size: 0.85rem;
1757
+ cursor: pointer;
1758
+ appearance: auto;
1759
+ }
1760
+
1761
+ .log-toolbar select option {
1762
+ background: #0f172a;
1763
+ color: #e2e8f0;
1764
+ }
1765
+
1766
+ .log-content {
1767
+ padding: 16px 28px 28px;
1768
+ max-height: 500px;
1769
+ overflow-y: auto;
1770
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1771
+ font-size: 0.78rem;
1772
+ line-height: 1.7;
1773
+ color: var(--text-muted);
1774
+ background: var(--bg-surface-alt);
1775
+ }
1776
+
1777
+ .log-content::-webkit-scrollbar {
1778
+ width: 6px;
1779
+ }
1780
+
1781
+ .log-content::-webkit-scrollbar-track {
1782
+ background: transparent;
1783
+ }
1784
+
1785
+ .log-content::-webkit-scrollbar-thumb {
1786
+ background: rgba(71, 85, 105, 0.5);
1787
+ border-radius: 3px;
1788
+ }
1789
+
1790
+ .log-line {
1791
+ padding: 1px 0;
1792
+ white-space: pre-wrap;
1793
+ word-break: break-all;
1794
+ }
1795
+
1796
+ .log-line.log-error {
1797
+ color: #fca5a5;
1798
+ }
1799
+
1800
+ .log-line.log-warn {
1801
+ color: #fbbf24;
1802
+ }
1803
+
1804
+ /* History viewer */
1805
+ .history-content {
1806
+ padding: 20px 28px 28px;
1807
+ max-height: 500px;
1808
+ overflow-y: auto;
1809
+ }
1810
+
1811
+ .history-content::-webkit-scrollbar {
1812
+ width: 6px;
1813
+ }
1814
+
1815
+ .history-content::-webkit-scrollbar-track {
1816
+ background: transparent;
1817
+ }
1818
+
1819
+ .history-content::-webkit-scrollbar-thumb {
1820
+ background: rgba(71, 85, 105, 0.5);
1821
+ border-radius: 3px;
1822
+ }
1823
+
1824
+ .history-list {
1825
+ display: flex;
1826
+ flex-direction: column;
1827
+ gap: 8px;
1828
+ }
1829
+
1830
+ .history-item {
1831
+ display: flex;
1832
+ align-items: center;
1833
+ justify-content: space-between;
1834
+ padding: 12px 16px;
1835
+ background: var(--bg-surface-alt);
1836
+ border: 1px solid var(--border-light);
1837
+ border-radius: 10px;
1838
+ transition: border-color 0.2s;
1839
+ }
1840
+
1841
+ .history-item:hover {
1842
+ border-color: rgba(59, 130, 246, 0.3);
1843
+ }
1844
+
1845
+ .history-info {
1846
+ display: flex;
1847
+ align-items: center;
1848
+ gap: 14px;
1849
+ flex: 1;
1850
+ min-width: 0;
1851
+ }
1852
+
1853
+ .history-time {
1854
+ color: #e2e8f0;
1855
+ font-size: 0.85rem;
1856
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
1857
+ white-space: nowrap;
1858
+ }
1859
+
1860
+ .history-reason {
1861
+ color: #94a3b8;
1862
+ font-size: 0.82rem;
1863
+ padding: 2px 8px;
1864
+ background: rgba(51, 65, 85, 0.3);
1865
+ border-radius: 6px;
1866
+ white-space: nowrap;
1867
+ }
1868
+
1869
+ .history-size {
1870
+ color: #64748b;
1871
+ font-size: 0.78rem;
1872
+ white-space: nowrap;
1873
+ }
package/server.js CHANGED
@@ -551,6 +551,7 @@ async function init() {
551
551
 
552
552
  // 创建代理
553
553
  app.post('/api/proxies', async (req, res) => {
554
+ configStore.saveSnapshot('create-proxy');
554
555
  const { name, port, requireAuth, authToken, providerId, defaultModel, routingStrategy, providerPool, providerWeight } = req.body;
555
556
 
556
557
  if (!name || !port || !providerId) {
@@ -592,6 +593,7 @@ async function init() {
592
593
 
593
594
  // 更新代理
594
595
  app.put('/api/proxies/:id', async (req, res) => {
596
+ configStore.saveSnapshot('update-proxy');
595
597
  const existing = configStore.getProxyById(req.params.id);
596
598
  if (!existing) return res.status(404).json({ error: 'Proxy not found' });
597
599
 
@@ -640,6 +642,7 @@ async function init() {
640
642
 
641
643
  // 删除代理
642
644
  app.delete('/api/proxies/:id', async (req, res) => {
645
+ configStore.saveSnapshot('delete-proxy');
643
646
  const existing = configStore.getProxyById(req.params.id);
644
647
  if (!existing) return res.status(404).json({ error: 'Proxy not found' });
645
648
 
@@ -666,6 +669,36 @@ async function init() {
666
669
  res.json({ success: true, running: false });
667
670
  });
668
671
 
672
+ // 批量启动所有代理
673
+ app.post('/api/proxies/start-all', async (req, res) => {
674
+ const proxies = configStore.getProxies();
675
+ const results = [];
676
+ for (const proxy of proxies) {
677
+ if (proxyManager.isRunning(proxy.id)) {
678
+ results.push({ id: proxy.id, name: proxy.name, skipped: true });
679
+ continue;
680
+ }
681
+ try {
682
+ await startProxyWithProvider(proxy);
683
+ results.push({ id: proxy.id, name: proxy.name, success: true });
684
+ } catch (err) {
685
+ results.push({ id: proxy.id, name: proxy.name, success: false, error: err.message });
686
+ }
687
+ }
688
+ res.json({ results });
689
+ });
690
+
691
+ // 批量停止所有代理
692
+ app.post('/api/proxies/stop-all', async (req, res) => {
693
+ const running = proxyManager.getRunningPorts();
694
+ const results = [];
695
+ for (const r of running) {
696
+ await proxyManager.stopProxy(r.id);
697
+ results.push({ id: r.id, name: r.name, success: true });
698
+ }
699
+ res.json({ results });
700
+ });
701
+
669
702
  // 获取运行状态
670
703
  app.get('/api/status', (req, res) => {
671
704
  res.json({
@@ -687,6 +720,22 @@ async function init() {
687
720
  });
688
721
  });
689
722
 
723
+ // 设置
724
+ app.get('/api/settings', (req, res) => {
725
+ res.json(configStore.getSettings());
726
+ });
727
+
728
+ app.put('/api/settings', (req, res) => {
729
+ const settings = req.body;
730
+ if (!settings || typeof settings !== 'object') {
731
+ return res.status(400).json({ error: '需要 settings 对象' });
732
+ }
733
+ for (const [key, value] of Object.entries(settings)) {
734
+ configStore.setSetting(key, value);
735
+ }
736
+ res.json(configStore.getSettings());
737
+ });
738
+
690
739
  // Token 用量统计
691
740
  app.get('/api/stats', (req, res) => {
692
741
  const { range, startDate, endDate, proxyId } = req.query;
@@ -704,6 +753,22 @@ async function init() {
704
753
  res.json({ ...stats, proxies });
705
754
  });
706
755
 
756
+ // 日志查看
757
+ app.get('/api/logs', (req, res) => {
758
+ const lines = Math.min(parseInt(req.query.lines) || 200, 2000);
759
+ try {
760
+ if (!fs.existsSync(logger.LOG_FILE)) {
761
+ return res.json({ lines: [] });
762
+ }
763
+ const content = fs.readFileSync(logger.LOG_FILE, 'utf8');
764
+ const allLines = content.split('\n').filter(l => l.trim());
765
+ const tail = allLines.slice(-lines);
766
+ res.json({ lines: tail, total: allLines.length });
767
+ } catch (err) {
768
+ res.json({ lines: [], error: err.message });
769
+ }
770
+ });
771
+
707
772
  // ==================== 配置导入/导出 ====================
708
773
 
709
774
  app.get('/api/config/export', (req, res) => {
@@ -733,6 +798,8 @@ async function init() {
733
798
  return res.status(400).json({ error: '需要 config 和 mode(overwrite/merge)' });
734
799
  }
735
800
 
801
+ configStore.saveSnapshot('import-' + mode);
802
+
736
803
  // 校验结构
737
804
  if (!Array.isArray(config.providers) || !Array.isArray(config.proxies)) {
738
805
  return res.status(400).json({ error: '配置格式错误:需要 providers 和 proxies 数组' });
@@ -837,6 +904,21 @@ async function init() {
837
904
  });
838
905
  });
839
906
 
907
+ // ==================== 配置版本历史 ====================
908
+
909
+ app.get('/api/config/history', (req, res) => {
910
+ const snapshots = configStore.getSnapshots();
911
+ res.json({ snapshots });
912
+ });
913
+
914
+ app.post('/api/config/rollback', async (req, res) => {
915
+ const { file } = req.body;
916
+ if (!file) return res.status(400).json({ error: '需要指定快照文件' });
917
+ const result = configStore.restoreSnapshot(file);
918
+ if (result.error) return res.status(400).json({ error: result.error });
919
+ res.json({ success: true });
920
+ });
921
+
840
922
  // 前端首页
841
923
  app.get('/', (req, res) => {
842
924
  res.sendFile(path.join(__dirname, 'public', 'index.html'));