linke-cli 0.3.0 → 0.3.1
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 +13 -0
- package/package.json +1 -1
- package/skills/linke/SKILL.md +1 -1
- package/src/bin.js +1 -0
- package/src/loginserver.js +18 -0
- package/src/web/login.html +89 -22
package/README.md
CHANGED
|
@@ -59,6 +59,19 @@ stdout 只输出业务 JSON;进度与错误走 stderr。exit code 契约见
|
|
|
59
59
|
经 `src/schools/registry.js` 暴露统一接口。新增学校 = 新增
|
|
60
60
|
`src/schools/<id>/` 适配器目录并注册。
|
|
61
61
|
|
|
62
|
+
## 已知问题与排障
|
|
63
|
+
|
|
64
|
+
- **登录页提交报「Failed to fetch」**:提交未送达本机登录服务。
|
|
65
|
+
常见原因是本机代理 / TUN 工具(Clash、Stash、Surge 等)劫持了
|
|
66
|
+
localhost 请求——为 localhost 加直连规则或暂时关闭代理后重试。
|
|
67
|
+
本次提交未送达,不会占用教务尝试次数。
|
|
68
|
+
- **CLI 运行在远程机器(远程桌面 / SSH)**:`linke login` 打印的
|
|
69
|
+
127.0.0.1 地址只在**那台机器**的浏览器可用。远程操作时请在被控
|
|
70
|
+
机器本机打开浏览器;手机填写用 `linke login --qr`(局域网地址)。
|
|
71
|
+
- **怀疑装到旧版本**:若走 npmmirror 等镜像源安装,镜像同步有延迟,
|
|
72
|
+
可能装到旧版。用 `npm ls -g linke-cli` 核对版本,或
|
|
73
|
+
`npm install -g linke-cli --registry https://registry.npmjs.org` 指定官方源。
|
|
74
|
+
|
|
62
75
|
## 开发
|
|
63
76
|
|
|
64
77
|
```bash
|
package/package.json
CHANGED
package/skills/linke/SKILL.md
CHANGED
package/src/bin.js
CHANGED
|
@@ -208,6 +208,7 @@ async function cmdLogin(flags) {
|
|
|
208
208
|
const handle = await startLoginServer({ qr, verify: verifyCredentials, onEvent })
|
|
209
209
|
progress('登录页已就绪(提交后网页内完成验证;5 分钟超时,成功或达尝试上限后自动关闭)')
|
|
210
210
|
progress(`本机访问: ${handle.urls.local}`)
|
|
211
|
+
progress('(该地址仅本机浏览器可用;CLI 运行在远程机器上时,请在那台机器的浏览器中打开)')
|
|
211
212
|
if (qr && handle.urls.lan.length > 0) {
|
|
212
213
|
progress('局域网访问(含会话令牌,勿转发他人):')
|
|
213
214
|
for (const lan of handle.urls.lan) progress(` ${lan}`)
|
package/src/loginserver.js
CHANGED
|
@@ -135,6 +135,24 @@ export function startLoginServer({
|
|
|
135
135
|
return
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
if (req.method === 'GET' && url.pathname === '/state') {
|
|
139
|
+
// 页面刷新后恢复上下文用(T7 打回验收 11/12):报告已提交次数
|
|
140
|
+
// 与剩余额度,让用户在重复提交前看到教务锁号警示。
|
|
141
|
+
// 与 /result 同口径:终结性结果未被取走前仍可查询。
|
|
142
|
+
if (url.searchParams.get('t') !== token) {
|
|
143
|
+
sendJson(403, { ok: false, error: '令牌错误' })
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
sendJson(200, {
|
|
147
|
+
status: lastResult ? 'done' : verifying ? 'verifying' : 'idle',
|
|
148
|
+
attempts,
|
|
149
|
+
remaining: Math.max(0, maxAttempts - attempts),
|
|
150
|
+
maxAttempts,
|
|
151
|
+
result: lastResult,
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
138
156
|
if (req.method === 'POST' && url.pathname === '/submit') {
|
|
139
157
|
const origin = req.headers.origin
|
|
140
158
|
if (origin && origin !== `http://${req.headers.host}`) {
|
package/src/web/login.html
CHANGED
|
@@ -102,7 +102,11 @@
|
|
|
102
102
|
const success = document.getElementById('success')
|
|
103
103
|
const userIdInput = document.getElementById('userId')
|
|
104
104
|
const passwordInput = document.getElementById('password')
|
|
105
|
+
const PROXY_HINT = '若本机运行 Clash / Stash / Surge 等代理或 TUN 工具,' +
|
|
106
|
+
'可能拦截了 localhost 请求——请为 localhost 加直连规则或暂时关闭后重试'
|
|
105
107
|
let polling = null
|
|
108
|
+
let pollFailures = 0
|
|
109
|
+
let submittedOnce = false
|
|
106
110
|
|
|
107
111
|
function showForm() {
|
|
108
112
|
form.style.display = ''
|
|
@@ -123,6 +127,33 @@
|
|
|
123
127
|
success.style.display = 'block'
|
|
124
128
|
document.getElementById('okInfo').textContent = info
|
|
125
129
|
}
|
|
130
|
+
function showBanner(text) {
|
|
131
|
+
msg.className = 'msg svc'
|
|
132
|
+
msg.textContent = text
|
|
133
|
+
}
|
|
134
|
+
function clearBanner() {
|
|
135
|
+
if (!submittedOnce || polling) { msg.className = 'msg' }
|
|
136
|
+
}
|
|
137
|
+
function renderFailResult(data) {
|
|
138
|
+
// 失败结果:回到表单(凭据错误重输 / 服务错误保留输入重发)
|
|
139
|
+
showForm()
|
|
140
|
+
if (data.type === 'credential') {
|
|
141
|
+
msg.className = 'msg cred'
|
|
142
|
+
msg.textContent = '教务返回:' + (data.message || '账号或密码错误') + '。\n' +
|
|
143
|
+
'⚠️ 连续输错密码可能导致教务账号被锁定,请确认后重试' +
|
|
144
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次尝试机会)。' : '。')
|
|
145
|
+
} else {
|
|
146
|
+
msg.className = 'msg svc'
|
|
147
|
+
msg.textContent = (data.message || '网络或验证码识别服务异常') + '。\n' +
|
|
148
|
+
'这通常与账号密码无关,稍后重试即可' +
|
|
149
|
+
(data.remaining > 0 ? '(剩余 ' + data.remaining + ' 次)' : '') + '。'
|
|
150
|
+
}
|
|
151
|
+
if (data.final) {
|
|
152
|
+
btn.disabled = true
|
|
153
|
+
btn.textContent = '已达尝试上限'
|
|
154
|
+
msg.textContent += '\n已达尝试上限(' + data.maxAttempts + ' 次),本服务即将关闭;请回终端重新运行 linke login。'
|
|
155
|
+
}
|
|
156
|
+
}
|
|
126
157
|
|
|
127
158
|
async function pollResult() {
|
|
128
159
|
try {
|
|
@@ -131,14 +162,21 @@
|
|
|
131
162
|
})
|
|
132
163
|
if (res.status === 403) {
|
|
133
164
|
stopPoll()
|
|
165
|
+
submittedOnce = false
|
|
134
166
|
msg.className = 'msg svc'
|
|
135
|
-
msg.textContent = '
|
|
167
|
+
msg.textContent = '登录服务已结束(成功、超时或达尝试上限)。\n' +
|
|
168
|
+
'请回终端查看结果;如需重新配置,请在终端重新运行 linke login。'
|
|
136
169
|
showForm()
|
|
170
|
+
btn.disabled = true
|
|
171
|
+
btn.textContent = '会话已结束'
|
|
137
172
|
return
|
|
138
173
|
}
|
|
139
174
|
const data = await res.json()
|
|
175
|
+
pollFailures = 0
|
|
176
|
+
clearBanner()
|
|
140
177
|
if (data.status === 'verifying' || data.status === 'idle') return
|
|
141
178
|
stopPoll()
|
|
179
|
+
submittedOnce = false
|
|
142
180
|
if (data.type === 'success') {
|
|
143
181
|
const s = data.summary || {}
|
|
144
182
|
const lines = []
|
|
@@ -148,27 +186,16 @@
|
|
|
148
186
|
showSuccess(lines.length ? lines.join('\n') : '教务登录验证通过,凭据已保存。')
|
|
149
187
|
return
|
|
150
188
|
}
|
|
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
|
-
}
|
|
189
|
+
renderFailResult(data)
|
|
170
190
|
} catch {
|
|
171
|
-
|
|
191
|
+
// 轮询失败 ≠ 提交失败(打回验收 9):静默重试,持续失败才提示,
|
|
192
|
+
// 且绝不引导重复提交(防占用教务尝试次数)
|
|
193
|
+
pollFailures += 1
|
|
194
|
+
if (pollFailures >= 12) {
|
|
195
|
+
showBanner('与服务联系中断(约 ' + Math.round(pollFailures * 0.8) + ' 秒无响应),仍在自动重试…\n' +
|
|
196
|
+
'⚠️ 请勿关闭本页或在此重复提交(重复提交会占用教务尝试次数,连续输错可能锁号)。\n' +
|
|
197
|
+
'可回终端查看验证是否已完成;若终端也已无响应,重新运行 linke login。')
|
|
198
|
+
}
|
|
172
199
|
}
|
|
173
200
|
}
|
|
174
201
|
function stopPoll() {
|
|
@@ -192,18 +219,58 @@
|
|
|
192
219
|
})
|
|
193
220
|
const data = await res.json()
|
|
194
221
|
if (!data.ok) throw new Error(data.error || '提交失败')
|
|
222
|
+
submittedOnce = true
|
|
195
223
|
showVerifying()
|
|
196
224
|
passwordInput.value = '' // 提交后即清空,避免长期驻留输入框
|
|
197
225
|
stopPoll()
|
|
226
|
+
pollFailures = 0
|
|
198
227
|
polling = setInterval(pollResult, 800)
|
|
199
228
|
pollResult()
|
|
200
229
|
} catch (err) {
|
|
230
|
+
// 提交 POST 失败(与轮询失败分开归因——打回验收 9)
|
|
201
231
|
msg.className = 'msg svc'
|
|
202
|
-
msg.textContent = '
|
|
232
|
+
msg.textContent = '提交失败:无法连接本机登录服务(' + (err.message || '网络异常') + ')。\n' +
|
|
233
|
+
'本次提交未送达,不会占用教务尝试次数,可稍后重试。\n' + PROXY_HINT + '。'
|
|
203
234
|
btn.disabled = false
|
|
204
235
|
btn.textContent = '验证并保存'
|
|
205
236
|
}
|
|
206
237
|
})
|
|
238
|
+
|
|
239
|
+
// 页面(重)加载时恢复上下文(打回验收 11/12):已提交过则给出
|
|
240
|
+
// 防重复警示,验证中则直接恢复验证态,避免用户无感重复提交
|
|
241
|
+
;(async function restoreState() {
|
|
242
|
+
try {
|
|
243
|
+
const res = await fetch('/state?t=' + encodeURIComponent(token))
|
|
244
|
+
if (res.status === 403) return // 会话结束,保持初始表单(提交会被拒)
|
|
245
|
+
const state = await res.json()
|
|
246
|
+
if (state.attempts > 0) {
|
|
247
|
+
msg.className = 'msg svc'
|
|
248
|
+
msg.textContent = '本会话已提交 ' + state.attempts + ' 次(上限 ' +
|
|
249
|
+
state.maxAttempts + ')。为避免教务账号因连续错密码被锁定,' +
|
|
250
|
+
'请确认凭据后再提交(剩余 ' + state.remaining + ' 次)。'
|
|
251
|
+
}
|
|
252
|
+
if (state.status === 'verifying') {
|
|
253
|
+
showVerifying()
|
|
254
|
+
submittedOnce = true
|
|
255
|
+
stopPoll()
|
|
256
|
+
polling = setInterval(pollResult, 800)
|
|
257
|
+
pollResult()
|
|
258
|
+
} else if (state.status === 'done' && state.result) {
|
|
259
|
+
submittedOnce = false
|
|
260
|
+
if (state.result.type === 'success') {
|
|
261
|
+
const s = state.result.summary || {}
|
|
262
|
+
const lines = []
|
|
263
|
+
if (s.name) lines.push('姓名:' + s.name)
|
|
264
|
+
if (s.weekNow) lines.push('当前教学周:第 ' + s.weekNow + ' 周')
|
|
265
|
+
showSuccess(lines.length ? lines.join('\n') : '教务登录验证通过,凭据已保存。')
|
|
266
|
+
} else {
|
|
267
|
+
renderFailResult(state.result)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
/* /state 获取失败不阻塞初始表单 */
|
|
272
|
+
}
|
|
273
|
+
})()
|
|
207
274
|
</script>
|
|
208
275
|
</body>
|
|
209
276
|
</html>
|