@swimmingliu/autovpn 2.2.1 → 2.2.3

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.
@@ -22,6 +22,7 @@ import {
22
22
  buildRunsStageProgressMarkup,
23
23
  buildSpeedTestDraft,
24
24
  buildSourceIterationDraft,
25
+ buildSourceLapEstimate,
25
26
  buildSidebarNav,
26
27
  buildViewModel,
27
28
  buildAccountPoolRecords,
@@ -119,6 +120,8 @@ const state = {
119
120
  sortBy: 'brand',
120
121
  busy: false,
121
122
  canSetExpiry: typeof window.vpnAutomation?.setAccountExpiry === 'function',
123
+ canRefreshExpiry: typeof window.vpnAutomation?.refreshAccountExpiry === 'function',
124
+ refreshingExpiry: false,
122
125
  expiryEditorId: null
123
126
  },
124
127
  isDemo: false,
@@ -655,6 +658,12 @@ function handleDocumentInput(event) {
655
658
  sourceDraft[sourceName][key] = Math.min(SOURCE_MAXIMUM_KEYS.get(key), sourceDraft[sourceName][key]);
656
659
  }
657
660
  }
661
+ // The row's estimate is derived from these inputs; refresh just that line
662
+ // so the caret in the number input is never replaced mid-edit.
663
+ if (key === 'max_laps' || key === 'dry_laps' || key === 'area_concurrency') {
664
+ const estimate = document.querySelector(`[data-source-estimate="${CSS.escape(sourceName)}"]`);
665
+ if (estimate) estimate.textContent = buildSourceLapEstimate(sourceDraft[sourceName], state.sourceCounts?.[sourceName]?.area_count);
666
+ }
658
667
  return;
659
668
  }
660
669
 
