@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,5 +1,11 @@
1
1
  import { getMessages, formatMessage } from './i18n.js';
2
2
  import { createLogViewState, createQrState, resolveRunControlState } from './state.js';
3
+ // Keep appearance changes separate from profile saves and running jobs, so a
4
+ // theme switch cannot discard a settings draft or restart the pipeline.
5
+ import { getThemePreference, initializeAppearance, selectTheme, toggleSidebar } from './appearance.js';
6
+ import { enhanceTables } from './tables.js';
7
+ import { icon } from './icons.js';
8
+ import { renderAnalytics, clearAnalytics } from './analytics.bundle.js';
3
9
  import {
4
10
  addAvailabilityTargetDraft,
5
11
  applyAvailabilityTargetDraft,
@@ -7,47 +13,33 @@ import {
7
13
  buildAvailabilityTargetDraft,
8
14
  buildPageMarkup,
9
15
  buildDashboardMetricsMarkup,
16
+ buildDashboardStateMarkup,
10
17
  buildLogCenterMarkup,
18
+ buildLogStateActions,
11
19
  buildRunsCurrentStageMarkup,
12
20
  buildRunsStageProgressMarkup,
21
+ buildSpeedTestDraft,
13
22
  buildSourceIterationDraft,
14
23
  buildSidebarNav,
15
24
  buildViewModel,
16
- buildTopbarActions,
25
+ buildAccountPoolRecords,
17
26
  removeAvailabilityTargetDraft,
27
+ resolveDashboardRunSummary,
28
+ validateSourceDraft,
18
29
  classifyLogEntry,
19
30
  escapeHtml,
20
31
  extractSourceUrlFromCurl,
21
32
  filterLogEntries
22
33
  } from './views.js';
23
34
 
35
+ initializeAppearance();
36
+
24
37
  const demoProfile = {
25
38
  sources: {
26
- leiting: {
27
- url: 'https://capture-1.vpn.example/api/v1/client/subscribe',
28
- key: 'lt-demo-key',
29
- enabled: true
30
- },
31
- heidong: {
32
- url: 'https://capture-2.vpn.example/api/v1/client/nodes',
33
- key: 'hd-demo-key',
34
- enabled: true
35
- },
36
- mifeng: {
37
- url: 'https://capture-3.vpn.example/api/v1/client/subscribe',
38
- key: 'mf-demo-key',
39
- enabled: true
40
- },
41
- 'xuanfeng-area': {
42
- url: 'https://capture-4.vpn.example/api/v1/client/subscribe',
43
- key: 'xf1-demo-key',
44
- enabled: true
45
- },
46
- 'xuanfeng-all-area': {
47
- url: 'https://capture-5.vpn.example/api/v1/client/subscribe',
48
- key: 'xf2-demo-key',
49
- enabled: false
50
- }
39
+ leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
40
+ heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
41
+ mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
42
+ xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, max_iterations: 4, plateau_limit: 2 }
51
43
  },
52
44
  speed_test: {
53
45
  min_download_mb_s: 1.0,
@@ -104,14 +96,31 @@ const state = {
104
96
  stageStatus: {},
105
97
  counts: {},
106
98
  sourceCounts: {},
99
+ extractProgress: {},
107
100
  language: 'zh-CN',
101
+ themePreference: getThemePreference(),
108
102
  activePage: 'dashboard',
109
103
  subtabs: {},
110
- subscriptionFormat: 'Clash',
111
104
  logFilter: '全部',
112
105
  settingsDrawer: null,
106
+ accountPool: {
107
+ status: 'idle',
108
+ stats: null,
109
+ accounts: [],
110
+ error: '',
111
+ message: '',
112
+ issues: [],
113
+ query: '',
114
+ statusFilter: 'all',
115
+ brandFilter: 'all',
116
+ sortBy: 'brand',
117
+ busy: false
118
+ },
113
119
  isDemo: false,
114
120
  runtime: document.body.dataset.runtime === 'web' ? 'web' : 'electron',
121
+ runOptions: { skipDeploy: false, skipVerify: false, saveBeforeRun: true },
122
+ activity: [],
123
+ activityUnavailable: 0,
115
124
  runState: 'idle',
116
125
  runResult: 'idle',
117
126
  logEntries: [],
@@ -141,7 +150,6 @@ const elements = {
141
150
  sidebarVersion: document.querySelector('#sidebarVersion'),
142
151
  sidebarNav: document.querySelector('#sidebarNav'),
143
152
  pageTitle: document.querySelector('#pageTitle'),
144
- pageSubtitle: document.querySelector('#pageSubtitle'),
145
153
  runStateBadge: document.querySelector('#runStateBadge'),
146
154
  pageActions: document.querySelector('#pageActions'),
147
155
  pageContent: document.querySelector('#pageContent'),
@@ -173,6 +181,7 @@ async function bootstrap() {
173
181
  hydrateInitialRuntimeState(loadedState);
174
182
  hydrateHistoricalEvents(loadedState?.logEvents);
175
183
  touchUpdate();
184
+ void hydrateAccountPool();
176
185
  await refreshQrCode();
177
186
  if (!loadedState?.retryArtifacts) {
178
187
  await hydrateRetryArtifacts();
@@ -186,6 +195,8 @@ async function bootstrap() {
186
195
 
187
196
  function hydrateInitialRuntimeState(loadedState = {}) {
188
197
  const loadedProfile = loadedState.profile ?? {};
198
+ state.activity = Array.isArray(loadedState.activity) ? loadedState.activity : [];
199
+ state.activityUnavailable = Number(loadedState.activityUnavailable ?? 0);
189
200
  state.profile = loadedProfile;
190
201
  state.savedProfile = structuredClone(loadedProfile);
191
202
  const nextRunState = String(loadedState.runState ?? '');
@@ -268,8 +279,20 @@ function renderAll() {
268
279
  document.documentElement.lang = state.language;
269
280
  document.title = messages.appTitle;
270
281
  document.body.dataset.page = state.activePage;
282
+ const activeElement = document.activeElement;
283
+ const focusKey = activeElement?.dataset
284
+ ? (activeElement.dataset.accountSearch !== undefined ? 'account-search'
285
+ : activeElement.dataset.drawerSource && activeElement.dataset.drawerKey
286
+ ? `drawer:${activeElement.dataset.drawerSource}:${activeElement.dataset.drawerKey}`
287
+ : '')
288
+ : '';
289
+ const focusSelection = focusKey && 'selectionStart' in activeElement
290
+ ? [activeElement.selectionStart, activeElement.selectionEnd]
291
+ : null;
271
292
 
272
293
  renderChrome(messages, viewModel);
294
+ const qrPanelOpen = document.querySelector('.subscription-qr')?.open === true;
295
+ clearAnalytics();
273
296
  elements.pageContent.innerHTML = buildPageMarkup(
274
297
  state.activePage,
275
298
  viewModel,
@@ -277,6 +300,25 @@ function renderAll() {
277
300
  state.language,
278
301
  state.subtabs
279
302
  );
303
+ // Every full render replaces the tables; reapply layout to the current DOM.
304
+ const qrPanel = document.querySelector('.subscription-qr');
305
+ if (qrPanel) qrPanel.open = qrPanelOpen;
306
+ enhanceTables(elements.pageContent);
307
+ renderAnalytics(document.querySelector('#dashboardAnalytics'), viewModel);
308
+ if (focusKey) {
309
+ const selector = focusKey === 'account-search'
310
+ ? '[data-account-search]'
311
+ : `[data-drawer-source="${CSS.escape(focusKey.split(':')[1])}"][data-drawer-key="${CSS.escape(focusKey.split(':')[2])}"]`;
312
+ const restored = document.querySelector(selector);
313
+ restored?.focus();
314
+ if (restored && focusSelection && focusSelection[0] !== null) {
315
+ try {
316
+ restored.setSelectionRange(focusSelection[0], focusSelection[1]);
317
+ } catch {
318
+ // Number inputs do not expose a text selection.
319
+ }
320
+ }
321
+ }
280
322
  syncRunDetailsPresentation();
281
323
  updateChromeState(messages);
282
324
  renderToast();
@@ -298,16 +340,9 @@ function renderChrome(messages, viewModel) {
298
340
  elements.sidebarTitle.textContent = messages.sidebarTitle;
299
341
  elements.sidebarVersion.textContent = messages.sidebarVersion;
300
342
  elements.pageTitle.textContent = messages.pageTitles[state.activePage];
301
- elements.pageSubtitle.textContent = messages.pageSubtitles[state.activePage];
302
343
  elements.sidebarNav.innerHTML = buildSidebarNav(messages, state.activePage);
303
344
  const controlState = resolveRunControlState(state.runState);
304
- elements.pageActions.innerHTML = buildTopbarActions(state.activePage, viewModel, messages, {
305
- runDisabled: controlState.runDisabled,
306
- stopDisabled: controlState.stopDisabled,
307
- runLabel: resolveRunButtonLabel(messages),
308
- stopLabel: messages.stopButton,
309
- saveLabel: messages.saveButton
310
- });
345
+ elements.pageActions.replaceChildren();
311
346
  updateChromeState(messages);
312
347
  }
313
348
 
@@ -318,7 +353,10 @@ function updateChromeState(messages = getMessages(state.language)) {
318
353
  for (const button of document.querySelectorAll('[data-run-action="start"]')) {
319
354
  button.disabled = controlState.runDisabled;
320
355
  button.setAttribute('aria-busy', String(controlState.isBusy));
321
- button.textContent = resolveRunButtonLabel(messages);
356
+ // Preserve the action icon when live pipeline status changes its label.
357
+ button.innerHTML = icon('play');
358
+ button.setAttribute('aria-label', resolveRunButtonLabel(messages));
359
+ button.title = resolveRunButtonLabel(messages);
322
360
  }
323
361
  for (const button of document.querySelectorAll('[data-run-action="stop"]')) {
324
362
  button.disabled = controlState.stopDisabled;
@@ -356,6 +394,16 @@ function runTone() {
356
394
  }
357
395
 
358
396
  async function handleDocumentClick(event) {
397
+ if (event.target.closest('#sidebarToggle')) {
398
+ toggleSidebar();
399
+ return;
400
+ }
401
+ const themeChoice = event.target.closest('[data-theme-choice]');
402
+ if (themeChoice) {
403
+ state.themePreference = themeChoice.dataset.themeChoice;
404
+ selectTheme(state.themePreference);
405
+ return;
406
+ }
359
407
  const jumpLatest = event.target.closest('[data-log-jump-latest]');
360
408
  if (jumpLatest) {
361
409
  state.logView.follow = true;
@@ -390,14 +438,6 @@ async function handleDocumentClick(event) {
390
438
  return;
391
439
  }
392
440
 
393
- const formatButton = event.target.closest('[data-subscription-format]');
394
- if (formatButton) {
395
- state.subscriptionFormat = formatButton.dataset.subscriptionFormat;
396
- refreshQrCode();
397
- renderAll();
398
- return;
399
- }
400
-
401
441
  const logFilterButton = event.target.closest('[data-log-filter]');
402
442
  if (logFilterButton) {
403
443
  state.logFilter = logFilterButton.dataset.logFilter;
@@ -486,6 +526,12 @@ async function handleDocumentClick(event) {
486
526
  return;
487
527
  }
488
528
 
529
+ const accountAction = event.target.closest('[data-account-action]');
530
+ if (accountAction) {
531
+ handleAccountPoolAction(accountAction);
532
+ return;
533
+ }
534
+
489
535
  const availabilityAction = event.target.closest('[data-availability-action]');
490
536
  if (availabilityAction) {
491
537
  handleAvailabilityTargetAction(availabilityAction);
@@ -508,6 +554,10 @@ async function handleDocumentClick(event) {
508
554
  }
509
555
 
510
556
  function handleDocumentInput(event) {
557
+ if (event.target.dataset.runOption) {
558
+ state.runOptions[event.target.dataset.runOption] = event.target.checked;
559
+ return;
560
+ }
511
561
  const target = event.target;
512
562
 
513
563
  if (!state.profile) {
@@ -530,6 +580,39 @@ function handleDocumentInput(event) {
530
580
  return;
531
581
  }
532
582
 
583
+ if (target.matches('[data-account-file]')) {
584
+ handleAccountFileSelected(target);
585
+ return;
586
+ }
587
+
588
+ if (target.matches('[data-account-search]')) {
589
+ state.accountPool.query = target.value;
590
+ touchUpdate();
591
+ renderAccountPoolRecords();
592
+ return;
593
+ }
594
+
595
+ if (target.matches('[data-account-status-filter]')) {
596
+ state.accountPool.statusFilter = target.value;
597
+ touchUpdate();
598
+ renderAccountPoolRecords();
599
+ return;
600
+ }
601
+
602
+ if (target.matches('[data-account-brand-filter]')) {
603
+ state.accountPool.brandFilter = target.value;
604
+ touchUpdate();
605
+ renderAccountPoolRecords();
606
+ return;
607
+ }
608
+
609
+ if (target.matches('[data-account-sort]')) {
610
+ state.accountPool.sortBy = target.value;
611
+ touchUpdate();
612
+ renderAccountPoolRecords();
613
+ return;
614
+ }
615
+
533
616
  if (target.matches('[data-drawer-source][data-drawer-key]')) {
534
617
  if (!state.settingsDrawer?.draft) {
535
618
  return;
@@ -540,17 +623,23 @@ function handleDocumentInput(event) {
540
623
  if (!sourceDraft?.[sourceName]) {
541
624
  return;
542
625
  }
626
+ // Only the official site is free text now. `enabled` is a boolean and the
627
+ // round fields are numbers, so nothing here needs the URL normaliser.
543
628
  const rawValue = target.type === 'checkbox' ? target.checked : target.value.trim();
544
- const normalizedValue = key === 'url' && typeof rawValue === 'string'
545
- ? normalizeSourceUrlInput(rawValue)
546
- : rawValue;
547
- if (target.type !== 'checkbox' && normalizedValue !== target.value) {
548
- target.value = normalizedValue;
549
- }
550
629
  sourceDraft[sourceName][key] =
551
630
  target.type === 'checkbox'
552
- ? normalizedValue
553
- : coerceProfileValue(normalizedValue, sourceDraft[sourceName][key]);
631
+ ? rawValue
632
+ : coerceProfileValue(rawValue, sourceDraft[sourceName][key]);
633
+ // A source that never set a round has no existing number to copy the type
634
+ // from, and `coerceProfileValue` would leave the field a string — which then
635
+ // lands in profile.toml quoted. Round controls are always numbers.
636
+ if (SOURCE_NUMERIC_KEYS.has(key)) {
637
+ const parsed = Number(sourceDraft[sourceName][key]);
638
+ if (Number.isFinite(parsed)) {
639
+ const floor = SOURCE_MINIMUM_ONE_KEYS.has(key) ? 1 : 0;
640
+ sourceDraft[sourceName][key] = Math.max(floor, Math.trunc(parsed));
641
+ }
642
+ }
554
643
  return;
555
644
  }
556
645
 
@@ -670,14 +759,6 @@ function resolveDrawerSourceDraft() {
670
759
  return state.settingsDrawer.draft?.sources ?? state.settingsDrawer.draft;
671
760
  }
672
761
 
673
- function normalizeSourceUrlInput(value) {
674
- const text = String(value ?? '').trim();
675
- if (!/^curl(?:\s|$)/i.test(text)) {
676
- return text;
677
- }
678
- return extractSourceUrlFromCurl(text) || text;
679
- }
680
-
681
762
  function setObjectPath(root, path, value) {
682
763
  const segments = String(path ?? '').split('.').filter(Boolean);
683
764
  if (!segments.length || !root) {
@@ -704,6 +785,23 @@ function coerceProfileValue(value, currentValue) {
704
785
  return value;
705
786
  }
706
787
 
788
+ /** Source fields that are always numbers, whatever the draft already held. */
789
+ const SOURCE_NUMERIC_KEYS = new Set([
790
+ 'max_iterations',
791
+ 'min_iterations',
792
+ 'plateau_limit',
793
+ 'max_runtime_seconds',
794
+ 'max_areas',
795
+ 'area_min',
796
+ 'area_max'
797
+ ]);
798
+
799
+ /** Of those, the ones the runner clamps to at least one. */
800
+ const SOURCE_MINIMUM_ONE_KEYS = new Set([
801
+ 'max_iterations',
802
+ 'plateau_limit'
803
+ ]);
804
+
707
805
  function resolveErrorMessage(error) {
708
806
  if (error instanceof Error && error.message) {
709
807
  return error.message;
@@ -865,11 +963,7 @@ function resolveActiveSubscriptionUrl() {
865
963
  return '';
866
964
  }
867
965
 
868
- const format = state.subscriptionFormat ?? 'Clash';
869
- if (format === 'Clash') {
870
- return baseUrl;
871
- }
872
- return `${baseUrl}?format=${encodeURIComponent(format.toLowerCase().replaceAll(' ', '-'))}`;
966
+ return baseUrl;
873
967
  }
874
968
 
875
969
  function openSettingsDrawer(section, opener = document.activeElement) {
@@ -937,7 +1031,7 @@ function buildSettingsDraft(section) {
937
1031
  }
938
1032
 
939
1033
  if (section === 'sources') return buildSourceIterationDraft(state.profile.sources);
940
- if (section === 'speed_test') return structuredClone(state.profile.speed_test);
1034
+ if (section === 'speed_test') return buildSpeedTestDraft(state.profile.speed_test);
941
1035
  if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
942
1036
  if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
943
1037
  if (section === 'paths') return structuredClone(state.profile.paths);
@@ -960,6 +1054,13 @@ async function saveSettingsDrawer() {
960
1054
  }
961
1055
 
962
1056
  const { section, draft } = state.settingsDrawer;
1057
+ if (section === 'sources') {
1058
+ const problem = validateSourceDraft(draft);
1059
+ if (problem) {
1060
+ showToast({ tone: 'danger', message: problem, durationMs: 4200 });
1061
+ return;
1062
+ }
1063
+ }
963
1064
  if (section !== 'about') {
964
1065
  state.profile[section] = resolveSettingsDraftPayload(section, draft);
965
1066
  }
@@ -985,6 +1086,182 @@ async function saveSettingsDrawer() {
985
1086
  closeSettingsDrawer({ restoreFocus: true });
986
1087
  }
987
1088
 
1089
+ async function hydrateAccountPool() {
1090
+ state.accountPool.status = 'loading';
1091
+ touchUpdate();
1092
+ renderAccountPoolPanelOnly();
1093
+ if (!window.vpnAutomation?.loadAccounts) {
1094
+ state.accountPool.status = 'unavailable';
1095
+ state.accountPool.error = '未连接账号服务';
1096
+ renderAccountPoolPanelOnly();
1097
+ return false;
1098
+ }
1099
+ try {
1100
+ const snapshot = await window.vpnAutomation.loadAccounts();
1101
+ if (!snapshot?.ok) {
1102
+ throw new Error(snapshot?.error || '账号池读取失败');
1103
+ }
1104
+ state.accountPool.status = 'ready';
1105
+ state.accountPool.stats = snapshot.stats ?? null;
1106
+ state.accountPool.accounts = Array.isArray(snapshot.accounts) ? snapshot.accounts : [];
1107
+ state.accountPool.error = '';
1108
+ } catch (error) {
1109
+ state.accountPool.status = 'error';
1110
+ state.accountPool.error = resolveErrorMessage(error);
1111
+ }
1112
+ touchUpdate();
1113
+ renderAccountPoolPanelOnly();
1114
+ return state.accountPool.status === 'ready';
1115
+ }
1116
+
1117
+ function renderAccountPoolPanelOnly() {
1118
+ // A full re-render, not a patched subtree: the panel shares the page with the
1119
+ // settings drawer, and replacing only the panel left the open drawer's draft
1120
+ // bound to detached nodes.
1121
+ renderAll();
1122
+ }
1123
+
1124
+ function renderAccountPoolRecords() {
1125
+ const holder = document.querySelector('#accountPoolRecords');
1126
+ if (!holder) return;
1127
+ const messages = getMessages(state.language);
1128
+ const viewModel = buildViewModel(state, messages, state.language);
1129
+ holder.innerHTML = buildAccountPoolRecords(viewModel.accountPool);
1130
+ enhanceTables(holder);
1131
+ }
1132
+
1133
+ async function handleAccountPoolAction(button) {
1134
+ const action = button.dataset.accountAction;
1135
+ if (action === 'refresh') {
1136
+ const ok = await hydrateAccountPool();
1137
+ showToast(
1138
+ ok
1139
+ ? { tone: 'success', message: '账号池已刷新' }
1140
+ : { tone: 'danger', message: state.accountPool.error || '账号池刷新失败' }
1141
+ );
1142
+ return;
1143
+ }
1144
+ if (action === 'upload') {
1145
+ document.querySelector('#accountPoolFile')?.click();
1146
+ return;
1147
+ }
1148
+ if (action === 'template') {
1149
+ await downloadAccountsTemplate();
1150
+ return;
1151
+ }
1152
+ if (action === 'toggle') {
1153
+ await toggleAccountStatus(Number(button.dataset.accountId));
1154
+ }
1155
+ }
1156
+
1157
+ async function downloadAccountsTemplate() {
1158
+ try {
1159
+ let content = '';
1160
+ if (window.vpnAutomation?.accountsTemplate) {
1161
+ const result = await window.vpnAutomation.accountsTemplate();
1162
+ content = String(result?.content ?? '');
1163
+ }
1164
+ if (!content) {
1165
+ throw new Error('模板不可用');
1166
+ }
1167
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8' });
1168
+ const url = URL.createObjectURL(blob);
1169
+ const anchor = document.createElement('a');
1170
+ anchor.href = url;
1171
+ anchor.download = 'accounts-template.csv';
1172
+ anchor.click();
1173
+ URL.revokeObjectURL(url);
1174
+ showToast({ tone: 'success', message: '模板已下载' });
1175
+ } catch (error) {
1176
+ showToast({ tone: 'danger', message: resolveErrorMessage(error) });
1177
+ }
1178
+ }
1179
+
1180
+ async function handleAccountFileSelected(input) {
1181
+ const file = input.files?.[0];
1182
+ if (!file) return;
1183
+ input.value = '';
1184
+ if (file.size > 1024 * 1024) {
1185
+ showToast({ tone: 'danger', message: '文件超过 1MB,请拆分后上传' });
1186
+ return;
1187
+ }
1188
+ state.accountPool.busy = true;
1189
+ state.accountPool.message = '';
1190
+ state.accountPool.issues = [];
1191
+ renderAccountPoolPanelOnly();
1192
+ // Tone of the closing toast follows what actually happened. A rejected import
1193
+ // must not end green just because the reload after it succeeded.
1194
+ let importOk = false;
1195
+ try {
1196
+ const content = await file.text();
1197
+ const result = await window.vpnAutomation.importAccounts(content, 'auto');
1198
+ state.accountPool.issues = Array.isArray(result?.issues) ? result.issues : [];
1199
+ if (!result?.ok) {
1200
+ state.accountPool.message = result?.error === 'no_usable_rows'
1201
+ ? '文件里没有可导入的账号'
1202
+ : (result?.error || '导入失败');
1203
+ showToast({ tone: 'danger', message: state.accountPool.message });
1204
+ } else {
1205
+ importOk = true;
1206
+ const summary = `导入完成:新增 ${result.inserted ?? 0},更新 ${result.updated ?? 0},改密 ${result.rotated ?? 0},恢复 ${result.revived ?? 0},未变 ${result.unchanged ?? 0}`;
1207
+ state.accountPool.message = state.accountPool.issues.length ? `${summary};${state.accountPool.issues.length} 行有问题` : summary;
1208
+ showToast({ tone: state.accountPool.issues.length ? 'warning' : 'success', message: summary });
1209
+ appendLog(`[账号池] ${summary}`);
1210
+ }
1211
+ } catch (error) {
1212
+ // The served web UI answers a rejected import with HTTP 400 and the parsed
1213
+ // issues; `web-adapter.js` carries that payload on the error so the line
1214
+ // numbers still reach the panel instead of a bare `request_failed`.
1215
+ const payload = error?.payload;
1216
+ if (payload && typeof payload === 'object') {
1217
+ state.accountPool.issues = Array.isArray(payload.issues) ? payload.issues : [];
1218
+ state.accountPool.message = payload.error === 'no_usable_rows'
1219
+ ? '文件里没有可导入的账号'
1220
+ : (payload.error || '导入失败');
1221
+ } else {
1222
+ state.accountPool.message = resolveErrorMessage(error);
1223
+ }
1224
+ showToast({ tone: 'danger', message: state.accountPool.message });
1225
+ } finally {
1226
+ state.accountPool.busy = false;
1227
+ }
1228
+ const reloaded = await hydrateAccountPool();
1229
+ if (!importOk) {
1230
+ // The panel now carries the failure reason, so a second toast would only
1231
+ // stack noise on top of the danger toast that already fired.
1232
+ return;
1233
+ }
1234
+ showToast({
1235
+ tone: state.accountPool.issues.length ? 'warning' : 'success',
1236
+ message: state.accountPool.message || (reloaded ? '账号池已更新' : state.accountPool.error)
1237
+ });
1238
+ }
1239
+
1240
+ async function toggleAccountStatus(id) {
1241
+ const account = (state.accountPool.accounts ?? []).find((item) => item.id === id);
1242
+ if (!account || !window.vpnAutomation?.setAccountStatus) {
1243
+ return;
1244
+ }
1245
+ const nextStatus = account.status === 'active' ? 'disabled' : 'active';
1246
+ state.accountPool.busy = true;
1247
+ renderAccountPoolPanelOnly();
1248
+ try {
1249
+ const result = await window.vpnAutomation.setAccountStatus({ id, status: nextStatus, reason: '停用(界面操作)' });
1250
+ if (!result?.ok) {
1251
+ throw new Error(result?.error || '操作失败');
1252
+ }
1253
+ showToast({
1254
+ tone: nextStatus === 'active' ? 'success' : 'neutral',
1255
+ message: `${account.account_masked} 已${nextStatus === 'active' ? '启用' : '停用'}`
1256
+ });
1257
+ } catch (error) {
1258
+ showToast({ tone: 'danger', message: resolveErrorMessage(error) });
1259
+ } finally {
1260
+ state.accountPool.busy = false;
1261
+ }
1262
+ await hydrateAccountPool();
1263
+ }
1264
+
988
1265
  async function saveProfile({ silent = false } = {}) {
989
1266
  if (!state.profile) {
990
1267
  return;
@@ -1078,6 +1355,7 @@ async function runPipeline() {
1078
1355
  state.stageStatus = {};
1079
1356
  state.counts = {};
1080
1357
  state.sourceCounts = {};
1358
+ state.extractProgress = {};
1081
1359
  state.extractDedupedFingerprints = new Set();
1082
1360
  state.logEntries = [];
1083
1361
  state.artifactDir = '';
@@ -1128,6 +1406,7 @@ async function retryStage() {
1128
1406
  state.stageStatus = {};
1129
1407
  state.counts = {};
1130
1408
  state.sourceCounts = {};
1409
+ state.extractProgress = {};
1131
1410
  state.extractDedupedFingerprints = new Set();
1132
1411
  state.logEntries = [];
1133
1412
  state.artifactDir = '';
@@ -1174,11 +1453,7 @@ async function retryStage() {
1174
1453
 
1175
1454
  function collectRunOptions() {
1176
1455
  const optionInputs = document.querySelectorAll('[data-run-option]');
1177
- const options = {
1178
- skipDeploy: false,
1179
- skipVerify: false,
1180
- saveBeforeRun: true
1181
- };
1456
+ const options = { ...state.runOptions };
1182
1457
  for (const input of optionInputs) {
1183
1458
  options[input.dataset.runOption] = Boolean(input.checked);
1184
1459
  }
@@ -1315,6 +1590,16 @@ function handlePipelineEvent(event, options = {}) {
1315
1590
  return;
1316
1591
  }
1317
1592
 
1593
+ if (event.type === 'extract_substage') {
1594
+ if (!['apk_download', 'apk_analysis', 'domain_discovery'].includes(event.substage)
1595
+ || !['running', 'success', 'failed', 'cached'].includes(event.status)) return;
1596
+ const key = [event.source_name, event.platform, event.substage].join(':');
1597
+ state.extractProgress[key] = { source: String(event.source_name ?? ''), platform: String(event.platform ?? ''), substage: event.substage, status: event.status };
1598
+ touchUpdate();
1599
+ renderRuntimeOnly({ chrome: false });
1600
+ return;
1601
+ }
1602
+
1318
1603
  if (event.type === 'stage') {
1319
1604
  state.stageStatus[event.stage] = normalizeStageStatus(event.status);
1320
1605
  appendLog(`[stage] ${event.stage} ${event.status}`, {
@@ -1488,6 +1773,7 @@ function resetRunScopedProgress() {
1488
1773
  state.stageStatus = {};
1489
1774
  state.counts = {};
1490
1775
  state.sourceCounts = {};
1776
+ state.extractProgress = {};
1491
1777
  state.extractDedupedFingerprints = new Set();
1492
1778
  state.outputFiles = [];
1493
1779
  state.nodeRows = [];
@@ -1645,7 +1931,12 @@ async function hydrateRetryArtifacts() {
1645
1931
  return;
1646
1932
  }
1647
1933
  hydrateRetryArtifactState(result.items ?? []);
1934
+ state.activity = Array.isArray(result.activity) ? result.activity : [];
1935
+ state.activityUnavailable = Number(result.activityUnavailable ?? 0);
1648
1936
  touchUpdate();
1937
+ // Completion refreshes history asynchronously, after the last pipeline
1938
+ // event. Update the visible calendar without requiring a page switch.
1939
+ renderRuntimeOnly({ chrome: false });
1649
1940
  } catch (error) {
1650
1941
  appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
1651
1942
  }
@@ -1703,13 +1994,8 @@ function renderActiveRuntimeSections(viewModel) {
1703
1994
  const anchorViewportTop = anchor?.getBoundingClientRect().top;
1704
1995
  logCenter.innerHTML = buildLogCenterMarkup(viewModel);
1705
1996
  const workspace = logCenter.closest('#logsWorkspace');
1706
- workspace?.querySelectorAll('[data-log-jump-latest], [data-log-undo-clear]').forEach((node) => node.remove());
1707
- if (!state.logView.follow && state.logView.unseenCount > 0) {
1708
- logCenter.insertAdjacentHTML('beforebegin', `<button class="btn btn-primary small log-jump-latest" data-log-jump-latest type="button">回到底部 · ${state.logView.unseenCount} 条新消息</button>`);
1709
- }
1710
- if (state.logView.clearedSnapshot) {
1711
- logCenter.insertAdjacentHTML('beforebegin', '<button class="btn btn-secondary small log-undo-clear" data-log-undo-clear type="button">撤销清空</button>');
1712
- }
1997
+ const stateActions = workspace?.querySelector('.log-state-actions');
1998
+ if (stateActions) stateActions.innerHTML = buildLogStateActions(viewModel);
1713
1999
  if (state.logView.follow) {
1714
2000
  logCenter.scrollTop = logCenter.scrollHeight;
1715
2001
  } else if (anchorId) {
@@ -1722,14 +2008,33 @@ function renderActiveRuntimeSections(viewModel) {
1722
2008
 
1723
2009
  const stageProgress = document.querySelector('#runsStageProgress');
1724
2010
  if (stageProgress) {
2011
+ const scrollTop = stageProgress.querySelector('.timeline')?.scrollTop ?? 0;
1725
2012
  stageProgress.outerHTML = buildRunsStageProgressMarkup(viewModel);
2013
+ document.querySelector('#runsStageProgress .timeline').scrollTop = scrollTop;
1726
2014
  }
1727
2015
 
1728
2016
  const currentStage = document.querySelector('#runsCurrentStage');
1729
2017
  if (currentStage) {
1730
- currentStage.outerHTML = buildRunsCurrentStageMarkup(viewModel);
2018
+ // Preserve the focused run controls while live events refresh details.
2019
+ const template = document.createElement('template');
2020
+ template.innerHTML = buildRunsCurrentStageMarkup(viewModel);
2021
+ currentStage.querySelector('[data-current-stage-body]').replaceWith(template.content.querySelector('[data-current-stage-body]'));
2022
+ currentStage.querySelector('.panel-headline > .badge').replaceWith(template.content.querySelector('.panel-headline > .badge'));
2023
+ }
2024
+
2025
+ const hero = document.querySelector('.status-hero-card');
2026
+ if (hero) {
2027
+ const summary = resolveDashboardRunSummary(viewModel);
2028
+ const copy = hero.querySelector('.dashboard-state-copy');
2029
+ if (copy.querySelector('h3')?.textContent !== viewModel.currentStatusLabel
2030
+ || Boolean(copy.querySelector('p')) !== summary.failed) {
2031
+ copy.innerHTML = buildDashboardStateMarkup(viewModel);
2032
+ }
2033
+ hero.classList.toggle('is-running', summary.isRunning);
2034
+ hero.dataset.tone = viewModel.runStateTone;
1731
2035
  }
1732
2036
 
2037
+ renderAnalytics(document.querySelector('#dashboardAnalytics'), viewModel);
1733
2038
  const dashboardMetrics = document.querySelector('#dashboardMetricsPanel');
1734
2039
  if (dashboardMetrics) {
1735
2040
  dashboardMetrics.outerHTML = buildDashboardMetricsMarkup(viewModel);