linke-cli 0.1.0 → 0.3.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.
- package/README.md +12 -3
- package/package.json +1 -1
- package/skills/linke/SKILL.md +12 -7
- package/src/bin.js +144 -13
- package/src/loginserver.js +293 -0
- package/src/qr.js +390 -0
- package/src/schools/sdufe/parsers.js +7 -3
- package/src/web/login.html +209 -0
package/README.md
CHANGED
|
@@ -10,19 +10,28 @@ skill 说明书(`skills/linke/SKILL.md`),一条命令装入 agent 的 skil
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g linke-cli # 或 npx linke-cli <命令>
|
|
13
|
-
linke
|
|
13
|
+
linke login # 首选配置:本机起登录页,自动打开浏览器(手机填写加 --qr)
|
|
14
14
|
linke schedule # 直接用,登录全自动
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
要求 Node.js ≥ 18.14,零运行时依赖。
|
|
18
18
|
|
|
19
|
+
`linke login` 的登录页由本机 CLI 提供(仅绑 127.0.0.1 随机端口,页面
|
|
20
|
+
随 npm 包分发可审计);**提交后在网页内完成教务登录验证**——验证中
|
|
21
|
+
动效 → 结果回显(成功显示姓名与教学周,凭据此时才落盘 0600;密码错
|
|
22
|
+
误页内提示并可重试,含教务锁号警示)。尝试上限 3 次、5 分钟超时,
|
|
23
|
+
任一终结即自动关闭服务。`--qr` 模式额外在终端展示局域网 URL 二维码
|
|
24
|
+
(含会话令牌),手机同 Wi-Fi 扫码填写、全程手机端闭环,凭据不经任
|
|
25
|
+
何云端。`linke config` 保留为非 TTY / SSH 场景的终端兜底。
|
|
26
|
+
|
|
19
27
|
## 命令
|
|
20
28
|
|
|
21
29
|
| 命令 | 说明 |
|
|
22
30
|
|---|---|
|
|
23
|
-
| `linke
|
|
31
|
+
| `linke login [--qr]` | 网页配置凭据(首选);`--qr` 出局域网二维码供手机填写 |
|
|
32
|
+
| `linke config [--clear]` | 终端录入/清除凭据(非 TTY 兜底;存 `~/.linke-cli/`,权限 600) |
|
|
33
|
+
| `linke verify` | 验证已存凭据(自动登录教务并显示身份) |
|
|
24
34
|
| `linke status` | 配置与会话状态(JSON) |
|
|
25
|
-
| `linke login` | 强制重登(平时自动,排障用) |
|
|
26
35
|
| `linke scores [--term 2025-2026-1]` | 成绩(缺省全部学期,JSON) |
|
|
27
36
|
| `linke schedule [--term ...] [--week 3]` | 课表(缺省当前学期全部周,JSON) |
|
|
28
37
|
| `linke schools` | 列出学校适配器 |
|
package/package.json
CHANGED
package/skills/linke/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: linke
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.3.0
|
|
4
4
|
description: "林课教务查询 CLI(山东财经大学正方教务):查课表、查成绩、看教务登录状态。当用户问「我这学期课表」「今天有什么课」「我的成绩/绩点」「挂了什么课」「教务登录状态」等教务只读信息时使用。登录全自动(验证码云端识别),无需用户介入。"
|
|
5
5
|
metadata:
|
|
6
6
|
requires:
|
|
@@ -20,8 +20,10 @@ linke status
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- `configured: false` → 用户还没配置凭据。**引导用户本人**在终端运行
|
|
23
|
-
`linke
|
|
24
|
-
|
|
23
|
+
`linke login`(本机起登录页并自动打开浏览器,手机填写加 `--qr` 出
|
|
24
|
+
局域网二维码;验证在网页内完成——通过才保存,失败页内重试,全程
|
|
25
|
+
无需看终端;凭据只存本机 `~/.linke-cli/`)。**你不代输密码、
|
|
26
|
+
不把密码写进任何命令**——凭据只经用户本人的网页/终端进入本机。
|
|
25
27
|
- `configured: true` 即可用,无需关心登录:每个命令内置鉴权状态机,
|
|
26
28
|
session 过期自动重登,只有凭据失效才需要人介入。
|
|
27
29
|
|
|
@@ -29,13 +31,15 @@ linke status
|
|
|
29
31
|
|
|
30
32
|
| 需求 | 命令 |
|
|
31
33
|
|---|---|
|
|
34
|
+
| 配置凭据(用户本人执行) | `linke login`(网页输入;`--qr` 手机扫码填写) |
|
|
35
|
+
| 终端配置(非 TTY / SSH 兜底) | `linke config` |
|
|
36
|
+
| 验证已存凭据 | `linke verify` |
|
|
32
37
|
| 本学期课表(全部周) | `linke schedule` |
|
|
33
38
|
| 指定学期课表 | `linke schedule --term 2025-2026-1` |
|
|
34
39
|
| 单周课表 | `linke schedule --week 3` |
|
|
35
40
|
| 全部成绩 | `linke scores` |
|
|
36
41
|
| 指定学期成绩 | `linke scores --term 2025-2026-1` |
|
|
37
42
|
| 登录/配置状态 | `linke status` |
|
|
38
|
-
| 强制重登(排障用) | `linke login` |
|
|
39
43
|
|
|
40
44
|
## 输出契约
|
|
41
45
|
|
|
@@ -53,11 +57,11 @@ linke status
|
|
|
53
57
|
| exit code | 含义 | 你的动作 |
|
|
54
58
|
|---|---|---|
|
|
55
59
|
| 0 | 成功 | 解析 stdout |
|
|
56
|
-
| 2 | 凭据失效(教务报密码错误) | 引导用户本人重新运行 `linke
|
|
60
|
+
| 2 | 凭据失效(教务报密码错误) | 引导用户本人重新运行 `linke login`,不要自行重试 |
|
|
57
61
|
| 3 | 验证码自动识别连续未通过 | 直接重试一次同一命令;仍失败告知用户稍后再试 |
|
|
58
62
|
| 4 | 网络不可达(教务或云端识别) | 告知用户检查网络后重试 |
|
|
59
63
|
| 5 | 解析失败(教务页面改版) | 告知用户到 linke-cli 仓库提 issue,不要反复重试 |
|
|
60
|
-
| 6 | 未配置凭据 | 引导用户本人运行 `linke
|
|
64
|
+
| 6 | 未配置凭据 | 引导用户本人运行 `linke login`(网页输入;SSH 场景 `linke config`) |
|
|
61
65
|
|
|
62
66
|
stderr 末行「提示:」是面向用户的自救说明,可直接转述。
|
|
63
67
|
|
|
@@ -68,7 +72,8 @@ stderr 末行「提示:」是面向用户的自救说明,可直接转述。
|
|
|
68
72
|
不要尝试用任何方式绕过。
|
|
69
73
|
2. **凭据纪律**:密码只存在于用户本机 `~/.linke-cli/`(权限 600)。
|
|
70
74
|
不询问、不存储、不转发用户密码;需要录入时只引导用户本人跑
|
|
71
|
-
`linke config
|
|
75
|
+
`linke login`(或 SSH 场景 `linke config`)。`linke login` 起的
|
|
76
|
+
网页服务只绑本机/局域网且带一次性令牌,凭据不经过任何云端。
|
|
72
77
|
3. **频次纪律**:教务系统对高频请求有风控,连续查询之间保持间隔,
|
|
73
78
|
不做轮询;一次问答内同一命令不重复执行超过 2 次。
|
|
74
79
|
4. 数据口径:成绩/课表数据实时来自教务系统,如与预期不符先用
|
package/src/bin.js
CHANGED
|
@@ -15,20 +15,25 @@ import {
|
|
|
15
15
|
import { installSkill, skillSourceDir } from './skill.js'
|
|
16
16
|
import { ask, askPassword } from './prompt.js'
|
|
17
17
|
import { emitJson, progress } from './util.js'
|
|
18
|
+
import { startLoginServer } from './loginserver.js'
|
|
19
|
+
import { buildQr, renderTerminal } from './qr.js'
|
|
20
|
+
import { exec } from 'node:child_process'
|
|
18
21
|
|
|
19
22
|
const USAGE = `linke —— 林课教务 CLI(只读查询,供 agent / 人类使用)
|
|
20
23
|
|
|
21
24
|
用法:
|
|
22
|
-
linke
|
|
23
|
-
|
|
24
|
-
linke
|
|
25
|
-
linke
|
|
26
|
-
linke
|
|
27
|
-
linke
|
|
28
|
-
linke
|
|
29
|
-
linke
|
|
30
|
-
linke
|
|
31
|
-
linke
|
|
25
|
+
linke login [--qr] 网页配置凭据(首选):本机起登录页自动开浏览器;
|
|
26
|
+
--qr 额外展示局域网二维码,手机同 Wi-Fi 填写
|
|
27
|
+
linke config [--school sdufe] [--api-base URL] 终端录入凭据(非 TTY / SSH 兜底)
|
|
28
|
+
linke config --clear 清除本机凭据
|
|
29
|
+
linke verify 验证已存凭据(自动登录教务并显示身份)
|
|
30
|
+
linke status 配置与教务会话状态(JSON)
|
|
31
|
+
linke scores [--term 2025-2026-1] 成绩查询(缺省=全部学期)
|
|
32
|
+
linke schedule [--term 2025-2026-1] [--week 3] 课表查询(缺省=当前学期全部周)
|
|
33
|
+
linke schools 列出可用学校适配器
|
|
34
|
+
linke skill install [--path ~/.agents/skills] 安装 agent skill 说明书
|
|
35
|
+
linke logout 清除本机教务 session(保留凭据)
|
|
36
|
+
linke help 本帮助
|
|
32
37
|
|
|
33
38
|
说明:
|
|
34
39
|
登录请求从本机直发教务系统;验证码图片仅用于云端识别(传图返文字)。
|
|
@@ -90,7 +95,7 @@ async function cmdConfig(flags) {
|
|
|
90
95
|
}
|
|
91
96
|
saveConfig({ school, userId, password, apiBase })
|
|
92
97
|
clearSession() // 凭据变更后旧 session 一律作废
|
|
93
|
-
progress(`已保存到 ~/.linke-cli/config.json(权限 600
|
|
98
|
+
progress(`已保存到 ~/.linke-cli/config.json(权限 600)。建议运行 linke verify 验证凭据,或直接运行任意查询命令。`)
|
|
94
99
|
return 0
|
|
95
100
|
}
|
|
96
101
|
|
|
@@ -112,7 +117,131 @@ async function cmdStatus() {
|
|
|
112
117
|
return 0
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
function openBrowser(url) {
|
|
121
|
+
const platform = process.platform
|
|
122
|
+
const cmd =
|
|
123
|
+
platform === 'darwin' ? `open ${JSON.stringify(url)}` :
|
|
124
|
+
platform === 'win32' ? `start "" ${JSON.stringify(url)}` :
|
|
125
|
+
`xdg-open ${JSON.stringify(url)}`
|
|
126
|
+
exec(cmd, () => {}) // 打开失败不致命:终端已打印 URL
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pickPrimaryLanUrl(urls) {
|
|
130
|
+
// 主网卡取舍:192.168.x 优先(家用路由常态),其次 10.x / 172.16-31.x
|
|
131
|
+
const score = (url) => {
|
|
132
|
+
if (url.includes('//192.168.')) return 0
|
|
133
|
+
if (url.includes('//10.')) return 1
|
|
134
|
+
if (/\/\/172\.(1[6-9]|2\d|3[01])\./.test(url)) return 2
|
|
135
|
+
return 3
|
|
136
|
+
}
|
|
137
|
+
return [...urls].sort((a, b) => score(a) - score(b))[0] || null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 验证回调工厂(T7 验收 8:复用 session 状态机的登录实现,不另写
|
|
142
|
+
* 登录路径)。凭据仅在验证通过后才落盘(0600 不变式),失败不留
|
|
143
|
+
* 半成品文件。导出供测试注入 fake 适配器验证分类与落盘纪律。
|
|
144
|
+
*/
|
|
145
|
+
export function createVerifyCredentials({ school = DEFAULT_SCHOOL, apiBase = DEFAULT_API_BASE } = {}) {
|
|
146
|
+
return async function verifyCredentials(userId, password) {
|
|
147
|
+
const config = { school, userId, password, apiBase }
|
|
148
|
+
const adapter = getAdapter(school)
|
|
149
|
+
try {
|
|
150
|
+
const session = await forceLogin(adapter, config) // 内部含 saveSession
|
|
151
|
+
saveConfig(config) // 验证通过才落盘
|
|
152
|
+
const info = session.userInfo || {}
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
summary: {
|
|
156
|
+
name: info.name || '',
|
|
157
|
+
unit: info.unit || '',
|
|
158
|
+
weekNow: info.week ? info.week.now : '',
|
|
159
|
+
weekAll: info.week ? info.week.all : '',
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof LinkeError && err.code === 'CREDENTIAL_INVALID') {
|
|
164
|
+
return { ok: false, kind: 'credential', message: err.message }
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, kind: 'service', message: err.message || String(err) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdLogin(flags) {
|
|
172
|
+
const qr = flags.qr === true
|
|
173
|
+
const verifyCredentials = createVerifyCredentials()
|
|
174
|
+
|
|
175
|
+
let terminalOutcome = 'closed'
|
|
176
|
+
const onEvent = (event) => {
|
|
177
|
+
switch (event.type) {
|
|
178
|
+
case 'submitted':
|
|
179
|
+
progress(`收到网页提交(第 ${event.attempts}/3 次尝试),正在验证教务登录...`)
|
|
180
|
+
break
|
|
181
|
+
case 'success': {
|
|
182
|
+
const s = event.summary || {}
|
|
183
|
+
const bits = [s.name, s.weekNow ? `第 ${s.weekNow} 周` : ''].filter(Boolean).join(' · ')
|
|
184
|
+
progress(`验证通过${bits ? `(${bits})` : ''}`)
|
|
185
|
+
progress(`凭据已保存到 ~/.linke-cli/config.json(权限 600),session 已就绪`)
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case 'credential-error':
|
|
189
|
+
progress(`教务返回密码错误${event.remaining > 0 ? `(剩余 ${event.remaining} 次尝试)` : ''}`)
|
|
190
|
+
break
|
|
191
|
+
case 'service-error':
|
|
192
|
+
progress(`验证服务异常:${event.message}${event.remaining > 0 ? `(剩余 ${event.remaining} 次尝试)` : ''}`)
|
|
193
|
+
break
|
|
194
|
+
case 'attempts-exhausted':
|
|
195
|
+
progress('已达尝试上限(3 次),登录服务关闭;请确认凭据后重新运行 linke login')
|
|
196
|
+
break
|
|
197
|
+
case 'timeout':
|
|
198
|
+
progress('超时未完成验证,凭据未保存;可重新运行 linke login')
|
|
199
|
+
break
|
|
200
|
+
default:
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
if (event.type === 'success') terminalOutcome = 'success'
|
|
204
|
+
if (event.type === 'timeout') terminalOutcome = 'timeout'
|
|
205
|
+
if (event.type === 'attempts-exhausted') terminalOutcome = 'exhausted'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handle = await startLoginServer({ qr, verify: verifyCredentials, onEvent })
|
|
209
|
+
progress('登录页已就绪(提交后网页内完成验证;5 分钟超时,成功或达尝试上限后自动关闭)')
|
|
210
|
+
progress(`本机访问: ${handle.urls.local}`)
|
|
211
|
+
if (qr && handle.urls.lan.length > 0) {
|
|
212
|
+
progress('局域网访问(含会话令牌,勿转发他人):')
|
|
213
|
+
for (const lan of handle.urls.lan) progress(` ${lan}`)
|
|
214
|
+
const primary = pickPrimaryLanUrl(handle.urls.lan)
|
|
215
|
+
if (primary) {
|
|
216
|
+
const { modules } = buildQr(Buffer.from(primary, 'utf8'))
|
|
217
|
+
process.stderr.write('\n' + renderTerminal(modules, 2) + '\n\n')
|
|
218
|
+
}
|
|
219
|
+
} else if (qr) {
|
|
220
|
+
progress('未探测到局域网 IPv4 地址,二维码不可用(本机访问不受影响)')
|
|
221
|
+
} else {
|
|
222
|
+
progress('提示:手机填写请改用 linke login --qr(展示局域网二维码)')
|
|
223
|
+
}
|
|
224
|
+
progress('正在打开浏览器...')
|
|
225
|
+
openBrowser(handle.urls.local)
|
|
226
|
+
process.on('SIGINT', () => {
|
|
227
|
+
handle.close()
|
|
228
|
+
progress('已取消,凭据未保存')
|
|
229
|
+
process.exit(130)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await handle.done
|
|
233
|
+
if (terminalOutcome === 'success') {
|
|
234
|
+
emitJson({ ok: true, outcome: 'success' })
|
|
235
|
+
return 0
|
|
236
|
+
}
|
|
237
|
+
if (terminalOutcome === 'exhausted') return 3 // 对齐 LOGIN_RETRY_EXHAUSTED 契约
|
|
238
|
+
if (terminalOutcome === 'timeout') return 1
|
|
239
|
+
// closed:页面未完成验证即被关闭(如令牌取走失败后的兜底收摊)
|
|
240
|
+
progress('登录服务已结束,凭据未保存;可重新运行 linke login')
|
|
241
|
+
return 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function cmdVerify() {
|
|
116
245
|
const config = requireConfig()
|
|
117
246
|
const adapter = getAdapter(config.school)
|
|
118
247
|
const session = await forceLogin(adapter, config)
|
|
@@ -198,7 +327,9 @@ async function dispatch(argv) {
|
|
|
198
327
|
case 'status':
|
|
199
328
|
return cmdStatus()
|
|
200
329
|
case 'login':
|
|
201
|
-
return cmdLogin()
|
|
330
|
+
return cmdLogin(flags)
|
|
331
|
+
case 'verify':
|
|
332
|
+
return cmdVerify()
|
|
202
333
|
case 'scores':
|
|
203
334
|
return cmdScores(flags)
|
|
204
335
|
case 'schedule':
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地登录服务(T7:网页内验证闭环):
|
|
3
|
+
*
|
|
4
|
+
* 流程:页面提交凭据 → 服务立即应答并进入「验证中」→ CLI 侧执行
|
|
5
|
+
* 完整教务登录验证(由 verify 回调注入,复用 session 状态机的登录
|
|
6
|
+
* 实现)→ 页面轮询 GET /result 获取结果 → 成功才由 verify 回调落盘。
|
|
7
|
+
*
|
|
8
|
+
* 令牌语义(T7 验收 3,会话化折中):URL 令牌在会话内有效——
|
|
9
|
+
* 成功 / 超时 / 达尝试上限即失效。尝试上限默认 3 次(保守值:
|
|
10
|
+
* 教务锁定阈值无档案记录,按卡文口径取 3),既防本机令牌爆破,
|
|
11
|
+
* 更防教务系统因连续错密码锁定账号。
|
|
12
|
+
*
|
|
13
|
+
* 安全口径(T6 不回归):Host 必须 IP 字面量(防 DNS rebinding)、
|
|
14
|
+
* Origin 存在须同源(防跨站)、POST/结果内容零日志;页面随包分发。
|
|
15
|
+
*/
|
|
16
|
+
import http from 'node:http'
|
|
17
|
+
import crypto from 'node:crypto'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
|
|
24
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
25
|
+
const DEFAULT_MAX_ATTEMPTS = 3
|
|
26
|
+
// 终结性结果(成功/上限耗尽)发出后留给页面的收尾窗口
|
|
27
|
+
const RESULT_LINGER_MS = 10 * 1000
|
|
28
|
+
|
|
29
|
+
function loginPageHtml() {
|
|
30
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
return fs.readFileSync(path.join(here, 'web', 'login.html'), 'utf8')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 枚举本机局域网 IPv4(虚拟网段在内,展示侧由调用方取舍) */
|
|
35
|
+
export function lanAddresses() {
|
|
36
|
+
const result = []
|
|
37
|
+
const interfaces = os.networkInterfaces()
|
|
38
|
+
for (const name of Object.keys(interfaces)) {
|
|
39
|
+
for (const info of interfaces[name] || []) {
|
|
40
|
+
if (info.family !== 'IPv4' || info.internal) continue
|
|
41
|
+
result.push({ name, address: info.address })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isIpLiteralHost(host) {
|
|
48
|
+
const hostname = String(host || '').split(':')[0]
|
|
49
|
+
if (!hostname) return false
|
|
50
|
+
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === '[::1]' || hostname === '::1'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 起本地登录服务。
|
|
55
|
+
* @param {object} options
|
|
56
|
+
* @param {boolean} options.qr 二维码模式:绑 0.0.0.0(含 127.0.0.1);默认仅 127.0.0.1
|
|
57
|
+
* @param {number} options.timeoutMs 超时自动关闭(会话最长时长)
|
|
58
|
+
* @param {number} options.maxAttempts 提交尝试上限(默认 3)
|
|
59
|
+
* @param {(userId: string, password: string) => Promise<{ok: true, summary: object} | {ok: false, kind: 'credential'|'service', message: string}>} options.verify
|
|
60
|
+
* 验证回调:执行完整教务登录验证;成功路径内完成凭据落盘(0600),
|
|
61
|
+
* 失败路径不得写任何凭据文件。kind 区分「凭据错误」(页内重试)
|
|
62
|
+
* 与「网络/识别服务失败」(稍后再试)。
|
|
63
|
+
* @param {(event: {type: string, [k: string]: any}) => void} options.onEvent
|
|
64
|
+
* 终端汇报事件:submitted / success / credential-error / service-error /
|
|
65
|
+
* attempts-exhausted / timeout
|
|
66
|
+
* @returns {Promise<{server, port, token, urls, close: () => void, done: Promise<{type: string}>, attempts: () => number}>}
|
|
67
|
+
*/
|
|
68
|
+
export function startLoginServer({
|
|
69
|
+
qr = false,
|
|
70
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
71
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
72
|
+
verify,
|
|
73
|
+
onEvent = () => {},
|
|
74
|
+
} = {}) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const token = crypto.randomBytes(16).toString('hex')
|
|
77
|
+
const pageHtml = loginPageHtml()
|
|
78
|
+
let sessionOpen = true // 令牌会话有效性:成功/超时/上限耗尽即 false
|
|
79
|
+
let attempts = 0
|
|
80
|
+
let verifying = false
|
|
81
|
+
let lastResult = null // {type:'success'|'credential'|'service', ...}
|
|
82
|
+
let resultAcked = false
|
|
83
|
+
let closed = false
|
|
84
|
+
let doneResolve
|
|
85
|
+
const done = new Promise((r) => {
|
|
86
|
+
doneResolve = r
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const server = http.createServer((req, res) => {
|
|
90
|
+
const url = new URL(req.url, 'http://placeholder')
|
|
91
|
+
const sendJson = (status, payload) => {
|
|
92
|
+
res.writeHead(status, { 'Content-Type': 'application/json;charset=utf-8' })
|
|
93
|
+
res.end(JSON.stringify(payload))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!isIpLiteralHost(req.headers.host)) {
|
|
97
|
+
sendJson(403, { ok: false, error: '非法 Host' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tokenOk = () => sessionOpen && url.searchParams.get('t') === token
|
|
102
|
+
|
|
103
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
104
|
+
if (!tokenOk()) {
|
|
105
|
+
res.writeHead(403, { 'Content-Type': 'text/html;charset=utf-8' })
|
|
106
|
+
res.end('<meta charset="utf-8"><p>链接无效或已失效:请回到终端重新运行 linke login。</p>')
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' })
|
|
110
|
+
res.end(pageHtml)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (req.method === 'GET' && url.pathname === '/result') {
|
|
115
|
+
// 结果轮询只校验令牌匹配:成功/上限后令牌会话已结束,但结果
|
|
116
|
+
// 仍须让页面取走(提交路径此时已被 sessionOpen 拒绝,无重放面)
|
|
117
|
+
if (url.searchParams.get('t') !== token) {
|
|
118
|
+
sendJson(403, { ok: false, error: '令牌错误' })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (lastResult) {
|
|
122
|
+
const terminal =
|
|
123
|
+
lastResult.type === 'success' ||
|
|
124
|
+
(lastResult.type !== 'success' && lastResult.final)
|
|
125
|
+
const payload = { status: 'done', ...lastResult }
|
|
126
|
+
if (terminal && !resultAcked) {
|
|
127
|
+
resultAcked = true
|
|
128
|
+
// 页面已收到终结性结果:短暂延迟后收摊
|
|
129
|
+
setTimeout(closeServer, 1000)
|
|
130
|
+
}
|
|
131
|
+
sendJson(200, payload)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
sendJson(200, { status: verifying ? 'verifying' : 'idle' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.method === 'POST' && url.pathname === '/submit') {
|
|
139
|
+
const origin = req.headers.origin
|
|
140
|
+
if (origin && origin !== `http://${req.headers.host}`) {
|
|
141
|
+
sendJson(403, { ok: false, error: '非法来源' })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// 令牌经请求体传递(页面为相对路径提交,URL 无 query),
|
|
145
|
+
// 此处只先校验会话有效性,令牌匹配在解析 body 后校验
|
|
146
|
+
if (!sessionOpen) {
|
|
147
|
+
sendJson(403, { ok: false, error: '会话已结束,请重新运行 linke login' })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (verifying) {
|
|
151
|
+
sendJson(409, { ok: false, error: '正在验证上一次提交,请稍候' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (attempts >= maxAttempts) {
|
|
155
|
+
sendJson(429, { ok: false, error: `已达尝试上限(${maxAttempts} 次)` })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const chunks = []
|
|
159
|
+
let size = 0
|
|
160
|
+
req.on('data', (chunk) => {
|
|
161
|
+
size += chunk.length
|
|
162
|
+
if (size > MAX_BODY_BYTES) {
|
|
163
|
+
req.destroy()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
chunks.push(chunk)
|
|
167
|
+
})
|
|
168
|
+
req.on('end', () => {
|
|
169
|
+
let body
|
|
170
|
+
try {
|
|
171
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
172
|
+
} catch {
|
|
173
|
+
sendJson(400, { ok: false, error: '请求体不是合法 JSON' })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (body.token !== token) {
|
|
177
|
+
sendJson(403, { ok: false, error: '令牌错误' })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const userId = String(body.userId || '').trim()
|
|
181
|
+
const password = String(body.password || '')
|
|
182
|
+
if (!userId || !password) {
|
|
183
|
+
sendJson(400, { ok: false, error: '学号与密码均不能为空' })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
// 计数在任何验证发生前完成:提交即视为一次教务尝试(保守口径)
|
|
187
|
+
attempts += 1
|
|
188
|
+
const remaining = maxAttempts - attempts
|
|
189
|
+
verifying = true
|
|
190
|
+
lastResult = null
|
|
191
|
+
resultAcked = false
|
|
192
|
+
sendJson(200, { ok: true, attempts, remaining })
|
|
193
|
+
onEvent({ type: 'submitted', attempts, remaining, userId })
|
|
194
|
+
// 注意:此处不打印/记录凭据(红线)
|
|
195
|
+
Promise.resolve()
|
|
196
|
+
.then(() => verify(userId, password))
|
|
197
|
+
.then((outcome) => {
|
|
198
|
+
verifying = false
|
|
199
|
+
if (outcome && outcome.ok) {
|
|
200
|
+
sessionOpen = false // 令牌会话结束(验收 3)
|
|
201
|
+
lastResult = {
|
|
202
|
+
type: 'success',
|
|
203
|
+
summary: outcome.summary || {},
|
|
204
|
+
maxAttempts,
|
|
205
|
+
}
|
|
206
|
+
onEvent({ type: 'success', summary: outcome.summary, userId })
|
|
207
|
+
} else {
|
|
208
|
+
const kind = outcome && outcome.kind === 'credential' ? 'credential' : 'service'
|
|
209
|
+
const final = remaining <= 0
|
|
210
|
+
lastResult = {
|
|
211
|
+
type: kind,
|
|
212
|
+
message: (outcome && outcome.message) || '验证失败',
|
|
213
|
+
remaining,
|
|
214
|
+
final,
|
|
215
|
+
maxAttempts,
|
|
216
|
+
}
|
|
217
|
+
onEvent({ type: kind === 'credential' ? 'credential-error' : 'service-error', message: lastResult.message, remaining, final, userId })
|
|
218
|
+
if (final) {
|
|
219
|
+
sessionOpen = false
|
|
220
|
+
scheduleForceClose()
|
|
221
|
+
onEvent({ type: 'attempts-exhausted', attempts })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
.catch((err) => {
|
|
226
|
+
verifying = false
|
|
227
|
+
const final = remaining <= 0
|
|
228
|
+
lastResult = {
|
|
229
|
+
type: 'service',
|
|
230
|
+
message: (err && err.message) || '验证过程异常',
|
|
231
|
+
remaining,
|
|
232
|
+
final,
|
|
233
|
+
maxAttempts,
|
|
234
|
+
}
|
|
235
|
+
onEvent({ type: 'service-error', message: lastResult.message, remaining, final, userId })
|
|
236
|
+
if (final) {
|
|
237
|
+
sessionOpen = false
|
|
238
|
+
scheduleForceClose()
|
|
239
|
+
onEvent({ type: 'attempts-exhausted', attempts })
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
sendJson(404, { ok: false, error: 'Not Found' })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
let lingerHandle = null
|
|
250
|
+
let timeoutHandle = null
|
|
251
|
+
|
|
252
|
+
const closeServer = () => {
|
|
253
|
+
if (closed) return
|
|
254
|
+
closed = true
|
|
255
|
+
clearTimeout(timeoutHandle)
|
|
256
|
+
clearTimeout(lingerHandle)
|
|
257
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
258
|
+
server.close()
|
|
259
|
+
sessionOpen = false
|
|
260
|
+
doneResolve({ type: 'closed' })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 终结性失败结果若始终无人来取(页面已关等),超窗强制收摊
|
|
264
|
+
const scheduleForceClose = () => {
|
|
265
|
+
clearTimeout(lingerHandle)
|
|
266
|
+
lingerHandle = setTimeout(closeServer, RESULT_LINGER_MS)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
timeoutHandle = setTimeout(() => {
|
|
270
|
+
onEvent({ type: 'timeout' })
|
|
271
|
+
sessionOpen = false
|
|
272
|
+
closeServer()
|
|
273
|
+
}, timeoutMs)
|
|
274
|
+
|
|
275
|
+
server.on('error', reject)
|
|
276
|
+
server.listen(0, qr ? '0.0.0.0' : '127.0.0.1', () => {
|
|
277
|
+
const port = server.address().port
|
|
278
|
+
const urls = {
|
|
279
|
+
local: `http://127.0.0.1:${port}/?t=${token}`,
|
|
280
|
+
lan: qr ? lanAddresses().map((i) => `http://${i.address}:${port}/?t=${token}`) : [],
|
|
281
|
+
}
|
|
282
|
+
resolve({
|
|
283
|
+
server,
|
|
284
|
+
port,
|
|
285
|
+
token,
|
|
286
|
+
urls,
|
|
287
|
+
close: closeServer,
|
|
288
|
+
done,
|
|
289
|
+
attempts: () => attempts,
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
}
|
package/src/qr.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 精简 QR Code 编码器(Model 2,byte mode,纠错级 L,version 1-6)。
|
|
3
|
+
* 为守住「零依赖」架构决策(T3)手写实现:局域网 URL ≈ 60-100 字节,
|
|
4
|
+
* v6-L 容量 136 字节已覆盖;version ≤ 6 无需 version info 块,复杂度可控。
|
|
5
|
+
*
|
|
6
|
+
* 结构:
|
|
7
|
+
* buildQr(bytes) → { size, modules }(布尔矩阵,true=黑)
|
|
8
|
+
* renderTerminal(matrix, quietZone) → 终端半块字符文本
|
|
9
|
+
*
|
|
10
|
+
* 正确性由 test/qr.test.mjs 与系统 qrencode 逐模块对拍保障。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---- version 参数表(L 级)----
|
|
14
|
+
// blocks: [ [totalCodewords, dataCodewords], ... ] 每块
|
|
15
|
+
const VERSION_PARAMS = {
|
|
16
|
+
1: { size: 21, blocks: [[26, 19]] },
|
|
17
|
+
2: { size: 25, blocks: [[44, 34]] },
|
|
18
|
+
3: { size: 29, blocks: [[70, 55]] },
|
|
19
|
+
4: { size: 33, blocks: [[100, 80]] },
|
|
20
|
+
5: { size: 37, blocks: [[134, 108]] },
|
|
21
|
+
6: { size: 41, blocks: [[86, 68], [86, 68]] },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// alignment pattern 中心坐标候选(仅右下角组合不与 finder 重叠,v2-6)
|
|
25
|
+
const ALIGN_POS = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34] }
|
|
26
|
+
|
|
27
|
+
// ---- GF(256),本原多项式 0x11d ----
|
|
28
|
+
const GF_EXP = new Uint8Array(512)
|
|
29
|
+
const GF_LOG = new Uint8Array(256)
|
|
30
|
+
;(function buildGf() {
|
|
31
|
+
let x = 1
|
|
32
|
+
for (let i = 0; i < 255; i++) {
|
|
33
|
+
GF_EXP[i] = x
|
|
34
|
+
GF_LOG[x] = i
|
|
35
|
+
x <<= 1
|
|
36
|
+
if (x & 0x100) x ^= 0x11d
|
|
37
|
+
}
|
|
38
|
+
for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]
|
|
39
|
+
})()
|
|
40
|
+
|
|
41
|
+
function gfMul(a, b) {
|
|
42
|
+
if (a === 0 || b === 0) return 0
|
|
43
|
+
return GF_EXP[GF_LOG[a] + GF_LOG[b]]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** RS 生成多项式 g(x) = ∏(x - α^i),系数最高次在前 */
|
|
47
|
+
function rsGeneratorPoly(degree) {
|
|
48
|
+
let poly = [1]
|
|
49
|
+
for (let i = 0; i < degree; i++) {
|
|
50
|
+
const next = new Array(poly.length + 1).fill(0)
|
|
51
|
+
for (let j = 0; j < poly.length; j++) {
|
|
52
|
+
next[j] ^= poly[j]
|
|
53
|
+
next[j + 1] ^= gfMul(poly[j], GF_EXP[i])
|
|
54
|
+
}
|
|
55
|
+
poly = next
|
|
56
|
+
}
|
|
57
|
+
return poly
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 求数据多项式的 RS 纠错码字(systematic 编码) */
|
|
61
|
+
function rsEncode(data, ecLen) {
|
|
62
|
+
const gen = rsGeneratorPoly(ecLen)
|
|
63
|
+
const rem = new Array(data.length + ecLen).fill(0)
|
|
64
|
+
for (let i = 0; i < data.length; i++) rem[i] = data[i]
|
|
65
|
+
for (let i = 0; i < data.length; i++) {
|
|
66
|
+
const factor = rem[i]
|
|
67
|
+
if (factor === 0) continue
|
|
68
|
+
for (let j = 0; j < gen.length; j++) {
|
|
69
|
+
rem[i + j] ^= gfMul(gen[j], factor)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return rem.slice(data.length)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 数据位流 ----
|
|
76
|
+
|
|
77
|
+
/** 按最小可用 version 编码为总码字流(含块交织);导出供测试分层验证 */
|
|
78
|
+
export function encodeCodewords(bytes) {
|
|
79
|
+
const len = bytes.length
|
|
80
|
+
let version = 0
|
|
81
|
+
for (let v = 1; v <= 6; v++) {
|
|
82
|
+
const dataCap = VERSION_PARAMS[v].blocks.reduce((sum, [t, d]) => sum + d, 0)
|
|
83
|
+
// byte mode 头部开销:mode 4bit + 计数 8bit
|
|
84
|
+
if (len + 2 <= dataCap) {
|
|
85
|
+
version = v
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!version) {
|
|
90
|
+
throw new Error(`内容过长(${len} 字节),超出 v6-L 容量`)
|
|
91
|
+
}
|
|
92
|
+
const { blocks } = VERSION_PARAMS[version]
|
|
93
|
+
const dataCap = blocks.reduce((sum, [t, d]) => sum + d, 0)
|
|
94
|
+
|
|
95
|
+
// 位流:mode(0100) + 8bit 计数 + 数据 + 终止符 + 填充
|
|
96
|
+
const bits = []
|
|
97
|
+
const push = (value, count) => {
|
|
98
|
+
for (let i = count - 1; i >= 0; i--) bits.push((value >> i) & 1)
|
|
99
|
+
}
|
|
100
|
+
push(0b0100, 4)
|
|
101
|
+
push(len, 8)
|
|
102
|
+
for (const byte of bytes) push(byte, 8)
|
|
103
|
+
const capacityBits = dataCap * 8
|
|
104
|
+
push(0, Math.min(4, capacityBits - bits.length))
|
|
105
|
+
while (bits.length % 8 !== 0) bits.push(0)
|
|
106
|
+
const stream = []
|
|
107
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
108
|
+
let byte = 0
|
|
109
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j]
|
|
110
|
+
stream.push(byte)
|
|
111
|
+
}
|
|
112
|
+
const padBytes = [0xec, 0x11]
|
|
113
|
+
let padIdx = 0
|
|
114
|
+
while (stream.length < dataCap) stream.push(padBytes[padIdx++ % 2])
|
|
115
|
+
|
|
116
|
+
// 分块 + 各块纠错 + 交织(数据列优先,纠错随后)
|
|
117
|
+
const dataBlocks = []
|
|
118
|
+
const ecBlocks = []
|
|
119
|
+
let offset = 0
|
|
120
|
+
for (const [, dataLen] of blocks) {
|
|
121
|
+
const chunk = stream.slice(offset, offset + dataLen)
|
|
122
|
+
offset += dataLen
|
|
123
|
+
dataBlocks.push(chunk)
|
|
124
|
+
ecBlocks.push(rsEncode(chunk, blocks[0][0] - blocks[0][1]))
|
|
125
|
+
}
|
|
126
|
+
const maxData = Math.max(...dataBlocks.map((b) => b.length))
|
|
127
|
+
const maxEc = Math.max(...ecBlocks.map((b) => b.length))
|
|
128
|
+
const codewords = []
|
|
129
|
+
for (let i = 0; i < maxData; i++) {
|
|
130
|
+
for (const block of dataBlocks) if (i < block.length) codewords.push(block[i])
|
|
131
|
+
}
|
|
132
|
+
for (let i = 0; i < maxEc; i++) {
|
|
133
|
+
for (const block of ecBlocks) if (i < block.length) codewords.push(block[i])
|
|
134
|
+
}
|
|
135
|
+
return { version, codewords }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- 矩阵 ----
|
|
139
|
+
|
|
140
|
+
function makeMatrices(size) {
|
|
141
|
+
const modules = Array.from({ length: size }, () => new Array(size).fill(false))
|
|
142
|
+
const reserved = Array.from({ length: size }, () => new Array(size).fill(false))
|
|
143
|
+
return { modules, reserved }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reserveFinder(modules, reserved, row, col) {
|
|
147
|
+
for (let r = -1; r <= 7; r++) {
|
|
148
|
+
for (let c = -1; c <= 7; c++) {
|
|
149
|
+
const rr = row + r
|
|
150
|
+
const cc = col + c
|
|
151
|
+
if (rr < 0 || rr >= modules.length || cc < 0 || cc >= modules.length) continue
|
|
152
|
+
const inRing =
|
|
153
|
+
(r >= 0 && r <= 6 && (c === 0 || c === 6)) ||
|
|
154
|
+
(c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
|
|
155
|
+
(r >= 2 && r <= 4 && c >= 2 && c <= 4)
|
|
156
|
+
modules[rr][cc] = inRing
|
|
157
|
+
reserved[rr][cc] = true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reserveAlignment(modules, reserved, centers, size) {
|
|
163
|
+
for (const [row, col] of centers) {
|
|
164
|
+
for (let r = -2; r <= 2; r++) {
|
|
165
|
+
for (let c = -2; c <= 2; c++) {
|
|
166
|
+
const rr = row + r
|
|
167
|
+
const cc = col + c
|
|
168
|
+
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue
|
|
169
|
+
if (reserved[rr][cc]) continue
|
|
170
|
+
const isRing = Math.max(Math.abs(r), Math.abs(c)) !== 1
|
|
171
|
+
modules[rr][cc] = isRing
|
|
172
|
+
reserved[rr][cc] = true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** format info 的两份副本共 30 个固定位置(值由 placeFormat 填充) */
|
|
179
|
+
function formatPositions(size) {
|
|
180
|
+
return [
|
|
181
|
+
[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
|
|
182
|
+
[7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8],
|
|
183
|
+
[size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8],
|
|
184
|
+
[size - 5, 8], [size - 6, 8], [size - 7, 8],
|
|
185
|
+
[8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5], [8, size - 4],
|
|
186
|
+
[8, size - 3], [8, size - 2], [8, size - 1],
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildFunctionPatterns(version) {
|
|
191
|
+
const { size } = VERSION_PARAMS[version]
|
|
192
|
+
const { modules, reserved } = makeMatrices(size)
|
|
193
|
+
// finder + separator(三个角)
|
|
194
|
+
reserveFinder(modules, reserved, 0, 0)
|
|
195
|
+
reserveFinder(modules, reserved, 0, size - 7)
|
|
196
|
+
reserveFinder(modules, reserved, size - 7, 0)
|
|
197
|
+
// timing
|
|
198
|
+
for (let i = 8; i < size - 8; i++) {
|
|
199
|
+
modules[6][i] = i % 2 === 0
|
|
200
|
+
reserved[6][i] = true
|
|
201
|
+
modules[i][6] = i % 2 === 0
|
|
202
|
+
reserved[i][6] = true
|
|
203
|
+
}
|
|
204
|
+
// alignment(跳过与 finder 保留区重叠的组合——v2-6 实际仅右下一个)
|
|
205
|
+
const centers = []
|
|
206
|
+
const pos = ALIGN_POS[version]
|
|
207
|
+
for (const r of pos) {
|
|
208
|
+
for (const c of pos) {
|
|
209
|
+
if (reserved[r] && reserved[r][c]) continue
|
|
210
|
+
centers.push([r, c])
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
reserveAlignment(modules, reserved, centers, size)
|
|
214
|
+
// format info 区预留(数据放置须跳过,值由 placeFormat 填充)
|
|
215
|
+
for (const [r, c] of formatPositions(size)) reserved[r][c] = true
|
|
216
|
+
// dark module
|
|
217
|
+
modules[4 * version + 9][8] = true
|
|
218
|
+
reserved[4 * version + 9][8] = true
|
|
219
|
+
return { modules, reserved }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const MASK_FUNCS = [
|
|
223
|
+
(r, c) => (r + c) % 2 === 0,
|
|
224
|
+
(r) => r % 2 === 0,
|
|
225
|
+
(r, c) => c % 3 === 0,
|
|
226
|
+
(r, c) => (r + c) % 3 === 0,
|
|
227
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
|
228
|
+
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
229
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
230
|
+
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
/** 把码字按蛇形顺序放置并用 mask 翻转(功能模块不动) */
|
|
234
|
+
function placeData(modules, reserved, codewords, maskFn) {
|
|
235
|
+
const size = modules.length
|
|
236
|
+
const bits = []
|
|
237
|
+
for (const byte of codewords) {
|
|
238
|
+
for (let i = 7; i >= 0; i--) bits.push((byte >> i) & 1)
|
|
239
|
+
}
|
|
240
|
+
let bitIdx = 0
|
|
241
|
+
const takeBit = () => (bitIdx < bits.length ? bits[bitIdx++] : 0)
|
|
242
|
+
let col = size - 1
|
|
243
|
+
let upward = true
|
|
244
|
+
while (col > 0) {
|
|
245
|
+
if (col === 6) col -= 1
|
|
246
|
+
for (let i = 0; i < size; i++) {
|
|
247
|
+
const row = upward ? size - 1 - i : i
|
|
248
|
+
for (const cc of [col, col - 1]) {
|
|
249
|
+
if (reserved[row][cc]) continue
|
|
250
|
+
let bit = takeBit()
|
|
251
|
+
if (maskFn(row, cc)) bit ^= 1
|
|
252
|
+
modules[row][cc] = bit === 1
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
col -= 2
|
|
256
|
+
upward = !upward
|
|
257
|
+
}
|
|
258
|
+
return modules
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** BCH(15,5) 计算 format 信息(未异或 mask 前的 15 bit) */
|
|
262
|
+
function formatBits(mask) {
|
|
263
|
+
const data = (0b01 << 3) | mask // L = 01
|
|
264
|
+
let rem = data
|
|
265
|
+
for (let i = 0; i < 10; i++) {
|
|
266
|
+
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
|
|
267
|
+
}
|
|
268
|
+
return ((data << 10) | (rem & 0x3ff)) ^ 0x5412
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function placeFormat(modules, mask) {
|
|
272
|
+
const size = modules.length
|
|
273
|
+
const bits = formatBits(mask)
|
|
274
|
+
const bitAt = (i) => ((bits >> i) & 1) === 1 // i: 14..0
|
|
275
|
+
const seq = formatPositions(size)
|
|
276
|
+
seq.forEach(([r, c], idx) => {
|
|
277
|
+
modules[r][c] = bitAt(idx < 15 ? 14 - idx : 14 - (idx - 15))
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- penalty(标准 4 规则,用于选最优掩码)----
|
|
282
|
+
|
|
283
|
+
function penaltyFor(modules) {
|
|
284
|
+
const size = modules.length
|
|
285
|
+
let penalty = 0
|
|
286
|
+
|
|
287
|
+
// N1/N3:行、列两个方向
|
|
288
|
+
for (const horizontal of [true, false]) {
|
|
289
|
+
for (let i = 0; i < size; i++) {
|
|
290
|
+
let run = 1
|
|
291
|
+
const cells = []
|
|
292
|
+
for (let j = 0; j < size; j++) {
|
|
293
|
+
cells.push(horizontal ? modules[i][j] : modules[j][i])
|
|
294
|
+
}
|
|
295
|
+
for (let j = 1; j < size; j++) {
|
|
296
|
+
if (cells[j] === cells[j - 1]) run++
|
|
297
|
+
else {
|
|
298
|
+
if (run >= 5) penalty += 3 + (run - 5)
|
|
299
|
+
run = 1
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (run >= 5) penalty += 3 + (run - 5)
|
|
303
|
+
const line = cells.map((v) => (v ? '1' : '0')).join('')
|
|
304
|
+
let from = 0
|
|
305
|
+
while (true) {
|
|
306
|
+
const idx1 = line.indexOf('10111010000', from)
|
|
307
|
+
const idx2 = line.indexOf('00001011101', from)
|
|
308
|
+
if (idx1 === -1 && idx2 === -1) break
|
|
309
|
+
penalty += 40
|
|
310
|
+
from = (idx1 === -1 ? idx2 : idx1) + 1
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// N2:2×2 同色块
|
|
316
|
+
for (let r = 0; r < size - 1; r++) {
|
|
317
|
+
for (let c = 0; c < size - 1; c++) {
|
|
318
|
+
if (
|
|
319
|
+
modules[r][c] === modules[r][c + 1] &&
|
|
320
|
+
modules[r][c] === modules[r + 1][c] &&
|
|
321
|
+
modules[r][c] === modules[r + 1][c + 1]
|
|
322
|
+
) {
|
|
323
|
+
penalty += 3
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// N4:暗模块比例偏离 50%
|
|
329
|
+
let dark = 0
|
|
330
|
+
for (const row of modules) for (const v of row) if (v) dark++
|
|
331
|
+
const percent = (dark * 100) / (size * size)
|
|
332
|
+
penalty += Math.floor(Math.abs(percent - 50) / 5) * 10
|
|
333
|
+
return penalty
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 编码入口:返回带掩码的完整矩阵。
|
|
338
|
+
* @param {Uint8Array|number[]} bytes byte mode 内容
|
|
339
|
+
*/
|
|
340
|
+
export function buildQr(bytes) {
|
|
341
|
+
const { version, codewords } = encodeCodewords(Array.from(bytes))
|
|
342
|
+
let best = null
|
|
343
|
+
let bestPenalty = Infinity
|
|
344
|
+
let bestMask = 0
|
|
345
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
346
|
+
const { modules, reserved } = buildFunctionPatterns(version)
|
|
347
|
+
placeData(modules, reserved, codewords, MASK_FUNCS[mask])
|
|
348
|
+
placeFormat(modules, mask)
|
|
349
|
+
const penalty = penaltyFor(modules)
|
|
350
|
+
if (penalty < bestPenalty) {
|
|
351
|
+
bestPenalty = penalty
|
|
352
|
+
best = modules
|
|
353
|
+
bestMask = mask
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { size: best.length, modules: best, mask: bestMask, version }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 按指定掩码构建(测试对拍用) */
|
|
360
|
+
export function buildQrWithMask(bytes, mask) {
|
|
361
|
+
const { version, codewords } = encodeCodewords(Array.from(bytes))
|
|
362
|
+
const { modules, reserved } = buildFunctionPatterns(version)
|
|
363
|
+
placeData(modules, reserved, codewords, MASK_FUNCS[mask])
|
|
364
|
+
placeFormat(modules, mask)
|
|
365
|
+
return { size: modules.length, modules, mask, version }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 终端渲染:半块字符,每字符 1 模块宽 × 2 模块高,含 quiet zone */
|
|
369
|
+
export function renderTerminal(matrix, quietZone = 4) {
|
|
370
|
+
const size = matrix.length
|
|
371
|
+
const total = size + quietZone * 2
|
|
372
|
+
const grid = Array.from({ length: total }, () => new Array(total).fill(false))
|
|
373
|
+
for (let r = 0; r < size; r++) {
|
|
374
|
+
for (let c = 0; c < size; c++) grid[r + quietZone][c + quietZone] = matrix[r][c]
|
|
375
|
+
}
|
|
376
|
+
const lines = []
|
|
377
|
+
for (let r = 0; r < total; r += 2) {
|
|
378
|
+
let line = ''
|
|
379
|
+
for (let c = 0; c < total; c++) {
|
|
380
|
+
const top = grid[r][c]
|
|
381
|
+
const bottom = grid[r + 1] ? grid[r + 1][c] : false
|
|
382
|
+
if (top && bottom) line += '█'
|
|
383
|
+
else if (top) line += '▀'
|
|
384
|
+
else if (bottom) line += '▄'
|
|
385
|
+
else line += ' '
|
|
386
|
+
}
|
|
387
|
+
lines.push(line)
|
|
388
|
+
}
|
|
389
|
+
return lines.join('\n')
|
|
390
|
+
}
|
|
@@ -10,23 +10,27 @@
|
|
|
10
10
|
import { stripSpaces, isJwLoginExpired } from '../../util.js'
|
|
11
11
|
import { parseError } from '../../errors.js'
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/** 解析个人主页:姓名/单位/专业/班级 + 当前教学周(用于登录确认与 status/成功页展示) */
|
|
14
14
|
export function parseUserData(html) {
|
|
15
15
|
if (!html || typeof html !== 'string') {
|
|
16
|
-
return { name: '', unit: '', discipline: '', class: '' }
|
|
16
|
+
return { name: '', unit: '', discipline: '', class: '', week: null }
|
|
17
17
|
}
|
|
18
18
|
const nameMatch = html.match(/<span class="blue f16 b">(.*?)<\/span>/)
|
|
19
19
|
const name = nameMatch ? nameMatch[1] : ''
|
|
20
20
|
const userMatches = html.matchAll(/middletopdwxxcont">(.*?)<\/div>/g)
|
|
21
21
|
const userData = Array.from(userMatches).map((m) => m[1])
|
|
22
|
+
// 周次(教务主页 xsMain_new.jsp 口径,交互文档 1.6)
|
|
23
|
+
const weekMatch = html.match(/<span class="main_text main_color">第(.*?)周<\/span>\/(.*?)周/)
|
|
24
|
+
const week = weekMatch ? { now: weekMatch[1] || '', all: weekMatch[2] || '' } : null
|
|
22
25
|
if (userData.length < 3) {
|
|
23
|
-
return { name: name || '', unit: '', discipline: '', class: '' }
|
|
26
|
+
return { name: name || '', unit: '', discipline: '', class: '', week }
|
|
24
27
|
}
|
|
25
28
|
return {
|
|
26
29
|
name: name || '',
|
|
27
30
|
unit: userData[0] || '',
|
|
28
31
|
discipline: userData[1] || '',
|
|
29
32
|
class: userData[2] || '',
|
|
33
|
+
week,
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>linke · 教务账号配置</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
12
|
+
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
13
|
+
background: #f5f6f8; color: #1f2328; padding: 24px;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) { body { background: #14171a; color: #e2e6ea; } }
|
|
16
|
+
.card {
|
|
17
|
+
width: 100%; max-width: 380px; background: #fff; border-radius: 12px;
|
|
18
|
+
padding: 28px 24px; box-shadow: 0 4px 24px rgba(0,0,0,.08);
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: dark) { .card { background: #1d2126; } }
|
|
21
|
+
h1 { font-size: 18px; margin: 0 0 4px; }
|
|
22
|
+
.sub { font-size: 13px; color: #6a737d; margin-bottom: 20px; line-height: 1.6; }
|
|
23
|
+
@media (prefers-color-scheme: dark) { .sub { color: #9aa4af; } }
|
|
24
|
+
label { display: block; font-size: 13px; margin: 14px 0 6px; }
|
|
25
|
+
input {
|
|
26
|
+
width: 100%; padding: 10px 12px; font-size: 16px; border: 1px solid #d0d7de;
|
|
27
|
+
border-radius: 8px; background: transparent; color: inherit;
|
|
28
|
+
}
|
|
29
|
+
@media (prefers-color-scheme: dark) { input { border-color: #3a4149; } }
|
|
30
|
+
input:focus { outline: 2px solid #4c8dff; border-color: transparent; }
|
|
31
|
+
button {
|
|
32
|
+
width: 100%; margin-top: 22px; padding: 11px; font-size: 15px; border: 0;
|
|
33
|
+
border-radius: 8px; background: #2da44e; color: #fff; cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
36
|
+
.msg { margin-top: 16px; font-size: 13px; line-height: 1.7; display: none; white-space: pre-line; }
|
|
37
|
+
.msg.cred { display: block; color: #cf222e; }
|
|
38
|
+
.msg.svc { display: block; color: #9a6700; }
|
|
39
|
+
/* 验证中 */
|
|
40
|
+
.verifying { display: none; text-align: center; padding: 18px 0 8px; }
|
|
41
|
+
.verifying .spinner {
|
|
42
|
+
width: 34px; height: 34px; margin: 0 auto 14px; border-radius: 50%;
|
|
43
|
+
border: 3px solid #d0d7de; border-top-color: #2da44e;
|
|
44
|
+
animation: spin 0.9s linear infinite;
|
|
45
|
+
}
|
|
46
|
+
@media (prefers-color-scheme: dark) { .verifying .spinner { border-color: #3a4149; border-top-color: #2da44e; } }
|
|
47
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
48
|
+
.verifying p { font-size: 14px; color: #6a737d; line-height: 1.7; }
|
|
49
|
+
@media (prefers-color-scheme: dark) { .verifying p { color: #9aa4af; } }
|
|
50
|
+
/* 成功 */
|
|
51
|
+
.success { display: none; text-align: center; padding: 10px 0 0; }
|
|
52
|
+
.success .mark { font-size: 40px; line-height: 1; }
|
|
53
|
+
.success h2 { font-size: 17px; margin: 10px 0 6px; }
|
|
54
|
+
.success .info { font-size: 14px; color: #6a737d; line-height: 1.8; }
|
|
55
|
+
@media (prefers-color-scheme: dark) { .success .info { color: #9aa4af; } }
|
|
56
|
+
.success .next {
|
|
57
|
+
margin-top: 16px; padding: 12px; border-radius: 8px; background: #f0f6ff;
|
|
58
|
+
font-size: 13px; line-height: 1.8; text-align: left;
|
|
59
|
+
}
|
|
60
|
+
@media (prefers-color-scheme: dark) { .success .next { background: #1b2635; } }
|
|
61
|
+
.success .next code { background: rgba(175,184,193,.25); padding: 1px 6px; border-radius: 4px; }
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<main class="card">
|
|
66
|
+
<h1>林课 linke · 教务账号配置</h1>
|
|
67
|
+
<p class="sub">此页面由<strong>你本机的 linke CLI</strong> 提供,密码只保存在本机
|
|
68
|
+
(~/.linke-cli/,权限 600),不会发送到任何服务器。</p>
|
|
69
|
+
|
|
70
|
+
<form id="form">
|
|
71
|
+
<label for="userId">学号</label>
|
|
72
|
+
<input id="userId" name="userId" autocomplete="username" inputmode="numeric" required>
|
|
73
|
+
<label for="password">教务密码</label>
|
|
74
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
75
|
+
<button id="btn" type="submit">验证并保存</button>
|
|
76
|
+
</form>
|
|
77
|
+
<p id="msg" class="msg"></p>
|
|
78
|
+
|
|
79
|
+
<div id="verifying" class="verifying">
|
|
80
|
+
<div class="spinner"></div>
|
|
81
|
+
<p id="verifyText">正在验证教务登录…<br>(自动获取并识别验证码,约需数秒,请勿关闭本页)</p>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div id="success" class="success">
|
|
85
|
+
<div class="mark">✅</div>
|
|
86
|
+
<h2 id="okTitle">验证成功</h2>
|
|
87
|
+
<p class="info" id="okInfo"></p>
|
|
88
|
+
<div class="next">凭据已保存到本机(权限 600)。<br>
|
|
89
|
+
现在可以直接问你的 AI:<br>
|
|
90
|
+
<code>「我这周有什么课」「我的成绩怎么样」</code><br>
|
|
91
|
+
也可以在终端运行 <code>linke schedule</code> 查看。
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</main>
|
|
95
|
+
<script>
|
|
96
|
+
const params = new URLSearchParams(location.search)
|
|
97
|
+
const token = params.get('t') || ''
|
|
98
|
+
const form = document.getElementById('form')
|
|
99
|
+
const btn = document.getElementById('btn')
|
|
100
|
+
const msg = document.getElementById('msg')
|
|
101
|
+
const verifying = document.getElementById('verifying')
|
|
102
|
+
const success = document.getElementById('success')
|
|
103
|
+
const userIdInput = document.getElementById('userId')
|
|
104
|
+
const passwordInput = document.getElementById('password')
|
|
105
|
+
let polling = null
|
|
106
|
+
|
|
107
|
+
function showForm() {
|
|
108
|
+
form.style.display = ''
|
|
109
|
+
verifying.style.display = 'none'
|
|
110
|
+
success.style.display = 'none'
|
|
111
|
+
btn.disabled = false
|
|
112
|
+
btn.textContent = '验证并保存'
|
|
113
|
+
}
|
|
114
|
+
function showVerifying() {
|
|
115
|
+
form.style.display = 'none'
|
|
116
|
+
msg.className = 'msg'
|
|
117
|
+
verifying.style.display = 'block'
|
|
118
|
+
}
|
|
119
|
+
function showSuccess(info) {
|
|
120
|
+
form.style.display = 'none'
|
|
121
|
+
verifying.style.display = 'none'
|
|
122
|
+
msg.className = 'msg'
|
|
123
|
+
success.style.display = 'block'
|
|
124
|
+
document.getElementById('okInfo').textContent = info
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function pollResult() {
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch('/result?t=' + encodeURIComponent(token), {
|
|
130
|
+
headers: { 'Accept': 'application/json' }
|
|
131
|
+
})
|
|
132
|
+
if (res.status === 403) {
|
|
133
|
+
stopPoll()
|
|
134
|
+
msg.className = 'msg svc'
|
|
135
|
+
msg.textContent = '会话已结束(成功或超时)。请回终端查看结果;如需重新配置请重新运行 linke login。'
|
|
136
|
+
showForm()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const data = await res.json()
|
|
140
|
+
if (data.status === 'verifying' || data.status === 'idle') return
|
|
141
|
+
stopPoll()
|
|
142
|
+
if (data.type === 'success') {
|
|
143
|
+
const s = data.summary || {}
|
|
144
|
+
const lines = []
|
|
145
|
+
if (s.name) lines.push('姓名:' + s.name)
|
|
146
|
+
if (s.unit) lines.push(s.unit)
|
|
147
|
+
if (s.weekNow) lines.push('当前教学周:第 ' + s.weekNow + ' 周' + (s.weekAll ? ' / 共 ' + s.weekAll + ' 周' : ''))
|
|
148
|
+
showSuccess(lines.length ? lines.join('\n') : '教务登录验证通过,凭据已保存。')
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
// 失败:回到表单
|
|
152
|
+
showForm()
|
|
153
|
+
if (data.type === 'credential') {
|
|
154
|
+
msg.className = 'msg cred'
|
|
155
|
+
msg.textContent = '教务返回:' + (data.message || '账号或密码错误') + '。\n' +
|
|
156
|
+
'⚠️ 连续输错密码可能导致教务账号被锁定,请确认后重试' +
|
|
157
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次尝试机会)。' : '。')
|
|
158
|
+
} else {
|
|
159
|
+
msg.className = 'msg svc'
|
|
160
|
+
msg.textContent = (data.message || '网络或验证码识别服务异常') + '。\n' +
|
|
161
|
+
'这通常与账号密码无关,稍后重试即可' +
|
|
162
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次)' : '') +
|
|
163
|
+
'。'
|
|
164
|
+
}
|
|
165
|
+
if (data.final) {
|
|
166
|
+
btn.disabled = true
|
|
167
|
+
btn.textContent = '已达尝试上限'
|
|
168
|
+
msg.textContent += '\n已达尝试上限(' + data.maxAttempts + ' 次),本服务即将关闭;请回终端重新运行 linke login。'
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
/* 网络瞬断继续轮询 */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function stopPoll() {
|
|
175
|
+
if (polling) { clearInterval(polling); polling = null }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
form.addEventListener('submit', async (e) => {
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
msg.className = 'msg'
|
|
181
|
+
btn.disabled = true
|
|
182
|
+
btn.textContent = '提交中...'
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch('/submit', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
token,
|
|
189
|
+
userId: userIdInput.value.trim(),
|
|
190
|
+
password: passwordInput.value
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
const data = await res.json()
|
|
194
|
+
if (!data.ok) throw new Error(data.error || '提交失败')
|
|
195
|
+
showVerifying()
|
|
196
|
+
passwordInput.value = '' // 提交后即清空,避免长期驻留输入框
|
|
197
|
+
stopPoll()
|
|
198
|
+
polling = setInterval(pollResult, 800)
|
|
199
|
+
pollResult()
|
|
200
|
+
} catch (err) {
|
|
201
|
+
msg.className = 'msg svc'
|
|
202
|
+
msg.textContent = '提交失败:' + err.message
|
|
203
|
+
btn.disabled = false
|
|
204
|
+
btn.textContent = '验证并保存'
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
</script>
|
|
208
|
+
</body>
|
|
209
|
+
</html>
|