@swimmingliu/autovpn 1.8.0 → 2.0.2

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.
@@ -1,22 +1,12 @@
1
1
  import { buildStageModel, PAGE_INDEX, PAGE_ORDER, resolveRunControlState } from './state.js';
2
+ import { icon } from './icons.js';
2
3
 
3
4
  const FALLBACK_PROFILE = {
4
5
  sources: {
5
- leiting: {
6
- url: 'https://capture-1.vpn.example/api/v1/client/subscribe',
7
- key: 'lt-demo-key',
8
- enabled: true
9
- },
10
- heilong: {
11
- url: 'https://capture-2.vpn.example/api/v1/client/nodes',
12
- key: 'hl-demo-key',
13
- enabled: true
14
- },
15
- custom: {
16
- url: 'https://capture-3.vpn.example/custom.txt',
17
- key: '',
18
- enabled: false
19
- }
6
+ leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
7
+ heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
8
+ mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
9
+ xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, max_iterations: 4, plateau_limit: 2 }
20
10
  },
21
11
  speed_test: {
22
12
  min_download_mb_s: 1,
@@ -38,14 +28,7 @@ const FALLBACK_PROFILE = {
38
28
  }
39
29
  };
40
30
 
41
- const NAV_ICONS = {
42
- dashboard: '⌂',
43
- runs: '◉',
44
- results: '▣',
45
- subscriptions: '♢',
46
- logs: '▤',
47
- settings: '⚙'
48
- };
31
+ // SVGs retain the same geometry across installed fonts and theme modes.
49
32
 
