@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.
@@ -116,8 +116,13 @@ export function buildViewModel(state, messages, language) {
116
116
  const subscriptionUrl = profile.deploy.subscription_url || FALLBACK_PROFILE.deploy.subscription_url;
117
117
  const subscriptionCards = buildSubscriptionCards(subscriptionUrl);
118
118
  const currentSubscription = subscriptionCards.find((card) => card.title === state.subscriptionFormat) ?? subscriptionCards[0];
119
- const displayLogs = (state.logEntries ?? []).map((entry) => classifyLogEntry(entry));
119
+ const displayLogs = (state.logEntries ?? []).map((entry, index) => ({
120
+ logId: entry?.logId ?? `history-${index}`,
121
+ ...classifyLogEntry(entry)
122
+ }));
120
123
  const logFilter = state.logFilter ?? '全部';
124
+ const filteredLogs = filterLogEntries(displayLogs, logFilter);
125
+ const logWindow = selectLogWindow(filteredLogs, state.logView);
121
126
  const stageRows = normalizeStageRows(state.stageStatus, state.runState);
122
127
  const currentStage = stageRows.find((row) => row.status === 'running') ?? stageRows.find((row) => row.status === 'success') ?? stageRows[0];
123
128
  const artifactDir = state.artifactDir ?? '';
@@ -140,11 +145,12 @@ export function buildViewModel(state, messages, language) {
140
145
  counts,
141
146
  subscriptionUrl,
142
147
  subscriptionFormat: state.subscriptionFormat ?? 'Clash',
143
- qrDataUrl: state.qrDataUrl ?? '',
148
+ qr: state.qr ?? { status: state.qrDataUrl ? 'success' : 'idle', dataUrl: state.qrDataUrl ?? '', message: '' },
144
149
  displayLogs,
145
150
  logFilter,
146
- logRows: buildLogRows(filterLogEntries(displayLogs, logFilter)),
147
- logGroups: groupLogEntriesByStage(filterLogEntries(displayLogs, logFilter)),
151
+ logView: state.logView ?? { follow: true, unseenCount: 0, clearedSnapshot: null },
152
+ logRows: buildLogRows(logWindow, false),
153
+ logGroups: groupLogEntriesByStage(logWindow, false),
148
154
  stageRows,
149
155
  currentStage,
150
156
  artifactDir,
@@ -194,6 +200,7 @@ export function buildSidebarNav(messages, activePage) {
194
200
  class="nav-item ${activePage === page ? 'active' : ''}"
195
201
  data-page-target="${page}"
196
202
  type="button"
203
+ ${activePage === page ? 'aria-current="page"' : ''}
197
204
  >
198
205
  <span class="nav-icon">${NAV_ICONS[page]}</span>
199
206
  <span class="nav-copy">${escapeHtml(messages.nav[page])}</span>
@@ -252,7 +259,7 @@ export function buildSidebarStatus(viewModel, messages, state, language) {
252
259
 
253
260
  export function buildPageMarkup(activePage, viewModel, messages, language, subtabs = {}) {
254
261
  return `
255
- <section class="page-shell" data-page-shell="${activePage}">
262
+ <section ${activePage === 'runs' ? 'id="runsWorkspace"' : ''} class="page-shell" data-page-shell="${activePage}">
256
263
  ${buildPageInner(activePage, viewModel, messages, language, subtabs)}
257
264
  </section>
258
265
  `;
@@ -358,11 +365,15 @@ function buildRunsPage(vm, messages) {
358
365
  const stageDisabled = vm.runControlState.isBusy || !vm.retryStageOptions.length ? 'disabled' : '';
359
366
  const retryDisabled = vm.runControlState.isBusy || !vm.selectedRetryArtifactDir || !vm.selectedRetryStage ? 'disabled' : '';
360
367
  return `
361
- <div id="runsWorkspace" class="page-grid runs-grid">
362
- <article class="panel wide-panel run-control-panel">
368
+ <div class="mobile-run-bar" data-mobile-run-bar>
363
369
  <button class="btn btn-primary run-big" data-run-action="start" type="button" ${runDisabled}>▶ 开始运行</button>
364
370
  <button class="btn btn-secondary run-big" data-run-action="stop" type="button" ${vm.runControlState.stopDisabled ? 'disabled' : ''}>■ 停止运行</button>
365
- <div class="retry-control-card">
371
+ </div>
372
+ <div class="page-grid runs-grid">
373
+ <article class="panel wide-panel run-control-panel">
374
+ <details class="run-secondary-controls">
375
+ <summary>重试历史、阶段与运行选项</summary>
376
+ <div class="retry-control-card">
366
377
  <div class="field compact retry-field retry-artifact-field">
367
378
  <span>历史 run</span>
368
379
  <select data-run-retry-artifact ${artifactDisabled}>
@@ -405,8 +416,8 @@ function buildRunsPage(vm, messages) {
405
416
  : '<div class="empty-state">暂无可重试 run</div>'}
406
417
  </div>
407
418
  <p class="retry-help">阶段重试会新建 artifact,并从所选阶段继续执行到 verify。</p>
408
- </div>
409
- <div class="run-options">
419
+ </div>
420
+ <div class="run-options">
410
421
  ${[
411
422
  ['跳过部署', 'skipDeploy', false],
412
423
  ['跳过验证', 'skipVerify', false],
@@ -414,7 +425,8 @@ function buildRunsPage(vm, messages) {
414
425
  ].map(([label, key, checked]) => `
415
426
  <label class="checkbox-chip"><input data-run-option="${escapeHtml(key)}" type="checkbox" ${checked ? 'checked' : ''} />${escapeHtml(label)}</label>
416
427
  `).join('')}
417
- </div>
428
+ </div>
429
+ </details>
418
430
  </article>
419
431
 
420
432
  ${buildRunsStageProgressMarkup(vm)}
@@ -518,11 +530,11 @@ function buildResultsPage(vm, messages) {
518
530
  <tbody>
519
531
  ${vm.nodeRows.length ? vm.nodeRows.map((row, index) => `
520
532
  <tr>
521
- <td>${index + 1}</td>
522
- <td>${escapeHtml(row.name || '—')}</td>
523
- <td>${escapeHtml(row.address || '—')}</td>
524
- <td>${escapeHtml(row.protocol || '—')}</td>
525
- <td class="mono">${escapeHtml(row.path || '—')}</td>
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>
526
538
  </tr>
527
539
  `).join('') : '<tr><td colspan="5">暂无节点,运行完成后显示。</td></tr>'}
528
540
  </tbody>
@@ -545,6 +557,7 @@ function buildSubscriptionsPage(vm, messages) {
545
557
  <button
546
558
  class="subtab ${vm.subscriptionFormat === format ? 'active' : ''}"
547
559
  data-subscription-format="${escapeHtml(format)}"
560
+ aria-pressed="${vm.subscriptionFormat === format}"
548
561
  type="button"
549
562
  >${escapeHtml(format)}</button>
550
563
  `).join('')}
@@ -559,7 +572,7 @@ function buildSubscriptionsPage(vm, messages) {
559
572
 
560
573
  <article class="panel slim-panel">
561
574
  <div class="panel-headline"><h3>订阅二维码</h3></div>
562
- <div class="qr-block">${renderQr(vm.qrDataUrl)}</div>
575
+ <div class="qr-block">${renderQr(vm.qr)}</div>
563
576
  <p class="panel-subcopy center-copy">扫码导入订阅</p>
564
577
  </article>
565
578
 
@@ -581,17 +594,24 @@ function buildLogsPage(vm, messages) {
581
594
  <button
582
595
  class="subtab ${vm.logFilter === filter ? 'active' : ''}"
583
596
  data-log-filter="${escapeHtml(filter)}"
597
+ aria-pressed="${vm.logFilter === filter ? 'true' : 'false'}"
584
598
  type="button"
585
599
  >${escapeHtml(filter)}</button>
586
600
  `).join('')}
587
601
  </div>
588
602
  <div class="toolbar-right">
589
- <button class="btn btn-secondary small" data-action="copy-log" type="button">复制日志</button>
590
- <button class="btn btn-secondary small" data-action="clear-log" type="button">清空显示</button>
591
- ${vm.runtime === 'web' ? '' : '<button class="btn btn-primary small" data-action="open-log-file" type="button">打开日志文件</button>'}
603
+ <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>'}
606
+ </div>
607
+ <div class="log-destructive-actions">
608
+ <button class="btn btn-secondary small" data-action="clear-log" type="button">清空显示</button>
609
+ </div>
592
610
  </div>
593
611
  </div>
594
- <div id="logCenterTable" class="terminal-output log-stream">
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
+ <div id="logCenterTable" class="terminal-output log-stream" role="log" aria-live="polite">
595
615
  ${buildLogCenterMarkup(vm)}
596
616
  </div>
597
617
  </article>
@@ -672,15 +692,20 @@ function buildSettingsDrawer(vm) {
672
692
 
673
693
  const style = vm.modalTransform ? `style="transform: ${escapeHtml(vm.modalTransform)};"` : '';
674
694
 
695
+ if (!isOpen) {
696
+ return '<div id="settingsDrawer" class="settings-drawer-shell" data-open="false" data-section="" hidden inert></div>';
697
+ }
698
+
675
699
  return `
676
700
  <div id="settingsDrawer" class="settings-drawer-shell" data-open="${isOpen ? 'true' : 'false'}" data-section="${escapeHtml(section)}">
677
701
  <button class="settings-drawer-backdrop" data-drawer-dismiss="backdrop" type="button" aria-label="关闭设置弹窗"></button>
678
- <aside class="settings-drawer-panel" ${style}>
702
+ <aside data-settings-dialog role="dialog" aria-modal="true" aria-labelledby="settingsDrawerTitle" class="settings-drawer-panel" ${style}>
679
703
  <div class="settings-drawer-head">
680
704
  <div>
681
705
  <span class="settings-card-kicker">编辑配置</span>
682
706
  <h3 id="settingsDrawerTitle">${escapeHtml(title)}</h3>
683
707
  </div>
708
+ <button class="icon-btn settings-drawer-close" data-drawer-close="cancel" type="button" aria-label="关闭${escapeHtml(title)}">×</button>
684
709
  </div>
685
710
  <div class="settings-drawer-body">
686
711
  ${isOpen ? buildSettingsDrawerBody(section, drawer.draft) : ''}
@@ -725,18 +750,19 @@ function buildSettingsDrawerBody(section, draft) {
725
750
  <thead><tr><th>启用</th><th>名称</th><th>地址</th><th>密钥</th></tr></thead>
726
751
  <tbody>
727
752
  ${Object.entries(sourceDraft).map(([name, source]) => `
728
- <tr>
729
- <td><input type="checkbox" data-drawer-source="${escapeHtml(name)}" data-drawer-key="enabled" ${source.enabled ? 'checked' : ''} /></td>
730
- <td><strong class="settings-source-name">${escapeHtml(SOURCE_NAMES[name] || name)}</strong></td>
731
- <td>
753
+ <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="地址">
732
757
  <textarea
733
758
  class="table-textarea mono"
734
759
  rows="3"
760
+ aria-label="${escapeHtml(SOURCE_NAMES[name] || name)}:地址"
735
761
  data-drawer-source="${escapeHtml(name)}"
736
762
  data-drawer-key="url"
737
763
  >${escapeHtml(source.url ?? '')}</textarea>
738
764
  </td>
739
- <td><input data-drawer-source="${escapeHtml(name)}" data-drawer-key="key" value="${escapeHtml(source.key ?? '')}" /></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>
740
766
  </tr>
741
767
  `).join('')}
742
768
  </tbody>
@@ -771,13 +797,13 @@ function buildSettingsDrawerBody(section, draft) {
771
797
  <thead><tr><th>启用</th><th>名称</th><th>URL</th><th>操作</th></tr></thead>
772
798
  <tbody>
773
799
  ${(draft?.targets ?? []).map((target, index) => `
774
- <tr>
775
- <td><input type="checkbox" data-availability-index="${index}" data-availability-key="enabled" ${target.enabled ? 'checked' : ''} /></td>
776
- <td><input data-availability-index="${index}" data-availability-key="name" value="${escapeHtml(target.name ?? '')}" /></td>
777
- <td>
778
- <textarea class="table-textarea mono" rows="3" data-availability-index="${index}" data-availability-key="url">${escapeHtml(target.url ?? '')}</textarea>
800
+ <tr data-settings-field-group>
801
+ <td data-field-label="启用"><input aria-label="${escapeHtml(formatAvailabilityName(target.name))}:启用" type="checkbox" data-availability-index="${index}" data-availability-key="enabled" ${target.enabled ? 'checked' : ''} /></td>
802
+ <td data-field-label="名称"><input aria-label="${escapeHtml(formatAvailabilityName(target.name))}:名称" data-availability-index="${index}" data-availability-key="name" value="${escapeHtml(target.name ?? '')}" /></td>
803
+ <td data-field-label="URL">
804
+ <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>
779
805
  </td>
780
- <td><button class="btn btn-secondary small" data-availability-action="remove" data-availability-index="${index}" type="button">删除</button></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>
781
807
  </tr>
782
808
  `).join('')}
783
809
  </tbody>
@@ -812,6 +838,11 @@ function buildSettingsDrawerBody(section, draft) {
812
838
  `;
813
839
  }
814
840
 
841
+ function formatAvailabilityName(name) {
842
+ const labels = { gemini: 'Gemini', chatgpt: 'ChatGPT', claude: 'Claude' };
843
+ return labels[String(name ?? '').toLowerCase()] ?? String(name || '网站');
844
+ }
845
+
815
846
  function renderDrawerField(label, type, value, path, isCompact = false, extraAttrs = '') {
816
847
  return `
817
848
  <label class="field ${isCompact ? 'compact' : ''}">
@@ -825,11 +856,17 @@ function renderBadge(text, tone) {
825
856
  return `<span class="badge ${tone}">${escapeHtml(text)}</span>`;
826
857
  }
827
858
 
828
- function renderQr(dataUrl) {
829
- if (!dataUrl) {
830
- return '<div class="qr-loading">二维码生成中</div>';
859
+ function renderQr(qr) {
860
+ if (qr.status === 'success' && qr.dataUrl) {
861
+ return `<img class="qr-image" alt="订阅二维码" src="${escapeHtml(qr.dataUrl)}" />`;
862
+ }
863
+ if (qr.status === 'loading' || qr.status === 'idle') {
864
+ return '<div class="qr-loading" role="status">二维码生成中</div>';
831
865
  }
832
- return `<img class="qr-image" alt="订阅二维码" src="${escapeHtml(dataUrl)}" />`;
866
+ if (qr.status === 'unavailable') {
867
+ return '<div class="qr-status"><strong>当前环境不支持二维码生成</strong><span>请使用上方“复制链接”导入订阅。</span></div>';
868
+ }
869
+ return `<div class="qr-status" role="alert"><strong>${escapeHtml(qr.message || '二维码生成失败')}</strong><button class="btn btn-secondary small" data-action="retry-qr" type="button">重试生成</button></div>`;
833
870
  }
834
871
 
835
872
  function buildShortcutDescriptors(messages) {
@@ -899,11 +936,6 @@ function buildSourceMetricRows(profile, sourceCounts = {}, countKey = 'raw_links
899
936
  ...Object.keys(profile.sources ?? {}),
900
937
  ...Object.keys(sourceCounts ?? {})
901
938
  ]);
902
- const hasRequestedValues = countKey === 'raw_links'
903
- || Object.values(sourceCounts ?? {}).some((counts) => counts && countKey in counts);
904
- if (!hasRequestedValues) {
905
- return [];
906
- }
907
939
  return Array.from(sourceNames)
908
940
  .sort((left, right) => {
909
941
  const leftIndex = SOURCE_DISPLAY_ORDER.indexOf(left);
@@ -913,7 +945,9 @@ function buildSourceMetricRows(profile, sourceCounts = {}, countKey = 'raw_links
913
945
  .map((name) => ({
914
946
  name,
915
947
  label: SOURCE_NAMES[name] || name,
916
- count: Number(sourceCounts[name]?.[countKey] ?? 0)
948
+ count: countKey !== 'raw_links' && !Object.hasOwn(sourceCounts[name] ?? {}, countKey)
949
+ ? '—'
950
+ : Number(sourceCounts[name]?.[countKey] ?? 0)
917
951
  }));
918
952
  }
919
953
 
@@ -938,7 +972,9 @@ export function extractSourceUrlFromCurl(value = '') {
938
972
  export function buildRegionStats(nodeRows = []) {
939
973
  const counts = new Map();
940
974
  for (const row of nodeRows) {
941
- const region = inferNodeRegion(row?.name);
975
+ const previewRegion = String(row?.regionCode ?? '').trim().toUpperCase();
976
+ const inferredRegion = previewRegion || inferNodeRegion(row?.name);
977
+ const region = inferredRegion === 'ZZ' || inferredRegion === 'OTHER' || inferredRegion === '其他' ? 'US' : inferredRegion;
942
978
  counts.set(region, (counts.get(region) ?? 0) + 1);
943
979
  }
944
980
  return Array.from(counts.entries()).map(([region, count]) => ({ region, count }));
@@ -1029,7 +1065,7 @@ export function applySourceIterationDraft(sources = {}, draft = {}) {
1029
1065
  function inferNodeRegion(name = '') {
1030
1066
  const normalized = String(name).replace(/^[^\p{L}\p{N}]+/u, '').trim();
1031
1067
  const match = normalized.match(/^([A-Z]{2})(?:\b|[\s_-])/);
1032
- return match ? match[1] : '其他';
1068
+ return match ? match[1] : 'US';
1033
1069
  }
1034
1070
 
1035
1071
  function coercePositiveInteger(value, fallback) {
@@ -1048,18 +1084,27 @@ function coerceAreaValue(value, fallback) {
1048
1084
  return Math.trunc(parsed);
1049
1085
  }
1050
1086
 
1051
- function buildLogRows(displayLogs) {
1052
- return displayLogs.slice(-28).map((entry) => ({
1087
+ function buildLogRows(displayLogs, trimWindow = true) {
1088
+ return (trimWindow ? displayLogs.slice(-28) : displayLogs).map((entry) => ({
1053
1089
  ...entry,
1054
1090
  levelClass: entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'success'
1055
1091
  }));
1056
1092
  }
1057
1093
 
1094
+ function selectLogWindow(entries, logView) {
1095
+ if (logView?.follow !== false) return entries.slice(-28);
1096
+ const anchorIndex = entries.findIndex((entry) => entry.logId === logView.anchorId);
1097
+ const start = anchorIndex >= 0
1098
+ ? Math.max(0, Math.min(anchorIndex - 8, Math.max(0, entries.length - 120)))
1099
+ : Math.max(0, entries.length - 120);
1100
+ return entries.slice(start, start + 120);
1101
+ }
1102
+
1058
1103
  function renderLogRows(rows) {
1059
1104
  if (!rows.length) {
1060
1105
  return '<div class="empty-state log-empty-state">暂无可显示日志</div>';
1061
1106
  }
1062
- return rows.map((row) => `<div class="log-line ${row.levelClass}">${escapeHtml(row.line)}</div>`).join('');
1107
+ return rows.map((row) => `<div class="log-line ${row.levelClass}" data-log-id="${escapeHtml(row.logId)}">${escapeHtml(row.line)}</div>`).join('');
1063
1108
  }
1064
1109
 
1065
1110
  function renderLogGroups(groups) {
@@ -1069,7 +1114,7 @@ function renderLogGroups(groups) {
1069
1114
  return groups.map((group) => `
1070
1115
  <section class="log-group">
1071
1116
  <div class="log-group-title">${escapeHtml(group.label)}</div>
1072
- ${group.rows.map((row) => `<div class="log-line ${row.levelClass}">${escapeHtml(row.line)}</div>`).join('')}
1117
+ ${group.rows.map((row) => `<div class="log-line ${row.levelClass}" data-log-id="${escapeHtml(row.logId)}">${escapeHtml(row.line)}</div>`).join('')}
1073
1118
  </section>
1074
1119
  `).join('');
1075
1120
  }
@@ -1103,9 +1148,9 @@ export function filterLogEntries(entries, filter = '全部') {
1103
1148
  return entries;
1104
1149
  }
1105
1150
 
1106
- export function groupLogEntriesByStage(entries) {
1151
+ export function groupLogEntriesByStage(entries, trimWindow = true) {
1107
1152
  const groups = new Map();
1108
- for (const row of buildLogRows(entries)) {
1153
+ for (const row of buildLogRows(entries, trimWindow)) {
1109
1154
  const label = row.stage || '其他';
1110
1155
  if (!groups.has(label)) {
1111
1156
  groups.set(label, { label, rows: [] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "1.6.9",
3
+ "version": "1.8.0",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {