@swimmingliu/autovpn 1.4.1 → 1.5.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.
@@ -0,0 +1,1258 @@
1
+ import { buildStageModel, PAGE_INDEX, PAGE_ORDER, resolveRunControlState } from './state.js';
2
+
3
+ const FALLBACK_PROFILE = {
4
+ sources: {
5
+ leiting: {
6
+ url: 'https://capture-1.vpn.example/api/v1/client/subscribe',
7
+ key: 'lt-demo-key',
8
+ enabled: true
9
+ },
10
+ heilong: {
11
+ url: 'https://capture-2.vpn.example/api/v1/client/nodes',
12
+ key: 'hl-demo-key',
13
+ enabled: true
14
+ },
15
+ custom: {
16
+ url: 'https://capture-3.vpn.example/custom.txt',
17
+ key: '',
18
+ enabled: false
19
+ }
20
+ },
21
+ speed_test: {
22
+ min_download_mb_s: 1,
23
+ timeout_seconds: 20,
24
+ concurrency: 3
25
+ },
26
+ deploy: {
27
+ project_name: 'sub-nodes',
28
+ pages_project_url: 'https://sub-nodes.pages.dev',
29
+ subscription_url: 'https://vpn.example.top/179ba8dd-3854-4747-b853-fc1868ef3937',
30
+ verify_subscription_url: 'https://www.swimmingliu.online/sub?token=8410fb43eb2176497f5beafc0c39f5bc',
31
+ account_id: '',
32
+ cloudflare_api_token: '',
33
+ pages_secret_admin: 'swimmingliu'
34
+ },
35
+ paths: {
36
+ project_root: '/Users/user/vpn-sub',
37
+ artifacts_root: '/Users/user/vpn-sub/artifacts'
38
+ }
39
+ };
40
+
41
+ const NAV_ICONS = {
42
+ dashboard: '⌂',
43
+ runs: '◉',
44
+ results: '▣',
45
+ subscriptions: '♢',
46
+ logs: '▤',
47
+ settings: '⚙'
48
+ };
49
+
50
+ const PAGE_STATUS = {
51
+ dashboard: ['', 'neutral'],
52
+ runs: ['', 'neutral'],
53
+ results: ['', 'neutral'],
54
+ subscriptions: ['', 'neutral'],
55
+ logs: ['', 'neutral'],
56
+ settings: ['', 'neutral']
57
+ };
58
+
59
+ const LOG_FILTERS = ['全部', '运行日志', '错误', '按阶段'];
60
+ const SUBSCRIPTION_FORMATS = ['Clash', 'Clash Meta', 'Sing-box', 'Surge'];
61
+ const EMPTY_COUNTS = {
62
+ raw_links: 0,
63
+ deduped_links: 0,
64
+ speedtest_links: 0,
65
+ availability_links: 0,
66
+ postprocess_links: 0
67
+ };
68
+
69
+ const SOURCE_DISPLAY_ORDER = ['leiting', 'heidong', 'mifeng', 'xuanfeng-area', 'xuanfeng-all-area'];
70
+ const RETRY_STAGE_ORDER = ['speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
71
+ const STAGE_DISPLAY_NAMES = {
72
+ doctor: 'doctor',
73
+ extract: 'extract',
74
+ dedupe: 'dedupe',
75
+ speedtest: 'speedtest',
76
+ availability: 'availability',
77
+ postprocess: 'postprocess',
78
+ render: 'render',
79
+ obfuscate: 'obfuscate',
80
+ deploy: 'deploy',
81
+ verify: 'verify'
82
+ };
83
+ const DEFAULT_AVAILABILITY_TARGETS = {
84
+ gemini: {
85
+ url: 'https://gemini.google.com',
86
+ enabled: true
87
+ },
88
+ chatgpt_ios: {
89
+ url: 'https://ios.chat.openai.com/',
90
+ enabled: true
91
+ },
92
+ chatgpt_web: {
93
+ url: 'https://api.openai.com/compliance/cookie_requirements',
94
+ enabled: true
95
+ },
96
+ claude: {
97
+ url: 'https://claude.ai/cdn-cgi/trace',
98
+ enabled: true
99
+ }
100
+ };
101
+
102
+ export function buildViewModel(state, messages, language) {
103
+ const profile = mergeProfile(state.profile ?? FALLBACK_PROFILE);
104
+ const sourceCounts = state.sourceCounts ?? {};
105
+ const dedupedSourceCount = Object.values(sourceCounts).reduce(
106
+ (total, counts) => total + Number(counts?.deduped_links ?? 0),
107
+ 0
108
+ );
109
+ const counts = {
110
+ ...EMPTY_COUNTS,
111
+ ...(state.counts ?? {})
112
+ };
113
+ if (!Number.isFinite(Number(counts.deduped_links)) || Number(counts.deduped_links) <= 0) {
114
+ counts.deduped_links = dedupedSourceCount || counts.deduped_links || counts.postprocess_links || 0;
115
+ }
116
+ const subscriptionUrl = profile.deploy.subscription_url || FALLBACK_PROFILE.deploy.subscription_url;
117
+ const subscriptionCards = buildSubscriptionCards(subscriptionUrl);
118
+ const currentSubscription = subscriptionCards.find((card) => card.title === state.subscriptionFormat) ?? subscriptionCards[0];
119
+ const displayLogs = (state.logEntries ?? []).map((entry) => classifyLogEntry(entry));
120
+ const logFilter = state.logFilter ?? '全部';
121
+ const stageRows = normalizeStageRows(state.stageStatus, state.runState);
122
+ const currentStage = stageRows.find((row) => row.status === 'running') ?? stageRows.find((row) => row.status === 'success') ?? stageRows[0];
123
+ const artifactDir = state.artifactDir ?? '';
124
+ const hasResult = Boolean(artifactDir || Object.values(counts).some((value) => Number(value) > 0));
125
+ const retryArtifacts = Array.isArray(state.retryArtifacts) ? state.retryArtifacts : [];
126
+ const selectedRetryArtifact = retryArtifacts.find((item) => item.artifact_dir === state.selectedRetryArtifactDir) ?? retryArtifacts[0] ?? null;
127
+ const retryStageOptions = deriveRetryStageOptions(selectedRetryArtifact);
128
+ const selectedRetryStage = retryStageOptions.includes(state.selectedRetryStage)
129
+ ? state.selectedRetryStage
130
+ : retryStageOptions[0] ?? '';
131
+ const deployment = {
132
+ project_name: state.deployment?.project_name ?? profile.deploy.project_name ?? '',
133
+ pages_project_url: state.deployment?.pages_project_url ?? profile.deploy.pages_project_url ?? '',
134
+ worker_entry: state.deployment?.worker_entry ?? (artifactDir ? `${artifactDir}/pages_bundle/_worker.js` : ''),
135
+ module_manifest_path: state.deployment?.module_manifest_path ?? (artifactDir ? `${artifactDir}/pages_bundle/manifest.json` : '')
136
+ };
137
+
138
+ return {
139
+ profile,
140
+ counts,
141
+ subscriptionUrl,
142
+ subscriptionFormat: state.subscriptionFormat ?? 'Clash',
143
+ qrDataUrl: state.qrDataUrl ?? '',
144
+ displayLogs,
145
+ logFilter,
146
+ logRows: buildLogRows(filterLogEntries(displayLogs, logFilter)),
147
+ logGroups: groupLogEntriesByStage(filterLogEntries(displayLogs, logFilter)),
148
+ stageRows,
149
+ currentStage,
150
+ artifactDir,
151
+ hasResult,
152
+ lastUpdated: formatDate(state.lastUpdateAt) || '—',
153
+ runStartedAt: formatDate(state.runStartedAt) || '—',
154
+ runElapsed: state.runState === 'running' ? formatElapsed(state.runStartedAt) : '—',
155
+ outputFiles: state.outputFiles ?? [],
156
+ nodeRows: state.nodeRows ?? [],
157
+ regionStats: buildRegionStats(state.nodeRows ?? []),
158
+ subscriptionCards,
159
+ currentSubscription,
160
+ sourceRows: buildSourceRows(profile),
161
+ sourceCounts,
162
+ rawSourceMetricRows: buildSourceMetricRows(profile, sourceCounts, 'raw_links'),
163
+ dedupedSourceMetricRows: buildSourceMetricRows(profile, sourceCounts, 'deduped_links'),
164
+ retryContext: state.retryContext ?? {},
165
+ deployment,
166
+ retryArtifacts,
167
+ selectedRetryArtifact,
168
+ selectedRetryArtifactDir: selectedRetryArtifact?.artifact_dir ?? '',
169
+ retryStageOptions,
170
+ selectedRetryStage,
171
+ runControlState: resolveRunControlState(state.runState),
172
+ statusItems: buildStatusItems(state, messages),
173
+ currentStatusLabel: resolveCurrentStatusLabel(state, messages),
174
+ currentTaskLabel: resolveCurrentTaskLabel(state, messages),
175
+ runStateLabel: messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle,
176
+ runStateTone: state.runState === 'running' ? 'success' : state.runState === 'stopping' ? 'warning' : 'neutral',
177
+ settingsDrawer: state.settingsDrawer ?? null,
178
+ modalTransform: state.modalTransform ?? ''
179
+ };
180
+ }
181
+
182
+ export function buildSidebarNav(messages, activePage) {
183
+ return PAGE_ORDER.map((page) => `
184
+ <button
185
+ id="${navId(page)}"
186
+ class="nav-item ${activePage === page ? 'active' : ''}"
187
+ data-page-target="${page}"
188
+ type="button"
189
+ >
190
+ <span class="nav-icon">${NAV_ICONS[page]}</span>
191
+ <span class="nav-copy">${escapeHtml(messages.nav[page])}</span>
192
+ </button>
193
+ `).join('');
194
+ }
195
+
196
+ export function buildTopbarActions(activePage, viewModel, messages, labels) {
197
+ const actionSets = {
198
+ dashboard: [
199
+ `<button class="btn btn-secondary" data-action="open-settings" type="button">${escapeHtml(messages.settingsButton)}</button>`,
200
+ `<button class="btn btn-danger" data-run-action="stop" type="button" ${labels.stopDisabled ? 'disabled' : ''}>${escapeHtml(labels.stopLabel)}</button>`,
201
+ `<button class="btn btn-primary" data-run-action="start" type="button" ${labels.runDisabled ? 'disabled' : ''}>${escapeHtml(labels.runLabel)}</button>`
202
+ ],
203
+ runs: [
204
+ ],
205
+ results: [
206
+ ],
207
+ subscriptions: [
208
+ ],
209
+ logs: [],
210
+ settings: []
211
+ };
212
+ return (actionSets[activePage] ?? []).join('');
213
+ }
214
+
215
+ export function buildShortcutStrip(messages) {
216
+ return buildShortcutDescriptors(messages).map((action) => `
217
+ <button
218
+ id="${action.id}"
219
+ class="shortcut-action"
220
+ data-shortcut-target="${action.page}"
221
+ type="button"
222
+ >
223
+ <span class="shortcut-accent ${action.tone}"></span>
224
+ <span>${escapeHtml(action.label)}</span>
225
+ </button>
226
+ `).join('');
227
+ }
228
+
229
+ export function buildSidebarStatus(viewModel, messages, state, language) {
230
+ return `
231
+ ${viewModel.statusItems.map((item) => `
232
+ <div class="status-row">
233
+ <span class="status-row-label">${escapeHtml(item.label)}</span>
234
+ <strong class="status-row-value">${escapeHtml(item.value)}</strong>
235
+ </div>
236
+ `).join('')}
237
+ <div class="status-divider"></div>
238
+ <div class="status-footnote">
239
+ <span>${escapeHtml(messages.currentTaskLabel)}</span>
240
+ <strong>${escapeHtml(resolveCurrentTaskLabel(state, messages))}</strong>
241
+ </div>
242
+ `;
243
+ }
244
+
245
+ export function buildPageMarkup(activePage, viewModel, messages, language, subtabs = {}) {
246
+ return `
247
+ <section class="page-shell" data-page-shell="${activePage}">
248
+ ${buildPageInner(activePage, viewModel, messages, language, subtabs)}
249
+ </section>
250
+ `;
251
+ }
252
+
253
+ export function escapeHtml(value) {
254
+ return String(value ?? '')
255
+ .replaceAll('&', '&amp;')
256
+ .replaceAll('<', '&lt;')
257
+ .replaceAll('>', '&gt;')
258
+ .replaceAll('"', '&quot;');
259
+ }
260
+
261
+ function buildPageInner(activePage, vm, messages) {
262
+ switch (activePage) {
263
+ case 'dashboard':
264
+ return buildDashboardPage(vm, messages);
265
+ case 'runs':
266
+ return buildRunsPage(vm, messages);
267
+ case 'results':
268
+ return buildResultsPage(vm, messages);
269
+ case 'subscriptions':
270
+ return buildSubscriptionsPage(vm, messages);
271
+ case 'logs':
272
+ return buildLogsPage(vm, messages);
273
+ default:
274
+ return buildSettingsPage(vm, messages);
275
+ }
276
+ }
277
+
278
+ function buildDashboardPage(vm, messages) {
279
+ return `
280
+ <div id="dashboardOverview" class="page-grid dashboard-grid">
281
+ <article class="panel wide-panel status-hero-card">
282
+ <div>
283
+ <span class="panel-subcopy">当前运行状态</span>
284
+ <h3>${escapeHtml(vm.currentStatusLabel)}</h3>
285
+ <p>${vm.hasResult ? '最近一次运行已完成' : '系统就绪,等待运行'}</p>
286
+ </div>
287
+ <div class="status-orb">✓</div>
288
+ </article>
289
+
290
+ ${buildDashboardMetricsMarkup(vm)}
291
+
292
+ <article class="panel">
293
+ <div class="panel-headline">
294
+ <h3>最近运行结果</h3>
295
+ ${vm.hasResult ? renderBadge('成功', 'success') : renderBadge('未开始', 'neutral')}
296
+ </div>
297
+ <div class="key-value-list">
298
+ <div class="key-value-row"><span>状态</span><strong>${vm.hasResult ? '成功' : '未开始'}</strong></div>
299
+ <div class="key-value-row"><span>Artifact 目录</span><strong class="mono">${escapeHtml(vm.artifactDir || '—')}</strong></div>
300
+ <div class="key-value-row"><span>更新时间</span><strong>${escapeHtml(vm.lastUpdated)}</strong></div>
301
+ </div>
302
+ </article>
303
+
304
+ <article class="panel">
305
+ <div class="panel-headline">
306
+ <h3>系统状态</h3>
307
+ ${renderBadge(vm.runStateLabel, vm.runStateTone)}
308
+ </div>
309
+ <div class="key-value-list">
310
+ ${vm.statusItems.map((item) => `
311
+ <div class="key-value-row">
312
+ <span>${escapeHtml(item.label)}</span>
313
+ <strong>${escapeHtml(item.value)}</strong>
314
+ </div>
315
+ `).join('')}
316
+ <div class="key-value-row">
317
+ <span>${escapeHtml(messages.currentTaskLabel)}</span>
318
+ <strong>${escapeHtml(vm.currentTaskLabel)}</strong>
319
+ </div>
320
+ </div>
321
+ </article>
322
+ </div>
323
+ `;
324
+ }
325
+
326
+ export function buildDashboardMetricsMarkup(vm) {
327
+ return `
328
+ <article id="dashboardMetricsPanel" class="panel wide-panel">
329
+ <div class="metric-grid four">
330
+ ${dashboardMetrics(vm.counts).map((metric) => `
331
+ <div class="metric-card ${metric.tone}" data-metric-key="${escapeHtml(metric.key)}">
332
+ <span>${escapeHtml(metric.label)}</span>
333
+ <strong>${escapeHtml(metric.value)}</strong>
334
+ <small>${escapeHtml(metric.detail)}</small>
335
+ ${metric.key === 'raw_links'
336
+ ? renderSourceMetricRows(vm.rawSourceMetricRows, '各数据源原始节点数')
337
+ : metric.key === 'deduped_links'
338
+ ? renderSourceMetricRows(vm.dedupedSourceMetricRows, '各数据源去重节点数')
339
+ : ''}
340
+ </div>
341
+ `).join('')}
342
+ </div>
343
+ </article>
344
+ `;
345
+ }
346
+
347
+ function buildRunsPage(vm, messages) {
348
+ const artifactDisabled = vm.runControlState.isBusy || !vm.retryArtifacts.length ? 'disabled' : '';
349
+ const runDisabled = vm.runControlState.runDisabled ? 'disabled' : '';
350
+ const stageDisabled = vm.runControlState.isBusy || !vm.retryStageOptions.length ? 'disabled' : '';
351
+ const retryDisabled = vm.runControlState.isBusy || !vm.selectedRetryArtifactDir || !vm.selectedRetryStage ? 'disabled' : '';
352
+ return `
353
+ <div id="runsWorkspace" class="page-grid runs-grid">
354
+ <article class="panel wide-panel run-control-panel">
355
+ <button class="btn btn-primary run-big" data-run-action="start" type="button" ${runDisabled}>▶ 开始运行</button>
356
+ <button class="btn btn-secondary run-big" data-run-action="stop" type="button" ${vm.runControlState.stopDisabled ? 'disabled' : ''}>■ 停止运行</button>
357
+ <div class="retry-control-card">
358
+ <div class="field compact retry-field retry-artifact-field">
359
+ <span>历史 run</span>
360
+ <select data-run-retry-artifact ${artifactDisabled}>
361
+ ${vm.retryArtifacts.length
362
+ ? vm.retryArtifacts.map((item) => `
363
+ <option value="${escapeHtml(item.artifact_dir)}" ${vm.selectedRetryArtifactDir === item.artifact_dir ? 'selected' : ''}>
364
+ ${escapeHtml(formatRetryArtifactLabel(item))}
365
+ </option>
366
+ `).join('')
367
+ : '<option value="">暂无可重试 run</option>'}
368
+ </select>
369
+ </div>
370
+ <label class="field compact retry-field">
371
+ <span>阶段</span>
372
+ <select data-run-retry-stage ${stageDisabled}>
373
+ ${vm.retryStageOptions.length
374
+ ? vm.retryStageOptions.map((stage) => `
375
+ <option value="${escapeHtml(stage)}" ${vm.selectedRetryStage === stage ? 'selected' : ''}>
376
+ ${escapeHtml(STAGE_DISPLAY_NAMES[stage] || stage)}
377
+ </option>
378
+ `).join('')
379
+ : '<option value="">暂无可重试阶段</option>'}
380
+ </select>
381
+ </label>
382
+ <button class="btn btn-secondary retry-stage-button" data-action="retry-stage" type="button" ${retryDisabled}>从所选阶段重试</button>
383
+ <div class="retry-summary-card">
384
+ ${vm.selectedRetryArtifact
385
+ ? `
386
+ <div class="retry-summary-head">
387
+ <strong>${escapeHtml(vm.selectedRetryArtifact.artifact_name)}</strong>
388
+ ${renderBadge(runStatusLabel(vm.selectedRetryArtifact.run_status), stateClass(vm.selectedRetryArtifact.run_status))}
389
+ </div>
390
+ <div class="retry-summary-meta">
391
+ <span>可重试阶段:${escapeHtml((vm.selectedRetryArtifact.retryable_stages ?? []).join(', ') || '暂无')}</span>
392
+ ${vm.selectedRetryArtifact.retry_context?.source_artifact_name
393
+ ? `<span>来源:${escapeHtml(vm.selectedRetryArtifact.retry_context.source_artifact_name)} · ${escapeHtml(vm.selectedRetryArtifact.retry_context.start_stage || '—')}</span>`
394
+ : '<span>来源:原始 run</span>'}
395
+ </div>
396
+ `
397
+ : '<div class="empty-state">暂无可重试 run</div>'}
398
+ </div>
399
+ <p class="retry-help">阶段重试会新建 artifact,并从所选阶段继续执行到 verify。</p>
400
+ </div>
401
+ <div class="run-options">
402
+ ${[
403
+ ['跳过部署', 'skipDeploy', false],
404
+ ['跳过验证', 'skipVerify', false],
405
+ ['保存配置后运行', 'saveBeforeRun', true]
406
+ ].map(([label, key, checked]) => `
407
+ <label class="checkbox-chip"><input data-run-option="${escapeHtml(key)}" type="checkbox" ${checked ? 'checked' : ''} />${escapeHtml(label)}</label>
408
+ `).join('')}
409
+ </div>
410
+ </article>
411
+
412
+ ${buildRunsStageProgressMarkup(vm)}
413
+ ${buildRunsCurrentStageMarkup(vm)}
414
+ </div>
415
+ `;
416
+ }
417
+
418
+ export function buildRunsStageProgressMarkup(vm) {
419
+ return `
420
+ <article id="runsStageProgress" class="panel timeline-panel">
421
+ <div class="panel-headline"><h3>阶段进度</h3><span class="panel-subcopy">全流程时间线</span></div>
422
+ <div class="timeline">
423
+ ${vm.stageRows.map((stage) => `
424
+ <div class="timeline-row">
425
+ <span class="timeline-index ${stage.status}"></span>
426
+ <span class="timeline-copy">${escapeHtml(stage.name)}</span>
427
+ <span class="inline-state ${stateClass(stage.status)}">${escapeHtml(stageLabel(stage.status))}</span>
428
+ </div>
429
+ `).join('')}
430
+ </div>
431
+ </article>
432
+ `;
433
+ }
434
+
435
+ export function buildRunsCurrentStageMarkup(vm) {
436
+ return `
437
+ <article id="runsCurrentStage" class="panel">
438
+ <div class="panel-headline"><h3>当前阶段详情</h3>${renderBadge(stageLabel(vm.currentStage.status), stateClass(vm.currentStage.status))}</div>
439
+ <div class="key-value-list">
440
+ <div class="key-value-row"><span>阶段</span><strong>${escapeHtml(vm.currentStage.name)}</strong></div>
441
+ <div class="key-value-row"><span>状态</span><strong>${escapeHtml(stageLabel(vm.currentStage.status))}</strong></div>
442
+ <div class="key-value-row"><span>开始时间</span><strong>${escapeHtml(vm.runStartedAt)}</strong></div>
443
+ <div class="key-value-row"><span>已耗时</span><strong>${escapeHtml(vm.runElapsed)}</strong></div>
444
+ <div class="key-value-row"><span>当前目标</span><strong>${escapeHtml(vm.currentStage.name === 'speedtest' ? '测速通过节点' : '流水线处理')}</strong></div>
445
+ </div>
446
+ <div class="progress-track"><div class="progress-fill" style="width:35%"></div></div>
447
+ </article>
448
+ `;
449
+ }
450
+
451
+ function buildResultsPage(vm, messages) {
452
+ const deployStage = vm.stageRows.find((row) => row.name === 'deploy');
453
+ const verifyStage = vm.stageRows.find((row) => row.name === 'verify');
454
+ return `
455
+ <div id="resultsWorkspace" class="page-grid results-grid">
456
+ <article class="panel wide-panel artifact-card">
457
+ <div>
458
+ <span class="panel-subcopy">最终节点来源</span>
459
+ <strong class="mono">${escapeHtml(vm.artifactDir || '暂无')}</strong>
460
+ </div>
461
+ <div class="toolbar-right">
462
+ <button class="btn btn-secondary" data-action="copy-nodes" type="button">复制节点</button>
463
+ <button class="btn btn-secondary" data-action="open-artifact-dir" type="button">打开目录</button>
464
+ </div>
465
+ </article>
466
+
467
+ ${vm.retryContext?.source_artifact_name ? `
468
+ <article class="panel wide-panel retry-origin-card">
469
+ <div class="panel-headline">
470
+ <h3>重试来源</h3>
471
+ ${renderBadge('阶段重试生成', 'warning')}
472
+ </div>
473
+ <div class="key-value-list">
474
+ <div class="key-value-row"><span>来源 run</span><strong class="mono">${escapeHtml(vm.retryContext.source_artifact_name)}</strong></div>
475
+ <div class="key-value-row"><span>起始阶段</span><strong>${escapeHtml(vm.retryContext.start_stage || '—')}</strong></div>
476
+ <div class="key-value-row"><span>来源目录</span><strong class="mono">${escapeHtml(vm.retryContext.source_artifact_dir || '—')}</strong></div>
477
+ </div>
478
+ </article>
479
+ ` : ''}
480
+
481
+ <article class="panel wide-panel">
482
+ <div class="panel-headline"><h3>本次 deploy 目标</h3><span class="panel-subcopy">部署摘要</span></div>
483
+ <div class="key-value-list">
484
+ <div class="key-value-row"><span>项目名称</span><strong>${escapeHtml(vm.deployment.project_name || '—')}</strong></div>
485
+ <div class="key-value-row"><span>Pages 地址</span><strong class="mono">${escapeHtml(vm.deployment.pages_project_url || '—')}</strong></div>
486
+ <div class="key-value-row"><span>入口文件</span><strong class="mono">${escapeHtml(vm.deployment.worker_entry || '—')}</strong></div>
487
+ <div class="key-value-row"><span>Manifest</span><strong class="mono">${escapeHtml(vm.deployment.module_manifest_path || '—')}</strong></div>
488
+ <div class="key-value-row"><span>deploy 状态</span><strong>${escapeHtml(stageLabel(deployStage?.status || 'pending'))}</strong></div>
489
+ <div class="key-value-row"><span>verify 状态</span><strong>${escapeHtml(stageLabel(verifyStage?.status || 'pending'))}</strong></div>
490
+ </div>
491
+ </article>
492
+
493
+ <article class="panel wide-panel">
494
+ <div class="panel-headline"><h3>区域统计</h3><span class="panel-subcopy">按最终节点名称中的区域码统计</span></div>
495
+ <div class="region-stat-grid">
496
+ ${vm.regionStats.length ? vm.regionStats.map((item) => `
497
+ <div class="region-stat-card">
498
+ <span>${escapeHtml(item.region)}</span>
499
+ <strong>${escapeHtml(item.count)}</strong>
500
+ </div>
501
+ `).join('') : '<div class="empty-state">暂无区域统计,运行完成后显示。</div>'}
502
+ </div>
503
+ </article>
504
+
505
+ <article class="panel wide-panel">
506
+ <div class="panel-headline"><h3>最终节点列表</h3><span class="panel-subcopy">${escapeHtml(vm.nodeRows.length)} 条</span></div>
507
+ <div id="resultNodePreview" class="table-wrap">
508
+ <table class="data-table decoded-node-table">
509
+ <thead><tr><th>#</th><th>节点名称</th><th>IP地址</th><th>协议</th><th>path</th></tr></thead>
510
+ <tbody>
511
+ ${vm.nodeRows.length ? vm.nodeRows.map((row, index) => `
512
+ <tr>
513
+ <td>${index + 1}</td>
514
+ <td>${escapeHtml(row.name || '—')}</td>
515
+ <td>${escapeHtml(row.address || '—')}</td>
516
+ <td>${escapeHtml(row.protocol || '—')}</td>
517
+ <td class="mono">${escapeHtml(row.path || '—')}</td>
518
+ </tr>
519
+ `).join('') : '<tr><td colspan="5">暂无节点,运行完成后显示。</td></tr>'}
520
+ </tbody>
521
+ </table>
522
+ </div>
523
+ </article>
524
+ </div>
525
+ `;
526
+ }
527
+
528
+ function buildSubscriptionsPage(vm, messages) {
529
+ const primaryCard = vm.currentSubscription;
530
+ return `
531
+ <div id="subscriptionCards" class="page-grid subscriptions-grid">
532
+ <article class="panel subscriptions-primary-panel">
533
+ <div class="panel-headline"><h3>主订阅地址</h3>${renderBadge('自动生成', 'success')}</div>
534
+ <div class="subscription-primary-stack">
535
+ <div class="subscription-tab-scroller">
536
+ ${SUBSCRIPTION_FORMATS.map((format) => `
537
+ <button
538
+ class="subtab ${vm.subscriptionFormat === format ? 'active' : ''}"
539
+ data-subscription-format="${escapeHtml(format)}"
540
+ type="button"
541
+ >${escapeHtml(format)}</button>
542
+ `).join('')}
543
+ </div>
544
+ <div class="subscription-primary mono">${escapeHtml(primaryCard.url)}</div>
545
+ <div class="action-grid">
546
+ <button class="btn btn-primary" data-copy-text="${escapeHtml(primaryCard.url)}" type="button">复制链接</button>
547
+ <button class="btn btn-secondary" data-open-url="${escapeHtml(primaryCard.url)}" type="button">打开订阅</button>
548
+ </div>
549
+ </div>
550
+ </article>
551
+
552
+ <article class="panel slim-panel">
553
+ <div class="panel-headline"><h3>订阅二维码</h3></div>
554
+ <div class="qr-block">${renderQr(vm.qrDataUrl)}</div>
555
+ <p class="panel-subcopy center-copy">扫码导入订阅</p>
556
+ </article>
557
+
558
+ <article class="panel wide-panel subscription-meta">
559
+ <div class="mini-stat"><span>最后生成时间</span><strong>${escapeHtml(vm.lastUpdated)}</strong></div>
560
+ <div class="mini-stat"><span>最终节点数量</span><strong>${escapeHtml(vm.counts.availability_links)} 个</strong></div>
561
+ </article>
562
+ </div>
563
+ `;
564
+ }
565
+
566
+ function buildLogsPage(vm, messages) {
567
+ return `
568
+ <div id="logsWorkspace" class="page-grid logs-grid">
569
+ <article class="panel wide-panel terminal-panel logs-panel">
570
+ <div class="toolbar-row log-toolbar">
571
+ <div class="toolbar-left">
572
+ ${LOG_FILTERS.map((filter) => `
573
+ <button
574
+ class="subtab ${vm.logFilter === filter ? 'active' : ''}"
575
+ data-log-filter="${escapeHtml(filter)}"
576
+ type="button"
577
+ >${escapeHtml(filter)}</button>
578
+ `).join('')}
579
+ </div>
580
+ <div class="toolbar-right">
581
+ <button class="btn btn-secondary small" data-action="copy-log" type="button">复制日志</button>
582
+ <button class="btn btn-secondary small" data-action="clear-log" type="button">清空显示</button>
583
+ <button class="btn btn-primary small" data-action="open-log-file" type="button">打开日志文件</button>
584
+ </div>
585
+ </div>
586
+ <div id="logCenterTable" class="terminal-output log-stream">
587
+ ${buildLogCenterMarkup(vm)}
588
+ </div>
589
+ </article>
590
+ </div>
591
+ `;
592
+ }
593
+
594
+ export function buildLogCenterMarkup(vm) {
595
+ return vm.logFilter === '按阶段' ? renderLogGroups(vm.logGroups) : renderLogRows(vm.logRows);
596
+ }
597
+
598
+ function buildSettingsPage(vm, messages) {
599
+ return `
600
+ <div id="settingsWorkspace" class="page-grid settings-grid">
601
+ <article class="panel wide-panel settings-overview-panel">
602
+ <div class="panel-headline">
603
+ <h3>设置总览</h3>
604
+ <span class="panel-subcopy">点击卡片后在弹窗中编辑,并在弹窗内直接保存</span>
605
+ </div>
606
+ <div class="settings-overview-grid">
607
+ ${buildSettingsCard('sources', '数据源配置', `已启用 ${vm.sourceRows.filter((row) => row.enabled).length} / ${vm.sourceRows.length} 个来源`, '管理抓取地址、密钥和启用状态')}
608
+ ${buildSettingsCard('speed_test', '测速配置', `最低 ${vm.profile.speed_test.min_download_mb_s} MB/s · 并发 ${vm.profile.speed_test.concurrency}`, '控制测速阈值、超时和并发')}
609
+ ${buildSettingsCard('availability_targets', 'AI可达性检测', `已启用 ${enabledAvailabilityTargetCount(vm.profile.availability_targets)} / ${Object.keys(vm.profile.availability_targets ?? {}).length} 个网站`, '配置 Gemini、ChatGPT、Claude 或自定义网站')}
610
+ ${buildSettingsCard('deploy', '部署配置', `项目 ${vm.profile.deploy.project_name || '未设置'}`, '项目名变化会自动联动默认 Pages 地址;手动修改 URL 后,后续不再自动覆盖')}
611
+ </div>
612
+ </article>
613
+
614
+ ${buildSettingsDrawer(vm)}
615
+ </div>
616
+ `;
617
+ }
618
+
619
+ function renderBoundField(label, type, value, id, binding = null) {
620
+ const bindingAttrs = binding
621
+ ? ` data-source="${escapeHtml(binding.source)}" data-key="${escapeHtml(binding.key)}"`
622
+ : '';
623
+ return `
624
+ <label class="field">
625
+ <span>${escapeHtml(label)}</span>
626
+ <input id="${id}" type="${type}" value="${escapeHtml(value)}"${bindingAttrs}${binding ? '' : ' readonly'} />
627
+ </label>
628
+ `;
629
+ }
630
+
631
+ function renderProfileField(label, type, value, id, path) {
632
+ return `
633
+ <label class="field">
634
+ <span>${escapeHtml(label)}</span>
635
+ <input id="${id}" type="${type}" value="${escapeHtml(value)}" data-profile-path="${escapeHtml(path)}" />
636
+ </label>
637
+ `;
638
+ }
639
+
640
+ function buildSettingsCard(section, title, summary, detail) {
641
+ return `
642
+ <button
643
+ class="settings-overview-card"
644
+ data-settings-card="${section}"
645
+ type="button"
646
+ >
647
+ <span class="settings-card-kicker">${escapeHtml(title)}</span>
648
+ <strong class="settings-card-summary" id="settingsCardSummary-${section}">${escapeHtml(summary)}</strong>
649
+ <span class="settings-card-detail">${escapeHtml(detail)}</span>
650
+ </button>
651
+ `;
652
+ }
653
+
654
+ function buildSettingsDrawer(vm) {
655
+ const drawer = vm.settingsDrawer;
656
+ const isOpen = Boolean(drawer);
657
+ const section = drawer?.section ?? '';
658
+ const title = {
659
+ sources: '数据源配置',
660
+ speed_test: '测速配置',
661
+ availability_targets: 'AI可达性检测',
662
+ deploy: '部署配置'
663
+ }[section] ?? '设置';
664
+
665
+ const style = vm.modalTransform ? `style="transform: ${escapeHtml(vm.modalTransform)};"` : '';
666
+
667
+ return `
668
+ <div id="settingsDrawer" class="settings-drawer-shell" data-open="${isOpen ? 'true' : 'false'}" data-section="${escapeHtml(section)}">
669
+ <button class="settings-drawer-backdrop" data-drawer-dismiss="backdrop" type="button" aria-label="关闭设置弹窗"></button>
670
+ <aside class="settings-drawer-panel" ${style}>
671
+ <div class="settings-drawer-head">
672
+ <div>
673
+ <span class="settings-card-kicker">编辑配置</span>
674
+ <h3 id="settingsDrawerTitle">${escapeHtml(title)}</h3>
675
+ </div>
676
+ </div>
677
+ <div class="settings-drawer-body">
678
+ ${isOpen ? buildSettingsDrawerBody(section, drawer.draft) : ''}
679
+ </div>
680
+ <div class="settings-drawer-actions">
681
+ <button class="btn btn-secondary" data-drawer-close="cancel" type="button">取消</button>
682
+ <button class="btn btn-primary" data-drawer-save="save" type="button">保存</button>
683
+ </div>
684
+ </aside>
685
+ </div>
686
+ `;
687
+ }
688
+
689
+ const SOURCE_NAMES = {
690
+ 'leiting': '雷霆',
691
+ 'heidong': '黑洞',
692
+ 'mifeng': '蜜蜂',
693
+ 'xuanfeng-area': '旋风部分区域',
694
+ 'xuanfeng-all-area': '旋风全区域',
695
+ 'heilong': '黑龙',
696
+ 'custom': '自定义'
697
+ };
698
+
699
+ function buildSettingsDrawerBody(section, draft) {
700
+ if (section === 'sources') {
701
+ const sourceDraft = draft?.sources ?? {};
702
+ return `
703
+ <div class="source-drawer-settings">
704
+ ${renderDrawerField('最大迭代次数', 'number', draft?.maxIterations ?? 40, 'sources.maxIterations', true, 'data-source-max-iterations min="1"')}
705
+ ${renderDrawerField('区域起始', 'number', draft?.areaMin ?? 0, 'sources.areaMin', true, 'data-source-area-min')}
706
+ ${renderDrawerField('区域结束', 'number', draft?.areaMax ?? 100, 'sources.areaMax', true, 'data-source-area-max')}
707
+ </div>
708
+ <div class="table-wrap">
709
+ <table class="data-table settings-source-table">
710
+ <colgroup>
711
+ <col class="settings-col-enabled" />
712
+ <col class="settings-col-name" />
713
+ <col class="settings-col-url" />
714
+ <col class="settings-col-key" />
715
+ </colgroup>
716
+ <thead><tr><th>启用</th><th>名称</th><th>地址</th><th>密钥</th></tr></thead>
717
+ <tbody>
718
+ ${Object.entries(sourceDraft).map(([name, source]) => `
719
+ <tr>
720
+ <td><input type="checkbox" data-drawer-source="${escapeHtml(name)}" data-drawer-key="enabled" ${source.enabled ? 'checked' : ''} /></td>
721
+ <td><strong class="settings-source-name">${escapeHtml(SOURCE_NAMES[name] || name)}</strong></td>
722
+ <td>
723
+ <textarea
724
+ class="table-textarea mono"
725
+ rows="3"
726
+ data-drawer-source="${escapeHtml(name)}"
727
+ data-drawer-key="url"
728
+ >${escapeHtml(source.url ?? '')}</textarea>
729
+ </td>
730
+ <td><input data-drawer-source="${escapeHtml(name)}" data-drawer-key="key" value="${escapeHtml(source.key ?? '')}" /></td>
731
+ </tr>
732
+ `).join('')}
733
+ </tbody>
734
+ </table>
735
+ </div>
736
+ `;
737
+ }
738
+
739
+ if (section === 'speed_test') {
740
+ return `
741
+ <div class="form-grid compact-form-grid">
742
+ ${renderDrawerField('最低下载速度(MB/s)', 'number', draft.min_download_mb_s, 'speed_test.min_download_mb_s', true)}
743
+ ${renderDrawerField('超时时间(秒)', 'number', draft.timeout_seconds, 'speed_test.timeout_seconds', true)}
744
+ ${renderDrawerField('并发数量', 'number', draft.concurrency, 'speed_test.concurrency', true)}
745
+ </div>
746
+ `;
747
+ }
748
+
749
+ if (section === 'availability_targets') {
750
+ return `
751
+ <div class="availability-toolbar">
752
+ <button class="btn btn-secondary small" data-availability-action="add" type="button">新增网站</button>
753
+ </div>
754
+ <div class="table-wrap">
755
+ <table class="data-table availability-target-table">
756
+ <colgroup>
757
+ <col class="availability-col-enabled" />
758
+ <col class="availability-col-name" />
759
+ <col class="availability-col-url" />
760
+ <col class="availability-col-actions" />
761
+ </colgroup>
762
+ <thead><tr><th>启用</th><th>名称</th><th>URL</th><th>操作</th></tr></thead>
763
+ <tbody>
764
+ ${(draft?.targets ?? []).map((target, index) => `
765
+ <tr>
766
+ <td><input type="checkbox" data-availability-index="${index}" data-availability-key="enabled" ${target.enabled ? 'checked' : ''} /></td>
767
+ <td><input data-availability-index="${index}" data-availability-key="name" value="${escapeHtml(target.name ?? '')}" /></td>
768
+ <td>
769
+ <textarea class="table-textarea mono" rows="3" data-availability-index="${index}" data-availability-key="url">${escapeHtml(target.url ?? '')}</textarea>
770
+ </td>
771
+ <td><button class="btn btn-secondary small" data-availability-action="remove" data-availability-index="${index}" type="button">删除</button></td>
772
+ </tr>
773
+ `).join('')}
774
+ </tbody>
775
+ </table>
776
+ </div>
777
+ `;
778
+ }
779
+
780
+ if (section === 'deploy') {
781
+ return `
782
+ <div class="notice-card">
783
+ <strong>部署配置说明</strong>
784
+ <p>项目名变化会自动联动默认 Pages 地址;手动修改 URL 后,后续不再自动覆盖。verify 订阅地址用于 deploy 后的健康检查,不影响页面展示二维码。</p>
785
+ </div>
786
+ <div class="form-grid compact-form-grid">
787
+ ${renderDrawerField('项目名称', 'text', draft.project_name, 'deploy.project_name')}
788
+ ${renderDrawerField('Pages 地址', 'text', draft.pages_project_url, 'deploy.pages_project_url')}
789
+ ${renderDrawerField('订阅地址', 'text', draft.subscription_url, 'deploy.subscription_url')}
790
+ ${renderDrawerField('verify 订阅地址', 'text', draft.verify_subscription_url, 'deploy.verify_subscription_url')}
791
+ ${renderDrawerField('Cloudflare Token', 'password', draft.cloudflare_api_token, 'deploy.cloudflare_api_token')}
792
+ ${renderDrawerField('Pages Secret ADMIN', 'password', draft.pages_secret_admin, 'deploy.pages_secret_admin')}
793
+ </div>
794
+ `;
795
+ }
796
+
797
+ return `
798
+ <div class="notice-card">
799
+ <strong>无可用配置</strong>
800
+ <p>该分组暂无可供编辑的内容。</p>
801
+ </div>
802
+ `;
803
+ }
804
+
805
+ function renderDrawerField(label, type, value, path, isCompact = false, extraAttrs = '') {
806
+ return `
807
+ <label class="field ${isCompact ? 'compact' : ''}">
808
+ <span>${escapeHtml(label)}</span>
809
+ <input type="${type}" value="${escapeHtml(value ?? '')}" data-drawer-path="${escapeHtml(path)}" ${extraAttrs} />
810
+ </label>
811
+ `;
812
+ }
813
+
814
+ function renderBadge(text, tone) {
815
+ return `<span class="badge ${tone}">${escapeHtml(text)}</span>`;
816
+ }
817
+
818
+ function renderQr(dataUrl) {
819
+ if (!dataUrl) {
820
+ return '<div class="qr-loading">二维码生成中</div>';
821
+ }
822
+ return `<img class="qr-image" alt="订阅二维码" src="${escapeHtml(dataUrl)}" />`;
823
+ }
824
+
825
+ function buildShortcutDescriptors(messages) {
826
+ return [
827
+ { id: 'shortcutRun', page: 'runs', label: messages.shortcutActions.run, tone: 'accent' },
828
+ { id: 'shortcutSettings', page: 'settings', label: messages.shortcutActions.settings, tone: 'warning' },
829
+ { id: 'shortcutResults', page: 'results', label: messages.shortcutActions.results, tone: 'success' },
830
+ { id: 'shortcutLogs', page: 'logs', label: messages.shortcutActions.logs, tone: 'accent' }
831
+ ];
832
+ }
833
+
834
+ function normalizeStageRows(stageStatus, runState) {
835
+ const actual = Object.keys(stageStatus ?? {}).length ? stageStatus : {};
836
+ return buildStageModel(actual);
837
+ }
838
+
839
+ function buildStatusItems(state, messages) {
840
+ return [
841
+ { label: '状态', value: messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle },
842
+ { label: '模式', value: state.isDemo ? messages.demoRunMode : messages.manualRunMode },
843
+ { label: '最后结果', value: messages.runResultLabels[state.runResult] ?? messages.runResultLabels.idle },
844
+ { label: '最后更新', value: formatDate(state.lastUpdateAt) || '—' }
845
+ ];
846
+ }
847
+
848
+ function dashboardMetrics(counts) {
849
+ return [
850
+ { key: 'raw_links', label: '原始节点', value: String(counts.raw_links), detail: '抓取输入', tone: 'accent' },
851
+ { key: 'deduped_links', label: '去重节点', value: String(counts.deduped_links ?? counts.postprocess_links), detail: '全局去重', tone: 'accent' },
852
+ { key: 'speedtest_links', label: '测速通过', value: String(counts.speedtest_links), detail: '速度达标', tone: 'success' },
853
+ { key: 'availability_links', label: '最终可用', value: String(counts.availability_links), detail: '验证通过', tone: 'success' }
854
+ ];
855
+ }
856
+
857
+ function renderSourceMetricRows(rows, ariaLabel) {
858
+ if (!rows.length) {
859
+ return '';
860
+ }
861
+ return `
862
+ <div class="metric-source-list" aria-label="${escapeHtml(ariaLabel)}">
863
+ ${rows.map((row) => `
864
+ <span class="metric-source-chip">${escapeHtml(row.label)} ${escapeHtml(row.count)}</span>
865
+ `).join('')}
866
+ </div>
867
+ `;
868
+ }
869
+
870
+ function buildSubscriptionCards(baseUrl) {
871
+ return SUBSCRIPTION_FORMATS.map((title) => ({
872
+ title,
873
+ url: title === 'Clash' ? baseUrl : `${baseUrl}?format=${encodeURIComponent(title.toLowerCase().replaceAll(' ', '-'))}`
874
+ }));
875
+ }
876
+
877
+ function buildSourceRows(profile) {
878
+ return Object.entries(profile.sources).map(([name, source], index) => ({
879
+ name,
880
+ primary: index === 0,
881
+ enabled: Boolean(source.enabled),
882
+ url: source.url ?? '',
883
+ key: source.key ?? ''
884
+ }));
885
+ }
886
+
887
+ function buildSourceMetricRows(profile, sourceCounts = {}, countKey = 'raw_links') {
888
+ const sourceNames = new Set([
889
+ ...Object.keys(profile.sources ?? {}),
890
+ ...Object.keys(sourceCounts ?? {})
891
+ ]);
892
+ const hasRequestedValues = countKey === 'raw_links'
893
+ || Object.values(sourceCounts ?? {}).some((counts) => counts && countKey in counts);
894
+ if (!hasRequestedValues) {
895
+ return [];
896
+ }
897
+ return Array.from(sourceNames)
898
+ .sort((left, right) => {
899
+ const leftIndex = SOURCE_DISPLAY_ORDER.indexOf(left);
900
+ const rightIndex = SOURCE_DISPLAY_ORDER.indexOf(right);
901
+ return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
902
+ })
903
+ .map((name) => ({
904
+ name,
905
+ label: SOURCE_NAMES[name] || name,
906
+ count: Number(sourceCounts[name]?.[countKey] ?? 0)
907
+ }));
908
+ }
909
+
910
+ export function extractSourceUrlFromCurl(value = '') {
911
+ const text = String(value ?? '').trim();
912
+ if (!text) {
913
+ return '';
914
+ }
915
+ if (!/^curl(?:\s|$)/i.test(text)) {
916
+ return text;
917
+ }
918
+
919
+ const quoted = text.match(/(['"])(https?:\/\/[\s\S]+?)\1/i);
920
+ if (quoted?.[2]) {
921
+ return quoted[2].trim();
922
+ }
923
+
924
+ const bare = text.match(/\bhttps?:\/\/[^\s'"]+/i);
925
+ return bare?.[0]?.trim() ?? '';
926
+ }
927
+
928
+ export function buildRegionStats(nodeRows = []) {
929
+ const counts = new Map();
930
+ for (const row of nodeRows) {
931
+ const region = inferNodeRegion(row?.name);
932
+ counts.set(region, (counts.get(region) ?? 0) + 1);
933
+ }
934
+ return Array.from(counts.entries()).map(([region, count]) => ({ region, count }));
935
+ }
936
+
937
+ export function buildSourceIterationDraft(sources = {}) {
938
+ const firstSource = Object.values(sources)[0] ?? {};
939
+ return {
940
+ sources: structuredClone(sources),
941
+ maxIterations: coercePositiveInteger(firstSource.max_iterations, 40),
942
+ areaMin: coerceAreaValue(firstSource.area_min, 0),
943
+ areaMax: coerceAreaValue(firstSource.area_max, 100)
944
+ };
945
+ }
946
+
947
+ export function buildAvailabilityTargetDraft(targets = {}) {
948
+ return {
949
+ targets: Object.entries(targets).map(([name, target]) => ({
950
+ name,
951
+ url: target?.url ?? '',
952
+ enabled: target?.enabled !== false
953
+ }))
954
+ };
955
+ }
956
+
957
+ export function addAvailabilityTargetDraft(draft, preferredName = 'custom') {
958
+ if (!draft?.targets) {
959
+ return;
960
+ }
961
+ const existing = new Set(draft.targets.map((target) => target.name));
962
+ let name = sanitizeTargetName(preferredName) || 'custom';
963
+ let index = 2;
964
+ while (existing.has(name)) {
965
+ name = `${sanitizeTargetName(preferredName) || 'custom'}-${index}`;
966
+ index += 1;
967
+ }
968
+ draft.targets.push({
969
+ name,
970
+ url: '',
971
+ enabled: true
972
+ });
973
+ }
974
+
975
+ export function removeAvailabilityTargetDraft(draft, index) {
976
+ if (!draft?.targets) {
977
+ return;
978
+ }
979
+ draft.targets.splice(Number(index), 1);
980
+ }
981
+
982
+ export function applyAvailabilityTargetDraft(draft = {}) {
983
+ const result = {};
984
+ for (const target of draft.targets ?? []) {
985
+ const name = sanitizeTargetName(target.name);
986
+ const url = String(target.url ?? '').trim();
987
+ if (!name || !url) {
988
+ continue;
989
+ }
990
+ result[name] = {
991
+ url,
992
+ enabled: target.enabled !== false
993
+ };
994
+ }
995
+ return result;
996
+ }
997
+
998
+ export function applySourceIterationDraft(sources = {}, draft = {}) {
999
+ const maxIterations = coercePositiveInteger(draft.maxIterations, 40);
1000
+ const areaMin = coerceAreaValue(draft.areaMin, 0);
1001
+ const areaMax = coerceAreaValue(draft.areaMax, 100);
1002
+ return Object.fromEntries(
1003
+ Object.entries(sources).map(([name, source]) => [
1004
+ name,
1005
+ {
1006
+ ...source,
1007
+ max_iterations: maxIterations,
1008
+ area_min: Math.min(areaMin, areaMax),
1009
+ area_max: Math.max(areaMin, areaMax)
1010
+ }
1011
+ ])
1012
+ );
1013
+ }
1014
+
1015
+ function inferNodeRegion(name = '') {
1016
+ const normalized = String(name).replace(/^[^\p{L}\p{N}]+/u, '').trim();
1017
+ const match = normalized.match(/^([A-Z]{2})(?:\b|[\s_-])/);
1018
+ return match ? match[1] : '其他';
1019
+ }
1020
+
1021
+ function coercePositiveInteger(value, fallback) {
1022
+ const parsed = Number(value);
1023
+ if (!Number.isFinite(parsed) || parsed < 1) {
1024
+ return fallback;
1025
+ }
1026
+ return Math.trunc(parsed);
1027
+ }
1028
+
1029
+ function coerceAreaValue(value, fallback) {
1030
+ const parsed = Number(value);
1031
+ if (!Number.isFinite(parsed)) {
1032
+ return fallback;
1033
+ }
1034
+ return Math.trunc(parsed);
1035
+ }
1036
+
1037
+ function buildLogRows(displayLogs) {
1038
+ return displayLogs.slice(-28).map((entry) => ({
1039
+ ...entry,
1040
+ levelClass: entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'success'
1041
+ }));
1042
+ }
1043
+
1044
+ function renderLogRows(rows) {
1045
+ if (!rows.length) {
1046
+ return '<div class="empty-state log-empty-state">暂无可显示日志</div>';
1047
+ }
1048
+ return rows.map((row) => `<div class="log-line ${row.levelClass}">${escapeHtml(row.line)}</div>`).join('');
1049
+ }
1050
+
1051
+ function renderLogGroups(groups) {
1052
+ if (!groups.length) {
1053
+ return '<div class="empty-state log-empty-state">暂无可显示日志</div>';
1054
+ }
1055
+ return groups.map((group) => `
1056
+ <section class="log-group">
1057
+ <div class="log-group-title">${escapeHtml(group.label)}</div>
1058
+ ${group.rows.map((row) => `<div class="log-line ${row.levelClass}">${escapeHtml(row.line)}</div>`).join('')}
1059
+ </section>
1060
+ `).join('');
1061
+ }
1062
+
1063
+ export function classifyLogEntry(rawEntry, overrides = {}) {
1064
+ if (rawEntry && typeof rawEntry === 'object' && 'line' in rawEntry && 'level' in rawEntry) {
1065
+ return {
1066
+ ...rawEntry,
1067
+ level: rawEntry.level ?? inferLogLevel(rawEntry.line),
1068
+ stage: rawEntry.stage ?? inferLogStage(rawEntry.line),
1069
+ kind: rawEntry.kind ?? 'log'
1070
+ };
1071
+ }
1072
+
1073
+ const line = String(rawEntry ?? '');
1074
+ return {
1075
+ line,
1076
+ level: overrides.level ?? inferLogLevel(line),
1077
+ stage: overrides.stage ?? inferLogStage(line),
1078
+ kind: overrides.kind ?? 'log'
1079
+ };
1080
+ }
1081
+
1082
+ export function filterLogEntries(entries, filter = '全部') {
1083
+ if (filter === '错误') {
1084
+ return entries.filter((entry) => entry.level === 'error');
1085
+ }
1086
+ if (filter === '运行日志') {
1087
+ return entries.filter((entry) => entry.level !== 'error');
1088
+ }
1089
+ return entries;
1090
+ }
1091
+
1092
+ export function groupLogEntriesByStage(entries) {
1093
+ const groups = new Map();
1094
+ for (const row of buildLogRows(entries)) {
1095
+ const label = row.stage || '其他';
1096
+ if (!groups.has(label)) {
1097
+ groups.set(label, { label, rows: [] });
1098
+ }
1099
+ groups.get(label).rows.push(row);
1100
+ }
1101
+ return Array.from(groups.values());
1102
+ }
1103
+
1104
+ function inferLogLevel(line) {
1105
+ const lower = String(line).toLowerCase();
1106
+ if (/\[error\]|error|failed|exception|traceback|错误|失败/.test(lower)) {
1107
+ return 'error';
1108
+ }
1109
+ if (/\[warn\]|warning|警告/.test(lower)) {
1110
+ return 'warning';
1111
+ }
1112
+ return 'info';
1113
+ }
1114
+
1115
+ function inferLogStage(line) {
1116
+ const lower = String(line).toLowerCase();
1117
+ const stages = ['doctor', 'extract', 'dedupe', 'speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
1118
+ const matches = stages.filter((stage) => lower.includes(stage));
1119
+ return matches.at(-1) ?? '';
1120
+ }
1121
+
1122
+ function mergeProfile(profile) {
1123
+ const workspace = profile?.workspace ?? {};
1124
+ const paths = profile?.paths ?? {};
1125
+ const mergedPaths = {
1126
+ ...FALLBACK_PROFILE.paths,
1127
+ ...workspace,
1128
+ ...paths
1129
+ };
1130
+
1131
+ return {
1132
+ ...FALLBACK_PROFILE,
1133
+ ...profile,
1134
+ sources: profile?.sources ?? FALLBACK_PROFILE.sources,
1135
+ availability_targets: profile?.availability_targets ?? DEFAULT_AVAILABILITY_TARGETS,
1136
+ speed_test: { ...FALLBACK_PROFILE.speed_test, ...(profile?.speed_test ?? {}) },
1137
+ deploy: { ...FALLBACK_PROFILE.deploy, ...(profile?.deploy ?? {}) },
1138
+ paths: mergedPaths,
1139
+ workspace: mergedPaths
1140
+ };
1141
+ }
1142
+
1143
+ function enabledAvailabilityTargetCount(targets = {}) {
1144
+ return Object.values(targets).filter((target) => target?.enabled !== false).length;
1145
+ }
1146
+
1147
+ function normalizeEditableList(value) {
1148
+ if (Array.isArray(value)) {
1149
+ return value.map((item) => String(item).trim()).filter(Boolean);
1150
+ }
1151
+ return splitEditableList(value);
1152
+ }
1153
+
1154
+ function splitEditableList(value) {
1155
+ return String(value ?? '')
1156
+ .split(/[\n,]/)
1157
+ .map((item) => item.trim())
1158
+ .filter(Boolean);
1159
+ }
1160
+
1161
+ function sanitizeTargetName(value) {
1162
+ return String(value ?? '')
1163
+ .trim()
1164
+ .toLowerCase()
1165
+ .replace(/[^a-z0-9_-]+/g, '-')
1166
+ .replace(/^-+|-+$/g, '');
1167
+ }
1168
+
1169
+ function stateClass(status) {
1170
+ if (status === 'success') return 'success';
1171
+ if (status === 'running') return 'warning';
1172
+ if (status === 'failed') return 'danger';
1173
+ return 'neutral';
1174
+ }
1175
+
1176
+ function stageLabel(status) {
1177
+ return {
1178
+ success: '完成',
1179
+ running: '运行中',
1180
+ failed: '失败',
1181
+ pending: '等待中'
1182
+ }[status] ?? status;
1183
+ }
1184
+
1185
+ function deriveRetryStageOptions(artifact) {
1186
+ if (!artifact?.retryable_stages) {
1187
+ return [];
1188
+ }
1189
+ return RETRY_STAGE_ORDER.filter((stage) => artifact.retryable_stages.includes(stage));
1190
+ }
1191
+
1192
+ function formatRetryArtifactLabel(item) {
1193
+ const suffix = item.retry_context?.source_artifact_name ? ' · retry' : '';
1194
+ return `${item.artifact_name} · ${runStatusLabel(item.run_status)}${suffix}`;
1195
+ }
1196
+
1197
+ function runStatusLabel(status) {
1198
+ return {
1199
+ success: '成功',
1200
+ failed: '失败',
1201
+ running: '运行中',
1202
+ pending: '待执行',
1203
+ unknown: '未知'
1204
+ }[status || 'unknown'] ?? status ?? '未知';
1205
+ }
1206
+
1207
+ function resolveCurrentStatusLabel(state, messages) {
1208
+ if (state.runResult === 'success' || state.runResult === 'demo') return '成功';
1209
+ if (state.runResult === 'failed') return '失败';
1210
+ if (state.runState === 'running') return '运行中';
1211
+ return '空闲';
1212
+ }
1213
+
1214
+ function formatDate(value) {
1215
+ if (!value) return '';
1216
+ return new Intl.DateTimeFormat('zh-CN', {
1217
+ year: 'numeric',
1218
+ month: '2-digit',
1219
+ day: '2-digit',
1220
+ hour: '2-digit',
1221
+ minute: '2-digit',
1222
+ second: '2-digit',
1223
+ hour12: false
1224
+ }).format(new Date(value)).replaceAll('/', '-');
1225
+ }
1226
+
1227
+ function formatElapsed(startedAt) {
1228
+ if (!startedAt) return '—';
1229
+ const total = Math.max(0, Math.floor((Date.now() - Number(startedAt)) / 1000));
1230
+ const minutes = String(Math.floor(total / 60)).padStart(2, '0');
1231
+ const seconds = String(total % 60).padStart(2, '0');
1232
+ return `00:${minutes}:${seconds}`;
1233
+ }
1234
+
1235
+ function resolveCurrentTaskLabel(state, messages) {
1236
+ const stageRows = normalizeStageRows(state.stageStatus, state.runState);
1237
+ const running = stageRows.find((row) => row.status === 'running');
1238
+ if (running) return messages.stageLabels[running.name] ?? running.name;
1239
+
1240
+ const failed = stageRows.find((row) => row.status === 'failed');
1241
+ if (failed) {
1242
+ return `${messages.stageLabels[failed.name] ?? failed.name} / ${messages.statusLabels.failed}`;
1243
+ }
1244
+
1245
+ if (state.runState === 'stopping') return '停止中';
1246
+
1247
+ const completed = stageRows.filter((row) => row.status === 'success');
1248
+ if (completed.length) {
1249
+ const lastStage = completed.at(-1);
1250
+ return messages.stageLabels[lastStage.name] ?? lastStage.name;
1251
+ }
1252
+
1253
+ return messages.taskWaiting;
1254
+ }
1255
+
1256
+ function navId(page) {
1257
+ return `nav${page.slice(0, 1).toUpperCase()}${page.slice(1)}`;
1258
+ }