50
33
  const PAGE_STATUS = {
51
34
  dashboard: ['', 'neutral'],
@@ -57,7 +40,6 @@ const PAGE_STATUS = {
57
40
  };
58
41
 
59
42
  const LOG_FILTERS = ['全部', '运行日志', '错误', '按阶段'];
60
- const SUBSCRIPTION_FORMATS = ['Clash', 'Clash Meta', 'Sing-box', 'Surge'];
61
43
  const EMPTY_COUNTS = {
62
44
  raw_links: 0,
63
45
  deduped_links: 0,
@@ -66,7 +48,7 @@ const EMPTY_COUNTS = {
66
48
  postprocess_links: 0
67
49
  };
68
50
 
69
- const SOURCE_DISPLAY_ORDER = ['leiting', 'heidong', 'mifeng', 'xuanfeng-area', 'xuanfeng-all-area'];
51
+ const SOURCE_DISPLAY_ORDER = ['leiting', 'heidong', 'mifeng', 'xuanfeng'];
70
52
  const RETRY_STAGE_ORDER = ['speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
71
53
  const STAGE_DISPLAY_NAMES = {
72
54
  doctor: 'doctor',
@@ -114,8 +96,6 @@ export function buildViewModel(state, messages, language) {
114
96
  counts.deduped_links = dedupedSourceCount || counts.deduped_links || counts.postprocess_links || 0;
115
97
  }
116
98
  const subscriptionUrl = profile.deploy.subscription_url || FALLBACK_PROFILE.deploy.subscription_url;
117
- const subscriptionCards = buildSubscriptionCards(subscriptionUrl);
118
- const currentSubscription = subscriptionCards.find((card) => card.title === state.subscriptionFormat) ?? subscriptionCards[0];
119
99
  const displayLogs = (state.logEntries ?? []).map((entry, index) => ({
120
100
  logId: entry?.logId ?? `history-${index}`,
121
101
  ...classifyLogEntry(entry)
@@ -144,7 +124,6 @@ export function buildViewModel(state, messages, language) {
144
124
  profile,
145
125
  counts,
146
126
  subscriptionUrl,
147
- subscriptionFormat: state.subscriptionFormat ?? 'Clash',
148
127
  qr: state.qr ?? { status: state.qrDataUrl ? 'success' : 'idle', dataUrl: state.qrDataUrl ?? '', message: '' },
149
128
  displayLogs,
150
129
  logFilter,
@@ -161,10 +140,9 @@ export function buildViewModel(state, messages, language) {
161
140
  outputFiles: state.outputFiles ?? [],
162
141
  nodeRows: state.nodeRows ?? [],
163
142
  regionStats: buildRegionStats(state.nodeRows ?? []),
164
- subscriptionCards,
165
- currentSubscription,
166
143
  sourceRows: buildSourceRows(profile),
167
144
  sourceCounts,
145
+ extractProgress: Object.values(state.extractProgress ?? {}),
168
146
  rawSourceMetricRows: buildSourceMetricRows(profile, sourceCounts, 'raw_links'),
169
147
  dedupedSourceMetricRows: buildSourceMetricRows(profile, sourceCounts, 'deduped_links'),
170
148
  retryContext: state.retryContext ?? {},
@@ -175,14 +153,24 @@ export function buildViewModel(state, messages, language) {
175
153
  retryStageOptions,
176
154
  selectedRetryStage,
177
155
  runControlState: resolveRunControlState(state.runState),
156
+ runOptions: state.runOptions ?? {},
157
+ activity: state.activity ?? [],
158
+ activityUnavailable: state.activityUnavailable ?? 0,
159
+ runState: state.runState ?? 'idle',
178
160
  statusItems: buildStatusItems(state, messages),
179
161
  currentStatusLabel: resolveCurrentStatusLabel(state, messages),
180
162
  currentTaskLabel: resolveCurrentTaskLabel(state, messages),
181
163
  runStateLabel: messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle,
182
164
  runStateTone: runStateTone(state.runState),
183
165
  settingsDrawer: state.settingsDrawer ?? null,
166
+ accountPool: {
167
+ ...EMPTY_ACCOUNT_POOL,
168
+ ...(state.accountPool ?? {}),
169
+ recordsMarkup: buildAccountPoolRecords({ ...EMPTY_ACCOUNT_POOL, ...(state.accountPool ?? {}) })
170
+ },
184
171
  modalTransform: state.modalTransform ?? '',
185
- runtime: state.runtime ?? 'electron'
172
+ runtime: state.runtime ?? 'electron',
173
+ themePreference: state.themePreference ?? 'system'
186
174
  };
187
175
  }
188
176
 
@@ -199,32 +187,24 @@ export function buildSidebarNav(messages, activePage) {
199
187
  id="${navId(page)}"
200
188
  class="nav-item ${activePage === page ? 'active' : ''}"
201
189
  data-page-target="${page}"
190
+ aria-label="${escapeHtml(messages.nav[page])}"
191
+ title="${escapeHtml(messages.nav[page])}"
202
192
  type="button"
203
193
  ${activePage === page ? 'aria-current="page"' : ''}
204
194
  >
205
- <span class="nav-icon">${NAV_ICONS[page]}</span>
195
+ <span class="nav-icon">${icon(page)}</span>
206
196
  <span class="nav-copy">${escapeHtml(messages.nav[page])}</span>
207
197
  </button>
208
198
  `).join('');
209
199
  }
210
200
 
211
- export function buildTopbarActions(activePage, viewModel, messages, labels) {
212
- const actionSets = {
213
- dashboard: [
214
- `<button class="btn btn-secondary" data-action="open-settings" type="button">${escapeHtml(messages.settingsButton)}</button>`,
215
- `<button class="btn btn-danger" data-run-action="stop" type="button" ${labels.stopDisabled ? 'disabled' : ''}>${escapeHtml(labels.stopLabel)}</button>`,
216
- `<button class="btn btn-primary" data-run-action="start" type="button" ${labels.runDisabled ? 'disabled' : ''}>${escapeHtml(labels.runLabel)}</button>`
217
- ],
218
- runs: [
219
- ],
220
- results: [
221
- ],
222
- subscriptions: [
223
- ],
224
- logs: [],
225
- settings: []
226
- };
227
- return (actionSets[activePage] ?? []).join('');
201
+ // Run actions belong to the state they affect; names remain available to
202
+ // assistive technology and tooltips without repeating visible button text.
203
+ function buildRunControls(vm, mobile = false) {
204
+ return `<div class="run-icon-controls ${mobile ? 'mobile-run-bar' : ''}" ${mobile ? 'data-mobile-run-bar' : ''}>
205
+ <button class="btn btn-primary icon-only" data-run-action="start" type="button" aria-label="立即运行" title="立即运行" ${vm.runControlState.runDisabled ? 'disabled' : ''}>${icon('play')}</button>
206
+ <button class="btn btn-secondary icon-only" data-run-action="stop" type="button" aria-label="停止运行" title="停止运行" ${vm.runControlState.stopDisabled ? 'disabled' : ''}>${icon('stop')}</button>
207
+ </div>`;
228
208
  }
229
209
 
230
210
  export function buildShortcutStrip(messages) {
@@ -281,8 +261,6 @@ function buildPageInner(activePage, vm, messages) {
281
261
  return buildRunsPage(vm, messages);
282
262
  case 'results':
283
263
  return buildResultsPage(vm, messages);
284
- case 'subscriptions':
285
- return buildSubscriptionsPage(vm, messages);
286
264
  case 'logs':
287
265
  return buildLogsPage(vm, messages);
288
266
  default:
@@ -290,50 +268,29 @@ function buildPageInner(activePage, vm, messages) {
290
268
  }
291
269
  }
292
270
 
271
+ // Page construction and live updates share the same current-run state;
272
+ // a retained artifact is never evidence that the active run succeeded.
273
+ export function resolveDashboardRunSummary(vm) {
274
+ return { isRunning: vm.runControlState?.isBusy === true, failed: vm.runState === 'failed' || vm.runStateTone === 'danger' };
275
+ }
276
+
277
+ export function buildDashboardStateMarkup(vm) {
278
+ const { failed } = resolveDashboardRunSummary(vm);
279
+ return `<h3>${escapeHtml(vm.currentStatusLabel)}</h3>${failed ? '<p>运行失败,可在日志中查看原因</p><button class="btn btn-secondary small" data-page-target="logs" type="button">查看日志</button>' : ''}`;
280
+ }
281
+
293
282
  function buildDashboardPage(vm, messages) {
283
+ const { isRunning } = resolveDashboardRunSummary(vm);
294
284
  return `
295
285
  <div id="dashboardOverview" class="page-grid dashboard-grid">
296
- <article class="panel wide-panel status-hero-card">
297
- <div>
298
- <span class="panel-subcopy">当前运行状态</span>
299
- <h3>${escapeHtml(vm.currentStatusLabel)}</h3>
300
- <p>${vm.hasResult ? '最近一次运行已完成' : '系统就绪,等待运行'}</p>
301
- </div>
302
- <div class="status-orb">✓</div>
286
+ <article class="panel wide-panel status-hero-card ${isRunning ? 'is-running' : ''}" data-run-state="${escapeHtml(vm.hasResult ? 'result' : 'empty')}">
287
+ <div class="dashboard-state-copy">${buildDashboardStateMarkup(vm)}</div>
288
+ ${buildRunControls(vm)}
303
289
  </article>
304
290
 
305
291
  ${buildDashboardMetricsMarkup(vm)}
306
292
 
307
- <article class="panel">
308
- <div class="panel-headline">
309
- <h3>最近运行结果</h3>
310
- ${vm.hasResult ? renderBadge('成功', 'success') : renderBadge('未开始', 'neutral')}
311
- </div>
312
- <div class="key-value-list">
313
- <div class="key-value-row"><span>状态</span><strong>${vm.hasResult ? '成功' : '未开始'}</strong></div>
314
- <div class="key-value-row"><span>Artifact 目录</span><strong class="mono">${escapeHtml(vm.artifactDir || '—')}</strong></div>
315
- <div class="key-value-row"><span>更新时间</span><strong>${escapeHtml(vm.lastUpdated)}</strong></div>
316
- </div>
317
- </article>
318
-
319
- <article class="panel">
320
- <div class="panel-headline">
321
- <h3>系统状态</h3>
322
- ${renderBadge(vm.runStateLabel, vm.runStateTone)}
323
- </div>
324
- <div class="key-value-list">
325
- ${vm.statusItems.map((item) => `
326
- <div class="key-value-row">
327
- <span>${escapeHtml(item.label)}</span>
328
- <strong>${escapeHtml(item.value)}</strong>
329
- </div>
330
- `).join('')}
331
- <div class="key-value-row">
332
- <span>${escapeHtml(messages.currentTaskLabel)}</span>
333
- <strong>${escapeHtml(vm.currentTaskLabel)}</strong>
334
- </div>
335
- </div>
336
- </article>
293
+ <div id="dashboardAnalytics" class="wide-panel"></div>
337
294
  </div>
338
295
  `;
339
296
  }
@@ -346,7 +303,6 @@ export function buildDashboardMetricsMarkup(vm) {
346
303
  <div class="metric-card ${metric.tone}" data-metric-key="${escapeHtml(metric.key)}">
347
304
  <span>${escapeHtml(metric.label)}</span>
348
305
  <strong>${escapeHtml(metric.value)}</strong>
349
- <small>${escapeHtml(metric.detail)}</small>
350
306
  ${metric.key === 'raw_links'
351
307
  ? renderSourceMetricRows(vm.rawSourceMetricRows, '各数据源原始节点数')
352
308
  : metric.key === 'deduped_links'
@@ -365,15 +321,11 @@ function buildRunsPage(vm, messages) {
365
321
  const stageDisabled = vm.runControlState.isBusy || !vm.retryStageOptions.length ? 'disabled' : '';
366
322
  const retryDisabled = vm.runControlState.isBusy || !vm.selectedRetryArtifactDir || !vm.selectedRetryStage ? 'disabled' : '';
367
323
  return `
368
- <div class="mobile-run-bar" data-mobile-run-bar>
369
- <button class="btn btn-primary run-big" data-run-action="start" type="button" ${runDisabled}>▶ 开始运行</button>
370
- <button class="btn btn-secondary run-big" data-run-action="stop" type="button" ${vm.runControlState.stopDisabled ? 'disabled' : ''}>■ 停止运行</button>
371
- </div>
372
324
  <div class="page-grid runs-grid">
373
325
  <article class="panel wide-panel run-control-panel">
374
326
  <details class="run-secondary-controls">
375
- <summary>重试历史、阶段与运行选项</summary>
376
- <div class="retry-control-card">
327
+ <summary>${vm.retryArtifacts.length ? '重试与运行选项' : '运行选项'}</summary>
328
+ ${vm.retryArtifacts.length ? `<div class="retry-control-card">
377
329
  <div class="field compact retry-field retry-artifact-field">
378
330
  <span>历史 run</span>
379
331
  <select data-run-retry-artifact ${artifactDisabled}>
@@ -415,15 +367,15 @@ function buildRunsPage(vm, messages) {
415
367
  `
416
368
  : '<div class="empty-state">暂无可重试 run</div>'}
417
369
  </div>
418
- <p class="retry-help">阶段重试会新建 artifact,并从所选阶段继续执行到 verify。</p>
419
370
  </div>
371
+ ` : ''}
420
372
  <div class="run-options">
421
373
  ${[
422
374
  ['跳过部署', 'skipDeploy', false],
423
375
  ['跳过验证', 'skipVerify', false],
424
376
  ['保存配置后运行', 'saveBeforeRun', true]
425
377
  ].map(([label, key, checked]) => `
426
- <label class="checkbox-chip"><input data-run-option="${escapeHtml(key)}" type="checkbox" ${checked ? 'checked' : ''} />${escapeHtml(label)}</label>
378
+ <label class="checkbox-chip"><input data-run-option="${escapeHtml(key)}" type="checkbox" ${(vm.runOptions[key] ?? checked) ? 'checked' : ''} />${escapeHtml(label)}</label>
427
379
  `).join('')}
428
380
  </div>
429
381
  </details>
@@ -438,14 +390,15 @@ function buildRunsPage(vm, messages) {
438
390
  export function buildRunsStageProgressMarkup(vm) {
439
391
  return `
440
392
  <article id="runsStageProgress" class="panel timeline-panel">
441
- <div class="panel-headline"><h3>阶段进度</h3><span class="panel-subcopy">全流程时间线</span></div>
442
- <div class="timeline">
393
+ <div class="panel-headline"><h3>阶段进度</h3></div>
394
+ <div class="timeline" tabindex="0" role="region" aria-label="流水线阶段">
443
395
  ${vm.stageRows.map((stage) => `
444
396
  <div class="timeline-row">
445
397
  <span class="timeline-index ${stage.status}"></span>
446
398
  <span class="timeline-copy">${escapeHtml(stage.name)}</span>
447
399
  <span class="inline-state ${stateClass(stage.status)}">${escapeHtml(stageLabel(stage.status))}</span>
448
400
  </div>
401
+ ${stage.name === 'extract' && vm.extractProgress?.length ? `<div class="timeline-substeps">${vm.extractProgress.map((step) => `<div class="timeline-substep"><span>${escapeHtml(SOURCE_NAMES[step.source] ?? step.source)} · ${escapeHtml(step.platform)} · ${escapeHtml({ apk_download: 'APK 下载', apk_analysis: 'APK 解析', domain_discovery: '域名探测' }[step.substage])}</span><span class="inline-state ${step.status === 'cached' ? 'neutral' : stateClass(step.status)}">${step.status === 'cached' ? '缓存' : escapeHtml(stageLabel(step.status))}</span></div>`).join('')}</div>` : ''}
449
402
  `).join('')}
450
403
  </div>
451
404
  </article>
@@ -455,15 +408,15 @@ export function buildRunsStageProgressMarkup(vm) {
455
408
  export function buildRunsCurrentStageMarkup(vm) {
456
409
  return `
457
410
  <article id="runsCurrentStage" class="panel">
458
- <div class="panel-headline"><h3>当前阶段详情</h3>${renderBadge(stageLabel(vm.currentStage.status), stateClass(vm.currentStage.status))}</div>
411
+ <div class="panel-headline run-stage-heading"><h3>当前阶段</h3>${renderBadge(stageLabel(vm.currentStage.status), stateClass(vm.currentStage.status))}</div>
412
+ <div data-current-stage-body>
459
413
  <div class="key-value-list">
460
414
  <div class="key-value-row"><span>阶段</span><strong>${escapeHtml(vm.currentStage.name)}</strong></div>
461
- <div class="key-value-row"><span>状态</span><strong>${escapeHtml(stageLabel(vm.currentStage.status))}</strong></div>
462
415
  <div class="key-value-row"><span>开始时间</span><strong>${escapeHtml(vm.runStartedAt)}</strong></div>
463
416
  <div class="key-value-row"><span>已耗时</span><strong>${escapeHtml(vm.runElapsed)}</strong></div>
464
417
  <div class="key-value-row"><span>当前目标</span><strong>${escapeHtml(vm.currentStage.name === 'speedtest' ? '测速通过节点' : '流水线处理')}</strong></div>
465
418
  </div>
466
- <div class="progress-track"><div class="progress-fill" style="width:35%"></div></div>
419
+ </div>
467
420
  </article>
468
421
  `;
469
422
  }
@@ -473,14 +426,16 @@ function buildResultsPage(vm, messages) {
473
426
  const verifyStage = vm.stageRows.find((row) => row.name === 'verify');
474
427
  return `
475
428
  <div id="resultsWorkspace" class="page-grid results-grid">
429
+ ${buildSubscriptionPanel(vm)}
430
+ <!-- Keep the artifact identity beside its actions without an introductory heading. -->
476
431
  <article class="panel wide-panel artifact-card">
477
432
  <div>
478
- <span class="panel-subcopy">最终节点来源</span>
433
+ <span class="sr-only">最终节点来源</span>
479
434
  <strong class="mono">${escapeHtml(vm.artifactDir || '暂无')}</strong>
480
435
  </div>
481
436
  <div class="toolbar-right">
482
- <button class="btn btn-secondary" data-action="copy-nodes" type="button">复制节点</button>
483
- <button class="btn btn-secondary" data-action="open-artifact-dir" type="button">打开目录</button>
437
+ <button class="btn btn-secondary icon-only" data-action="copy-nodes" type="button" aria-label="复制节点" title="复制节点">${icon('copy')}</button>
438
+ ${vm.runtime === 'web' ? '' : `<button class="btn btn-secondary icon-only" data-action="open-artifact-dir" type="button" aria-label="打开目录" title="打开目录">${icon('folder')}</button>`}
484
439
  </div>
485
440
  </article>
486
441
 
@@ -499,7 +454,27 @@ function buildResultsPage(vm, messages) {
499
454
  ` : ''}
500
455
 
501
456
  <article class="panel wide-panel">
502
- <div class="panel-headline"><h3>本次 deploy 目标</h3><span class="panel-subcopy">部署摘要</span></div>
457
+ <div class="panel-headline node-list-heading"><h3 class="sr-only">最终节点列表</h3><span class="panel-subcopy">${escapeHtml(vm.nodeRows.length)} 个节点</span></div>
458
+ <div id="resultNodePreview" class="table-wrap">
459
+ <table class="data-table decoded-node-table" data-column-table="nodes">
460
+ <thead><tr><th>#</th><th>节点名称</th><th>IP地址</th><th>协议</th><th>path</th></tr></thead>
461
+ <tbody>
462
+ ${vm.nodeRows.length ? vm.nodeRows.map((row, index) => `
463
+ <tr>
464
+ <td><span class="node-card-field">序号</span><span>${index + 1}</span></td>
465
+ <td><span class="node-card-field">节点名称</span><span>${escapeHtml(row.name || '—')}</span></td>
466
+ <td><span class="node-card-field">IP地址</span><span>${escapeHtml(row.address || '—')}</span></td>
467
+ <td><span class="node-card-field">协议</span><span>${escapeHtml(row.protocol || '—')}</span></td>
468
+ <td class="mono"><span class="node-card-field">path</span><span>${escapeHtml(row.path || '—')}</span></td>
469
+ </tr>
470
+ `).join('') : '<tr><td colspan="5">暂无节点,运行完成后显示。</td></tr>'}
471
+ </tbody>
472
+ </table>
473
+ </div>
474
+ </article>
475
+ <!-- Low-frequency deployment paths used to bury the node comparison table. -->
476
+ <details class="panel wide-panel result-details" ${deployStage?.status === 'failed' || verifyStage?.status === 'failed' ? 'open' : ''}>
477
+ <summary>部署详情 ${renderBadge(stageLabel(deployStage?.status || 'pending'), stateClass(deployStage?.status || 'pending'))}</summary>
503
478
  <div class="key-value-list">
504
479
  <div class="key-value-row"><span>项目名称</span><strong>${escapeHtml(vm.deployment.project_name || '—')}</strong></div>
505
480
  <div class="key-value-row"><span>Pages 地址</span><strong class="mono">${escapeHtml(vm.deployment.pages_project_url || '—')}</strong></div>
@@ -508,10 +483,9 @@ function buildResultsPage(vm, messages) {
508
483
  <div class="key-value-row"><span>deploy 状态</span><strong>${escapeHtml(stageLabel(deployStage?.status || 'pending'))}</strong></div>
509
484
  <div class="key-value-row"><span>verify 状态</span><strong>${escapeHtml(stageLabel(verifyStage?.status || 'pending'))}</strong></div>
510
485
  </div>
511
- </article>
486
+ </details>
512
487
 
513
- <article class="panel wide-panel">
514
- <div class="panel-headline"><h3>区域统计</h3><span class="panel-subcopy">按最终节点名称中的区域码统计</span></div>
488
+ <details class="panel wide-panel result-details"><summary>区域统计</summary>
515
489
  <div class="region-stat-grid">
516
490
  ${vm.regionStats.length ? vm.regionStats.map((item) => `
517
491
  <div class="region-stat-card">
@@ -520,68 +494,23 @@ function buildResultsPage(vm, messages) {
520
494
  </div>
521
495
  `).join('') : '<div class="empty-state">暂无区域统计,运行完成后显示。</div>'}
522
496
  </div>
523
- </article>
497
+ </details>
524
498
 
525
- <article class="panel wide-panel">
526
- <div class="panel-headline"><h3>最终节点列表</h3><span class="panel-subcopy">${escapeHtml(vm.nodeRows.length)} 条</span></div>
527
- <div id="resultNodePreview" class="table-wrap">
528
- <table class="data-table decoded-node-table">
529
- <thead><tr><th>#</th><th>节点名称</th><th>IP地址</th><th>协议</th><th>path</th></tr></thead>
530
- <tbody>
531
- ${vm.nodeRows.length ? vm.nodeRows.map((row, index) => `
532
- <tr>
533
- <td><span class="node-card-field">序号</span><span>${index + 1}</span></td>
534
- <td><span class="node-card-field">节点名称</span><span>${escapeHtml(row.name || '—')}</span></td>
535
- <td><span class="node-card-field">IP地址</span><span>${escapeHtml(row.address || '—')}</span></td>
536
- <td><span class="node-card-field">协议</span><span>${escapeHtml(row.protocol || '—')}</span></td>
537
- <td class="mono"><span class="node-card-field">path</span><span>${escapeHtml(row.path || '—')}</span></td>
538
- </tr>
539
- `).join('') : '<tr><td colspan="5">暂无节点,运行完成后显示。</td></tr>'}
540
- </tbody>
541
- </table>
542
- </div>
543
- </article>
544
499
  </div>
545
500
  `;
546
501
  }
547
502
 
548
- function buildSubscriptionsPage(vm, messages) {
549
- const primaryCard = vm.currentSubscription;
550
- return `
551
- <div id="subscriptionCards" class="page-grid subscriptions-grid">
552
- <article class="panel subscriptions-primary-panel">
553
- <div class="panel-headline"><h3>主订阅地址</h3>${renderBadge('自动生成', 'success')}</div>
554
- <div class="subscription-primary-stack">
555
- <div class="subscription-tab-scroller">
556
- ${SUBSCRIPTION_FORMATS.map((format) => `
557
- <button
558
- class="subtab ${vm.subscriptionFormat === format ? 'active' : ''}"
559
- data-subscription-format="${escapeHtml(format)}"
560
- aria-pressed="${vm.subscriptionFormat === format}"
561
- type="button"
562
- >${escapeHtml(format)}</button>
563
- `).join('')}
564
- </div>
565
- <div class="subscription-primary mono">${escapeHtml(primaryCard.url)}</div>
566
- <div class="action-grid">
567
- <button class="btn btn-primary" data-copy-text="${escapeHtml(primaryCard.url)}" type="button">复制链接</button>
568
- <button class="btn btn-secondary" data-open-url="${escapeHtml(primaryCard.url)}" type="button">打开订阅</button>
569
- </div>
570
- </div>
571
- </article>
572
-
573
- <article class="panel slim-panel">
574
- <div class="panel-headline"><h3>订阅二维码</h3></div>
575
- <div class="qr-block">${renderQr(vm.qr)}</div>
576
- <p class="panel-subcopy center-copy">扫码导入订阅</p>
577
- </article>
578
-
579
- <article class="panel wide-panel subscription-meta">
580
- <div class="mini-stat"><span>最后生成时间</span><strong>${escapeHtml(vm.lastUpdated)}</strong></div>
581
- <div class="mini-stat"><span>最终节点数量</span><strong>${escapeHtml(vm.counts.availability_links)} 个</strong></div>
582
- </article>
503
+ // Results contain the one canonical subscription. QR is an object action,
504
+ // rather than a second navigation destination with a large permanent card.
505
+ function buildSubscriptionPanel(vm) {
506
+ return `<article id="subscriptionCards" class="panel wide-panel results-subscription-card">
507
+ <div class="subscription-primary mono">${escapeHtml(vm.subscriptionUrl)}</div>
508
+ <div class="subscription-actions">
509
+ <button class="btn btn-primary icon-only" data-copy-text="${escapeHtml(vm.subscriptionUrl)}" type="button" aria-label="复制链接" title="复制链接">${icon('copy')}</button>
510
+ <button class="btn btn-secondary icon-only" data-open-url="${escapeHtml(vm.subscriptionUrl)}" type="button" aria-label="打开订阅" title="打开订阅">${icon('external')}</button>
511
+ <details class="subscription-qr"><summary class="icon-btn" aria-label="订阅二维码" title="订阅二维码">${icon('qr')}</summary><div class="subscription-qr-panel">${renderQr(vm.qr)}</div></details>
583
512
  </div>
584
- `;
513
+ </article>`;
585
514
  }
586
515
 
587
516
  function buildLogsPage(vm, messages) {
@@ -601,16 +530,15 @@ function buildLogsPage(vm, messages) {
601
530
  </div>
602
531
  <div class="toolbar-right">
603
532
  <div class="log-utility-actions">
604
- <button class="btn btn-secondary small" data-action="copy-log" type="button">复制日志</button>
605
- ${vm.runtime === 'web' ? '' : '<button class="btn btn-primary small" data-action="open-log-file" type="button">打开日志文件</button>'}
533
+ <button class="btn btn-secondary small icon-only" data-action="copy-log" type="button" aria-label="复制日志" title="复制日志">${icon('copy')}</button>
534
+ ${vm.runtime === 'web' ? '' : `<button class="btn btn-secondary small icon-only" data-action="open-log-file" type="button" aria-label="打开日志文件" title="打开日志文件">${icon('folder')}</button>`}
606
535
  </div>
607
536
  <div class="log-destructive-actions">
608
- <button class="btn btn-secondary small" data-action="clear-log" type="button">清空显示</button>
537
+ <button class="btn btn-secondary small icon-only" data-action="clear-log" type="button" aria-label="清空显示" title="清空显示">${icon('trash')}</button>
609
538
  </div>
539
+ <div class="log-state-actions">${buildLogStateActions(vm)}</div>
610
540
  </div>
611
541
  </div>
612
- ${!vm.logView.follow && vm.logView.unseenCount > 0 ? `<button class="btn btn-primary small log-jump-latest" data-log-jump-latest type="button">回到底部 · ${vm.logView.unseenCount} 条新消息</button>` : ''}
613
- ${vm.logView.clearedSnapshot ? '<button class="btn btn-secondary small log-undo-clear" data-log-undo-clear type="button">撤销清空</button>' : ''}
614
542
  <div id="logCenterTable" class="terminal-output log-stream" role="log" aria-live="polite">
615
543
  ${buildLogCenterMarkup(vm)}
616
544
  </div>
@@ -619,31 +547,182 @@ function buildLogsPage(vm, messages) {
619
547
  `;
620
548
  }
621
549
 
550
+ // Transient log actions share the existing toolbar, so appearing feedback
551
+ // never shifts the stream or covers a log line being read.
552
+ export function buildLogStateActions(vm) {
553
+ const label = `回到底部 · ${vm.logView.unseenCount} 条新消息`;
554
+ return `${!vm.logView.follow && vm.logView.unseenCount > 0 ? `<button class="btn btn-primary small icon-only" data-log-jump-latest type="button" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">${icon('download')}</button>` : ''}${vm.logView.clearedSnapshot ? `<button class="btn btn-secondary small icon-only" data-log-undo-clear type="button" aria-label="撤销清空" title="撤销清空">${icon('refresh')}</button>` : ''}`;
555
+ }
556
+
622
557
  export function buildLogCenterMarkup(vm) {
623
558
  return vm.logFilter === '按阶段' ? renderLogGroups(vm.logGroups) : renderLogRows(vm.logRows);
624
559
  }
625
560
 
626
561
  function buildSettingsPage(vm, messages) {
562
+ // The entries already identify their editor. Repeated instructions used to
563
+ // push the account tools below the first viewport.
627
564
  return `
628
565
  <div id="settingsWorkspace" class="page-grid settings-grid">
629
566
  <article class="panel wide-panel settings-overview-panel">
630
- <div class="panel-headline">
631
- <h3>设置总览</h3>
632
- <span class="panel-subcopy">点击卡片后在弹窗中编辑,并在弹窗内直接保存</span>
633
- </div>
634
567
  <div class="settings-overview-grid">
635
- ${buildSettingsCard('sources', '数据源配置', `已启用 ${vm.sourceRows.filter((row) => row.enabled).length} / ${vm.sourceRows.length} 个来源`, '管理抓取地址、密钥和启用状态')}
636
- ${buildSettingsCard('speed_test', '测速配置', `最低 ${vm.profile.speed_test.min_download_mb_s} MB/s · 并发 ${vm.profile.speed_test.concurrency}`, '控制测速阈值、超时和并发')}
637
- ${buildSettingsCard('availability_targets', 'AI可达性检测', `已启用 ${enabledAvailabilityTargetCount(vm.profile.availability_targets)} / ${Object.keys(vm.profile.availability_targets ?? {}).length} 个网站`, '配置 Gemini、ChatGPT、Claude 或自定义网站')}
638
- ${buildSettingsCard('deploy', '部署配置', `项目 ${vm.profile.deploy.project_name || '未设置'}`, '项目名变化会自动联动默认 Pages 地址;手动修改 URL 后,后续不再自动覆盖')}
568
+ ${buildSettingsCard('sources', '数据源配置', `${vm.sourceRows.filter((row) => row.enabled).length} / ${vm.sourceRows.length} 个官网 · 双端并发`)}
569
+ ${buildSettingsCard('speed_test', '测速配置', `最低 ${vm.profile.speed_test.min_download_mb_s} MB/s · 并发 ${vm.profile.speed_test.concurrency}`)}
570
+ ${buildSettingsCard('availability_targets', 'AI可达性检测', `已启用 ${enabledAvailabilityTargetCount(vm.profile.availability_targets)} / ${Object.keys(vm.profile.availability_targets ?? {}).length} 个网站`)}
571
+ ${buildSettingsCard('deploy', '部署配置', `项目 ${vm.profile.deploy.project_name || '未设置'}`)}
572
+ </div>
573
+ </article>
574
+
575
+ <article class="panel wide-panel appearance-panel">
576
+ <h3>${icon('sun')}外观</h3>
577
+ <div class="theme-choices" role="group" aria-label="界面主题">
578
+ ${[['system', 'monitor', '跟随系统'], ['light', 'sun', '浅色'], ['dark', 'moon', '深色']].map(([value, glyph, label]) => `<button class="theme-choice" type="button" data-theme-choice="${value}" aria-pressed="${vm.themePreference === value}">${icon(glyph)}${label}</button>`).join('')}
639
579
  </div>
640
580
  </article>
641
581
 
582
+ ${buildAccountPoolPanel(vm)}
642
583
  ${buildSettingsDrawer(vm)}
643
584
  </div>
644
585
  `;
645
586
  }
646
587
 
588
+ export function filterAccountPoolAccounts(pool = {}) {
589
+ const query = String(pool.query ?? '').trim().toLowerCase();
590
+ const status = pool.statusFilter ?? 'all';
591
+ const brand = pool.brandFilter ?? 'all';
592
+ const rows = (pool.accounts ?? []).filter((account) => {
593
+ if (status !== 'all' && account.status !== status) return false;
594
+ if (brand !== 'all' && account.brand !== brand) return false;
595
+ if (!query) return true;
596
+ return [account.account_masked, account.note, account.status_reason, SOURCE_NAMES[account.brand]]
597
+ .some((value) => String(value ?? '').toLowerCase().includes(query));
598
+ });
599
+ const sortBy = pool.sortBy ?? 'brand';
600
+ return rows.sort((left, right) => {
601
+ if (sortBy === 'recent') return String(right.last_used_at || '').localeCompare(String(left.last_used_at || '')) || left.id - right.id;
602
+ if (sortBy === 'uses') return Number(right.use_count ?? 0) - Number(left.use_count ?? 0) || left.id - right.id;
603
+ if (sortBy === 'expiry') return String(left.expires_at || '9999').localeCompare(String(right.expires_at || '9999')) || left.id - right.id;
604
+ return String(left.brand).localeCompare(String(right.brand)) || left.id - right.id;
605
+ });
606
+ }
607
+
608
+ export function buildAccountPoolRecords(pool = EMPTY_ACCOUNT_POOL) {
609
+ if (pool.status === 'loading') {
610
+ return '<div class="account-pool-empty" role="status">正在读取账号池…</div>';
611
+ }
612
+ if (pool.status === 'error' || pool.status === 'unavailable') {
613
+ return `<div class="account-pool-empty" role="alert">${escapeHtml(pool.error || '账号池不可用')}</div>`;
614
+ }
615
+ const rows = filterAccountPoolAccounts(pool);
616
+ if (rows.length === 0) {
617
+ const hasAny = (pool.accounts ?? []).length > 0;
618
+ return `<div class="account-pool-empty">${hasAny ? '没有匹配的账号' : '暂无账号,上传 CSV 后可用。'}</div>`;
619
+ }
620
+ return `
621
+ <div class="table-wrap account-pool-table-wrap">
622
+ <table class="data-table account-pool-table" data-column-table="accounts">
623
+ <colgroup>
624
+ <col class="account-col-account" />
625
+ <col class="account-col-brand" />
626
+ <col class="account-col-status" />
627
+ <col class="account-col-expiry" />
628
+ <col class="account-col-usage" />
629
+ <col class="account-col-actions" />
630
+ </colgroup>
631
+ <thead><tr><th>账号</th><th>加速器</th><th>状态</th><th>到期</th><th>使用</th><th>操作</th></tr></thead>
632
+ <tbody>
633
+ ${rows.map((account) => {
634
+ const meta = ACCOUNT_STATUS_META[account.status] ?? { label: account.status, tone: 'neutral' };
635
+ const retired = account.status !== 'active';
636
+ return `
637
+ <tr data-account-row="${account.id}">
638
+ <td data-field-label="账号"><code class="account-mask">${escapeHtml(account.account_masked)}</code>${account.note ? `<span class="account-note">${escapeHtml(account.note)}</span>` : ''}</td>
639
+ <td data-field-label="加速器">${escapeHtml(SOURCE_NAMES[account.brand] ?? account.brand)}</td>
640
+ <td data-field-label="状态">${renderBadge(meta.label, meta.tone)}${account.status_reason ? `<span class="account-note">${escapeHtml(account.status_reason)}</span>` : ''}</td>
641
+ <td data-field-label="到期">${escapeHtml(account.expires_at || '—')}</td>
642
+ <td data-field-label="使用"><span class="account-usage">${Number(account.use_count ?? 0)} 次</span><span class="account-note">${Number(account.success_count ?? 0)} 成功 / ${Number(account.fail_count ?? 0)} 失败</span></td>
643
+ <td data-field-label="操作"><button class="btn btn-secondary small ${retired ? '' : 'destructive'}" data-account-action="toggle" data-account-id="${account.id}" type="button" ${pool.busy ? 'disabled' : ''}>${retired ? '启用' : '停用'}</button></td>
644
+ </tr>
645
+ `;
646
+ }).join('')}
647
+ </tbody>
648
+ </table>
649
+ </div>
650
+ <p class="account-pool-count">显示 ${rows.length} / ${(pool.accounts ?? []).length} 个账号</p>
651
+ `;
652
+ }
653
+
654
+ export function buildAccountPoolPanel(vm) {
655
+ const pool = vm.accountPool ?? EMPTY_ACCOUNT_POOL;
656
+ const stats = pool.stats ?? { total: 0, active: 0, disabled: 0, expired: 0, invalid: 0, expiring_soon: 0, by_brand: [] };
657
+ const total = Number(stats.total ?? 0);
658
+ const segments = ['active', 'disabled', 'expired', 'invalid'];
659
+ const message = pool.message || (pool.status === 'error' ? pool.error : '');
660
+ // A failed load must not render a wall of zeros: "0 可用 / 共 0 个" reads as a
661
+ // real empty pool, and counts left over from the last good load read as a
662
+ // successful refresh. Show the state instead and let the records area carry
663
+ // the reason.
664
+ const unavailable = pool.status === 'error' || pool.status === 'unavailable';
665
+ const summary = unavailable
666
+ ? ''
667
+ : `
668
+ <div class="account-pool-summary">
669
+ <div class="account-pool-total">
670
+ <strong>${Number(stats.active ?? 0)}</strong>
671
+ <span>可用账号 / 共 ${total} 个</span>
672
+ </div>
673
+ <div class="account-pool-status-grid">
674
+ ${segments.filter((status) => status !== 'active').map((status) => `
675
+ <div class="account-pool-stat ${ACCOUNT_STATUS_META[status].tone}">
676
+ <span>${ACCOUNT_STATUS_META[status].label}</span>
677
+ <strong>${Number(stats[status] ?? 0)}</strong>
678
+ </div>
679
+ `).join('')}
680
+ </div>
681
+ ${Number(stats.expiring_soon ?? 0) > 0 ? `<p class="account-pool-expiry">7 天内到期 ${Number(stats.expiring_soon)} 个</p>` : ''}
682
+ </div>
683
+
684
+ <div class="account-pool-toolbar">
685
+ <label class="account-pool-search"><span class="sr-only">搜索</span><input data-account-search type="search" value="${escapeHtml(pool.query ?? '')}" placeholder="账号掩码或备注" /></label>
686
+ <label><span class="sr-only">状态</span><select data-account-status-filter>
687
+ <option value="all" ${pool.statusFilter === 'all' ? 'selected' : ''}>全部状态</option>
688
+ ${segments.map((status) => `<option value="${status}" ${pool.statusFilter === status ? 'selected' : ''}>${ACCOUNT_STATUS_META[status].label}</option>`).join('')}
689
+ </select></label>
690
+ <label><span class="sr-only">加速器</span><select data-account-brand-filter>
691
+ <option value="all" ${pool.brandFilter === 'all' ? 'selected' : ''}>全部加速器</option>
692
+ ${SOURCE_DISPLAY_ORDER.map((brand) => `<option value="${brand}" ${pool.brandFilter === brand ? 'selected' : ''}>${SOURCE_NAMES[brand]}</option>`).join('')}
693
+ </select></label>
694
+ <label><span class="sr-only">排序</span><select data-account-sort>
695
+ <option value="brand" ${pool.sortBy === 'brand' ? 'selected' : ''}>按加速器</option>
696
+ <option value="recent" ${pool.sortBy === 'recent' ? 'selected' : ''}>最近使用</option>
697
+ <option value="uses" ${pool.sortBy === 'uses' ? 'selected' : ''}>使用次数</option>
698
+ <option value="expiry" ${pool.sortBy === 'expiry' ? 'selected' : ''}>到期时间</option>
699
+ </select></label>
700
+ </div>
701
+ `;
702
+ return `
703
+ <article class="panel wide-panel account-pool-panel" id="accountPoolPanel" aria-busy="${pool.busy || pool.status === 'loading' ? 'true' : 'false'}">
704
+ <div class="panel-headline account-pool-headline">
705
+ <div>
706
+ <h3>账号池</h3>
707
+ </div>
708
+ <div class="account-pool-actions">
709
+ <button class="btn btn-secondary small icon-only" data-account-action="refresh" type="button" ${pool.busy ? 'disabled' : ''} aria-label="刷新账号池" title="刷新账号池">${icon('refresh')}</button>
710
+ <button class="btn btn-secondary small icon-only" data-account-action="template" type="button" ${pool.busy ? 'disabled' : ''} aria-label="下载模板" title="下载模板">${icon('download')}</button>
711
+ <button class="btn btn-primary small" data-account-action="upload" type="button" ${pool.busy ? 'disabled' : ''}>${icon('upload')}${pool.busy ? '导入中…' : '上传 CSV'}</button>
712
+ <input id="accountPoolFile" data-account-file type="file" accept=".csv,.txt,text/csv,text/plain" hidden aria-hidden="true" tabindex="-1" />
713
+ </div>
714
+ </div>
715
+
716
+ ${summary}
717
+
718
+ <div id="accountPoolRecords" class="account-pool-records">${pool.recordsMarkup ?? ''}</div>
719
+ <p class="account-pool-message ${pool.issues?.length ? 'warning' : ''}" role="status" aria-live="polite" ${message || pool.issues?.length ? '' : 'hidden'}>
720
+ ${escapeHtml(message)}${pool.issues?.length ? ` ${pool.issues.slice(0, 3).map((issue) => `第 ${escapeHtml(String(issue.line ?? ''))} 行:${escapeHtml(issue.message)}`).join(';')}${pool.issues.length > 3 ? `;另有 ${pool.issues.length - 3} 行` : ''}` : ''}
721
+ </p>
722
+ </article>
723
+ `;
724
+ }
725
+
647
726
  function renderBoundField(label, type, value, id, binding = null) {
648
727
  const bindingAttrs = binding
649
728
  ? ` data-source="${escapeHtml(binding.source)}" data-key="${escapeHtml(binding.key)}"`
@@ -665,16 +744,17 @@ function renderProfileField(label, type, value, id, path) {
665
744
  `;
666
745
  }
667
746
 
668
- function buildSettingsCard(section, title, summary, detail) {
747
+ function buildSettingsCard(section, title, summary) {
669
748
  return `
670
749
  <button
671
750
  class="settings-overview-card"
672
751
  data-settings-card="${section}"
673
752
  type="button"
674
753
  >
675
- <span class="settings-card-kicker">${escapeHtml(title)}</span>
676
- <strong class="settings-card-summary" id="settingsCardSummary-${section}">${escapeHtml(summary)}</strong>
677
- <span class="settings-card-detail">${escapeHtml(detail)}</span>
754
+ <span class="settings-card-icon">${icon(section)}</span>
755
+ <span class="settings-card-copy"><span class="settings-card-kicker">${escapeHtml(title)}</span>
756
+ <span class="settings-card-summary" id="settingsCardSummary-${section}">${escapeHtml(summary)}</span></span>
757
+ ${icon('chevron', 'settings-card-arrow')}
678
758
  </button>
679
759
  `;
680
760
  }
@@ -702,13 +782,12 @@ function buildSettingsDrawer(vm) {
702
782
  <aside data-settings-dialog role="dialog" aria-modal="true" aria-labelledby="settingsDrawerTitle" class="settings-drawer-panel" ${style}>
703
783
  <div class="settings-drawer-head">
704
784
  <div>
705
- <span class="settings-card-kicker">编辑配置</span>
706
785
  <h3 id="settingsDrawerTitle">${escapeHtml(title)}</h3>
707
786
  </div>
708
- <button class="icon-btn settings-drawer-close" data-drawer-close="cancel" type="button" aria-label="关闭${escapeHtml(title)}">×</button>
787
+ <button class="icon-btn settings-drawer-close" data-drawer-close="cancel" type="button" aria-label="关闭${escapeHtml(title)}" title="关闭${escapeHtml(title)}">${icon('close')}</button>
709
788
  </div>
710
789
  <div class="settings-drawer-body">
711
- ${isOpen ? buildSettingsDrawerBody(section, drawer.draft) : ''}
790
+ ${isOpen ? buildSettingsDrawerBody(section, drawer.draft, vm) : ''}
712
791
  </div>
713
792
  <div class="settings-drawer-actions">
714
793
  <button class="btn btn-secondary" data-drawer-close="cancel" type="button">取消</button>
@@ -720,54 +799,110 @@ function buildSettingsDrawer(vm) {
720
799
  }
721
800
 
722
801
  const SOURCE_NAMES = {
723
- 'leiting': '雷霆',
724
- 'heidong': '黑洞',
725
- 'mifeng': '蜜蜂',
726
- 'xuanfeng-area': '旋风部分区域',
727
- 'xuanfeng-all-area': '旋风全区域',
728
- 'heilong': '黑龙',
729
- 'custom': '自定义'
802
+ leiting: '雷霆',
803
+ heidong: '黑洞',
804
+ mifeng: '蜜蜂',
805
+ xuanfeng: '旋风'
806
+ };
807
+
808
+ const LEGACY_SOURCE_ALIASES = {
809
+ 'xuanfeng-area': 'xuanfeng',
810
+ 'xuanfeng-all-area': 'xuanfeng',
811
+ xuanfeng1: 'xuanfeng',
812
+ xuanfeng2: 'xuanfeng'
813
+ };
814
+
815
+ const ACCOUNT_STATUS_META = {
816
+ active: { label: '可用', tone: 'success' },
817
+ disabled: { label: '停用', tone: 'neutral' },
818
+ expired: { label: '到期', tone: 'warning' },
819
+ invalid: { label: '失效', tone: 'danger' }
820
+ };
821
+
822
+ const EMPTY_ACCOUNT_POOL = {
823
+ status: 'idle',
824
+ stats: null,
825
+ accounts: [],
826
+ error: '',
827
+ message: '',
828
+ issues: [],
829
+ query: '',
830
+ statusFilter: 'all',
831
+ brandFilter: 'all',
832
+ sortBy: 'brand',
833
+ busy: false
730
834
  };
731
835
 
732
- function buildSettingsDrawerBody(section, draft) {
836
+ /** Credential sources are also configured by their official site. */
837
+ const OFFICIAL_SITES = {
838
+ leiting: 'https://www.68123836.com',
839
+ xuanfeng: 'https://www.16863988.com',
840
+ heidong: 'https://www.67986457.com',
841
+ mifeng: 'https://www.86933221.com'
842
+ };
843
+
844
+ function buildSettingsDrawerBody(section, draft, vm = null) {
733
845
  if (section === 'sources') {
734
- const sourceDraft = draft?.sources ?? {};
846
+ const sourceDraft = normalizeOfficialSourceDraft(draft?.sources ?? {});
735
847
  return `
848
+ <details class="context-help"><summary>抓取与早停规则</summary>
736
849
  <div class="source-drawer-settings">
737
- ${renderDrawerField('最大迭代次数', 'number', draft?.maxIterations ?? 40, 'sources.maxIterations', true, 'data-source-max-iterations min="1"')}
738
- ${renderDrawerField('早停次数', 'number', draft?.plateauLimit ?? 20, 'sources.plateauLimit', true, 'data-source-plateau-limit min="1"')}
739
- ${renderDrawerField('区域起始', 'number', draft?.areaMin ?? 0, 'sources.areaMin', true, 'data-source-area-min')}
740
- ${renderDrawerField('区域结束', 'number', draft?.areaMax ?? 100, 'sources.areaMax', true, 'data-source-area-max')}
850
+ <div class="source-drawer-settings-copy">
851
+ <strong>每个加速器只填官网</strong>
852
+ <span>密钥从 APK 自动发现,账号密码由账号池提供。Android 与 iOS 两个通道同时抓取,每条线路按「轮数」循环请求,连续「早停」轮没有新节点就停止。</span>
853
+ </div>
741
854
  </div>
855
+ </details>
742
856
  <div class="table-wrap">
743
857
  <table class="data-table settings-source-table">
744
858
  <colgroup>
745
859
  <col class="settings-col-enabled" />
746
860
  <col class="settings-col-name" />
747
- <col class="settings-col-url" />
748
- <col class="settings-col-key" />
861
+ <col class="settings-col-connection" />
862
+ <col class="settings-col-rounds" />
749
863
  </colgroup>
750
- <thead><tr><th>启用</th><th>名称</th><th>地址</th><th>密钥</th></tr></thead>
864
+ <thead><tr><th>启用</th><th>加速器</th><th>官网</th><th>循环</th></tr></thead>
751
865
  <tbody>
752
- ${Object.entries(sourceDraft).map(([name, source]) => `
866
+ ${SOURCE_DISPLAY_ORDER.map((name) => {
867
+ const source = sourceDraft[name];
868
+ const label = escapeHtml(SOURCE_NAMES[name] || name);
869
+ const pool = vm?.accountPool?.stats?.by_brand?.find((item) => item.brand === name);
870
+ const poolHint = vm?.accountPool?.stats
871
+ ? `账号池 ${Number(pool?.active ?? 0)} 个可用`
872
+ : '账号池未连接';
873
+ return `
753
874
  <tr data-settings-field-group>
754
- <td data-field-label="启用"><input aria-label="${escapeHtml(SOURCE_NAMES[name] || name)}:启用" type="checkbox" data-drawer-source="${escapeHtml(name)}" data-drawer-key="enabled" ${source.enabled ? 'checked' : ''} /></td>
755
- <td data-field-label="名称"><strong class="settings-source-name">${escapeHtml(SOURCE_NAMES[name] || name)}</strong></td>
756
- <td data-field-label="地址">
757
- <textarea
758
- class="table-textarea mono"
759
- rows="3"
760
- aria-label="${escapeHtml(SOURCE_NAMES[name] || name)}:地址"
761
- data-drawer-source="${escapeHtml(name)}"
762
- data-drawer-key="url"
763
- >${escapeHtml(source.url ?? '')}</textarea>
875
+ <td data-field-label="启用"><input aria-label="${label}:启用" type="checkbox" data-drawer-source="${escapeHtml(name)}" data-drawer-key="enabled" ${source.enabled !== false ? 'checked' : ''} /></td>
876
+ <td data-field-label="加速器">
877
+ <strong class="settings-source-name">${label}</strong>
878
+ <span class="settings-source-hint ${Number(pool?.active ?? 0) > 0 ? 'ready' : 'empty'}">${escapeHtml(poolHint)}</span>
879
+ </td>
880
+ <td data-field-label="官网">
881
+ <input aria-label="${label}:官网地址" inputmode="url" spellcheck="false" class="mono" data-drawer-source="${escapeHtml(name)}" data-drawer-key="official_site" placeholder="${escapeHtml(OFFICIAL_SITES[name] ?? '')}" value="${escapeHtml(source.official_site ?? '')}" />
882
+ </td>
883
+ <td data-field-label="循环">
884
+ <div class="source-credential-rounds">
885
+ <label class="source-field">
886
+ <span>轮数</span>
887
+ <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="每条线路循环请求的次数;每轮服务端会返回不同落地机" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? 4)}" />
888
+ </label>
889
+ <label class="source-field">
890
+ <span>早停</span>
891
+ <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:连续空轮早停次数" title="连续多少轮没有新节点就停止" data-drawer-source="${escapeHtml(name)}" data-drawer-key="plateau_limit" value="${escapeHtml(source.plateau_limit ?? 2)}" />
892
+ </label>
893
+ </div>
894
+ <span class="source-round-summary">双端并发</span>
764
895
  </td>
765
- <td data-field-label="密钥"><input aria-label="${escapeHtml(SOURCE_NAMES[name] || name)}:密钥" data-drawer-source="${escapeHtml(name)}" data-drawer-key="key" value="${escapeHtml(source.key ?? '')}" /></td>
766
896
  </tr>
767
- `).join('')}
897
+ `;
898
+ }).join('')}
768
899
  </tbody>
769
900
  </table>
770
901
  </div>
902
+ <details class="context-help source-drawer-notice"><summary>账号与线路规则</summary>
903
+
904
+ <p>登录被拒会把该账号标记为失效并停用,会员到期会按日期自动停用;更换密码后重新上传 CSV 即可恢复。抓取结果里免费线路和中国大陆线路会被跳过。</p>
905
+ </details>
771
906
  `;
772
907
  }
773
908
 
@@ -803,7 +938,7 @@ function buildSettingsDrawerBody(section, draft) {
803
938
  <td data-field-label="URL">
804
939
  <textarea aria-label="${escapeHtml(formatAvailabilityName(target.name))}:URL" class="table-textarea mono" rows="3" data-availability-index="${index}" data-availability-key="url">${escapeHtml(target.url ?? '')}</textarea>
805
940
  </td>
806
- <td data-field-label="操作"><button class="btn btn-secondary small destructive" data-availability-action="remove" data-availability-index="${index}" type="button">删除</button></td>
941
+ <td data-field-label="操作"><button class="btn btn-secondary small destructive icon-only" data-availability-action="remove" data-availability-index="${index}" type="button" aria-label="删除${escapeHtml(formatAvailabilityName(target.name))}" title="删除${escapeHtml(formatAvailabilityName(target.name))}">${icon('trash')}</button></td>
807
942
  </tr>
808
943
  `).join('')}
809
944
  </tbody>
@@ -814,16 +949,16 @@ function buildSettingsDrawerBody(section, draft) {
814
949
 
815
950
  if (section === 'deploy') {
816
951
  return `
817
- <div class="notice-card">
818
- <strong>部署配置说明</strong>
952
+ <details class="context-help">
953
+ <summary>地址联动与验证规则</summary>
819
954
  <p>项目名变化会自动联动默认 Pages 地址;手动修改 URL 后,后续不再自动覆盖。verify 订阅地址用于 deploy 后的健康检查,不影响页面展示二维码。</p>
820
- </div>
955
+ </details>
821
956
  <div class="form-grid compact-form-grid">
822
957
  ${renderDrawerField('项目名称', 'text', draft.project_name, 'deploy.project_name')}
958
+ ${renderDrawerField('最少节点数', 'number', draft.min_final_links ?? 10, 'deploy.min_final_links', false, 'min="0" step="1"')}
823
959
  ${renderDrawerField('Pages 地址', 'text', draft.pages_project_url, 'deploy.pages_project_url')}
824
960
  ${renderDrawerField('订阅地址', 'text', draft.subscription_url, 'deploy.subscription_url')}
825
961
  ${renderDrawerField('verify 订阅地址', 'text', draft.verify_subscription_url, 'deploy.verify_subscription_url')}
826
- ${renderDrawerField('最少节点数', 'number', draft.min_final_links ?? 10, 'deploy.min_final_links', false, 'min="0" step="1"')}
827
962
  ${renderDrawerField('Cloudflare Token', 'password', draft.cloudflare_api_token, 'deploy.cloudflare_api_token')}
828
963
  ${renderDrawerField('Pages Secret ADMIN', 'password', draft.pages_secret_admin, 'deploy.pages_secret_admin')}
829
964
  </div>
@@ -845,15 +980,15 @@ function formatAvailabilityName(name) {
845
980
 
846
981
  function renderDrawerField(label, type, value, path, isCompact = false, extraAttrs = '') {
847
982
  return `
848
- <label class="field ${isCompact ? 'compact' : ''}">
983
+ <label class="field ${isCompact ? 'compact' : ''} ${path.startsWith('deploy.') && (path.includes('url') || path.includes('token') || path.includes('secret')) ? 'field-wide' : ''}">
849
984
  <span>${escapeHtml(label)}</span>
850
985
  <input type="${type}" value="${escapeHtml(value ?? '')}" data-drawer-path="${escapeHtml(path)}" ${extraAttrs} />
851
986
  </label>
852
987
  `;
853
988
  }
854
989
 
855
- function renderBadge(text, tone) {
856
- return `<span class="badge ${tone}">${escapeHtml(text)}</span>`;
990
+ function renderBadge(text, tone, attributes = '') {
991
+ return `<span class="badge ${tone}"${attributes ? ` ${attributes}` : ''}>${escapeHtml(text)}</span>`;
857
992
  }
858
993
 
859
994
  function renderQr(qr) {
@@ -914,20 +1049,16 @@ function renderSourceMetricRows(rows, ariaLabel) {
914
1049
  `;
915
1050
  }
916
1051
 
917
- function buildSubscriptionCards(baseUrl) {
918
- return SUBSCRIPTION_FORMATS.map((title) => ({
919
- title,
920
- url: title === 'Clash' ? baseUrl : `${baseUrl}?format=${encodeURIComponent(title.toLowerCase().replaceAll(' ', '-'))}`
921
- }));
922
- }
923
-
924
1052
  function buildSourceRows(profile) {
925
1053
  return Object.entries(profile.sources).map(([name, source], index) => ({
926
1054
  name,
1055
+ label: SOURCE_NAMES[name] || name,
927
1056
  primary: index === 0,
928
- enabled: Boolean(source.enabled),
929
- url: source.url ?? '',
930
- key: source.key ?? ''
1057
+ enabled: source.enabled !== false,
1058
+ mode: 'credential',
1059
+ officialSite: source.official_site ?? '',
1060
+ maxIterations: Number(source.max_iterations ?? 4),
1061
+ plateauLimit: Number(source.plateau_limit ?? 2)
931
1062
  }));
932
1063
  }
933
1064
 
@@ -980,14 +1111,19 @@ export function buildRegionStats(nodeRows = []) {
980
1111
  return Array.from(counts.entries()).map(([region, count]) => ({ region, count }));
981
1112
  }
982
1113
 
1114
+ // Fresh profiles have no speed_test section. Reuse these defaults in both
1115
+ // the settings card and its draft so the displayed controls can always open.
1116
+ export function buildSpeedTestDraft(config = {}) {
1117
+ return structuredClone({ ...FALLBACK_PROFILE.speed_test, ...config });
1118
+ }
1119
+
983
1120
  export function buildSourceIterationDraft(sources = {}) {
984
- const firstSource = Object.values(sources)[0] ?? {};
1121
+ const normalized = normalizeOfficialSourceDraft(sources);
1122
+ const firstSource = Object.values(normalized)[0] ?? {};
985
1123
  return {
986
- sources: structuredClone(sources),
987
- maxIterations: coercePositiveInteger(firstSource.max_iterations, 40),
988
- plateauLimit: coercePositiveInteger(firstSource.plateau_limit, 20),
989
- areaMin: coerceAreaValue(firstSource.area_min, 0),
990
- areaMax: coerceAreaValue(firstSource.area_max, 100)
1124
+ sources: normalized,
1125
+ maxIterations: coercePositiveInteger(firstSource.max_iterations, 4),
1126
+ plateauLimit: coercePositiveInteger(firstSource.plateau_limit, 2)
991
1127
  };
992
1128
  }
993
1129
 
@@ -1043,25 +1179,52 @@ export function applyAvailabilityTargetDraft(draft = {}) {
1043
1179
  }
1044
1180
 
1045
1181
  export function applySourceIterationDraft(sources = {}, draft = {}) {
1046
- const maxIterations = coercePositiveInteger(draft.maxIterations, 40);
1047
- const plateauLimit = coercePositiveInteger(draft.plateauLimit, 20);
1048
- const areaMin = coerceAreaValue(draft.areaMin, 0);
1049
- const areaMax = coerceAreaValue(draft.areaMax, 100);
1182
+ // The drawer edits each row's rounds in place, and each row is the source of
1183
+ // truth when the draft carries rows. The shared `maxIterations` /
1184
+ // `plateauLimit` fields remain only for callers that pass bare values, which
1185
+ // then apply to every row.
1186
+ if (draft?.sources) {
1187
+ return normalizeOfficialSourceDraft(draft.sources);
1188
+ }
1189
+ const maxIterations = coercePositiveInteger(draft.maxIterations, 4);
1190
+ const plateauLimit = coercePositiveInteger(draft.plateauLimit, 2);
1191
+ const normalized = normalizeOfficialSourceDraft(sources);
1050
1192
  return Object.fromEntries(
1051
- Object.entries(sources).map(([name, source]) => [
1193
+ Object.entries(normalized).map(([name, source]) => [
1052
1194
  name,
1053
- {
1054
- ...source,
1055
- max_iterations: maxIterations,
1056
- min_iterations: Math.min(coercePositiveInteger(source.min_iterations, 0), maxIterations),
1057
- plateau_limit: plateauLimit,
1058
- area_min: Math.min(areaMin, areaMax),
1059
- area_max: Math.max(areaMin, areaMax)
1060
- }
1195
+ { ...source, max_iterations: maxIterations, plateau_limit: plateauLimit }
1061
1196
  ])
1062
1197
  );
1063
1198
  }
1064
1199
 
1200
+ export function validateSourceDraft(draft = {}) {
1201
+ const sources = draft.sources ?? {};
1202
+ for (const [name, source] of Object.entries(sources)) {
1203
+ // Only the vendor rows are edited here. A custom url source is passed
1204
+ // through untouched, so validating its `official_site` would reject a save
1205
+ // for a field the drawer never showed.
1206
+ if (!SOURCE_DISPLAY_ORDER.includes(name)) continue;
1207
+ if (source.enabled === false) continue;
1208
+ const value = String(source.official_site ?? '').trim();
1209
+ let parsed;
1210
+ try {
1211
+ parsed = new URL(value);
1212
+ } catch {
1213
+ return `${SOURCE_NAMES[name] ?? name}:请填写有效的官网地址`;
1214
+ }
1215
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
1216
+ return `${SOURCE_NAMES[name] ?? name}:官网地址必须以 http:// 或 https:// 开头`;
1217
+ }
1218
+ }
1219
+ if (!Number.isFinite(Number(draft.maxIterations)) || Number(draft.maxIterations) < 1) {
1220
+ return '循环轮数至少为 1';
1221
+ }
1222
+ if (!Number.isFinite(Number(draft.plateauLimit)) || Number(draft.plateauLimit) < 1) {
1223
+ return '早停轮数至少为 1';
1224
+ }
1225
+ return '';
1226
+ }
1227
+
1065
1228
  function inferNodeRegion(name = '') {
1066
1229
  const normalized = String(name).replace(/^[^\p{L}\p{N}]+/u, '').trim();
1067
1230
  const match = normalized.match(/^([A-Z]{2})(?:\b|[\s_-])/);
@@ -1092,7 +1255,10 @@ function buildLogRows(displayLogs, trimWindow = true) {
1092
1255
  }
1093
1256
 
1094
1257
  function selectLogWindow(entries, logView) {
1095
- if (logView?.follow !== false) return entries.slice(-28);
1258
+ // With the compact shell, 28 lines fit entirely on a tall desktop window.
1259
+ // That left no scroll range to pause following and shifted text on append.
1260
+ // Keep the same bounded 120-line window in both follow and paused modes.
1261
+ if (logView?.follow !== false) return entries.slice(-120);
1096
1262
  const anchorIndex = entries.findIndex((entry) => entry.logId === logView.anchorId);
1097
1263
  const start = anchorIndex >= 0
1098
1264
  ? Math.max(0, Math.min(anchorIndex - 8, Math.max(0, entries.length - 120)))
@@ -1178,6 +1344,65 @@ function inferLogStage(line) {
1178
1344
  return matches.at(-1) ?? '';
1179
1345
  }
1180
1346
 
1347
+ // Fields the drawer no longer edits. `min_iterations` and `platforms` stay: the
1348
+ // round policy and `resolveCredentialPlatforms` still read them, so a pinned
1349
+ // debug profile that sets them must survive a save from the drawer.
1350
+ const OBSOLETE_SOURCE_FIELDS = [
1351
+ 'url', 'key', 'account', 'password', 'platform',
1352
+ 'use_random_area', 'area_min', 'area_max', 'max_areas',
1353
+ 'resume_from_iteration', 'failure_limit'
1354
+ ];
1355
+
1356
+ export function normalizeOfficialSourceDraft(sources = {}) {
1357
+ const collapsed = {};
1358
+ // A non-vendor row (a custom `url` source) is kept out of the vendor table
1359
+ // but must survive a save: the drawer does not render it, and dropping it
1360
+ // would delete a working source the operator never touched.
1361
+ const extras = {};
1362
+ for (const [name, source] of Object.entries(sources ?? {})) {
1363
+ const canonical = LEGACY_SOURCE_ALIASES[name] ?? name;
1364
+ if (!SOURCE_DISPLAY_ORDER.includes(canonical)) {
1365
+ extras[canonical] = { ...source };
1366
+ continue;
1367
+ }
1368
+ const existing = collapsed[canonical];
1369
+ collapsed[canonical] = existing
1370
+ ? {
1371
+ ...existing,
1372
+ ...source,
1373
+ // The first row listed the vendor's rounds; a legacy duplicate must
1374
+ // not overwrite them just by appearing later in the file.
1375
+ max_iterations: existing.max_iterations ?? source.max_iterations,
1376
+ plateau_limit: existing.plateau_limit ?? source.plateau_limit,
1377
+ enabled: existing.enabled !== false || source.enabled !== false
1378
+ }
1379
+ : { ...source };
1380
+ }
1381
+ const vendorRows = Object.fromEntries(SOURCE_DISPLAY_ORDER.map((name) => {
1382
+ const source = collapsed[name] ?? {};
1383
+ const wasCredential = source.mode === 'credential';
1384
+ const normalized = {
1385
+ ...source,
1386
+ mode: 'credential',
1387
+ // Fill the vendor default only for a row that has never been stored.
1388
+ // A cleared input is a real edit, not a request for the default: refilling
1389
+ // it here would undo what the operator just typed.
1390
+ official_site: source.official_site === undefined
1391
+ ? (OFFICIAL_SITES[name] ?? '')
1392
+ : String(source.official_site).trim(),
1393
+ enabled: source.enabled !== false,
1394
+ max_iterations: wasCredential ? coercePositiveInteger(source.max_iterations, 4) : 4,
1395
+ plateau_limit: wasCredential ? coercePositiveInteger(source.plateau_limit, 2) : 2
1396
+ };
1397
+ for (const field of OBSOLETE_SOURCE_FIELDS) delete normalized[field];
1398
+ // A URL-mode floor is the same mismatch as its URL-mode cap: 10 URL fetches
1399
+ // are not 10 route-loop rounds, so it goes with the rest of the old shape.
1400
+ if (!wasCredential) delete normalized.min_iterations;
1401
+ return [name, normalized];
1402
+ }));
1403
+ return { ...vendorRows, ...extras };
1404
+ }
1405
+
1181
1406
  function mergeProfile(profile) {
1182
1407
  const workspace = profile?.workspace ?? {};
1183
1408
  const paths = profile?.paths ?? {};
@@ -1190,9 +1415,9 @@ function mergeProfile(profile) {
1190
1415
  return {
1191
1416
  ...FALLBACK_PROFILE,
1192
1417
  ...profile,
1193
- sources: profile?.sources ?? FALLBACK_PROFILE.sources,
1418
+ sources: normalizeOfficialSourceDraft(profile?.sources ?? FALLBACK_PROFILE.sources),
1194
1419
  availability_targets: profile?.availability_targets ?? DEFAULT_AVAILABILITY_TARGETS,
1195
- speed_test: { ...FALLBACK_PROFILE.speed_test, ...(profile?.speed_test ?? {}) },
1420
+ speed_test: buildSpeedTestDraft(profile?.speed_test),
1196
1421
  deploy: { ...FALLBACK_PROFILE.deploy, ...(profile?.deploy ?? {}) },
1197
1422
  paths: mergedPaths,
1198
1423
  workspace: mergedPaths
@@ -1264,9 +1489,12 @@ function runStatusLabel(status) {
1264
1489
  }
1265
1490
 
1266
1491
  function resolveCurrentStatusLabel(state, messages) {
1492
+ // A retained result belongs to the previous run. Live state takes priority
1493
+ // when another run starts through the service or another client.
1494
+ if (state.runState === 'running') return '运行中';
1495
+ if (state.runState === 'stopping') return '停止中';
1267
1496
  if (state.runResult === 'success' || state.runResult === 'demo') return '成功';
1268
1497
  if (state.runResult === 'failed') return '失败';
1269
- if (state.runState === 'running') return '运行中';
1270
1498
  return '空闲';
1271
1499
  }
1272
1500