@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.
- package/dist/artifacts/list.js +37 -7
- package/dist/backend/node-backend.js +90 -6
- package/dist/cli/accounts-commands.js +2 -1
- package/dist/config/profile.js +37 -3
- package/dist/pipeline/brand-cache.js +22 -0
- package/dist/pipeline/credential-source.js +350 -73
- package/dist/pipeline/extract.js +2 -1
- package/dist/pipeline/orchestrator.js +10 -2
- package/dist/server/http.js +10 -0
- package/dist/server/runtime.js +337 -32
- package/dist/server/web-adapter.js +4 -0
- package/dist/web/renderer/analytics.bundle.js +9 -9
- package/dist/web/renderer/app.js +72 -2
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +1 -0
- package/dist/web/renderer/styles.css +2 -2
- package/dist/web/renderer/views.js +157 -28
- package/dist/web/renderer/workspace.css +50 -9
- package/package.json +1 -1
package/dist/web/renderer/app.js
CHANGED
|
@@ -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',
|
|
@@ -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
|
-
/*
|
|
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
|
-
//
|
|
5
|
-
// cursor over that brand's fixed area list and pulls
|
|
6
|
-
// the count is per platform and per round
|
|
7
|
-
//
|
|
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
|
|
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
|
|
907
|
+
<span>每次运行先按该加速器的节点列表生成一份固定区域列表(去掉中国大陆与免费区域、去重、保持原顺序),运行中不再改变。一遍 = 把该加速器全部区域各请求一次(实测 雷霆 30 / 黑洞 31 / 蜜蜂 31 / 旋风 26);一遍就是一轮,双端各自计遍。「同时请求数」控制一遍内同时发出的请求数量(默认 4),每遍分 ceil(区域数 ÷ 同时请求数) 批依次发出。</span>
|
|
896
908
|
</div>
|
|
897
909
|
<div class="source-drawer-settings-copy">
|
|
898
|
-
<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
|
|
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
|
|
933
|
-
<input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}
|
|
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
|
|
941
|
-
<input type="number" min="1"
|
|
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
|
|
945
|
-
<input type="number" min="1" max="16" step="1" inputmode="numeric" aria-label="${label}
|
|
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"
|
|
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', '
|
|
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:
|
|
1529
|
-
plateau_limit:
|
|
1530
|
-
areas_per_round:
|
|
1531
|
-
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
|
-
|
|
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
|
-
.
|
|
109
|
-
.analytics-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.
|
|
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:
|
|
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; }
|