wkgeo-cli 0.2.3 → 0.2.4
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/lib/commands/auth.mjs +27 -45
- package/lib/commands/billing.mjs +2 -1
- package/lib/login.mjs +199 -25
- package/lib/page.mjs +51 -54
- package/package.json +1 -1
package/lib/commands/auth.mjs
CHANGED
|
@@ -70,10 +70,11 @@ export function resolveAutoDevice(flags = {}, env = {}) {
|
|
|
70
70
|
* ⇒ 本函数**不再**按"有没有代理"给默认值:用户没显式指定时**一律返回 0**。
|
|
71
71
|
* 也就是说 `wkgeo login` 默认会一路等回调到 `--timeout`,**不会**因为等太久自动改设备码。
|
|
72
72
|
*
|
|
73
|
-
*
|
|
73
|
+
* 设备码只在这两种情况出现(见 loginCommand.run):
|
|
74
74
|
* 1. 用户显式 `--device` / `--no-wait` / `--device-continue`;
|
|
75
|
-
* 2. 检测到系统代理且判定回调不可用(`--no-auto-device`
|
|
76
|
-
*
|
|
75
|
+
* 2. 检测到系统代理且判定回调不可用(`--no-auto-device` 可关)。
|
|
76
|
+
* ⚠️ 0.2.4 起:**本地回调端口起不来不再自动改设备码**(老板反馈"服务没起来却换了模式"),
|
|
77
|
+
* 而是如实报 `kind:"listen"` 并在 hint 里明确建议用户显式加 `--device`。
|
|
77
78
|
*
|
|
78
79
|
* ## 保留的显式开关(向后兼容,不参与默认判断)
|
|
79
80
|
* `--callback-first-wait <时长>`(默认单位**秒**;`0` / `off` / `none` = 关闭)
|
|
@@ -192,9 +193,7 @@ function bothPathsFailedError(deviceError, reason, { loginTimeoutMs }) {
|
|
|
192
193
|
const callbackSide =
|
|
193
194
|
reason === 'callback_timeout'
|
|
194
195
|
? `等待授权超时(${loginTimeoutMs}ms,本机回调监听当时仍然开着)`
|
|
195
|
-
:
|
|
196
|
-
? '本地回调端口起不来(根本没起监听)'
|
|
197
|
-
: '首轮等待没收到回调(本机回调监听当时仍然开着)';
|
|
196
|
+
: '首轮等待没收到回调(本机回调监听当时仍然开着)';
|
|
198
197
|
return new CliError(`本地回调没成功,改用设备码也失败了:${deviceError.message}`, {
|
|
199
198
|
kind: deviceError.kind || 'device_authorization_failed',
|
|
200
199
|
code: deviceError.code === undefined ? null : deviceError.code,
|
|
@@ -216,13 +215,13 @@ const TERMINAL_DEVICE_KINDS = new Set(['denied', 'expired', 'not_implemented', '
|
|
|
216
215
|
|
|
217
216
|
export const loginCommand = {
|
|
218
217
|
name: 'login',
|
|
219
|
-
summary: '浏览器授权一次,把 30
|
|
218
|
+
summary: '浏览器授权一次,把 30 天令牌存到本地(默认本地回调模式;设备码仅在显式 --device / 检测到系统代理时使用)',
|
|
220
219
|
usage:
|
|
221
220
|
'wkgeo login [--scope "site:read site:write billing:self"] [--no-browser] [--open-browser] [--client-id <id>] [--callback-port <n>] [--timeout <秒>]\n' +
|
|
222
221
|
'wkgeo login --device # 设备码模式:不需要回调地址/监听端口,给用户一个短码(兜底路径)\n' +
|
|
223
222
|
'wkgeo login --device --no-wait # ★ 只申请短码就退出(AI 端友好),稍后用下面这条领令牌\n' +
|
|
224
223
|
'wkgeo login --device-continue # 用上次保存的设备码换票、写完凭据就完成登录\n' +
|
|
225
|
-
'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI
|
|
224
|
+
'wkgeo login --no-auto-device # 检测到系统代理也不自动改设备码(强制回调;CLI 会警告)\n' +
|
|
226
225
|
'wkgeo login --open-browser # 强制尝试打开系统浏览器(⚠️ 现在与默认行为等价,保留只为显式语义与兼容)\n' +
|
|
227
226
|
'wkgeo login --callback-first-wait 60 # ★ 可选:显式启用"首轮等待 60 秒没回调就并行改设备码"(默认关闭;0/off = 关闭)\n' +
|
|
228
227
|
'wkgeo login --timeout 600 # 等待授权 600 秒(默认单位是秒,不是毫秒;也支持 600s / 500ms)',
|
|
@@ -237,7 +236,7 @@ export const loginCommand = {
|
|
|
237
236
|
* ⚠️ 它**压不过**非交互判定(CI 里写它也不弹);与 `--no-browser` 同时出现时 `--no-browser` 优先。
|
|
238
237
|
*/
|
|
239
238
|
'open-browser': { type: 'boolean', desc: '强制尝试打开系统浏览器(⚠️ 仅在人类终端生效;CI/非交互环境仍不打开)' },
|
|
240
|
-
device: { type: 'boolean', desc: '用设备码模式(RFC 8628
|
|
239
|
+
device: { type: 'boolean', desc: '用设备码模式(RFC 8628)代替本地回调:不需要任何回调地址,对代理/防火墙免疫(显式兜底;CLI 不会在端口失败时自动切换过来)' },
|
|
241
240
|
/**
|
|
242
241
|
* ★ 老板 2026-10-02 要的"AI 端友好路径"。
|
|
243
242
|
* 为什么做成"申请完就退出"而不是"申请完继续挂着轮询"(后者靠 JSONL 事件流也能做):
|
|
@@ -250,7 +249,7 @@ export const loginCommand = {
|
|
|
250
249
|
'device-continue': { type: 'boolean', desc: '继续上一次 --no-wait/被中断的设备码登录(读取本地保存的设备码并轮询到出结果)' },
|
|
251
250
|
'auto-device': {
|
|
252
251
|
type: 'boolean',
|
|
253
|
-
desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调"
|
|
252
|
+
desc: '检测到系统代理时自动改用设备码(**默认开**;--no-auto-device 或 WKONGAI_AUTO_DEVICE=0 关闭,关闭时会警告"代理可能吃掉回调")',
|
|
254
253
|
},
|
|
255
254
|
/**
|
|
256
255
|
* ★ 可选自愈(默认**关闭**,0.2.2 起)。
|
|
@@ -345,7 +344,7 @@ export const loginCommand = {
|
|
|
345
344
|
*/
|
|
346
345
|
const proxyDetected = !!(proxyInfo && proxyInfo.detected);
|
|
347
346
|
let useDevice = wantsDevice;
|
|
348
|
-
/** 为什么会走设备码:proxy_detected /
|
|
347
|
+
/** 为什么会走设备码:proxy_detected / callback_first_wait(仅显式开启时) */
|
|
349
348
|
let downgradeReason = null;
|
|
350
349
|
if (!useDevice && autoDevice && proxyDetected) {
|
|
351
350
|
useDevice = true;
|
|
@@ -414,7 +413,7 @@ export const loginCommand = {
|
|
|
414
413
|
*
|
|
415
414
|
* 老板原话:「默认的也不应该是设备授权,默认的就是那个传统的授权方式(浏览器打开授权页 +
|
|
416
415
|
* 本地 localhost 回调)。设备码是本地读不了/代理时才用的。」
|
|
417
|
-
* ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码"
|
|
416
|
+
* ⇒ 这里明说"本次就是回调、等多久都不会自动改设备码",同时列出**唯一会走设备码**的两种情况,
|
|
418
417
|
* 并给出"主动提前切换"的显式开关,免得用户以为 CLI 背着他改道。
|
|
419
418
|
* ⚠️ 只在"回调模式 + 没检测到代理 + 没显式开首轮等待"时打:其它情况一律不打(正常环境不要噪音)。
|
|
420
419
|
*/
|
|
@@ -423,10 +422,12 @@ export const loginCommand = {
|
|
|
423
422
|
`ℹ️ 未检测到系统代理:本次使用浏览器回调授权(默认),会一直等你完成授权(最多 ${Math.round(config.loginTimeoutMs / 1000)} 秒)。`,
|
|
424
423
|
);
|
|
425
424
|
log(
|
|
426
|
-
' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device
|
|
427
|
-
'
|
|
425
|
+
' 不会因为"首轮等待/等待超时"到点就自动改用设备码;只有显式 `--device` 或检测到系统代理时才会用设备码' +
|
|
426
|
+
'(`--no-auto-device` 可关掉代理这一种自动切换)。',
|
|
427
|
+
);
|
|
428
|
+
log(
|
|
429
|
+
' (本地回调端口起不来会**如实报错**并建议 `--device`,不会静默换模式;想主动提前切换:`--callback-first-wait <秒>`。)',
|
|
428
430
|
);
|
|
429
|
-
log(' (想主动提前切换:`--callback-first-wait <秒>`;想直接走设备码:`--device`。)');
|
|
430
431
|
}
|
|
431
432
|
|
|
432
433
|
/**
|
|
@@ -694,35 +695,18 @@ export const loginCommand = {
|
|
|
694
695
|
/** 设备码那条路也失败了 ⇒ 优先报"两条路都不通"(哪怕回调那边先抛了自己的错) */
|
|
695
696
|
if (deviceSide.combinedError) throw deviceSide.combinedError;
|
|
696
697
|
/**
|
|
697
|
-
* ★★ 0.2.
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
698
|
+
* ★★ 0.2.4(2026-10-21 老板真机反馈):**bind 失败不再静默改走设备码**。
|
|
699
|
+
*
|
|
700
|
+
* 旧行为(0.2.2~0.2.3):本地回调端口起不来 → 命令层自动申请设备码继续授权。
|
|
701
|
+
* 现场问题是:用户明明要的是"本地服务 + 浏览器回调",CLI 却在他不知情时换了
|
|
702
|
+
* 另一套授权协议(设备码),127.0.0.1 回调页始终没有出现,看起来就是"本地服务
|
|
703
|
+
* 没启动、浏览器也没东西可打开"。
|
|
704
|
+
*
|
|
705
|
+
* 新口径:**如实失败**,把 `--device` 作为明确的下一步建议交给用户/调用方
|
|
706
|
+
* (`runLogin` 抛的 `kind:"listen"` 错误 hint 里已经写了)。
|
|
707
|
+
* 设备码只在这两种情况下出现:用户显式 `--device` / 检测到系统代理。
|
|
705
708
|
*/
|
|
706
|
-
|
|
707
|
-
if (!canSwitch) throw e;
|
|
708
|
-
log('');
|
|
709
|
-
log(`⚠️ 本地回调端口起不来(${e.message}):这是"本地读不了回调"的真实失败,改用【设备码模式】继续授权。`);
|
|
710
|
-
log('ℹ️ 设备码不需要本地监听(常见原因是端口被安全软件/防火墙拦);授权页照旧点「同意」即可。');
|
|
711
|
-
let device;
|
|
712
|
-
try {
|
|
713
|
-
device = await requestDeviceCode({ config, deps: loginDeps });
|
|
714
|
-
} catch (e2) {
|
|
715
|
-
throw bothPathsFailedError(e2, 'callback_listen_failed', {
|
|
716
|
-
loginTimeoutMs: config.loginTimeoutMs,
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
savePending(device);
|
|
720
|
-
announceDeviceCode({ config, log, deps: deviceDeps({ no_wait: false, proxy_warning: proxyPayload }), device });
|
|
721
|
-
// 这一次再失败就如实抛出(不无限重试:两条路都失败,用户该看到真原因了)
|
|
722
|
-
result = await pollDeviceToken({ config, log, deps: loginDeps, device });
|
|
723
|
-
deletePendingDevice(pendingFile);
|
|
724
|
-
useDevice = true;
|
|
725
|
-
downgradeReason = 'callback_listen_failed';
|
|
709
|
+
throw e;
|
|
726
710
|
}
|
|
727
711
|
|
|
728
712
|
if (deviceSide.reason) {
|
|
@@ -792,8 +776,6 @@ export const loginCommand = {
|
|
|
792
776
|
out.info(' (本次因"检测到系统代理"自动改用了设备码模式:不再依赖 127.0.0.1 回调,绕过列表写了什么也不作依据。)');
|
|
793
777
|
if (downgradeReason === 'callback_first_wait')
|
|
794
778
|
out.info(' (已按显式 --callback-first-wait:首轮等待没收到回调,已自动改用设备码模式完成登录。)');
|
|
795
|
-
if (downgradeReason === 'callback_listen_failed')
|
|
796
|
-
out.info(' (本地回调端口起不来,已自动改用设备码模式完成登录。)');
|
|
797
779
|
}
|
|
798
780
|
return data;
|
|
799
781
|
},
|
package/lib/commands/billing.mjs
CHANGED
|
@@ -348,7 +348,8 @@ export const walletRechargeCommand = {
|
|
|
348
348
|
/**
|
|
349
349
|
* 自动打开收银台。
|
|
350
350
|
*
|
|
351
|
-
* ⚠️ 复用 `login.mjs` 的 `openUrlForUser`(多策略 + 结果归一化 +
|
|
351
|
+
* ⚠️ 复用 `login.mjs` 的 `openUrlForUser`(多策略 + 结果归一化 + 唯一闸门:
|
|
352
|
+
* 人类终端默认打开,CI/非交互与 `--no-browser` 不打开),
|
|
352
353
|
* **不是**在这里再写一份 spawn —— 那几个坑(cmd start 的引号、URL 里的 `&`
|
|
353
354
|
* 被 shell 吃掉、error 事件没挂)在那边已经踩过并修过,复制一份必然会再踩。
|
|
354
355
|
* ⚠️ 打开失败**绝不允许**让命令失败:地址已经完整打印(人类可读)并放在
|
package/lib/login.mjs
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
import http from 'node:http';
|
|
42
42
|
import { spawn } from 'node:child_process';
|
|
43
|
+
import { win32 as pathWin32 } from 'node:path';
|
|
43
44
|
import { URL } from 'node:url';
|
|
44
45
|
|
|
45
46
|
import { createApi } from './api.mjs';
|
|
@@ -99,6 +100,63 @@ import { CliError, EXIT } from './errors.mjs';
|
|
|
99
100
|
* ② 虽是"用户双击"路径、命中率最高,却带上面那个残余解析风险,所以只在前一条不可用时
|
|
100
101
|
* 才启用;③ 收尾。这样单条失败**不再等于**整个自动打开失败(这正是这次要修的问题)。
|
|
101
102
|
*/
|
|
103
|
+
/**
|
|
104
|
+
* ★★ 2026-10-21(老板真机反馈「Application not found,浏览器没打开」):Windows 默认处理器
|
|
105
|
+
* 坏了的时候,**不能只依赖 rundll32 / cmd start / powershell**——它们可能报告"应用未找到"
|
|
106
|
+
* 却仍然以退出码 0 结束(静默失败),于是后面的策略根本不会被尝试。
|
|
107
|
+
*
|
|
108
|
+
* ⇒ 在三条通用策略之后追加 **Chrome / Edge 的绝对路径**(不经过任何 shell、不依赖文件关联)。
|
|
109
|
+
* 路径覆盖 32/64 位安装位置与用户级安装(LOCALAPPDATA);某个路径不存在时 spawn 会立刻
|
|
110
|
+
* ENOENT,`openInBrowserWithFallback` 会静默试下一条,不会打扰用户。
|
|
111
|
+
*
|
|
112
|
+
* ⚠️ 顺序保持"默认处理器优先":用户平时登录态在默认浏览器里,直接抢开 Chrome 会让他
|
|
113
|
+
* 重新登录一遍(体验更差)。所以绝对路径只能排在通用策略**之后**当兜底。
|
|
114
|
+
* ⚠️ 这里用 `path.win32.join` 而不是 `path.join`:在非 Windows 上跑单测时也必须拼出
|
|
115
|
+
* Windows 风格路径,否则断言会随运行平台变化。
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [env] 环境变量(默认 process.env;单测可注入确定性的路径)
|
|
118
|
+
* @returns {Array<{label:string, cmd:string, args:(url:string)=>string[]}>}
|
|
119
|
+
*/
|
|
120
|
+
export function windowsAbsoluteBrowserStrategies(env = process.env) {
|
|
121
|
+
const source = env && typeof env === 'object' ? env : {};
|
|
122
|
+
const programFiles = source.ProgramFiles || source.PROGRAMFILES || 'C:\\Program Files';
|
|
123
|
+
const programFilesX86 = source['ProgramFiles(x86)'] || source['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
|
|
124
|
+
const localAppData = source.LOCALAPPDATA || source.LocalAppData || '';
|
|
125
|
+
/** 安装根 + 诊断标签:先 Program Files / x86,再用户级 LOCALAPPDATA */
|
|
126
|
+
const roots = [
|
|
127
|
+
{ label: 'Program Files', root: programFiles },
|
|
128
|
+
{ label: 'Program Files (x86)', root: programFilesX86 },
|
|
129
|
+
];
|
|
130
|
+
if (localAppData) roots.push({ label: 'LOCALAPPDATA', root: localAppData });
|
|
131
|
+
const strategies = [];
|
|
132
|
+
// Chrome 在前(老板/大多数国内用户桌面 Chrome 命中率最高),Edge 在后
|
|
133
|
+
for (const { label, root } of roots) {
|
|
134
|
+
strategies.push({
|
|
135
|
+
label: `chrome · ${label}`,
|
|
136
|
+
cmd: pathWin32.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
137
|
+
args: (url) => [url],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const { label, root } of roots) {
|
|
141
|
+
strategies.push({
|
|
142
|
+
label: `edge · ${label}`,
|
|
143
|
+
cmd: pathWin32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
144
|
+
args: (url) => [url],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return strategies;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 通用 Windows 启动器"其实没打开"的输出特征(老板真机报的就是 `Application not found`)。
|
|
152
|
+
*
|
|
153
|
+
* 为什么要看输出而不是只看退出码:`rundll32` / `cmd start` 在文件关联坏掉时可能**退出码 0**,
|
|
154
|
+
* 但 stderr/stdout 里已经写了找不到应用。只信退出码就会把它当成功,绝对路径兜底永远不会触发
|
|
155
|
+
* ——这正是这次要修的那条真机故障。见 `launchStrategy` 的 captureOutput 分支。
|
|
156
|
+
*/
|
|
157
|
+
const WINDOWS_HANDLER_FAILURE_PATTERN =
|
|
158
|
+
/application not found|windows cannot find|cannot find the file|系统找不到|找不到应用程序|不是内部或外部命令|no application is associated/i;
|
|
159
|
+
|
|
102
160
|
export const BROWSER_STRATEGY_TABLE = {
|
|
103
161
|
/**
|
|
104
162
|
* ⚠️⚠️ Windows 第 ② 条(`cmd /c start`)的**引号**是必须的(老板真机踩过,见上面那段记录):
|
|
@@ -116,11 +174,31 @@ export const BROWSER_STRATEGY_TABLE = {
|
|
|
116
174
|
*/
|
|
117
175
|
win32: [
|
|
118
176
|
// ① 最稳的一条:不经任何 shell,URL 作为独立 argv 逐字符原样交给系统(线上实测能弹浏览器的那条)
|
|
119
|
-
{
|
|
177
|
+
{
|
|
178
|
+
label: 'rundll32 url.dll,FileProtocolHandler',
|
|
179
|
+
cmd: 'rundll32',
|
|
180
|
+
args: (url) => ['url.dll,FileProtocolHandler', url],
|
|
181
|
+
captureOutput: true,
|
|
182
|
+
failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
|
|
183
|
+
},
|
|
120
184
|
// ② 兜底:命令与引号写法**保持原样**(引号必须在字符串里);它要经 cmd 解析,存在"退出码 0 却截断 URL"的残余风险,所以不排第一
|
|
121
|
-
{
|
|
185
|
+
{
|
|
186
|
+
label: 'cmd /c start',
|
|
187
|
+
cmd: 'cmd',
|
|
188
|
+
args: (url) => ['/c', `start "" "${url}"`],
|
|
189
|
+
captureOutput: true,
|
|
190
|
+
failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
|
|
191
|
+
},
|
|
122
192
|
// ③ 前两条都哑了时的最后一条
|
|
123
|
-
{
|
|
193
|
+
{
|
|
194
|
+
label: 'powershell Start-Process',
|
|
195
|
+
cmd: 'powershell',
|
|
196
|
+
args: (url) => ['-NoProfile', '-Command', `Start-Process '${url}'`],
|
|
197
|
+
captureOutput: true,
|
|
198
|
+
failureOutputPattern: WINDOWS_HANDLER_FAILURE_PATTERN,
|
|
199
|
+
},
|
|
200
|
+
// ④~⑨ 默认处理器/文件关联坏掉时的绝对路径兜底(Chrome / Edge,见 windowsAbsoluteBrowserStrategies)
|
|
201
|
+
...windowsAbsoluteBrowserStrategies(),
|
|
124
202
|
],
|
|
125
203
|
darwin: [
|
|
126
204
|
{ label: 'open', cmd: 'open', args: (url) => [url] },
|
|
@@ -191,9 +269,21 @@ export function strategiesFor(platform, strategies) {
|
|
|
191
269
|
return BROWSER_STRATEGY_TABLE[platform] || FALLBACK_STRATEGIES;
|
|
192
270
|
}
|
|
193
271
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
272
|
+
/**
|
|
273
|
+
* 子进程的固定选项:独立进程组(CLI 退出后浏览器继续活着)、不闪黑框。
|
|
274
|
+
*
|
|
275
|
+
* ★ 0.2.4:只有**异步多策略**链路里、且策略声明了 `captureOutput` 时才接管输出
|
|
276
|
+
* (用来识别 Windows 通用启动器"退出码 0 但输出 Application not found"的静默失败)。
|
|
277
|
+
* 同步版 `openInBrowser`(device.mjs 用)必须保持 `stdio:'ignore'` —— 它不消费管道,
|
|
278
|
+
* 接管输出反而可能把子进程的管道缓冲区写满。
|
|
279
|
+
*/
|
|
280
|
+
function browserSpawnOptions(strategy = null, { captureOutput = false } = {}) {
|
|
281
|
+
const capture = captureOutput === true && strategy && strategy.captureOutput === true;
|
|
282
|
+
return {
|
|
283
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'ignore',
|
|
284
|
+
detached: true,
|
|
285
|
+
windowsHide: true,
|
|
286
|
+
};
|
|
197
287
|
}
|
|
198
288
|
|
|
199
289
|
/**
|
|
@@ -333,16 +423,30 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
|
|
|
333
423
|
return new Promise((resolve) => {
|
|
334
424
|
let settled = false;
|
|
335
425
|
let timer = null;
|
|
426
|
+
/** ★ 0.2.4:Windows 通用启动器的输出(最多留 4KB,只为识别"Application not found") */
|
|
427
|
+
let output = '';
|
|
428
|
+
let child = null;
|
|
336
429
|
const settle = (result) => {
|
|
337
430
|
if (settled) return;
|
|
338
431
|
settled = true;
|
|
339
432
|
if (timer) clearTimeout(timer);
|
|
433
|
+
// 超时/退出后把管道关掉:否则 pipe 句柄可能把 CLI 的事件循环多吊一会儿
|
|
434
|
+
for (const stream of [child && child.stdout, child && child.stderr]) {
|
|
435
|
+
try {
|
|
436
|
+
if (stream && typeof stream.destroy === 'function') stream.destroy();
|
|
437
|
+
} catch {
|
|
438
|
+
/* 清理失败不影响结论 */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
340
441
|
resolve(result);
|
|
341
442
|
};
|
|
342
443
|
|
|
343
|
-
let child;
|
|
344
444
|
try {
|
|
345
|
-
child = spawnImpl(
|
|
445
|
+
child = spawnImpl(
|
|
446
|
+
strategy.cmd,
|
|
447
|
+
strategy.args(target),
|
|
448
|
+
browserSpawnOptions(strategy, { captureOutput: strategy.captureOutput === true }),
|
|
449
|
+
);
|
|
346
450
|
} catch (e) {
|
|
347
451
|
resolve({ ok: false, error: e });
|
|
348
452
|
return;
|
|
@@ -352,6 +456,20 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
|
|
|
352
456
|
return;
|
|
353
457
|
}
|
|
354
458
|
|
|
459
|
+
if (strategy.captureOutput === true) {
|
|
460
|
+
const collect = (chunk) => {
|
|
461
|
+
if (output.length >= 4096) return;
|
|
462
|
+
output += String(chunk).slice(0, 4096 - output.length);
|
|
463
|
+
};
|
|
464
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
465
|
+
if (stream && typeof stream.on === 'function') {
|
|
466
|
+
// 流错误(管道提前关闭等)不能变成未捕获异常把 CLI 崩掉
|
|
467
|
+
stream.on('error', () => {});
|
|
468
|
+
stream.on('data', collect);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
355
473
|
timer = setTimeout(() => settle({ ok: true, timedOut: true }), timeoutMs);
|
|
356
474
|
|
|
357
475
|
if (typeof child.on === 'function') {
|
|
@@ -359,7 +477,21 @@ function launchStrategy(strategy, target, spawnImpl, timeoutMs) {
|
|
|
359
477
|
child.on('error', (e) => settle({ ok: false, error: e }));
|
|
360
478
|
// 进程正常退出才算成功:非零退出码意味着"它自己认为没干成"(例如 xdg-open 找不到浏览器)
|
|
361
479
|
child.on('exit', (code, signal) => {
|
|
362
|
-
if (code === 0)
|
|
480
|
+
if (code === 0) {
|
|
481
|
+
/**
|
|
482
|
+
* ★ 0.2.4:退出码 0 **不等于**浏览器真的打开了 —— Windows 上文件关联坏掉时,
|
|
483
|
+
* rundll32 / cmd start 会打出 `Application not found` 却仍然返回 0。
|
|
484
|
+
* 这里把这种"静默失败"识别出来,交给下一条(Chrome/Edge 绝对路径)兜底。
|
|
485
|
+
*/
|
|
486
|
+
const pattern = strategy.failureOutputPattern;
|
|
487
|
+
if (pattern instanceof RegExp && output && pattern.test(output)) {
|
|
488
|
+
return settle({
|
|
489
|
+
ok: false,
|
|
490
|
+
error: new Error(`退出码 0,但输出表明没有打开:${output.trim().replace(/\s+/g, ' ').slice(0, 160)}`),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return settle({ ok: true });
|
|
494
|
+
}
|
|
363
495
|
settle({ ok: false, error: new Error(`退出码 ${code}${signal ? `(信号 ${signal})` : ''}`) });
|
|
364
496
|
});
|
|
365
497
|
} else {
|
|
@@ -808,13 +940,10 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
|
|
|
808
940
|
/**
|
|
809
941
|
* ★★ 最终是否自动打开系统浏览器 = **唯一闸门**的结论。
|
|
810
942
|
*
|
|
811
|
-
* ★
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* 「还是返回以前那种打开跳到一个浏览器,然后打开授权那个吧,大家也都是那个的。
|
|
816
|
-
* 我们在右侧这个……能力还不会太完善……还是走传统的路吧。」
|
|
817
|
-
* ⇒ 稳定(成熟的系统启动器)+ `window.close()` 在独立浏览器里生效(页面能自己关)。
|
|
943
|
+
* ★ 2026-10-21(老板真机反馈「CI 里 login 弹了浏览器」):
|
|
944
|
+
* **人类终端**默认打开;**非交互 / CI**(stdout 非 TTY / CI 环境变量 /
|
|
945
|
+
* `WKONGAI_CLI_NO_BROWSER=1`)与 `--no-browser` 都不打开。
|
|
946
|
+
* `json` / `isTTY` 不再决定"要不要弹"(`json` 只影响 `mode` 诊断字段)。
|
|
818
947
|
* ⚠️ 无论哪种情况,**绝不**因为"没打开浏览器"让登录失败 —— 地址一直完整打印,回调照旧等。
|
|
819
948
|
*/
|
|
820
949
|
const shouldOpenBrowser = browserIntent.allow;
|
|
@@ -1214,23 +1343,68 @@ export async function runLogin({ config, log = () => {}, deps = {} }) {
|
|
|
1214
1343
|
server.listen(port, config.callbackHost);
|
|
1215
1344
|
});
|
|
1216
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* ★★ 0.2.4(老板真机反馈):端口优先 53682;被占用就自动换系统分配的空闲端口;
|
|
1348
|
+
* **bind 彻底失败时只报明确错误 + `--device` 建议,绝不静默改走设备码**
|
|
1349
|
+
* (设备码是另一条授权协议,用户没要求时不能替他换;命令层也不再兜底,见 commands/auth.mjs)。
|
|
1350
|
+
*/
|
|
1351
|
+
const preferredPort = deps.listenPort ?? config.callbackPort;
|
|
1352
|
+
const buildListenError = (cause, { afterFallback = false } = {}) => {
|
|
1353
|
+
const what = afterFallback
|
|
1354
|
+
? `首选端口 ${preferredPort} 被占用后,系统分配端口也无法监听`
|
|
1355
|
+
: `无法在 ${config.callbackHost}:${preferredPort} 上监听本地回调服务`;
|
|
1356
|
+
const codeText = cause && cause.code ? `(${cause.code})` : '';
|
|
1357
|
+
return new CliError(`${what}${codeText}:${(cause && cause.message) || cause}`, {
|
|
1358
|
+
kind: 'listen',
|
|
1359
|
+
hint:
|
|
1360
|
+
'本地回调服务没有启动,所以不会打开浏览器。请检查端口占用/防火墙,或用 --callback-port 指定其它端口;' +
|
|
1361
|
+
`若本机确实无法监听回环地址,请显式改用 \`${config.cmdName || 'wkgeo'} login --device\`(设备码模式,不需要本地回调)` +
|
|
1362
|
+
(proxyHint ? `。另外:${proxyHint}` : ''),
|
|
1363
|
+
cause,
|
|
1364
|
+
});
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1217
1367
|
let bound;
|
|
1218
1368
|
try {
|
|
1219
|
-
bound = await listenOn(
|
|
1369
|
+
bound = await listenOn(preferredPort);
|
|
1220
1370
|
} catch (e) {
|
|
1221
|
-
if (e && e.code === 'EADDRINUSE') {
|
|
1222
|
-
log(
|
|
1223
|
-
|
|
1371
|
+
if (e && e.code === 'EADDRINUSE' && Number(preferredPort) !== 0) {
|
|
1372
|
+
log(`⚠️ 回调端口 ${preferredPort} 已被占用,自动改用系统分配的空闲端口(redirect_uri 会使用实际监听端口)`);
|
|
1373
|
+
try {
|
|
1374
|
+
bound = await listenOn(0);
|
|
1375
|
+
} catch (e2) {
|
|
1376
|
+
throw buildListenError(e2, { afterFallback: true });
|
|
1377
|
+
}
|
|
1224
1378
|
} else {
|
|
1225
|
-
throw
|
|
1226
|
-
kind: 'listen',
|
|
1227
|
-
hint: `检查防火墙/端口占用,或用 --callback-port 指定其它端口${proxyHint ? `。另外:${proxyHint}` : ''}`,
|
|
1228
|
-
cause: e,
|
|
1229
|
-
});
|
|
1379
|
+
throw buildListenError(e);
|
|
1230
1380
|
}
|
|
1231
1381
|
}
|
|
1232
1382
|
const actualPort = bound.port;
|
|
1383
|
+
/**
|
|
1384
|
+
* ⚠️ 顺序硬保证(0.2.4):**监听真的成功**之后才允许构造/打印授权地址、才允许打开浏览器。
|
|
1385
|
+
* 理论上 `listenOn` resolve 时 server 一定处于 listening;这里再核一次,是为了让
|
|
1386
|
+
* "服务没起来却把浏览器弹出去"这类故障在代码层**不可能**发生(而不是靠调用顺序的自觉)。
|
|
1387
|
+
*/
|
|
1388
|
+
if (!server.listening) {
|
|
1389
|
+
// 极端防御:真走到这里说明监听状态丢了 —— 先尽量关掉,再明确报错(绝不弹浏览器)
|
|
1390
|
+
try {
|
|
1391
|
+
server.close();
|
|
1392
|
+
} catch {
|
|
1393
|
+
/* 本来就没监听,忽略 */
|
|
1394
|
+
}
|
|
1395
|
+
throw new CliError('本地回调服务没有进入监听状态,已取消打开浏览器', {
|
|
1396
|
+
kind: 'listen',
|
|
1397
|
+
hint:
|
|
1398
|
+
`请重新运行 \`${config.cmdName || 'wkgeo'} login\`;若仍失败,改用 \`${config.cmdName || 'wkgeo'} login --device\`(设备码模式,不需要本地回调)`,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1233
1401
|
redirectUri = `http://${config.callbackHost}:${actualPort}${config.callbackPath}`;
|
|
1402
|
+
/**
|
|
1403
|
+
* ★★ 老板要求的可观测证据(0.2.4):先明确告诉用户"本地服务已经在监听",再谈浏览器。
|
|
1404
|
+
* 顺序是:listen 成功 → 这行日志 → 打印授权网址 → 尝试打开浏览器。
|
|
1405
|
+
* 用户/排障者因此能一眼分辨"服务没起来"和"浏览器没打开"这两类完全不同的故障。
|
|
1406
|
+
*/
|
|
1407
|
+
log(`✅ 回调服务已启动 ${redirectUri}`);
|
|
1234
1408
|
const authUrl = new URL(authorizeBase);
|
|
1235
1409
|
authUrl.searchParams.set('client_id', config.clientId);
|
|
1236
1410
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
package/lib/page.mjs
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* 3. **不依赖 JavaScript**:正文(结论 + 账号 + 兜底文案)全部是静态 HTML + CSS。
|
|
22
22
|
* 这个页面出现的时机很敏感(用户刚在同意页点完"同意"),任何脚本报错
|
|
23
23
|
* 都会让人以为授权失败,所以脚本只能做**锦上添花**的事,绝不能承载信息。
|
|
24
|
-
*
|
|
24
|
+
* 目前唯一的脚本是"尝试自动关闭标签页"(见 `autoCloseScript`):
|
|
25
25
|
* 它失败 / 被浏览器拒绝 / 被用户插件拦掉,页面照旧完整可读。
|
|
26
|
-
* ⚠️
|
|
26
|
+
* ⚠️ 0.2.4 起成功页与失败页都会带它(老板反馈「页面关不掉」),
|
|
27
|
+
* 差别只在延迟(成功 1.2s / 失败 6s),正文永远是静态可读的。
|
|
27
28
|
* 4. **所有动态内容必须转义**(`escapeHtml`)。账号标识来自服务端 `userinfo`,
|
|
28
29
|
* 其中昵称是**用户自己可控**的字符串,直接拼进 HTML 就等于在本机页面上
|
|
29
30
|
* 开一个自伤型 XSS(本机页面同样能去访问 `http://127.0.0.1` 上的其它服务)。
|
|
@@ -168,43 +169,27 @@ function badgeSvg(tone) {
|
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
/**
|
|
171
|
-
*
|
|
172
|
+
* 「尝试自动关闭标签页」的极简内联脚本。
|
|
172
173
|
*
|
|
173
174
|
* ## 为什么加这段(老板 2026-10-01 对比阿里云 MCP 的实测结论)
|
|
174
175
|
* 老板的原话:阿里云那套"点完授权,页面**自动就关了**,这样看着很正常"。
|
|
175
|
-
*
|
|
176
|
+
* 我们的现状是:跳回本机后留一张页面,**要用户自己动手关**——不够顺。
|
|
176
177
|
*
|
|
177
|
-
* ##
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* * **只有** CLI 真的换到令牌、并且凭据已经写进本机之后的**那张成功页**才尝试关闭
|
|
184
|
-
* (对应 `login.mjs` 里 `api.token(...)` → `writeCredentials(...)` → `successPage(...)`
|
|
185
|
-
* 这个顺序,**不许调换**);
|
|
186
|
-
* * 中间态页面(例如"正在换取令牌…")即使将来真加了,也**不自动关闭**,统一由成功页收尾;
|
|
187
|
-
* * 错误页(state 失败 / 缺少授权码 / 授权未完成·拒绝 / 换票失败 / 405)**一律不关**,
|
|
188
|
-
* 详见 `errorPage` 的说明。
|
|
178
|
+
* ## 0.2.4(2026-10-21 老板反馈「127 那个页面关不掉」)
|
|
179
|
+
* **授权成功页和失败页都要能自动关闭**,避免用户卡在一个已经结束的页面上。
|
|
180
|
+
* 页面仍然保留「本页面可以关闭了 / 直接关闭标签页」的兜底文案(浏览器只允许脚本关闭
|
|
181
|
+
* **由脚本打开的**窗口,我们这张页大概率关不掉,文案就是兜底)。
|
|
182
|
+
* * 成功页:延迟 1.2 秒关(先让用户看清「授权成功」再关,上来就关会让人以为页面崩了);
|
|
183
|
+
* * 失败页:延迟 6 秒关(失败原因要留够时间看清楚,但绝不能永远留在屏幕上)。
|
|
189
184
|
*
|
|
190
|
-
* ##
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* "用户自己打开的标签页",`window.close()` 通常被**静默忽略**
|
|
195
|
-
* (Chrome / Edge 控制台里会留一句 "Scripts may close only the windows that
|
|
196
|
-
* were opened by them",Firefox 类似)。
|
|
197
|
-
* 所以:
|
|
198
|
-
* * 这段脚本**不是**"关掉页面"的保证,只是"能关就顺手关掉";
|
|
199
|
-
* * 页面上那句「本页面可以关闭了」**必须保留**——它就是给关不掉的情况兜底的;
|
|
200
|
-
* * 绝不为了"看起来关掉了"去做跳转 / 假按钮 / alert(那才是骗用户)。
|
|
185
|
+
* ## 🔴 时机约束(阿里云 `mcp-remote` 踩过的坑,别翻回去)
|
|
186
|
+
* 只有 CLI **真的换到令牌、并且凭据已经写进本机之后**的那张成功页才算"确实成功";
|
|
187
|
+
* 收到 code 就宣告成功并关窗是不允许的(换票失败时用户已经被打发走,只剩疑惑)。
|
|
188
|
+
* 失败页虽然也自动关,但原因文案必须完整可读,且页面里**不出现任何令牌/授权码**。
|
|
201
189
|
*
|
|
202
190
|
* ## 为什么延迟 + 重试两次
|
|
203
|
-
* *
|
|
204
|
-
*
|
|
205
|
-
* 页面一闪而过同样让人发慌)。
|
|
206
|
-
* * **再试一次**:部分浏览器(含移动端内核)对第一次 `close()` 会忽略、
|
|
207
|
-
* 第二次才生效;也有的是 DOM 尚未稳定时忽略。重试成本几乎为零,所以试两次。
|
|
191
|
+
* * **延迟**:先让用户看清结论;
|
|
192
|
+
* * **再试一次**:部分浏览器(含移动端内核)对第一次 `close()` 会忽略、第二次才生效。
|
|
208
193
|
* 两次之后就**不再骚扰**——继续调下去只会刷一屏控制台报错。
|
|
209
194
|
*
|
|
210
195
|
* ## 约束(单测钉住)
|
|
@@ -212,8 +197,12 @@ function badgeSvg(tone) {
|
|
|
212
197
|
* 不用 `eval`;整段脚本不碰 DOM,也不接收任何数据(页面里没有令牌,脚本里更没有)。
|
|
213
198
|
* * 用 `try/catch` 包住:某些内嵌浏览器(Electron / 企业管控浏览器)里
|
|
214
199
|
* `window.close` 可能直接被禁用甚至抛异常,不能因此弄脏页面。
|
|
200
|
+
*
|
|
201
|
+
* @param {number} delayMs 首次尝试关闭前的延迟(毫秒;必须是有限正数)
|
|
215
202
|
*/
|
|
216
|
-
|
|
203
|
+
function autoCloseScript(delayMs) {
|
|
204
|
+
const delay = Number.isFinite(delayMs) && delayMs >= 0 ? Math.round(delayMs) : 1200;
|
|
205
|
+
return `<script>
|
|
217
206
|
(function(){
|
|
218
207
|
var n = 0;
|
|
219
208
|
function tryClose(){
|
|
@@ -221,9 +210,10 @@ const AUTO_CLOSE_SCRIPT = `<script>
|
|
|
221
210
|
try { window.close(); } catch (e) { /* 内嵌浏览器可能直接禁用,忽略 */ }
|
|
222
211
|
if (n < 2) setTimeout(tryClose, 700);
|
|
223
212
|
}
|
|
224
|
-
setTimeout(tryClose,
|
|
213
|
+
setTimeout(tryClose, ${delay});
|
|
225
214
|
})();
|
|
226
215
|
</script>`;
|
|
216
|
+
}
|
|
227
217
|
|
|
228
218
|
/**
|
|
229
219
|
* 拼出整份文档。
|
|
@@ -237,11 +227,11 @@ const AUTO_CLOSE_SCRIPT = `<script>
|
|
|
237
227
|
* @param {string} [spec.note] 小字说明
|
|
238
228
|
* @param {string} [spec.hint] 更小的补充提示
|
|
239
229
|
* @param {boolean} [spec.autoClose] 是否附上"尝试自动关闭标签页"的脚本。
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
230
|
+
* 0.2.4 起**成功页与失败页都打开它**(老板反馈「页面关不掉」),差别只在延迟:
|
|
231
|
+
* 成功页 1.2 秒(先看清"授权成功"),失败页 6 秒(先看清原因)。
|
|
232
|
+
* 打开它的页面**必须同时**有「本页面可以关闭了 / 直接关闭标签页」这类兜底文案
|
|
233
|
+
* (浏览器大概率不允许脚本关闭"用户自己打开的"标签页)。
|
|
234
|
+
* @param {number} [spec.autoCloseDelayMs] 首次尝试关闭前的延迟(默认 1200ms)
|
|
245
235
|
*/
|
|
246
236
|
export function renderPage({
|
|
247
237
|
title,
|
|
@@ -252,6 +242,7 @@ export function renderPage({
|
|
|
252
242
|
note,
|
|
253
243
|
hint,
|
|
254
244
|
autoClose = false,
|
|
245
|
+
autoCloseDelayMs = 1200,
|
|
255
246
|
} = {}) {
|
|
256
247
|
const parts = [badgeSvg(tone), `<h1>${escapeHtml(heading)}</h1>`];
|
|
257
248
|
if (lead) parts.push(`<p class="lead">${escapeHtml(lead)}</p>`);
|
|
@@ -275,7 +266,7 @@ export function renderPage({
|
|
|
275
266
|
<main class="card" role="main">
|
|
276
267
|
${parts.join('\n')}
|
|
277
268
|
</main>
|
|
278
|
-
<footer>悟空AI · 云站平台</footer>${autoClose ? `\n${
|
|
269
|
+
<footer>悟空AI · 云站平台</footer>${autoClose ? `\n${autoCloseScript(autoCloseDelayMs)}` : ''}
|
|
279
270
|
</body>
|
|
280
271
|
</html>`;
|
|
281
272
|
return { title, html };
|
|
@@ -292,11 +283,11 @@ ${parts.join('\n')}
|
|
|
292
283
|
*
|
|
293
284
|
* 文案分区(老板指定):大标题 / 副文案(回终端)/ 已登录账号 / 小字说明。
|
|
294
285
|
*
|
|
295
|
-
* ##
|
|
286
|
+
* ## 会尝试自动关闭标签页的页面之一(`autoClose: true`)
|
|
296
287
|
* 这张页只在 `login.mjs` 里 **`api.token(...)` 换到令牌 → `writeCredentials(...)` 写盘之后**
|
|
297
288
|
* 才被返回,所以"授权成功"是**已经落地的事实**,不是"收到了 code"这种半成品状态
|
|
298
289
|
* —— 这是与阿里云 `mcp-remote` 最关键的区别(它收到 code 就宣告成功并关窗,换票失败时
|
|
299
|
-
* 用户已经被打发走了)。详见 `
|
|
290
|
+
* 用户已经被打发走了)。详见 `autoCloseScript` 的注释。
|
|
300
291
|
* ⚠️ 副文案里的「本页面可以关闭了」和 hint 里的"直接关闭标签页"是**兜底**,
|
|
301
292
|
* 关不掉时用户照着做就行,两条都不许删(单测钉住)。
|
|
302
293
|
*
|
|
@@ -372,25 +363,31 @@ export function alreadyDonePage(account) {
|
|
|
372
363
|
* @param {string} spec.heading 大标题(短,一眼能懂)
|
|
373
364
|
* @param {string} spec.reason 明确的中文原因
|
|
374
365
|
* @param {string} [spec.hint] 补充说明(例如"没有写入任何凭据")
|
|
366
|
+
* @param {boolean} [spec.autoClose] 是否尝试自动关闭(默认 **true**,0.2.4 老板反馈"关不掉")
|
|
367
|
+
* @param {number} [spec.autoCloseDelayMs] 首次关闭延迟(默认 6000ms:先让用户看清原因)
|
|
375
368
|
*
|
|
376
|
-
* ##
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
* ⚠️
|
|
384
|
-
* 真要改这条口径,请先读完 `AUTO_CLOSE_SCRIPT` 上面那段(阿里云 `mcp-remote` 的教训)。
|
|
369
|
+
* ## 0.2.4(2026-10-21 老板反馈「127 那个页面关不掉」)
|
|
370
|
+
* 失败页**也**尝试自动关闭,但延迟 6 秒 —— 既不让用户卡死在一个已经结束的页面上,
|
|
371
|
+
* 也留够时间看清"为什么没成"。正文(原因 + 回终端重试)全部是静态 HTML,
|
|
372
|
+
* 脚本关不掉时页面照旧可读,且带「直接关闭标签页」的兜底文案。
|
|
373
|
+
* ⚠️ 仍然**不允许**把失败页做成"一出来就关":失败原因是排障的唯一线索,
|
|
374
|
+
* 而且其中"state 校验失败 / 缺少授权码 / 405"出现时 CLI 还在等真正的回调,
|
|
375
|
+
* 用户可能正照着页面说明重试。延迟关闭是这两者之间的折中。
|
|
376
|
+
* ⚠️ 任何页面都绝不回显 code / 令牌(模板里没有接收它们的位置)。
|
|
385
377
|
*/
|
|
386
|
-
export function errorPage({ tone = 'error', heading, reason, hint } = {}) {
|
|
378
|
+
export function errorPage({ tone = 'error', heading, reason, hint, autoClose = true, autoCloseDelayMs = 6000 } = {}) {
|
|
379
|
+
const seconds = Math.max(1, Math.round(autoCloseDelayMs / 1000));
|
|
380
|
+
const closeHint = autoClose
|
|
381
|
+
? `本页面会在约 ${seconds} 秒后自动关闭;如果没有关掉,直接关闭标签页即可。`
|
|
382
|
+
: '看完后直接关闭标签页即可。';
|
|
387
383
|
return renderPage({
|
|
388
384
|
tone,
|
|
389
385
|
title: `${heading} · 悟空AI`,
|
|
390
386
|
heading,
|
|
391
387
|
lead: reason,
|
|
392
388
|
note: hint,
|
|
393
|
-
hint: RETRY_HINT
|
|
394
|
-
|
|
389
|
+
hint: `${RETRY_HINT} ${closeHint}`,
|
|
390
|
+
autoClose,
|
|
391
|
+
autoCloseDelayMs,
|
|
395
392
|
});
|
|
396
393
|
}
|