@swimmingliu/autovpn 1.6.9 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { getMessages, formatMessage } from './i18n.js';
2
- import { resolveRunControlState } from './state.js';
2
+ import { createLogViewState, createQrState, resolveRunControlState } from './state.js';
3
3
  import {
4
4
  addAvailabilityTargetDraft,
5
5
  applyAvailabilityTargetDraft,
@@ -115,6 +115,7 @@ const state = {
115
115
  runState: 'idle',
116
116
  runResult: 'idle',
117
117
  logEntries: [],
118
+ logView: createLogViewState(),
118
119
  extractDedupedFingerprints: new Set(),
119
120
  artifactDir: '',
120
121
  retryArtifacts: [],
@@ -125,12 +126,16 @@ const state = {
125
126
  outputFiles: [],
126
127
  nodeRows: [],
127
128
  toast: null,
128
- qrDataUrl: '',
129
+ qr: createQrState(),
129
130
  runStartedAt: null,
130
131
  lastUpdateAt: null,
131
132
  modalTransform: ''
132
133
  };
133
134
 
135
+ let settingsDrawerOpener = null;
136
+ let runDetailsMobileBreakpoint = null;
137
+ let nextLogSequence = 0;
138
+
134
139
  const elements = {
135
140
  sidebarTitle: document.querySelector('#sidebarTitle'),
136
141
  sidebarVersion: document.querySelector('#sidebarVersion'),
@@ -144,6 +149,7 @@ const elements = {
144
149
  };
145
150
 
146
151
  let toastTimer = null;
152
+ let qrRequestId = 0;
147
153
 
148
154
  async function bootstrap() {
149
155
  state.language = 'zh-CN';
@@ -216,6 +222,9 @@ function bindActions() {
216
222
  document.addEventListener('click', handleDocumentClick);
217
223
  document.addEventListener('input', handleDocumentInput);
218
224
  document.addEventListener('change', handleDocumentInput);
225
+ document.addEventListener('keydown', handleSettingsDrawerKeydown);
226
+ document.addEventListener('scroll', handleLogScroll, true);
227
+ window.addEventListener('resize', syncRunDetailsPresentation);
219
228
 
220
229
  document.addEventListener('mousedown', (e) => {
221
230
  const header = e.target.closest('.settings-drawer-head');
@@ -268,18 +277,30 @@ function renderAll() {
268
277
  state.language,
269
278
  state.subtabs
270
279
  );
280
+ syncRunDetailsPresentation();
281
+ updateChromeState(messages);
271
282
  renderToast();
272
283
  }
273
284
 
285
+ function syncRunDetailsPresentation() {
286
+ const details = document.querySelector('details.run-secondary-controls');
287
+ if (!details) return;
288
+ const mobile = window.matchMedia('(max-width: 720px)').matches;
289
+ const isNewDetails = details.dataset.breakpointInitialized !== 'true';
290
+ if (isNewDetails || mobile !== runDetailsMobileBreakpoint) {
291
+ details.open = !mobile;
292
+ details.dataset.breakpointInitialized = 'true';
293
+ }
294
+ runDetailsMobileBreakpoint = mobile;
295
+ }
296
+
274
297
  function renderChrome(messages, viewModel) {
275
- const controlState = resolveRunControlState(state.runState);
276
298
  elements.sidebarTitle.textContent = messages.sidebarTitle;
277
299
  elements.sidebarVersion.textContent = messages.sidebarVersion;
278
300
  elements.pageTitle.textContent = messages.pageTitles[state.activePage];
279
301
  elements.pageSubtitle.textContent = messages.pageSubtitles[state.activePage];
280
- elements.runStateBadge.textContent = messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle;
281
- elements.runStateBadge.className = `badge ${runTone()}`;
282
302
  elements.sidebarNav.innerHTML = buildSidebarNav(messages, state.activePage);
303
+ const controlState = resolveRunControlState(state.runState);
283
304
  elements.pageActions.innerHTML = buildTopbarActions(state.activePage, viewModel, messages, {
284
305
  runDisabled: controlState.runDisabled,
285
306
  stopDisabled: controlState.stopDisabled,
@@ -287,6 +308,25 @@ function renderChrome(messages, viewModel) {
287
308
  stopLabel: messages.stopButton,
288
309
  saveLabel: messages.saveButton
289
310
  });
311
+ updateChromeState(messages);
312
+ }
313
+
314
+ function updateChromeState(messages = getMessages(state.language)) {
315
+ const controlState = resolveRunControlState(state.runState);
316
+ elements.runStateBadge.textContent = messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle;
317
+ elements.runStateBadge.className = `badge ${runTone()}`;
318
+ for (const button of document.querySelectorAll('[data-run-action="start"]')) {
319
+ button.disabled = controlState.runDisabled;
320
+ button.setAttribute('aria-busy', String(controlState.isBusy));
321
+ button.textContent = resolveRunButtonLabel(messages);
322
+ }
323
+ for (const button of document.querySelectorAll('[data-run-action="stop"]')) {
324
+ button.disabled = controlState.stopDisabled;
325
+ button.setAttribute('aria-busy', String(state.runState === 'stopping'));
326
+ }
327
+ for (const button of document.querySelectorAll('[data-action="retry-stage"]')) {
328
+ button.disabled = controlState.isBusy || !state.selectedRetryArtifactDir || !state.selectedRetryStage;
329
+ }
290
330
  }
291
331
 
292
332
  function resolveRunButtonLabel(messages) {
@@ -316,6 +356,26 @@ function runTone() {
316
356
  }
317
357
 
318
358
  async function handleDocumentClick(event) {
359
+ const jumpLatest = event.target.closest('[data-log-jump-latest]');
360
+ if (jumpLatest) {
361
+ state.logView.follow = true;
362
+ state.logView.unseenCount = 0;
363
+ renderAll();
364
+ scrollLogToLatest();
365
+ return;
366
+ }
367
+
368
+ const undoClear = event.target.closest('[data-log-undo-clear]');
369
+ if (undoClear && state.logView.clearedSnapshot) {
370
+ state.logEntries = state.logView.clearedSnapshot;
371
+ state.logView.clearedSnapshot = null;
372
+ state.logView.follow = true;
373
+ state.logView.unseenCount = 0;
374
+ state.toast = null;
375
+ renderAll();
376
+ scrollLogToLatest();
377
+ return;
378
+ }
319
379
  const navButton = event.target.closest('[data-page-target]');
320
380
  if (navButton) {
321
381
  state.activePage = navButton.dataset.pageTarget;
@@ -347,7 +407,7 @@ async function handleDocumentClick(event) {
347
407
 
348
408
  const copyButton = event.target.closest('[data-copy-text]');
349
409
  if (copyButton) {
350
- copyText(copyButton.dataset.copyText);
410
+ copyText(copyButton.dataset.copyText, { control: copyButton, successLog: '已复制订阅链接' });
351
411
  return;
352
412
  }
353
413
 
@@ -389,10 +449,15 @@ async function handleDocumentClick(event) {
389
449
  successToast: formatMessage(messages.copiedNodesToastMessage, { count: nodeLinks.length }),
390
450
  successLog: formatMessage(messages.copiedNodesLogMessage, { count: nodeLinks.length }),
391
451
  failureToast: formatMessage(messages.copyFailedToastMessage, { error: '{error}' }),
392
- failureLog: formatMessage(messages.copyFailedLogMessage, { error: '{error}' })
452
+ failureLog: formatMessage(messages.copyFailedLogMessage, { error: '{error}' }),
453
+ control: action
393
454
  });
394
455
  return;
395
456
  }
457
+ if (action?.dataset.action === 'retry-qr') {
458
+ refreshQrCode();
459
+ return;
460
+ }
396
461
  if (action?.dataset.action === 'retry-stage') {
397
462
  retryStage();
398
463
  return;
@@ -402,8 +467,12 @@ async function handleDocumentClick(event) {
402
467
  return;
403
468
  }
404
469
  if (action?.dataset.action === 'clear-log') {
470
+ state.logView.clearedSnapshot = [...state.logEntries];
405
471
  state.logEntries = [];
472
+ state.logView.follow = true;
473
+ state.logView.unseenCount = 0;
406
474
  renderAll();
475
+ showToast({ tone: 'neutral', message: '日志已清空', durationMs: 5000, action: 'undo-clear' });
407
476
  return;
408
477
  }
409
478
  if (action?.dataset.action === 'open-log-file') {
@@ -413,7 +482,7 @@ async function handleDocumentClick(event) {
413
482
 
414
483
  const settingsCard = event.target.closest('[data-settings-card]');
415
484
  if (settingsCard) {
416
- openSettingsDrawer(settingsCard.dataset.settingsCard);
485
+ openSettingsDrawer(settingsCard.dataset.settingsCard, settingsCard);
417
486
  return;
418
487
  }
419
488
 
@@ -424,16 +493,12 @@ async function handleDocumentClick(event) {
424
493
  }
425
494
 
426
495
  if (event.target.closest('[data-drawer-dismiss="backdrop"]')) {
427
- state.settingsDrawer = null;
428
- state.modalTransform = '';
429
- renderAll();
496
+ closeSettingsDrawer({ restoreFocus: true });
430
497
  return;
431
498
  }
432
499
 
433
500
  if (event.target.closest('[data-drawer-close="cancel"]')) {
434
- state.settingsDrawer = null;
435
- state.modalTransform = '';
436
- renderAll();
501
+ closeSettingsDrawer({ restoreFocus: true });
437
502
  return;
438
503
  }
439
504
 
@@ -661,16 +726,20 @@ async function writeClipboardText(text) {
661
726
  throw new Error('clipboard_unavailable');
662
727
  }
663
728
 
664
- function showToast({ tone = 'neutral', message, durationMs = 2400 }) {
729
+ function showToast({ tone = 'neutral', message, durationMs = 2400, action = null }) {
665
730
  if (!message) {
666
731
  return;
667
732
  }
668
- state.toast = { tone, message };
733
+ state.toast = { tone, message, action };
669
734
  renderToast();
670
735
  if (toastTimer) {
671
736
  clearTimeout(toastTimer);
672
737
  }
673
738
  toastTimer = window.setTimeout(() => {
739
+ if (state.toast?.action === 'undo-clear') {
740
+ state.logView.clearedSnapshot = null;
741
+ renderActiveRuntimeSections(buildViewModel(state, getMessages(state.language), state.language));
742
+ }
674
743
  state.toast = null;
675
744
  renderToast();
676
745
  toastTimer = null;
@@ -688,6 +757,7 @@ function renderToast() {
688
757
  elements.toastRoot.innerHTML = `
689
758
  <div class="toast ${escapeHtml(state.toast.tone || 'neutral')}" data-toast data-toast-tone="${escapeHtml(state.toast.tone || 'neutral')}">
690
759
  ${escapeHtml(state.toast.message)}
760
+ ${state.toast.action === 'undo-clear' ? '<button class="btn small" data-log-undo-clear type="button">撤销</button>' : ''}
691
761
  </div>
692
762
  `;
693
763
  }
@@ -700,19 +770,27 @@ async function copyText(value, options = {}) {
700
770
  return;
701
771
  }
702
772
 
773
+ const control = options.control;
774
+ if (control?.getAttribute('aria-busy') === 'true') return;
775
+ if (control) {
776
+ control.disabled = true;
777
+ control.setAttribute('aria-busy', 'true');
778
+ }
703
779
  try {
704
780
  await writeClipboardText(text);
705
781
  showToast({ tone: 'success', message: options.successToast ?? messages.copiedToastMessage });
706
- appendLog(options.successLog ?? formatMessage(messages.copiedMessage, { value: text }));
782
+ appendLog(options.successLog ?? '已复制内容');
707
783
  } catch (error) {
708
- const detail = resolveErrorMessage(error);
709
784
  showToast({
710
785
  tone: 'danger',
711
- message: (options.failureToast ?? formatMessage(messages.copyFailedToastMessage, { error: detail })).replace('{error}', detail)
786
+ message: options.safeFailureToast ?? '复制失败,请重试'
712
787
  });
713
- appendLog(
714
- (options.failureLog ?? formatMessage(messages.copyFailedLogMessage, { error: detail })).replace('{error}', detail)
715
- );
788
+ appendLog(options.safeFailureLog ?? '复制操作失败');
789
+ } finally {
790
+ if (control?.isConnected) {
791
+ control.disabled = false;
792
+ control.setAttribute('aria-busy', 'false');
793
+ }
716
794
  }
717
795
  }
718
796
 
@@ -758,19 +836,27 @@ async function openCurrentLogFile() {
758
836
  }
759
837
 
760
838
  async function refreshQrCode() {
839
+ const requestId = ++qrRequestId;
761
840
  const subscriptionUrl = resolveActiveSubscriptionUrl();
762
841
  if (!subscriptionUrl || !window.vpnAutomation?.generateQr) {
763
- state.qrDataUrl = '';
842
+ state.qr = createQrState('unavailable', '', '当前环境不支持二维码生成');
843
+ renderAll();
764
844
  return;
765
845
  }
766
846
 
847
+ state.qr = createQrState('loading');
848
+ renderAll();
767
849
  try {
768
850
  const result = await window.vpnAutomation.generateQr(subscriptionUrl);
769
- state.qrDataUrl = result?.dataUrl ?? '';
770
- renderAll();
771
- } catch {
772
- state.qrDataUrl = '';
851
+ if (requestId !== qrRequestId || subscriptionUrl !== resolveActiveSubscriptionUrl()) return;
852
+ state.qr = result?.dataUrl
853
+ ? createQrState('success', result.dataUrl)
854
+ : createQrState('unavailable', '', '二维码服务未返回图片');
855
+ } catch (error) {
856
+ if (requestId !== qrRequestId || subscriptionUrl !== resolveActiveSubscriptionUrl()) return;
857
+ state.qr = createQrState('error', '', resolveErrorMessage(error));
773
858
  }
859
+ renderAll();
774
860
  }
775
861
 
776
862
  function resolveActiveSubscriptionUrl() {
@@ -786,7 +872,7 @@ function resolveActiveSubscriptionUrl() {
786
872
  return `${baseUrl}?format=${encodeURIComponent(format.toLowerCase().replaceAll(' ', '-'))}`;
787
873
  }
788
874
 
789
- function openSettingsDrawer(section) {
875
+ function openSettingsDrawer(section, opener = document.activeElement) {
790
876
  if (!state.profile) {
791
877
  return;
792
878
  }
@@ -797,8 +883,52 @@ function openSettingsDrawer(section) {
797
883
  }
798
884
 
799
885
  state.modalTransform = '';
886
+ settingsDrawerOpener = opener?.matches?.('[data-settings-card]')
887
+ ? `[data-settings-card="${CSS.escape(opener.dataset.settingsCard)}"]`
888
+ : opener?.id
889
+ ? `#${CSS.escape(opener.id)}`
890
+ : null;
800
891
  state.settingsDrawer = { section, draft };
801
892
  renderAll();
893
+ queueMicrotask(() => {
894
+ const dialog = document.querySelector('[data-settings-dialog]');
895
+ const focusTarget = dialog?.querySelector('input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled])');
896
+ focusTarget?.focus();
897
+ });
898
+ }
899
+
900
+ function closeSettingsDrawer({ restoreFocus = true } = {}) {
901
+ const openerSelector = settingsDrawerOpener;
902
+ state.settingsDrawer = null;
903
+ state.modalTransform = '';
904
+ settingsDrawerOpener = null;
905
+ renderAll();
906
+ if (restoreFocus && openerSelector) {
907
+ queueMicrotask(() => document.querySelector(openerSelector)?.focus());
908
+ }
909
+ }
910
+
911
+ function handleSettingsDrawerKeydown(event) {
912
+ const dialog = document.querySelector('[data-settings-dialog]');
913
+ if (!dialog) return;
914
+ if (event.key === 'Escape') {
915
+ event.preventDefault();
916
+ closeSettingsDrawer({ restoreFocus: true });
917
+ return;
918
+ }
919
+ if (event.key !== 'Tab') return;
920
+ const focusable = [...dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')]
921
+ .filter((element) => !element.hidden && element.getClientRects().length > 0);
922
+ if (!focusable.length) return;
923
+ const first = focusable[0];
924
+ const last = focusable.at(-1);
925
+ if (event.shiftKey && document.activeElement === first) {
926
+ event.preventDefault();
927
+ last.focus();
928
+ } else if (!event.shiftKey && document.activeElement === last) {
929
+ event.preventDefault();
930
+ first.focus();
931
+ }
802
932
  }
803
933
 
804
934
  function buildSettingsDraft(section) {
@@ -833,8 +963,6 @@ async function saveSettingsDrawer() {
833
963
  if (section !== 'about') {
834
964
  state.profile[section] = resolveSettingsDraftPayload(section, draft);
835
965
  }
836
- state.settingsDrawer = null;
837
- state.modalTransform = '';
838
966
  touchUpdate();
839
967
  if (section === 'deploy') {
840
968
  await refreshQrCode();
@@ -851,9 +979,10 @@ async function saveSettingsDrawer() {
851
979
  `[settings] deploy saved project=${state.profile.deploy.project_name} url=${state.profile.deploy.pages_project_url}`
852
980
  );
853
981
  }
982
+ closeSettingsDrawer({ restoreFocus: true });
854
983
  return;
855
984
  }
856
- renderAll();
985
+ closeSettingsDrawer({ restoreFocus: true });
857
986
  }
858
987
 
859
988
  async function saveProfile({ silent = false } = {}) {
@@ -957,7 +1086,7 @@ async function runPipeline() {
957
1086
  state.selectedRetryStage = '';
958
1087
  state.runStartedAt = Date.now();
959
1088
  touchUpdate();
960
- renderAll();
1089
+ renderRuntimeOnly();
961
1090
  appendLog(messages.pipelineStarted);
962
1091
 
963
1092
  if (runOptions.saveBeforeRun) {
@@ -968,7 +1097,7 @@ async function runPipeline() {
968
1097
  state.runState = 'idle';
969
1098
  state.runResult = 'demo';
970
1099
  touchUpdate();
971
- renderAll();
1100
+ renderRuntimeOnly();
972
1101
  appendLog(formatMessage(messages.pipelineFinished, { code: 'demo' }));
973
1102
  return;
974
1103
  }
@@ -983,7 +1112,7 @@ async function runPipeline() {
983
1112
  state.runResult = 'failed';
984
1113
  state.runStartedAt = null;
985
1114
  touchUpdate();
986
- renderAll();
1115
+ renderRuntimeOnly();
987
1116
  appendLog(formatMessage(messages.pipelineFailed, { error: error.message }));
988
1117
  }
989
1118
  }
@@ -1006,7 +1135,7 @@ async function retryStage() {
1006
1135
  state.nodeRows = [];
1007
1136
  state.runStartedAt = Date.now();
1008
1137
  touchUpdate();
1009
- renderAll();
1138
+ renderRuntimeOnly();
1010
1139
  appendLog(`[retry] artifact=${state.selectedRetryArtifactDir} stage=${state.selectedRetryStage}`);
1011
1140
 
1012
1141
  const runOptions = collectRunOptions();
@@ -1019,7 +1148,7 @@ async function retryStage() {
1019
1148
  state.runResult = 'failed';
1020
1149
  state.runStartedAt = null;
1021
1150
  touchUpdate();
1022
- renderAll();
1151
+ renderRuntimeOnly();
1023
1152
  appendLog(formatMessage(messages.pipelineFailed, { error: 'retry bridge unavailable' }));
1024
1153
  return;
1025
1154
  }
@@ -1038,7 +1167,7 @@ async function retryStage() {
1038
1167
  state.runResult = 'failed';
1039
1168
  state.runStartedAt = null;
1040
1169
  touchUpdate();
1041
- renderAll();
1170
+ renderRuntimeOnly();
1042
1171
  appendLog(formatMessage(messages.pipelineFailed, { error: error.message }));
1043
1172
  }
1044
1173
  }
@@ -1085,7 +1214,7 @@ async function stopPipeline() {
1085
1214
 
1086
1215
  state.runState = 'stopping';
1087
1216
  touchUpdate();
1088
- renderAll();
1217
+ renderRuntimeOnly();
1089
1218
  appendLog(messages.pipelineStopping);
1090
1219
 
1091
1220
  try {
@@ -1101,7 +1230,7 @@ async function stopPipeline() {
1101
1230
  state.runState = 'idle';
1102
1231
  state.runStartedAt = null;
1103
1232
  touchUpdate();
1104
- renderAll();
1233
+ renderRuntimeOnly();
1105
1234
  appendLog(messages.stopUnavailable);
1106
1235
  }
1107
1236
  return;
@@ -1122,7 +1251,7 @@ function finishRun(result = {}) {
1122
1251
  state.runState = 'idle';
1123
1252
  state.runResult = 'stopped';
1124
1253
  touchUpdate();
1125
- renderAll();
1254
+ renderRuntimeOnly();
1126
1255
  appendLog(messages.pipelineStopped);
1127
1256
  void hydrateRetryArtifacts();
1128
1257
  return;
@@ -1132,7 +1261,7 @@ function finishRun(result = {}) {
1132
1261
  state.runState = 'success';
1133
1262
  state.runResult = 'success';
1134
1263
  touchUpdate();
1135
- renderAll();
1264
+ renderRuntimeOnly();
1136
1265
  appendLog(formatMessage(messages.pipelineFinished, { code: result.code ?? 0 }));
1137
1266
  void hydrateRetryArtifacts();
1138
1267
  return;
@@ -1141,7 +1270,7 @@ function finishRun(result = {}) {
1141
1270
  state.runState = 'failed';
1142
1271
  state.runResult = 'failed';
1143
1272
  touchUpdate();
1144
- renderAll();
1273
+ renderRuntimeOnly();
1145
1274
  if (result.error) {
1146
1275
  appendLog(formatMessage(messages.pipelineFailed, { error: result.error }));
1147
1276
  } else {
@@ -1165,7 +1294,7 @@ function handlePipelineEvent(event, options = {}) {
1165
1294
  state.runResult = 'success';
1166
1295
  }
1167
1296
  touchUpdate();
1168
- renderAll();
1297
+ renderRuntimeOnly();
1169
1298
  }
1170
1299
  return;
1171
1300
  }
@@ -1187,7 +1316,7 @@ function handlePipelineEvent(event, options = {}) {
1187
1316
  }
1188
1317
 
1189
1318
  if (event.type === 'stage') {
1190
- state.stageStatus[event.stage] = event.status;
1319
+ state.stageStatus[event.stage] = normalizeStageStatus(event.status);
1191
1320
  appendLog(`[stage] ${event.stage} ${event.status}`, {
1192
1321
  kind: 'stage',
1193
1322
  stage: event.stage,
@@ -1199,7 +1328,7 @@ function handlePipelineEvent(event, options = {}) {
1199
1328
  }
1200
1329
 
1201
1330
  if (event.type === 'summary') {
1202
- state.stageStatus = event.stage_status ?? {};
1331
+ state.stageStatus = normalizeStageStatuses(event.stage_status);
1203
1332
  state.counts = normalizeCounts(event.counts ?? {});
1204
1333
  state.sourceCounts = normalizeSourceCounts(event.source_counts ?? state.sourceCounts);
1205
1334
  state.retryContext = event.retry_context ?? state.retryContext ?? {};
@@ -1332,12 +1461,38 @@ function handlePipelineEvent(event, options = {}) {
1332
1461
  }
1333
1462
 
1334
1463
  if (event.type === 'run_started') {
1335
- state.artifactDir = event.artifact_dir ?? '';
1464
+ const nextArtifactDir = String(event.artifact_dir ?? '');
1465
+ if (nextArtifactDir !== state.artifactDir) {
1466
+ resetRunScopedProgress();
1467
+ state.runState = 'running';
1468
+ state.runResult = 'running';
1469
+ state.runStartedAt = Date.now();
1470
+ }
1471
+ state.artifactDir = nextArtifactDir;
1336
1472
  touchUpdate();
1337
1473
  renderAll();
1338
1474
  }
1339
1475
  }
1340
1476
 
1477
+ function normalizeStageStatus(status) {
1478
+ return status === 'skipped' ? '已跳过' : status;
1479
+ }
1480
+
1481
+ function normalizeStageStatuses(stageStatuses = {}) {
1482
+ return Object.fromEntries(
1483
+ Object.entries(stageStatuses ?? {}).map(([stage, status]) => [stage, normalizeStageStatus(status)])
1484
+ );
1485
+ }
1486
+
1487
+ function resetRunScopedProgress() {
1488
+ state.stageStatus = {};
1489
+ state.counts = {};
1490
+ state.sourceCounts = {};
1491
+ state.extractDedupedFingerprints = new Set();
1492
+ state.outputFiles = [];
1493
+ state.nodeRows = [];
1494
+ }
1495
+
1341
1496
  function normalizeCounts(counts) {
1342
1497
  return {
1343
1498
  ...counts,
@@ -1355,7 +1510,9 @@ function normalizeSourceCounts(sourceCounts = {}) {
1355
1510
  {
1356
1511
  ...counts,
1357
1512
  raw_links: Number(counts?.raw_links ?? 0),
1358
- deduped_links: Number(counts?.deduped_links ?? 0)
1513
+ ...(counts && Object.hasOwn(counts, 'deduped_links')
1514
+ ? { deduped_links: Number(counts.deduped_links) }
1515
+ : {})
1359
1516
  }
1360
1517
  ])
1361
1518
  );
@@ -1368,8 +1525,16 @@ function updateExtractMetrics(event) {
1368
1525
  }
1369
1526
 
1370
1527
  const previous = state.sourceCounts[sourceName] ?? {};
1371
- const rawLinks = Number(event.total_links ?? previous.raw_links ?? 0);
1372
- const sourceDedupedLinks = Number(event.deduped_links ?? previous.deduped_links ?? rawLinks);
1528
+ const previousRawLinks = validNonNegativeNumber(previous.raw_links) ?? 0;
1529
+ const previousDedupedLinks = validNonNegativeNumber(previous.deduped_links) ?? 0;
1530
+ const rawLinks = Math.max(previousRawLinks, validNonNegativeNumber(event.total_links) ?? previousRawLinks);
1531
+ const eventDedupedLinks = event.deduped_links == null
1532
+ ? rawLinks
1533
+ : validNonNegativeNumber(event.deduped_links) ?? previousDedupedLinks;
1534
+ const sourceDedupedLinks = Math.max(
1535
+ previousDedupedLinks,
1536
+ eventDedupedLinks
1537
+ );
1373
1538
  if (Array.isArray(event.new_item_fingerprints)) {
1374
1539
  for (const fingerprint of event.new_item_fingerprints) {
1375
1540
  if (fingerprint) {
@@ -1395,6 +1560,11 @@ function updateExtractMetrics(event) {
1395
1560
  }
1396
1561
  }
1397
1562
 
1563
+ function validNonNegativeNumber(value) {
1564
+ const number = Number(value);
1565
+ return Number.isFinite(number) && number >= 0 ? number : null;
1566
+ }
1567
+
1398
1568
  async function hydrateArtifactPreview() {
1399
1569
  if (!state.artifactDir || !window.vpnAutomation?.previewArtifact) {
1400
1570
  state.outputFiles = [];
@@ -1430,7 +1600,7 @@ function hydrateArtifactState(result) {
1430
1600
  state.runResult = 'failed';
1431
1601
  }
1432
1602
  if (result.stage_status) {
1433
- state.stageStatus = result.stage_status;
1603
+ state.stageStatus = normalizeStageStatuses(result.stage_status);
1434
1604
  }
1435
1605
  }
1436
1606
 
@@ -1491,7 +1661,23 @@ function resolveDefaultRetryStage(artifact) {
1491
1661
  }
1492
1662
 
1493
1663
  function appendLog(message, overrides = {}) {
1494
- state.logEntries.push(classifyLogEntry(message, overrides));
1664
+ const logCenter = document.querySelector('#logCenterTable');
1665
+ if (logCenter) {
1666
+ state.logView.follow = logCenter.scrollHeight - logCenter.scrollTop - logCenter.clientHeight <= 32;
1667
+ if (!state.logView.follow) {
1668
+ const streamTop = logCenter.getBoundingClientRect().top;
1669
+ state.logView.anchorId = [...logCenter.querySelectorAll('.log-line')]
1670
+ .find((line) => line.getBoundingClientRect().bottom >= streamTop)
1671
+ ?.dataset.logId ?? state.logView.anchorId;
1672
+ }
1673
+ }
1674
+ if (!state.logView.follow) {
1675
+ state.logView.unseenCount += 1;
1676
+ }
1677
+ state.logEntries.push({
1678
+ ...classifyLogEntry(message, overrides),
1679
+ logId: `live-${++nextLogSequence}`
1680
+ });
1495
1681
  touchUpdate();
1496
1682
  renderRuntimeOnly({ chrome: false });
1497
1683
  }
@@ -1500,7 +1686,7 @@ function renderRuntimeOnly({ chrome = true } = {}) {
1500
1686
  const messages = getMessages(state.language);
1501
1687
  const viewModel = buildViewModel(state, messages, state.language);
1502
1688
  if (chrome) {
1503
- renderChrome(messages, viewModel);
1689
+ updateChromeState(messages);
1504
1690
  }
1505
1691
  renderActiveRuntimeSections(viewModel);
1506
1692
  renderToast();
@@ -1509,7 +1695,29 @@ function renderRuntimeOnly({ chrome = true } = {}) {
1509
1695
  function renderActiveRuntimeSections(viewModel) {
1510
1696
  const logCenter = document.querySelector('#logCenterTable');
1511
1697
  if (logCenter) {
1698
+ const previousScrollTop = logCenter.scrollTop;
1699
+ const streamTop = logCenter.getBoundingClientRect().top;
1700
+ const anchor = [...logCenter.querySelectorAll('.log-line')]
1701
+ .find((line) => line.getBoundingClientRect().bottom >= streamTop);
1702
+ const anchorId = anchor?.dataset.logId;
1703
+ const anchorViewportTop = anchor?.getBoundingClientRect().top;
1512
1704
  logCenter.innerHTML = buildLogCenterMarkup(viewModel);
1705
+ 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
+ }
1713
+ if (state.logView.follow) {
1714
+ logCenter.scrollTop = logCenter.scrollHeight;
1715
+ } else if (anchorId) {
1716
+ const restoredAnchor = logCenter.querySelector(`[data-log-id="${CSS.escape(anchorId)}"]`);
1717
+ logCenter.scrollTop += (restoredAnchor?.getBoundingClientRect().top ?? anchorViewportTop) - anchorViewportTop;
1718
+ } else {
1719
+ logCenter.scrollTop = previousScrollTop;
1720
+ }
1513
1721
  }
1514
1722
 
1515
1723
  const stageProgress = document.querySelector('#runsStageProgress');
@@ -1528,6 +1736,21 @@ function renderActiveRuntimeSections(viewModel) {
1528
1736
  }
1529
1737
  }
1530
1738
 
1739
+ function handleLogScroll(event) {
1740
+ if (event.target?.id !== 'logCenterTable') return;
1741
+ const distance = event.target.scrollHeight - event.target.scrollTop - event.target.clientHeight;
1742
+ state.logView.follow = distance <= 32;
1743
+ if (state.logView.follow) {
1744
+ state.logView.unseenCount = 0;
1745
+ event.target.closest('#logsWorkspace')?.querySelectorAll('[data-log-jump-latest]').forEach((node) => node.remove());
1746
+ }
1747
+ }
1748
+
1749
+ function scrollLogToLatest() {
1750
+ const logCenter = document.querySelector('#logCenterTable');
1751
+ if (logCenter) logCenter.scrollTop = logCenter.scrollHeight;
1752
+ }
1753
+
1531
1754
  function resolveVisibleLogEntries() {
1532
1755
  return filterLogEntries((state.logEntries ?? []).map((entry) => classifyLogEntry(entry)), state.logFilter);
1533
1756
  }