@swimmingliu/autovpn 2.0.3 → 2.1.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.
@@ -9,8 +9,10 @@ import { renderAnalytics, clearAnalytics } from './analytics.bundle.js';
9
9
  import {
10
10
  addAvailabilityTargetDraft,
11
11
  applyAvailabilityTargetDraft,
12
+ applyNetworkDraft,
12
13
  applySourceIterationDraft,
13
14
  buildAvailabilityTargetDraft,
15
+ buildNetworkDraft,
14
16
  buildPageMarkup,
15
17
  buildDashboardMetricsMarkup,
16
18
  buildDashboardStateMarkup,
@@ -25,6 +27,7 @@ import {
25
27
  buildAccountPoolRecords,
26
28
  removeAvailabilityTargetDraft,
27
29
  resolveDashboardRunSummary,
30
+ SOURCE_ROUND_DEFAULTS,
28
31
  validateSourceDraft,
29
32
  classifyLogEntry,
30
33
  escapeHtml,
@@ -36,10 +39,10 @@ initializeAppearance();
36
39
 
37
40
  const demoProfile = {
38
41
  sources: {
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 }
42
+ leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
43
+ heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
44
+ mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
45
+ xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, ...SOURCE_ROUND_DEFAULTS }
43
46
  },
44
47
  speed_test: {
45
48
  min_download_mb_s: 1.0,
@@ -114,7 +117,9 @@ const state = {
114
117
  statusFilter: 'all',
115
118
  brandFilter: 'all',
116
119
  sortBy: 'brand',
117
- busy: false
120
+ busy: false,
121
+ canSetExpiry: typeof window.vpnAutomation?.setAccountExpiry === 'function',
122
+ expiryEditorId: null
118
123
  },
119
124
  isDemo: false,
120
125
  runtime: document.body.dataset.runtime === 'web' ? 'web' : 'electron',
@@ -198,7 +203,10 @@ function hydrateInitialRuntimeState(loadedState = {}) {
198
203
  const loadedProfile = loadedState.profile ?? {};
199
204
  state.activity = Array.isArray(loadedState.activity) ? loadedState.activity : [];
200
205
  state.activityUnavailable = Number(loadedState.activityUnavailable ?? 0);
201
- state.profile = loadedProfile;
206
+ // An older profile has no [network] section. Seeding the displayed defaults
207
+ // here means saving any other drawer writes the section the pipeline reads
208
+ // instead of leaving it absent, and the network drawer opens on real values.
209
+ state.profile = { ...loadedProfile, network: buildNetworkDraft(loadedProfile.network) };
202
210
  state.savedProfile = structuredClone(loadedProfile);
203
211
  const nextRunState = String(loadedState.runState ?? '');
204
212
  if (['idle', 'running', 'stopping', 'failed', 'success'].includes(nextRunState)) {
@@ -642,6 +650,10 @@ function handleDocumentInput(event) {
642
650
  const floor = SOURCE_MINIMUM_ONE_KEYS.has(key) ? 1 : 0;
643
651
  sourceDraft[sourceName][key] = Math.max(floor, Math.trunc(parsed));
644
652
  }
653
+ // The area controls have a hard ceiling the backend also clamps to.
654
+ if (SOURCE_MAXIMUM_KEYS.has(key)) {
655
+ sourceDraft[sourceName][key] = Math.min(SOURCE_MAXIMUM_KEYS.get(key), sourceDraft[sourceName][key]);
656
+ }
645
657
  }
646
658
  return;
647
659
  }
@@ -793,6 +805,8 @@ const SOURCE_NUMERIC_KEYS = new Set([
793
805
  'max_iterations',
794
806
  'min_iterations',
795
807
  'plateau_limit',
808
+ 'areas_per_round',
809
+ 'area_concurrency',
796
810
  'max_runtime_seconds',
797
811
  'max_areas',
798
812
  'area_min',
@@ -802,7 +816,15 @@ const SOURCE_NUMERIC_KEYS = new Set([
802
816
  /** Of those, the ones the runner clamps to at least one. */
803
817
  const SOURCE_MINIMUM_ONE_KEYS = new Set([
804
818
  'max_iterations',
805
- 'plateau_limit'
819
+ 'plateau_limit',
820
+ 'areas_per_round',
821
+ 'area_concurrency'
822
+ ]);
823
+
824
+ /** Numeric source fields with a hard upper bound, matching the backend clamp. */
825
+ const SOURCE_MAXIMUM_KEYS = new Map([
826
+ ['areas_per_round', 64],
827
+ ['area_concurrency', 16]
806
828
  ]);
807
829
 
808
830
  function resolveErrorMessage(error) {
@@ -1037,6 +1059,7 @@ function buildSettingsDraft(section) {
1037
1059
  if (section === 'speed_test') return buildSpeedTestDraft(state.profile.speed_test);
1038
1060
  if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
1039
1061
  if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
1062
+ if (section === 'network') return buildNetworkDraft(state.profile.network);
1040
1063
  if (section === 'paths') return structuredClone(state.profile.paths);
1041
1064
  if (section === 'about') return { version: getMessages(state.language).sidebarVersion };
1042
1065
  return null;
@@ -1091,6 +1114,9 @@ async function saveSettingsDrawer() {
1091
1114
 
1092
1115
  async function hydrateAccountPool() {
1093
1116
  state.accountPool.status = 'loading';
1117
+ // Re-detect each hydration: the preload bridge is fixed for the page, but a
1118
+ // test harness or a future H5 adapter may attach it after bootstrap.
1119
+ state.accountPool.canSetExpiry = typeof window.vpnAutomation?.setAccountExpiry === 'function';
1094
1120
  touchUpdate();
1095
1121
  renderAccountPoolPanelOnly();
1096
1122
  if (!window.vpnAutomation?.loadAccounts) {
@@ -1154,7 +1180,54 @@ async function handleAccountPoolAction(button) {
1154
1180
  }
1155
1181
  if (action === 'toggle') {
1156
1182
  await toggleAccountStatus(Number(button.dataset.accountId));
1183
+ return;
1184
+ }
1185
+ if (action === 'expiry-edit') {
1186
+ state.accountPool.expiryEditorId = Number(button.dataset.accountId);
1187
+ renderAll();
1188
+ queueMicrotask(() => document.querySelector(`[data-account-expiry-input="${CSS.escape(String(button.dataset.accountId))}"]`)?.focus());
1189
+ return;
1190
+ }
1191
+ if (action === 'expiry-cancel') {
1192
+ state.accountPool.expiryEditorId = null;
1193
+ renderAll();
1194
+ return;
1195
+ }
1196
+ if (action === 'expiry-save') {
1197
+ const id = Number(button.dataset.accountId);
1198
+ const input = document.querySelector(`[data-account-expiry-input="${CSS.escape(String(id))}"]`);
1199
+ await setAccountExpiry(id, input?.value ?? '');
1200
+ }
1201
+ }
1202
+
1203
+ async function setAccountExpiry(id, expiresAt) {
1204
+ const account = (state.accountPool.accounts ?? []).find((item) => item.id === id);
1205
+ // Feature-detected: the browser build has no preload bridge, so this path is
1206
+ // never reachable there and the row hides the affordance.
1207
+ if (!account || typeof window.vpnAutomation?.setAccountExpiry !== 'function') {
1208
+ return;
1209
+ }
1210
+ const value = String(expiresAt ?? '').trim();
1211
+ if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1212
+ showToast({ tone: 'danger', message: '请选择有效的到期日期' });
1213
+ return;
1214
+ }
1215
+ state.accountPool.busy = true;
1216
+ renderAccountPoolPanelOnly();
1217
+ try {
1218
+ // Preload bridge signature: setAccountExpiry(id, 'YYYY-MM-DD'); '' clears it.
1219
+ const result = await window.vpnAutomation.setAccountExpiry(id, value);
1220
+ if (!result?.ok) {
1221
+ throw new Error(result?.error || '操作失败');
1222
+ }
1223
+ state.accountPool.expiryEditorId = null;
1224
+ showToast({ tone: 'success', message: value ? `${account.account_masked} 到期日已设为 ${value}` : `${account.account_masked} 到期日已清除` });
1225
+ } catch (error) {
1226
+ showToast({ tone: 'danger', message: resolveErrorMessage(error) });
1227
+ } finally {
1228
+ state.accountPool.busy = false;
1157
1229
  }
1230
+ await hydrateAccountPool();
1158
1231
  }
1159
1232
 
1160
1233
  async function downloadAccountsTemplate() {
@@ -1291,6 +1364,9 @@ function resolveSettingsDraftPayload(section, draft) {
1291
1364
  if (section === 'deploy') {
1292
1365
  return sanitizeDeployDraft(draft);
1293
1366
  }
1367
+ if (section === 'network') {
1368
+ return applyNetworkDraft(draft);
1369
+ }
1294
1370
  return structuredClone(draft);
1295
1371
  }
1296
1372
 
@@ -1693,6 +1769,24 @@ function handlePipelineEventDetails(event, options = {}) {
1693
1769
  return;
1694
1770
  }
1695
1771
 
1772
+ if (event.type === 'extract_platform_started') {
1773
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}开始提取,最多 ${event.requested_iterations ?? 0} 次,最少 ${event.min_iterations ?? 0} 次`, {
1774
+ kind: 'stage',
1775
+ stage: 'extract',
1776
+ level: 'info'
1777
+ });
1778
+ return;
1779
+ }
1780
+
1781
+ if (event.type === 'extract_platform_completed') {
1782
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
1783
+ kind: 'stage',
1784
+ stage: 'extract',
1785
+ level: 'info'
1786
+ });
1787
+ return;
1788
+ }
1789
+
1696
1790
  if (event.type === 'extract_source_started') {
1697
1791
  appendLog(`[extract] ${event.source_name} 开始提取,最多 ${event.requested_iterations ?? 0} 次,最少 ${event.min_iterations ?? 0} 次`, {
1698
1792
  kind: 'stage',
@@ -1705,7 +1799,7 @@ function handlePipelineEventDetails(event, options = {}) {
1705
1799
  if (event.type === 'extract_request_result') {
1706
1800
  const status = event.success ? '成功' : '失败';
1707
1801
  const retry = event.will_retry ? ',将重试' : '';
1708
- appendLog(`[extract] ${event.source_name} #${event.iteration} ${event.via} ${status}${retry}`, {
1802
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration} ${event.via} ${status}${retry}`, {
1709
1803
  kind: 'stage',
1710
1804
  stage: 'extract',
1711
1805
  level: event.success ? 'info' : 'warning'
@@ -1714,7 +1808,7 @@ function handlePipelineEventDetails(event, options = {}) {
1714
1808
  }
1715
1809
 
1716
1810
  if (event.type === 'extract_decrypt_result' && !event.success) {
1717
- appendLog(`[extract] ${event.source_name} #${event.iteration} 解密失败`, {
1811
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration} 解密失败`, {
1718
1812
  kind: 'stage',
1719
1813
  stage: 'extract',
1720
1814
  level: 'error'
@@ -1734,7 +1828,10 @@ function handlePipelineEventDetails(event, options = {}) {
1734
1828
 
1735
1829
  if (event.type === 'extract_iteration') {
1736
1830
  if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics(event);
1737
- appendLog(`[extract] ${event.source_name} #${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
1831
+ // Android and iOS run the same round numbers independently, so a bare `#N`
1832
+ // shows two identical lines. The tag only appears when the event carries a
1833
+ // platform, which keeps older backends and the URL-mode stream unchanged.
1834
+ appendLog(`[extract] ${event.source_name} ${platformTag(event.platform)}#${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
1738
1835
  kind: 'stage',
1739
1836
  stage: 'extract',
1740
1837
  level: 'info'
@@ -1976,6 +2073,14 @@ function resolveDefaultRetryStage(artifact) {
1976
2073
  return failedStage || retryableStages.at(-1) || '';
1977
2074
  }
1978
2075
 
2076
+ // Both channels emit the same round numbers, so the log needs the channel on
2077
+ // the line. `[android] ` keeps the rest of the format untouched; an event with
2078
+ // no platform (older backend, URL-mode extract) renders exactly as before.
2079
+ function platformTag(platform) {
2080
+ const value = String(platform ?? '').trim().toLowerCase();
2081
+ return value === 'android' || value === 'ios' ? `[${value}] ` : '';
2082
+ }
2083
+
1979
2084
  function appendLog(message, overrides = {}) {
1980
2085
  const logCenter = document.querySelector('#logCenterTable');
1981
2086
  if (logCenter) {
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.0.3',
8
+ sidebarVersion: 'v.2.1.0',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -1390,6 +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
1395
  grid-template-columns: repeat(2, minmax(72px, 1fr));
1394
1396
  gap: 8px;
1395
1397
  }
@@ -1478,7 +1480,7 @@ body[data-page="logs"] #logCenterTable {
1478
1480
  }
1479
1481
 
1480
1482
  .settings-col-rounds {
1481
- width: 196px;
1483
+ width: 232px;
1482
1484
  }
1483
1485
 
1484
1486
  .availability-col-url {
@@ -1704,13 +1706,40 @@ body[data-page="logs"] #logCenterTable {
1704
1706
  }
1705
1707
 
1706
1708
  .account-col-expiry {
1707
- width: 110px;
1709
+ width: 178px;
1708
1710
  }
1709
1711
 
1710
1712
  .account-col-usage {
1711
1713
  width: 132px;
1712
1714
  }
1713
1715
 
1716
+ .account-expiry-value {
1717
+ display: block;
1718
+ font-size: 13px;
1719
+ }
1720
+
1721
+ .account-expiry-value.unknown {
1722
+ color: var(--text-soft);
1723
+ }
1724
+
1725
+ .account-expiry-edit {
1726
+ margin-top: 4px;
1727
+ }
1728
+
1729
+ .account-expiry-editor {
1730
+ display: flex;
1731
+ flex-wrap: wrap;
1732
+ align-items: center;
1733
+ gap: 6px;
1734
+ margin-top: 6px;
1735
+ }
1736
+
1737
+ .account-expiry-editor input {
1738
+ min-width: 132px;
1739
+ padding-inline: 8px;
1740
+ font-size: 12px;
1741
+ }
1742
+
1714
1743
  .account-col-actions {
1715
1744
  width: 92px;
1716
1745
  }
@@ -2841,6 +2870,12 @@ body[data-page="logs"] #logCenterTable {
2841
2870
  min-height: 44px;
2842
2871
  }
2843
2872
 
2873
+ /* The expiry editor sits in that same action cell at phone widths, so its
2874
+ date input has to meet the same tap-target floor. */
2875
+ .account-expiry-editor input {
2876
+ min-height: 44px;
2877
+ }
2878
+
2844
2879
  .app-frame {
2845
2880
  display: block;
2846
2881
  height: auto;
@@ -8,8 +8,8 @@ const TABLES = {
8
8
  ],
9
9
  accounts: [
10
10
  ['account', '账号', 240, 160], ['brand', '加速器', 110, 90],
11
- ['status', '状态', 160, 120], ['expiry', '到期', 150, 120],
12
- ['usage', '使用', 180, 140], ['actions', '操作', 96, 80]
11
+ ['status', '状态', 150, 120], ['expiry', '到期', 180, 130],
12
+ ['usage', '使用', 150, 120], ['actions', '操作', 96, 80]
13
13
  ]
14
14
  };
15
15
  const initialized = new WeakSet();
@@ -1,12 +1,32 @@
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 is one
5
+ // cursor step over that brand's fixed area list (one region by default), so the
6
+ // count is per platform and per region: 4/2 would fetch only a handful of nodes.
7
+ export const SOURCE_ROUND_DEFAULTS = {
8
+ max_iterations: 200,
9
+ plateau_limit: 50,
10
+ areas_per_round: 8,
11
+ area_concurrency: 4
12
+ };
13
+ export const SOURCE_ROUND_LIMITS = {
14
+ max_iterations: [1, Number.POSITIVE_INFINITY],
15
+ plateau_limit: [1, Number.POSITIVE_INFINITY],
16
+ areas_per_round: [1, 64],
17
+ area_concurrency: [1, 16]
18
+ };
19
+ export const NETWORK_DEFAULTS = {
20
+ proxy_mode: 'proxy',
21
+ proxy_url: 'http://127.0.0.1:7897'
22
+ };
23
+
4
24
  const FALLBACK_PROFILE = {
5
25
  sources: {
6
- leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
7
- heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
8
- mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
9
- xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, max_iterations: 4, plateau_limit: 2 }
26
+ leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
27
+ heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
28
+ mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, ...SOURCE_ROUND_DEFAULTS },
29
+ xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, ...SOURCE_ROUND_DEFAULTS }
10
30
  },
11
31
  speed_test: {
12
32
  min_download_mb_s: 1,
@@ -569,6 +589,7 @@ function buildSettingsPage(vm, messages) {
569
589
  ${buildSettingsCard('speed_test', '测速配置', `最低 ${vm.profile.speed_test.min_download_mb_s} MB/s · 并发 ${vm.profile.speed_test.concurrency}`)}
570
590
  ${buildSettingsCard('availability_targets', 'AI可达性检测', `已启用 ${enabledAvailabilityTargetCount(vm.profile.availability_targets)} / ${Object.keys(vm.profile.availability_targets ?? {}).length} 个网站`)}
571
591
  ${buildSettingsCard('deploy', '部署配置', `项目 ${vm.profile.deploy.project_name || '未设置'}`)}
592
+ ${buildSettingsCard('network', '网络出口', networkSummary(vm.profile.network))}
572
593
  </div>
573
594
  </article>
574
595
 
@@ -633,12 +654,24 @@ export function buildAccountPoolRecords(pool = EMPTY_ACCOUNT_POOL) {
633
654
  ${rows.map((account) => {
634
655
  const meta = ACCOUNT_STATUS_META[account.status] ?? { label: account.status, tone: 'neutral' };
635
656
  const retired = account.status !== 'active';
657
+ const expiry = String(account.expires_at ?? '').trim();
658
+ const editingExpiry = pool.canSetExpiry && Number(pool.expiryEditorId) === Number(account.id);
636
659
  return `
637
660
  <tr data-account-row="${account.id}">
638
661
  <td data-field-label="账号"><code class="account-mask">${escapeHtml(account.account_masked)}</code>${account.note ? `<span class="account-note">${escapeHtml(account.note)}</span>` : ''}</td>
639
662
  <td data-field-label="加速器">${escapeHtml(SOURCE_NAMES[account.brand] ?? account.brand)}</td>
640
663
  <td data-field-label="状态">${renderBadge(meta.label, meta.tone)}${account.status_reason ? `<span class="account-note">${escapeHtml(account.status_reason)}</span>` : ''}</td>
641
- <td data-field-label="到期">${escapeHtml(account.expires_at || '—')}</td>
664
+ <td data-field-label="到期">
665
+ <span class="account-expiry-value ${expiry ? '' : 'unknown'}">${escapeHtml(expiry || '未知')}</span>
666
+ ${pool.canSetExpiry ? (editingExpiry
667
+ ? `<div class="account-expiry-editor">
668
+ <input type="date" aria-label="${escapeHtml(account.account_masked)}:到期日期" data-account-expiry-input="${account.id}" value="${escapeHtml(expiry.slice(0, 10))}" ${pool.busy ? 'disabled' : ''} />
669
+ <button class="btn btn-primary small" data-account-action="expiry-save" data-account-id="${account.id}" type="button" ${pool.busy ? 'disabled' : ''}>保存</button>
670
+ <button class="btn btn-secondary small" data-account-action="expiry-cancel" data-account-id="${account.id}" type="button" ${pool.busy ? 'disabled' : ''}>取消</button>
671
+ </div>`
672
+ : `<button class="btn btn-secondary small account-expiry-edit" data-account-action="expiry-edit" data-account-id="${account.id}" type="button" ${pool.busy ? 'disabled' : ''}>${expiry ? '改期' : '设置到期'}</button>`)
673
+ : ''}
674
+ </td>
642
675
  <td data-field-label="使用"><span class="account-usage">${Number(account.use_count ?? 0)} 次</span><span class="account-note">${Number(account.success_count ?? 0)} 成功 / ${Number(account.fail_count ?? 0)} 失败</span></td>
643
676
  <td data-field-label="操作"><button class="btn btn-secondary small ${retired ? '' : 'destructive'}" data-account-action="toggle" data-account-id="${account.id}" type="button" ${pool.busy ? 'disabled' : ''}>${retired ? '启用' : '停用'}</button></td>
644
677
  </tr>
@@ -679,6 +712,7 @@ export function buildAccountPoolPanel(vm) {
679
712
  `).join('')}
680
713
  </div>
681
714
  ${Number(stats.expiring_soon ?? 0) > 0 ? `<p class="account-pool-expiry">7 天内到期 ${Number(stats.expiring_soon)} 个</p>` : ''}
715
+ ${Number.isFinite(Number(stats.expiry_unknown)) && Number(stats.expiry_unknown) > 0 ? `<p class="account-pool-expiry unknown">到期时间未知 ${Number(stats.expiry_unknown)}</p>` : ''}
682
716
  </div>
683
717
 
684
718
  <div class="account-pool-toolbar">
@@ -767,7 +801,8 @@ function buildSettingsDrawer(vm) {
767
801
  sources: '数据源配置',
768
802
  speed_test: '测速配置',
769
803
  availability_targets: 'AI可达性检测',
770
- deploy: '部署配置'
804
+ deploy: '部署配置',
805
+ network: '网络出口'
771
806
  }[section] ?? '设置';
772
807
 
773
808
  const style = vm.modalTransform ? `style="transform: ${escapeHtml(vm.modalTransform)};"` : '';
@@ -830,7 +865,11 @@ const EMPTY_ACCOUNT_POOL = {
830
865
  statusFilter: 'all',
831
866
  brandFilter: 'all',
832
867
  sortBy: 'brand',
833
- busy: false
868
+ busy: false,
869
+ // Feature-detected from the preload bridge. The H5/browser build has no
870
+ // setAccountExpiry, so the row simply omits the affordance there.
871
+ canSetExpiry: false,
872
+ expiryEditorId: null
834
873
  };
835
874
 
836
875
  /** Credential sources are also configured by their official site. */
@@ -849,7 +888,13 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
849
888
  <div class="source-drawer-settings">
850
889
  <div class="source-drawer-settings-copy">
851
890
  <strong>每个加速器只填官网</strong>
852
- <span>密钥从 APK 自动发现,账号密码由账号池提供。Android 与 iOS 两个通道同时抓取,每条线路按「轮数」循环请求,连续「早停」轮没有新节点就停止。</span>
891
+ <span>密钥从 APK 自动发现,账号密码由账号池提供。Android 与 iOS 两个通道同时抓取;「轮数」由两端各自独立跑满,1000 就是 Android 1000 轮加 iOS 1000 轮,不是 500+500。</span>
892
+ </div>
893
+ <div class="source-drawer-settings-copy">
894
+ <span>每次运行先按该加速器的节点列表生成一份固定区域列表(去掉中国大陆与免费区域、去重、保持原顺序),运行中不再改变。每一轮用游标依次取出「每轮区域数」个区域(默认 8 个,取完回绕);「区域并发」控制该轮这些区域请求同时发出的数量(默认 4)。31 个区域默认四轮即可覆盖一遍,轮数按各平台独立计算。</span>
895
+ </div>
896
+ <div class="source-drawer-settings-copy">
897
+ <span>「早停」按连续无新增节点的轮数计数。是否为新节点按节点指纹判断(address / port / id / transport / host / path / tls / sni),并在 Android 与 iOS 之间统一去重:改名重复的节点、或另一端已经抓到的节点,都不会让计数归零。</span>
853
898
  </div>
854
899
  </div>
855
900
  </details>
@@ -884,14 +929,22 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
884
929
  <div class="source-credential-rounds">
885
930
  <label class="source-field">
886
931
  <span>轮数</span>
887
- <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="每条线路循环请求的次数;每轮服务端会返回不同落地机" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? 4)}" />
932
+ <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000 轮);每轮是固定区域列表上的一个游标步,默认只取 1 个区域" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations)}" />
888
933
  </label>
889
934
  <label class="source-field">
890
935
  <span>早停</span>
891
- <input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:连续空轮早停次数" title="连续多少轮没有新节点就停止" data-drawer-source="${escapeHtml(name)}" data-drawer-key="plateau_limit" value="${escapeHtml(source.plateau_limit ?? 2)}" />
936
+ <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)}" />
937
+ </label>
938
+ <label class="source-field">
939
+ <span>每轮区域数</span>
940
+ <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)}" />
941
+ </label>
942
+ <label class="source-field">
943
+ <span>区域并发</span>
944
+ <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)}" />
892
945
  </label>
893
946
  </div>
894
- <span class="source-round-summary">双端并发</span>
947
+ <span class="source-round-summary">双端各自计轮 · 每轮按固定区域列表推进</span>
895
948
  </td>
896
949
  </tr>
897
950
  `;
@@ -965,6 +1018,25 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
965
1018
  `;
966
1019
  }
967
1020
 
1021
+ if (section === 'network') {
1022
+ const network = buildNetworkDraft(draft);
1023
+ return `
1024
+ <details class="context-help">
1025
+ <summary>出口与回退规则</summary>
1026
+ <p>代理:全程经 proxy_url 抓取,代理没在监听时自动改为直连;自动:先直连,遇到 IP 被封(403/429 或封锁页)后整轮改走代理;直连:不使用代理。默认走本机 127.0.0.1:7897。</p>
1027
+ </details>
1028
+ <div class="form-grid compact-form-grid network-form-grid">
1029
+ <label class="field compact">
1030
+ <span>出口模式</span>
1031
+ <select data-drawer-path="network.proxy_mode" aria-label="出口模式">
1032
+ ${[['proxy', '代理'], ['auto', '自动(被封后切代理)'], ['direct', '直连']].map(([value, label]) => `<option value="${value}" ${network.proxy_mode === value ? 'selected' : ''}>${label}</option>`).join('')}
1033
+ </select>
1034
+ </label>
1035
+ ${renderDrawerField('代理地址', 'text', network.proxy_url, 'network.proxy_url', true, 'inputmode="url" spellcheck="false" placeholder="http://127.0.0.1:7897"')}
1036
+ </div>
1037
+ `;
1038
+ }
1039
+
968
1040
  return `
969
1041
  <div class="notice-card">
970
1042
  <strong>无可用配置</strong>
@@ -973,6 +1045,29 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
973
1045
  `;
974
1046
  }
975
1047
 
1048
+ export function buildNetworkDraft(network = {}) {
1049
+ const mode = String(network?.proxy_mode ?? '').trim().toLowerCase();
1050
+ const url = String(network?.proxy_url ?? '').trim();
1051
+ return {
1052
+ proxy_mode: ['proxy', 'direct', 'auto'].includes(mode) ? mode : NETWORK_DEFAULTS.proxy_mode,
1053
+ proxy_url: url || NETWORK_DEFAULTS.proxy_url
1054
+ };
1055
+ }
1056
+
1057
+ export function applyNetworkDraft(draft = {}) {
1058
+ const network = buildNetworkDraft(draft);
1059
+ return {
1060
+ proxy_mode: network.proxy_mode,
1061
+ proxy_url: network.proxy_url
1062
+ };
1063
+ }
1064
+
1065
+ function networkSummary(network = {}) {
1066
+ const resolved = buildNetworkDraft(network);
1067
+ const modeLabel = { proxy: '代理', auto: '自动', direct: '直连' }[resolved.proxy_mode];
1068
+ return resolved.proxy_mode === 'direct' ? modeLabel : `${modeLabel} ${resolved.proxy_url}`;
1069
+ }
1070
+
976
1071
  function formatAvailabilityName(name) {
977
1072
  const labels = { gemini: 'Gemini', chatgpt: 'ChatGPT', claude: 'Claude' };
978
1073
  return labels[String(name ?? '').toLowerCase()] ?? String(name || '网站');
@@ -1057,8 +1152,10 @@ function buildSourceRows(profile) {
1057
1152
  enabled: source.enabled !== false,
1058
1153
  mode: 'credential',
1059
1154
  officialSite: source.official_site ?? '',
1060
- maxIterations: Number(source.max_iterations ?? 4),
1061
- plateauLimit: Number(source.plateau_limit ?? 2)
1155
+ maxIterations: Number(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations),
1156
+ plateauLimit: Number(source.plateau_limit ?? SOURCE_ROUND_DEFAULTS.plateau_limit),
1157
+ areasPerRound: Number(source.areas_per_round ?? SOURCE_ROUND_DEFAULTS.areas_per_round),
1158
+ areaConcurrency: Number(source.area_concurrency ?? SOURCE_ROUND_DEFAULTS.area_concurrency)
1062
1159
  }));
1063
1160
  }
1064
1161
 
@@ -1122,8 +1219,10 @@ export function buildSourceIterationDraft(sources = {}) {
1122
1219
  const firstSource = Object.values(normalized)[0] ?? {};
1123
1220
  return {
1124
1221
  sources: normalized,
1125
- maxIterations: coercePositiveInteger(firstSource.max_iterations, 4),
1126
- plateauLimit: coercePositiveInteger(firstSource.plateau_limit, 2)
1222
+ maxIterations: coercePositiveInteger(firstSource.max_iterations, SOURCE_ROUND_DEFAULTS.max_iterations),
1223
+ plateauLimit: coercePositiveInteger(firstSource.plateau_limit, SOURCE_ROUND_DEFAULTS.plateau_limit),
1224
+ areasPerRound: coerceBoundedInteger(firstSource.areas_per_round, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64),
1225
+ areaConcurrency: coerceBoundedInteger(firstSource.area_concurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16)
1127
1226
  };
1128
1227
  }
1129
1228
 
@@ -1186,13 +1285,21 @@ export function applySourceIterationDraft(sources = {}, draft = {}) {
1186
1285
  if (draft?.sources) {
1187
1286
  return normalizeOfficialSourceDraft(draft.sources);
1188
1287
  }
1189
- const maxIterations = coercePositiveInteger(draft.maxIterations, 4);
1190
- const plateauLimit = coercePositiveInteger(draft.plateauLimit, 2);
1288
+ const maxIterations = coercePositiveInteger(draft.maxIterations, SOURCE_ROUND_DEFAULTS.max_iterations);
1289
+ const plateauLimit = coercePositiveInteger(draft.plateauLimit, SOURCE_ROUND_DEFAULTS.plateau_limit);
1290
+ const areasPerRound = coerceBoundedInteger(draft.areasPerRound, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64);
1291
+ const areaConcurrency = coerceBoundedInteger(draft.areaConcurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16);
1191
1292
  const normalized = normalizeOfficialSourceDraft(sources);
1192
1293
  return Object.fromEntries(
1193
1294
  Object.entries(normalized).map(([name, source]) => [
1194
1295
  name,
1195
- { ...source, max_iterations: maxIterations, plateau_limit: plateauLimit }
1296
+ {
1297
+ ...source,
1298
+ max_iterations: maxIterations,
1299
+ plateau_limit: plateauLimit,
1300
+ areas_per_round: areasPerRound,
1301
+ area_concurrency: areaConcurrency
1302
+ }
1196
1303
  ])
1197
1304
  );
1198
1305
  }
@@ -1222,6 +1329,20 @@ export function validateSourceDraft(draft = {}) {
1222
1329
  if (!Number.isFinite(Number(draft.plateauLimit)) || Number(draft.plateauLimit) < 1) {
1223
1330
  return '早停轮数至少为 1';
1224
1331
  }
1332
+ for (const [name, source] of Object.entries(sources)) {
1333
+ if (!SOURCE_DISPLAY_ORDER.includes(name)) continue;
1334
+ for (const [key, label, [, max]] of [
1335
+ ['areas_per_round', '每轮区域数', SOURCE_ROUND_LIMITS.areas_per_round],
1336
+ ['area_concurrency', '区域并发', SOURCE_ROUND_LIMITS.area_concurrency]
1337
+ ]) {
1338
+ const raw = source?.[key];
1339
+ if (raw === undefined || raw === null || raw === '') continue;
1340
+ const value = Number(raw);
1341
+ if (!Number.isFinite(value) || value < 1 || value > max) {
1342
+ return `${SOURCE_NAMES[name] ?? name}:${label}必须是 1-${max} 之间的整数`;
1343
+ }
1344
+ }
1345
+ }
1225
1346
  return '';
1226
1347
  }
1227
1348
 
@@ -1239,6 +1360,16 @@ function coercePositiveInteger(value, fallback) {
1239
1360
  return Math.trunc(parsed);
1240
1361
  }
1241
1362
 
1363
+ // The two area controls have a closed range; a stored value from outside it is
1364
+ // clamped rather than rejected, so an older or hand-edited profile still opens.
1365
+ function coerceBoundedInteger(value, fallback, min, max) {
1366
+ const parsed = Number(value);
1367
+ if (!Number.isFinite(parsed)) {
1368
+ return fallback;
1369
+ }
1370
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
1371
+ }
1372
+
1242
1373
  function coerceAreaValue(value, fallback) {
1243
1374
  const parsed = Number(value);
1244
1375
  if (!Number.isFinite(parsed)) {
@@ -1374,6 +1505,8 @@ export function normalizeOfficialSourceDraft(sources = {}) {
1374
1505
  // not overwrite them just by appearing later in the file.
1375
1506
  max_iterations: existing.max_iterations ?? source.max_iterations,
1376
1507
  plateau_limit: existing.plateau_limit ?? source.plateau_limit,
1508
+ areas_per_round: existing.areas_per_round ?? source.areas_per_round,
1509
+ area_concurrency: existing.area_concurrency ?? source.area_concurrency,
1377
1510
  enabled: existing.enabled !== false || source.enabled !== false
1378
1511
  }
1379
1512
  : { ...source };
@@ -1391,8 +1524,10 @@ export function normalizeOfficialSourceDraft(sources = {}) {
1391
1524
  ? (OFFICIAL_SITES[name] ?? '')
1392
1525
  : String(source.official_site).trim(),
1393
1526
  enabled: source.enabled !== false,
1394
- max_iterations: wasCredential ? coercePositiveInteger(source.max_iterations, 4) : 4,
1395
- plateau_limit: wasCredential ? coercePositiveInteger(source.plateau_limit, 2) : 2
1527
+ max_iterations: wasCredential ? coercePositiveInteger(source.max_iterations, SOURCE_ROUND_DEFAULTS.max_iterations) : SOURCE_ROUND_DEFAULTS.max_iterations,
1528
+ plateau_limit: wasCredential ? coercePositiveInteger(source.plateau_limit, SOURCE_ROUND_DEFAULTS.plateau_limit) : SOURCE_ROUND_DEFAULTS.plateau_limit,
1529
+ areas_per_round: wasCredential ? coerceBoundedInteger(source.areas_per_round, SOURCE_ROUND_DEFAULTS.areas_per_round, 1, 64) : SOURCE_ROUND_DEFAULTS.areas_per_round,
1530
+ area_concurrency: wasCredential ? coerceBoundedInteger(source.area_concurrency, SOURCE_ROUND_DEFAULTS.area_concurrency, 1, 16) : SOURCE_ROUND_DEFAULTS.area_concurrency
1396
1531
  };
1397
1532
  for (const field of OBSOLETE_SOURCE_FIELDS) delete normalized[field];
1398
1533
  // A URL-mode floor is the same mismatch as its URL-mode cap: 10 URL fetches
@@ -1419,6 +1554,7 @@ function mergeProfile(profile) {
1419
1554
  availability_targets: profile?.availability_targets ?? DEFAULT_AVAILABILITY_TARGETS,
1420
1555
  speed_test: buildSpeedTestDraft(profile?.speed_test),
1421
1556
  deploy: { ...FALLBACK_PROFILE.deploy, ...(profile?.deploy ?? {}) },
1557
+ network: buildNetworkDraft(profile?.network),
1422
1558
  paths: mergedPaths,
1423
1559
  workspace: mergedPaths
1424
1560
  };