@@ -807,6 +816,9 @@ const SOURCE_NUMERIC_KEYS = new Set([
807
816
  'plateau_limit',
808
817
  'areas_per_round',
809
818
  'area_concurrency',
819
+ 'max_laps',
820
+ 'dry_laps',
821
+ 'min_laps',
810
822
  'max_runtime_seconds',
811
823
  'max_areas',
812
824
  'area_min',
@@ -818,7 +830,9 @@ const SOURCE_MINIMUM_ONE_KEYS = new Set([
818
830
  'max_iterations',
819
831
  'plateau_limit',
820
832
  'areas_per_round',
821
- 'area_concurrency'
833
+ 'area_concurrency',
834
+ 'max_laps',
835
+ 'dry_laps'
822
836
  ]);
823
837
 
824
838
  /** Numeric source fields with a hard upper bound, matching the backend clamp. */
@@ -1055,7 +1069,7 @@ function buildSettingsDraft(section) {
1055
1069
  return null;
1056
1070
  }
1057
1071
 
1058
- if (section === 'sources') return buildSourceIterationDraft(state.profile.sources);
1072
+ if (section === 'sources') return buildSourceIterationDraft(state.profile.sources, state.sourceCounts);
1059
1073
  if (section === 'speed_test') return buildSpeedTestDraft(state.profile.speed_test);
1060
1074
  if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
1061
1075
  if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
@@ -1117,6 +1131,7 @@ async function hydrateAccountPool() {
1117
1131
  // Re-detect each hydration: the preload bridge is fixed for the page, but a
1118
1132
  // test harness or a future H5 adapter may attach it after bootstrap.
1119
1133
  state.accountPool.canSetExpiry = typeof window.vpnAutomation?.setAccountExpiry === 'function';
1134
+ state.accountPool.canRefreshExpiry = typeof window.vpnAutomation?.refreshAccountExpiry === 'function';
1120
1135
  touchUpdate();
1121
1136
  renderAccountPoolPanelOnly();
1122
1137
  if (!window.vpnAutomation?.loadAccounts) {
@@ -1170,6 +1185,10 @@ async function handleAccountPoolAction(button) {
1170
1185
  );
1171
1186
  return;
1172
1187
  }
1188
+ if (action === 'refresh-expiry') {
1189
+ await refreshAccountExpiry();
1190
+ return;
1191
+ }
1173
1192
  if (action === 'upload') {
1174
1193
  document.querySelector('#accountPoolFile')?.click();
1175
1194
  return;
@@ -1200,6 +1219,46 @@ async function handleAccountPoolAction(button) {
1200
1219
  }
1201
1220
  }
1202
1221
 
1222
+ /**
1223
+ * Log in to the pool to learn each account's real membership date.
1224
+ *
1225
+ * A run leases one account per brand, so a freshly imported pool keeps blank
1226
+ * dates almost forever. This walks the active rows deliberately; the worker
1227
+ * retires the lapsed and non-member ones, and the panel then shows the counts
1228
+ * it actually changed rather than a generic success.
1229
+ */
1230
+ async function refreshAccountExpiry() {
1231
+ if (state.accountPool.refreshingExpiry || typeof window.vpnAutomation?.refreshAccountExpiry !== 'function') {
1232
+ return;
1233
+ }
1234
+ state.accountPool.refreshingExpiry = true;
1235
+ state.accountPool.busy = true;
1236
+ renderAccountPoolPanelOnly();
1237
+ try {
1238
+ const result = await window.vpnAutomation.refreshAccountExpiry({});
1239
+ if (result?.ok === false) {
1240
+ throw new Error(result.error || '核对失败');
1241
+ }
1242
+ const checked = Number(result?.checked ?? 0);
1243
+ const updated = Number(result?.updated ?? 0);
1244
+ const expired = Number(result?.expired ?? 0);
1245
+ const retired = Number(result?.retired ?? 0);
1246
+ const failed = Number(result?.failed ?? 0);
1247
+ showToast({
1248
+ tone: failed > 0 ? 'warning' : 'success',
1249
+ message: `核对 ${checked} 个账号:更新到期日 ${updated} 个,剔除 ${expired + retired} 个`
1250
+ + `${failed > 0 ? `,${failed} 个未能登录` : ''}`,
1251
+ durationMs: 5200
1252
+ });
1253
+ } catch (error) {
1254
+ showToast({ tone: 'danger', message: resolveErrorMessage(error), durationMs: 5200 });
1255
+ } finally {
1256
+ state.accountPool.refreshingExpiry = false;
1257
+ state.accountPool.busy = false;
1258
+ }
1259
+ await hydrateAccountPool();
1260
+ }
1261
+
1203
1262
  async function setAccountExpiry(id, expiresAt) {
1204
1263
  const account = (state.accountPool.accounts ?? []).find((item) => item.id === id);
1205
1264
  // Feature-detected: the browser build has no preload bridge, so this path is
@@ -1817,6 +1876,17 @@ function handlePipelineEventDetails(event, options = {}) {
1817
1876
  }
1818
1877
 
1819
1878
  if (event.type === 'extract_source_completed') {
1879
+ // The frozen area list's length feeds the drawer's lap estimate; older
1880
+ // artifacts have no area_count, so it stays optional everywhere.
1881
+ if (event.source_name && Number(event.area_count) >= 1) {
1882
+ state.sourceCounts = {
1883
+ ...state.sourceCounts,
1884
+ [event.source_name]: {
1885
+ ...(state.sourceCounts[event.source_name] ?? {}),
1886
+ area_count: Math.trunc(Number(event.area_count))
1887
+ }
1888
+ };
1889
+ }
1820
1890
  if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
1821
1891
  appendLog(`[extract] ${event.source_name} 完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
1822
1892
  kind: 'stage',
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.2.1',
8
+ sidebarVersion: 'v.2.2.3',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -19,6 +19,7 @@ const paths = {
19
19
  play: '<path d="m8 4 12 8-12 8z"/>',
20
20
  stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
21
21
  check: '<path d="m5 12 4 4L19 6"/>',
22
+ shield: '<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6z"/><path d="m9 12 2 2 4-4"/>',
22
23
  chevron: '<path d="m9 5 7 7-7 7"/>',
23
24
  sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/>',
24
25
  moon: '<path d="M21 13A9 9 0 0 1 11 3 9 9 0 1 0 21 13Z"/>',
@@ -1390,8 +1390,8 @@ body[data-page="logs"] #logCenterTable {
1390
1390
 
1391
1391
  .source-credential-rounds {
1392
1392
  display: grid;
1393
- /* Four controls in a 232px column: two per line, so the labels stay readable
1394
- next to a 36px number input. */
1393
+ /* Three controls in a 232px column: two per line, so the labels stay
1394
+ readable next to a 36px number input. */
1395
1395
  grid-template-columns: repeat(2, minmax(72px, 1fr));
1396
1396
  gap: 8px;
1397
1397
  }
@@ -1,10 +1,10 @@
1
1
  import { buildStageModel, PAGE_INDEX, PAGE_ORDER, resolveRunControlState } from './state.js';
2
2
  import { icon } from './icons.js';
3
3
 
4
- // Defaults for a source row that has never stored a value. A round advances a
5
- // cursor over that brand's fixed area list and pulls `areas_per_round` ids, so
6
- // the count is per platform and per round: 4/2 would fetch only a handful of
7
- // nodes.
4
+ // Legacy round defaults, kept for a row that carries no lap key. A round
5
+ // advances a cursor over that brand's fixed area list and pulls
6
+ // `areas_per_round` ids, so the count is per platform and per round. The drawer
7
+ // no longer edits these; lap mode below is what it shows.
8
8
  export const SOURCE_ROUND_DEFAULTS = {
9
9
  max_iterations: 200,
10
10
  plateau_limit: 50,
@@ -17,6 +17,15 @@ export const SOURCE_ROUND_LIMITS = {
17
17
  areas_per_round: [1, 64],
18
18
  area_concurrency: [1, 16]
19
19
  };
20
+ // Lap mode: one lap asks every area on the vendor's frozen list exactly once,
21
+ // so the drawer only asks for laps and concurrency — no round/area conversion.
22
+ export const SOURCE_LAP_DEFAULTS = {
23
+ max_laps: 50,
24
+ dry_laps: 13
25
+ };
26
+ // Used only to seed a legacy row and to estimate before the first run has
27
+ // measured the list; the pipeline itself never reads this number.
28
+ export const REFERENCE_AREA_COUNT = 31;
20
29
  export const NETWORK_DEFAULTS = {
21
30
  proxy_mode: 'proxy',
22
31
  proxy_url: 'http://127.0.0.1:7897'
@@ -742,6 +751,7 @@ export function buildAccountPoolPanel(vm) {
742
751
  </div>
743
752
  <div class="account-pool-actions">
744
753
  <button class="btn btn-secondary small icon-only" data-account-action="refresh" type="button" ${pool.busy ? 'disabled' : ''} aria-label="刷新账号池" title="刷新账号池">${icon('refresh')}</button>
754
+ ${pool.canRefreshExpiry ? `<button class="btn btn-secondary small" data-account-action="refresh-expiry" type="button" ${pool.busy ? 'disabled' : ''} title="逐个登录账号池,读取真实会员到期日;已过期和非会员账号会被停用并退出轮换">${icon('shield')}${pool.refreshingExpiry ? '核对中…' : '核对会员到期'}</button>` : ''}
745
755
  <button class="btn btn-secondary small icon-only" data-account-action="template" type="button" ${pool.busy ? 'disabled' : ''} aria-label="下载模板" title="下载模板">${icon('download')}</button>
746
756
  <button class="btn btn-primary small" data-account-action="upload" type="button" ${pool.busy ? 'disabled' : ''}>${icon('upload')}${pool.busy ? '导入中…' : '上传 CSV'}</button>
747
757
  <input id="accountPoolFile" data-account-file type="file" accept=".csv,.txt,text/csv,text/plain" hidden aria-hidden="true" tabindex="-1" />
@@ -870,6 +880,8 @@ const EMPTY_ACCOUNT_POOL = {
870
880
  // Feature-detected from the preload bridge. The H5/browser build has no
871
881
  // setAccountExpiry, so the row simply omits the affordance there.
872
882
  canSetExpiry: false,
883
+ canRefreshExpiry: false,
884
+ refreshingExpiry: false,
873
885
  expiryEditorId: null
874
886
  };
875
887
 
@@ -889,13 +901,13 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
889
901
  <div class="source-drawer-settings">
890
902
  <div class="source-drawer-settings-copy">
891
903
  <strong>每个加速器只填官网</strong>
892
- <span>密钥从 APK 自动发现,账号密码由账号池提供。Android 与 iOS 两个通道同时抓取;「轮数」由两端各自独立跑满,1000 就是 Android 1000 轮加 iOS 1000 轮,不是 500+500。</span>
904
+ <span>密钥从 APK 自动发现,账号密码由账号池提供。Android 与 iOS 两个通道同时抓取;「遍数」由两端各自独立跑满,50 遍就是 Android 50 遍加 iOS 50 遍,不是 25+25。</span>
893
905
  </div>
894
906
  <div class="source-drawer-settings-copy">
895
- <span>每次运行先按该加速器的节点列表生成一份固定区域列表(去掉中国大陆与免费区域、去重、保持原顺序),运行中不再改变。每一轮用游标依次取出「每轮区域数」个区域(默认 8 个,取完回绕);「区域并发」控制该轮这些区域请求同时发出的数量(默认 4)。31 个区域默认四轮即可覆盖一遍,轮数按各平台独立计算。</span>
907
+ <span>每次运行先按该加速器的节点列表生成一份固定区域列表(去掉中国大陆与免费区域、去重、保持原顺序),运行中不再改变。一遍 = 把该加速器全部区域各请求一次(实测 雷霆 30 / 黑洞 31 / 蜜蜂 31 / 旋风 26);一遍就是一轮,双端各自计遍。「同时请求数」控制一遍内同时发出的请求数量(默认 4),每遍分 ceil(区域数 ÷ 同时请求数) 批依次发出。</span>
896
908
  </div>
897
909
  <div class="source-drawer-settings-copy">
898
- <span>「早停」按连续无新增节点的轮数计数。是否为新节点按节点指纹判断(address / port / id / transport / host / path / tls / sni),并在 Android 与 iOS 之间统一去重:改名重复的节点、或另一端已经抓到的节点,都不会让计数归零。</span>
910
+ <span>「空转早停」按连续没有新增节点的遍数计数:连续几遍一个新节点都没有,就提前停止该通道。是否为新节点按节点指纹判断(address / port / id / transport / host / path / tls / sni),并在 Android 与 iOS 之间统一去重:改名重复的节点、或另一端已经抓到的节点,都不会让计数归零。</span>
899
911
  </div>
900
912
  </div>
901
913
  </details>
@@ -907,7 +919,7 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
907
919
  <col class="settings-col-connection" />
908
920
  <col class="settings-col-rounds" />
909
921
  </colgroup>
910
- <thead><tr><th>启用</th><th>加速器</th><th>官网</th><th>循环</th></tr></thead>
922
+ <thead><tr><th>启用</th><th>加速器</th><th>官网</th><th>抓取</th></tr></thead>
911
923
  <tbody>
912
924
  ${SOURCE_DISPLAY_ORDER.map((name) => {
913
925
  const source = sourceDraft[name];
@@ -926,26 +938,22 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
926
938
  <td data-field-label="官网">
927
939
  <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 ?? '')}" />
928
940
  </td>
929
- <td data-field-label="循环">
941
+ <td data-field-label="抓取">
930
942
  <div class="source-credential-rounds">
931
943
  <label class="source-field">
932
- <span>轮数</span>
933
- <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000 轮);每轮是固定区域列表上的一个游标步,按「每轮区域数」取区域" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations)}" />
934
- </label>
935
- <label class="source-field">
936
- <span>早停</span>
937
- <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 ?? SOURCE_ROUND_DEFAULTS.plateau_limit)}" />
944
+ <span>抓取遍数</span>
945
+ <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取遍数" title="Android 与 iOS 各自独立跑满该遍数(50 = 两端各 50 遍);一遍把该加速器全部区域各请求一次" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_laps" value="${escapeHtml(source.max_laps ?? SOURCE_LAP_DEFAULTS.max_laps)}" />
938
946
  </label>
939
947
  <label class="source-field">
940
- <span>每轮区域数</span>
941
- <input type="number" min="1" max="64" step="1" inputmode="numeric" aria-label="${label}:每轮区域数" title="每轮从固定区域列表按游标取出的区域数,列表在运行中不变;取完回绕" data-drawer-source="${escapeHtml(name)}" data-drawer-key="areas_per_round" value="${escapeHtml(source.areas_per_round ?? SOURCE_ROUND_DEFAULTS.areas_per_round)}" />
948
+ <span>空转早停(遍)</span>
949
+ <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:空转早停(遍)" title="连续多少遍没有真正新增节点就停止该通道;节点指纹在双端之间统一去重" data-drawer-source="${escapeHtml(name)}" data-drawer-key="dry_laps" value="${escapeHtml(source.dry_laps ?? SOURCE_LAP_DEFAULTS.dry_laps)}" />
942
950
  </label>
943
951
  <label class="source-field">
944
- <span>区域并发</span>
945
- <input type="number" min="1" max="16" step="1" inputmode="numeric" aria-label="${label}:区域并发" title="该轮区域请求同时发出的数量" data-drawer-source="${escapeHtml(name)}" data-drawer-key="area_concurrency" value="${escapeHtml(source.area_concurrency ?? SOURCE_ROUND_DEFAULTS.area_concurrency)}" />
952
+ <span>同时请求数</span>
953
+ <input type="number" min="1" max="16" step="1" inputmode="numeric" aria-label="${label}:同时请求数" title="一遍内同时发出的区域请求数量;每遍分 ceil(区域数 ÷ 同时请求数) 批依次发出" data-drawer-source="${escapeHtml(name)}" data-drawer-key="area_concurrency" value="${escapeHtml(source.area_concurrency ?? SOURCE_ROUND_DEFAULTS.area_concurrency)}" />
946
954
  </label>
947
955
  </div>
948
- <span class="source-round-summary">双端各自计轮 · 每轮按固定区域列表推进</span>
956
+ <span class="source-round-summary" data-source-estimate="${escapeHtml(name)}">${escapeHtml(buildSourceLapEstimate(source, vm?.sourceCounts?.[name]?.area_count))}</span>
949
957
  </td>
950
958
  </tr>
951
959
  `;
@@ -1156,7 +1164,9 @@ function buildSourceRows(profile) {
1156
1164
  maxIterations: Number(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations),
1157
1165
  plateauLimit: Number(source.plateau_limit ?? SOURCE_ROUND_DEFAULTS.plateau_limit),
1158
1166
  areasPerRound: Number(source.areas_per_round ?? SOURCE_ROUND_DEFAULTS.areas_per_round),
1159
- areaConcurrency: Number(source.area_concurrency ?? SOURCE_ROUND_DEFAULTS.area_concurrency)
1167
+ areaConcurrency: Number(source.area_concurrency ?? SOURCE_ROUND_DEFAULTS.area_concurrency),
1168
+ maxLaps: Number(source.max_laps ?? SOURCE_LAP_DEFAULTS.max_laps),
1169
+ dryLaps: Number(source.dry_laps ?? SOURCE_LAP_DEFAULTS.dry_laps)
1160
1170
  }));
1161
1171
  }
1162
1172
 
@@ -1215,8 +1225,74 @@ export function buildSpeedTestDraft(config = {}) {
1215
1225
  return structuredClone({ ...FALLBACK_PROFILE.speed_test, ...config });
1216
1226
  }
1217
1227
 
1218
- export function buildSourceIterationDraft(sources = {}) {
1228
+ export function buildSourceIterationDraft(sources = {}, sourceCounts = {}) {
1229
+ // Snapshot which rows actually stored a legacy budget BEFORE normalization
1230
+ // fills every row with the legacy defaults; a fresh row must get the shipped
1231
+ // lap defaults, not a conversion of numbers it never had. Aliases count too.
1232
+ const storedLegacy = new Set();
1233
+ // The stored caps themselves, keyed by canonical name. Normalization fills
1234
+ // every row with the shipped legacy defaults, so converting from the
1235
+ // normalized row would turn a URL row's stored 40 rounds into 200 and then
1236
+ // save 200 back — the operator's number must come from the file.
1237
+ const storedCaps = new Map();
1238
+ for (const [name, source] of Object.entries(sources ?? {})) {
1239
+ const canonical = LEGACY_SOURCE_ALIASES[name] ?? name;
1240
+ if (hasStoredLapValue(source?.max_iterations) || hasStoredLapValue(source?.plateau_limit)) {
1241
+ storedLegacy.add(canonical);
1242
+ if (!storedCaps.has(canonical)) storedCaps.set(canonical, source);
1243
+ }
1244
+ }
1219
1245
  const normalized = normalizeOfficialSourceDraft(sources);
1246
+ // Seeding is draft-only: opening the drawer converts nothing on disk, and
1247
+ // 取消 writes nothing. Saving is the opt-in to lap mode; the estimate line
1248
+ // shows the converted request counts before 保存 is pressed.
1249
+ for (const [name, source] of Object.entries(normalized)) {
1250
+ if (!SOURCE_DISPLAY_ORDER.includes(name)) continue;
1251
+ const storedLaps = hasStoredLapValue(source.max_laps) ? coercePositiveInteger(source.max_laps, SOURCE_LAP_DEFAULTS.max_laps) : null;
1252
+ const storedDry = hasStoredLapValue(source.dry_laps) ? coercePositiveInteger(source.dry_laps, SOURCE_LAP_DEFAULTS.dry_laps) : null;
1253
+ if (storedLaps !== null || storedDry !== null) {
1254
+ source.max_laps = storedLaps ?? SOURCE_LAP_DEFAULTS.max_laps;
1255
+ // Same clamp as the conversion below: a profile written by an older
1256
+ // build (or hand-edited) can already hold dry > max, and the drawer's
1257
+ // validator refuses that pair — an untouched row must stay savable.
1258
+ source.dry_laps = Math.min(source.max_laps, storedDry ?? SOURCE_LAP_DEFAULTS.dry_laps);
1259
+ continue;
1260
+ }
1261
+ // Only a row that actually stored a legacy budget is converted; a fresh
1262
+ // row gets the shipped lap defaults (50 / 13), not a conversion of the
1263
+ // normalization-filled legacy defaults.
1264
+ if (!storedLegacy.has(name)) {
1265
+ source.max_laps = SOURCE_LAP_DEFAULTS.max_laps;
1266
+ source.dry_laps = SOURCE_LAP_DEFAULTS.dry_laps;
1267
+ continue;
1268
+ }
1269
+ // A legacy row is converted at the measured list length when a run has
1270
+ // reported one, else the reference count (labelled 估算 in the drawer).
1271
+ const areas = observedAreaCount(sourceCounts?.[name]?.area_count);
1272
+ const stored = storedCaps.get(name) ?? {};
1273
+ // The stored caps seed the lap estimate. A URL row's caps count URL
1274
+ // fetches rather than route-loop rounds, so the conversion is an estimate
1275
+ // for that row, which the drawer labels 估算.
1276
+ const legacyIterations = coercePositiveInteger(stored.max_iterations, SOURCE_ROUND_DEFAULTS.max_iterations);
1277
+ const legacyPlateau = coercePositiveInteger(stored.plateau_limit, SOURCE_ROUND_DEFAULTS.plateau_limit);
1278
+ const legacyWidth = coerceBoundedInteger(stored.areas_per_round ?? source.areas_per_round, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64);
1279
+ // Saving opts the row into lap mode, so the stored budget is kept beside
1280
+ // the lap keys: removing them later restores exactly these rounds. A row
1281
+ // that never stored a budget keeps the normalization default.
1282
+ if (stored.mode === 'credential') {
1283
+ source.max_iterations = legacyIterations;
1284
+ source.plateau_limit = legacyPlateau;
1285
+ }
1286
+ // The estimate uses the stored numbers even for a URL row: 3000 URL
1287
+ // iterations at width 8 is the work that row used to do.
1288
+ const seedIterations = coercePositiveInteger(stored.max_iterations, legacyIterations);
1289
+ const seedPlateau = coercePositiveInteger(stored.plateau_limit, legacyPlateau);
1290
+ source.max_laps = Math.max(1, Math.round((seedIterations * legacyWidth) / areas));
1291
+ // A round-mode row may legitimately carry a plateau above its cap (the cap
1292
+ // wins there). Converted to laps that pair would be refused by the drawer's
1293
+ // own validator, so the seed clamps it: an untouched row must stay savable.
1294
+ source.dry_laps = Math.min(source.max_laps, Math.max(1, Math.round((seedPlateau * legacyWidth) / areas)));
1295
+ }
1220
1296
  const firstSource = Object.values(normalized)[0] ?? {};
1221
1297
  return {
1222
1298
  sources: normalized,
@@ -1227,6 +1303,33 @@ export function buildSourceIterationDraft(sources = {}) {
1227
1303
  };
1228
1304
  }
1229
1305
 
1306
+ /** A cleared input is '' — still an edit in progress, not an absent field. */
1307
+ function hasStoredLapValue(value) {
1308
+ return value !== undefined && value !== null && value !== '';
1309
+ }
1310
+
1311
+ /** The measured list length when a run reported one, else the reference count. */
1312
+ function observedAreaCount(value) {
1313
+ const parsed = Number(value);
1314
+ return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : REFERENCE_AREA_COUNT;
1315
+ }
1316
+
1317
+ /**
1318
+ * The row's derived request budget. Laps × areas is exact arithmetic, so the
1319
+ * operator can check it by hand; waves use min(C, N) because a tiny debug list
1320
+ * clamps the workers to its own length.
1321
+ */
1322
+ export function buildSourceLapEstimate(source = {}, areaCount) {
1323
+ const measured = Number.isFinite(Number(areaCount)) && Number(areaCount) >= 1;
1324
+ const areas = observedAreaCount(areaCount);
1325
+ const laps = coercePositiveInteger(source.max_laps, SOURCE_LAP_DEFAULTS.max_laps);
1326
+ const dry = coercePositiveInteger(source.dry_laps, SOURCE_LAP_DEFAULTS.dry_laps);
1327
+ const concurrency = coerceBoundedInteger(source.area_concurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16);
1328
+ const waves = Math.ceil(areas / Math.min(concurrency, areas));
1329
+ return `区域 ${areas}(${measured ? '实测' : '估算'}) · 最多 ${laps} 遍 × ${areas} 区域 = ${laps * areas} 次请求/端` +
1330
+ ` · 并发 ${concurrency} → 每遍 ${waves} 批(约 ${waves} 秒/遍) · 早停 ${dry} 遍 = ${dry * areas} 次`;
1331
+ }
1332
+
1230
1333
  export function buildAvailabilityTargetDraft(targets = {}) {
1231
1334
  return {
1232
1335
  targets: Object.entries(targets).map(([name, target]) => ({
@@ -1332,9 +1435,27 @@ export function validateSourceDraft(draft = {}) {
1332
1435
  }
1333
1436
  for (const [name, source] of Object.entries(sources)) {
1334
1437
  if (!SOURCE_DISPLAY_ORDER.includes(name)) continue;
1438
+ for (const [key, label] of [
1439
+ ['max_laps', '抓取遍数'],
1440
+ ['dry_laps', '空转早停(遍)']
1441
+ ]) {
1442
+ const raw = source?.[key];
1443
+ if (raw === undefined || raw === null || raw === '') continue;
1444
+ const value = Number(raw);
1445
+ if (!Number.isFinite(value) || value < 1 || Math.trunc(value) !== value) {
1446
+ return `${SOURCE_NAMES[name] ?? name}:${label}至少为 1`;
1447
+ }
1448
+ }
1449
+ const laps = Number(source?.max_laps);
1450
+ const dry = Number(source?.dry_laps);
1451
+ // More dry laps than total laps means the early stop could never fire;
1452
+ // that is a typo, not a policy, so it is refused before save.
1453
+ if (Number.isFinite(laps) && Number.isFinite(dry) && laps >= 1 && dry > laps) {
1454
+ return `${SOURCE_NAMES[name] ?? name}:空转早停(遍)不能大于抓取遍数`;
1455
+ }
1335
1456
  for (const [key, label, [, max]] of [
1336
1457
  ['areas_per_round', '每轮区域数', SOURCE_ROUND_LIMITS.areas_per_round],
1337
- ['area_concurrency', '区域并发', SOURCE_ROUND_LIMITS.area_concurrency]
1458
+ ['area_concurrency', '同时请求数', SOURCE_ROUND_LIMITS.area_concurrency]
1338
1459
  ]) {
1339
1460
  const raw = source?.[key];
1340
1461
  if (raw === undefined || raw === null || raw === '') continue;
@@ -1508,6 +1629,9 @@ export function normalizeOfficialSourceDraft(sources = {}) {
1508
1629
  plateau_limit: existing.plateau_limit ?? source.plateau_limit,
1509
1630
  areas_per_round: existing.areas_per_round ?? source.areas_per_round,
1510
1631
  area_concurrency: existing.area_concurrency ?? source.area_concurrency,
1632
+ max_laps: existing.max_laps ?? source.max_laps,
1633
+ dry_laps: existing.dry_laps ?? source.dry_laps,
1634
+ min_laps: existing.min_laps ?? source.min_laps,
1511
1635
  enabled: existing.enabled !== false || source.enabled !== false
1512
1636
  }
1513
1637
  : { ...source };
@@ -1515,6 +1639,11 @@ export function normalizeOfficialSourceDraft(sources = {}) {
1515
1639
  const vendorRows = Object.fromEntries(SOURCE_DISPLAY_ORDER.map((name) => {
1516
1640
  const source = collapsed[name] ?? {};
1517
1641
  const wasCredential = source.mode === 'credential';
1642
+ // A lap row already carries the budget in the operator's own unit. Its
1643
+ // legacy caps are kept verbatim so removing the lap keys later restores
1644
+ // exactly the rounds that were stored, instead of the shipped default.
1645
+ const hasLapKey = ['max_laps', 'dry_laps', 'min_laps'].some((key) => hasStoredLapValue(source[key]));
1646
+ const keepLegacyCaps = wasCredential || hasLapKey;
1518
1647
  const normalized = {
1519
1648
  ...source,
1520
1649
  mode: 'credential',
@@ -1525,15 +1654,15 @@ export function normalizeOfficialSourceDraft(sources = {}) {
1525
1654
  ? (OFFICIAL_SITES[name] ?? '')
1526
1655
  : String(source.official_site).trim(),
1527
1656
  enabled: source.enabled !== false,
1528
- max_iterations: wasCredential ? coercePositiveInteger(source.max_iterations, SOURCE_ROUND_DEFAULTS.max_iterations) : SOURCE_ROUND_DEFAULTS.max_iterations,
1529
- plateau_limit: wasCredential ? coercePositiveInteger(source.plateau_limit, SOURCE_ROUND_DEFAULTS.plateau_limit) : SOURCE_ROUND_DEFAULTS.plateau_limit,
1530
- areas_per_round: wasCredential ? coerceBoundedInteger(source.areas_per_round, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64) : SOURCE_ROUND_DEFAULTS.areas_per_round,
1531
- area_concurrency: wasCredential ? coerceBoundedInteger(source.area_concurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16) : SOURCE_ROUND_DEFAULTS.area_concurrency
1657
+ max_iterations: keepLegacyCaps ? coercePositiveInteger(source.max_iterations, SOURCE_ROUND_DEFAULTS.max_iterations) : SOURCE_ROUND_DEFAULTS.max_iterations,
1658
+ plateau_limit: keepLegacyCaps ? coercePositiveInteger(source.plateau_limit, SOURCE_ROUND_DEFAULTS.plateau_limit) : SOURCE_ROUND_DEFAULTS.plateau_limit,
1659
+ areas_per_round: keepLegacyCaps ? coerceBoundedInteger(source.areas_per_round, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64) : SOURCE_ROUND_DEFAULTS.areas_per_round,
1660
+ area_concurrency: keepLegacyCaps ? coerceBoundedInteger(source.area_concurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16) : SOURCE_ROUND_DEFAULTS.area_concurrency
1532
1661
  };
1533
1662
  for (const field of OBSOLETE_SOURCE_FIELDS) delete normalized[field];
1534
1663
  // A URL-mode floor is the same mismatch as its URL-mode cap: 10 URL fetches
1535
1664
  // are not 10 route-loop rounds, so it goes with the rest of the old shape.
1536
- if (!wasCredential) delete normalized.min_iterations;
1665
+ if (!wasCredential && !hasLapKey) delete normalized.min_iterations;
1537
1666
  return [name, normalized];
1538
1667
  }));
1539
1668
  return { ...vendorRows, ...extras };
@@ -103,14 +103,43 @@ input, select, textarea { min-height: 36px; padding-inline: 10px; border-radius:
103
103
  margins and list markers out of chart captions and legend rows. */
104
104
  .dashboard-analytics p { margin: 0; }
105
105
  .dashboard-analytics ul { list-style: none; margin: 0; padding: 0; }
106
- .analytics-distributions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
106
+ /* One card per row: a distribution's legend is always wider than its ring, so
107
+ side-by-side cards forced the country list into a narrow column and left the
108
+ two cards at different heights. Full width lets the chips flow sideways. */
109
+ .analytics-distributions { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; }
110
+ /* Before the first run neither card has a legend to flow, so two full-width
111
+ cards would be two empty bands. Side by side and short until data arrives. */
112
+ .analytics-distributions:has(.chart-empty) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
113
+ .analytics-distribution { position: relative; overflow: hidden; isolation: isolate; }
114
+ /* The card's own light: a wash behind the ring tinted by the focused country,
115
+ so hovering a chip moves the light and the arc as one object. */
116
+ .analytics-aura { position: absolute; inset: 0; z-index: -1; pointer-events: none; transition: background 320ms cubic-bezier(.16, 1, .3, 1); }
107
117
  .analytics-distribution h3, .analytics-heading h3 { font-size: 14px; font-weight: 600; margin: 0 0 16px; }
108
- .distribution-layout { display: grid; grid-template-columns: minmax(128px, .85fr) minmax(0, 1.15fr); align-items: center; gap: 20px; }
109
- .analytics-distribution [data-donut-legend] { min-height: 44px; color: var(--text); font-size: 13px; border: 0; background: transparent; }
110
- .analytics-distribution [data-donut-legend]:hover, .analytics-distribution [data-donut-legend]:focus-visible { background: var(--surface-muted); }
111
- .distribution-share { color: var(--text-soft); margin-left: 10px; font-size: 12px; }
112
- .chart-empty { min-height: 176px; display: flex; align-items: center; justify-content: center; gap: 20px; color: var(--text-soft); font-size: 13px; }
113
- .empty-donut { width: 130px; height: 130px; border: 22px solid var(--surface-muted); border-radius: 50%; }
118
+ .analytics-count { font-size: 12px; color: var(--text-soft); }
119
+ .analytics-count strong { color: var(--text); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
120
+ /* Ring left, chips flowing right. A country list is the long side: one
121
+ full-width row per country stretched the panel to the length of the list
122
+ instead of the height of the ring. */
123
+ .distribution-layout { display: grid; grid-template-columns: 184px minmax(0, 1fr); align-items: center; gap: 26px; }
124
+ .distribution-plot { max-width: 184px; }
125
+ .distribution-plot svg { filter: drop-shadow(0 10px 22px rgb(29 39 71 / 16%)); }
126
+ :root[data-theme="dark"] .distribution-plot svg { filter: drop-shadow(0 10px 26px rgb(0 0 0 / 45%)); }
127
+ .ring-readout { display: grid; justify-items: center; gap: 3px; }
128
+ .ring-value { font-size: 30px; font-weight: 750; letter-spacing: -.02em; line-height: 1.05; font-variant-numeric: tabular-nums; }
129
+ .ring-caption { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-soft); white-space: nowrap; }
130
+ .distribution-chips { display: flex; flex-wrap: wrap; align-content: flex-start; gap: 7px 8px; margin: 0; padding: 0; list-style: none; }
131
+ /* Chips drive the ring's focus, so they carry the same 44px target as every
132
+ other control; the flow layout keeps that height from stretching the card. */
133
+ .distribution-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 550; line-height: 1.1; cursor: pointer; box-shadow: 0 1px 2px rgb(25 30 55 / 4%); }
134
+ .distribution-chip:hover, .distribution-chip:focus-visible { border-color: var(--chip-color, var(--accent)); background: color-mix(in srgb, var(--chip-color, var(--accent)) 10%, var(--surface)); outline: none; }
135
+ .distribution-chip.is-active { border-color: var(--chip-color, var(--accent)); background: color-mix(in srgb, var(--chip-color, var(--accent)) 14%, var(--surface)); box-shadow: 0 4px 14px color-mix(in srgb, var(--chip-color, var(--accent)) 26%, transparent); }
136
+ .distribution-dot { width: 8px; height: 8px; flex: none; border-radius: 50%; }
137
+ .distribution-flag { font-size: 13px; line-height: 1; }
138
+ .distribution-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
139
+ .distribution-value { font-variant-numeric: tabular-nums; font-weight: 750; }
140
+ .distribution-share { color: var(--text-soft); font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
141
+ .chart-empty { min-height: 104px; display: flex; align-items: center; justify-content: center; gap: 16px; color: var(--text-soft); font-size: 13px; }
142
+ .empty-donut { width: 76px; height: 76px; border: 13px solid var(--surface-muted); border-radius: 50%; }
114
143
  .analytics-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
115
144
  .analytics-heading h3 { margin-bottom: 0; }
116
145
  .analytics-heading > span { font-size: 12px; color: var(--text-soft); }
@@ -313,7 +342,7 @@ body[data-page="logs"] #logsWorkspace > .panel { grid-template-rows: auto minmax
313
342
  .metric-card:nth-child(3) { border-left: 0; }
314
343
  }
315
344
  @media (max-width: 960px) {
316
- .analytics-distributions { grid-template-columns: 1fr; }
345
+ .analytics-distributions, .analytics-distributions:has(.chart-empty) { grid-template-columns: 1fr; }
317
346
  .sidebar-toggle { display: none; }
318
347
  .btn, .btn.small, .btn.icon-only, .icon-btn, .theme-choice, input, select, textarea { min-height: 44px; }
319
348
  .btn.icon-only, .icon-btn { width: 44px; min-width: 44px; height: 44px; }
@@ -322,7 +351,10 @@ body[data-page="logs"] #logsWorkspace > .panel { grid-template-rows: auto minmax
322
351
  .column-options > summary { min-height: 44px; }
323
352
  }
324
353
  @media (max-width: 720px) {
325
- .distribution-layout { grid-template-columns: 120px minmax(0, 1fr); gap: 10px; }
354
+ .distribution-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; justify-items: center; }
355
+ .distribution-plot { max-width: 156px; }
356
+ .distribution-chips { justify-content: center; }
357
+ .ring-value { font-size: 26px; }
326
358
  .analytics-distribution [data-donut-legend] { padding-inline: 6px; }
327
359
  .account-pool-table-wrap { max-height: none; }
328
360
  .sidebar-nav { grid-template-columns: repeat(5, minmax(0, 1fr)); }
@@ -384,3 +416,12 @@ body[data-page="logs"] #logsWorkspace > .panel { grid-template-rows: auto minmax
384
416
  @media (prefers-reduced-motion: reduce) {
385
417
  *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
386
418
  }
419
+ /* Stacked share bar: one row split by value, for lists too short to fill a ring. */
420
+ .share-track { display: flex; gap: 3px; height: 20px; border-radius: 999px; overflow: hidden; background: var(--surface-muted); box-shadow: inset 0 1px 2px rgb(25 30 55 / 6%); }
421
+ .share-segment { display: block; min-width: 4px; transform-origin: left center; background: linear-gradient(180deg, color-mix(in srgb, var(--segment-color) 82%, #fff) 0%, var(--segment-color) 100%); transition: opacity 200ms ease, filter 200ms ease; }
422
+ .share-segment.is-dimmed { opacity: .34; }
423
+ .share-segment.is-active { filter: saturate(1.15) brightness(1.06); }
424
+ .share-chips { margin-top: 14px; }
425
+ .calendar-stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
426
+ .calendar-stat { display: inline-flex; align-items: baseline; gap: 5px; padding: 5px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-soft); font-size: 12px; color: var(--text-soft); }
427
+ .calendar-stat strong { color: var(--text); font-size: 14px; font-weight: 750; font-variant-numeric: tabular-nums; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {