Commit 16e2134b by luoqi

fix(登录): 带 code 进 /plans 时卡在「正在进入召回工作台…」

复现条件:localStorage **已经有票**(第二次进来)+ URL 带 ?code=。
干净浏览器不复现,所以第一次测没发现。

时序:
  ① store 反水合 → isAuthenticated 立刻 true → AuthGate 放行 children
  ② /plans 落地解析器跑起来 → 主管 → router.replace('/supervisor')
  ③ `await exchangeCode()` 才回来,然后 replaceState 把地址写回 /plans

③ 把 ② 那次跳转冲掉了 —— Next 的路由树被拉回 plans(实测 history.state 里
`__PRIVATE_NEXTJS_INTERNALS_TREE` 停在 plans),而解析器已经 return、不会再试,
于是永远停在占位文案上。

⇒ 剥 code 与 exchange 成不成功无关,提到 await **之前**做。本 effect 跑在首次提交上
(那时 mounted 还是 false、children 尚未挂载),任何页面的落地跳转都必然在它之后,
不会再被回写覆盖。
️ replaceState 第二个参数改传 window.history.state 而不是 `{}` ——
   App Router 的路由树就存在 history state 里,传空对象等于把它抹掉。

实测三种都过:
  已登录 + /plans?code=        → 落 /supervisor
  已登录 + /plans?exec=1&code= → 走执行页(exec=1 没被剥 code 时一起吃掉)
  清空 storage + /plans?code=  → 落 /supervisor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2aedd46d
Pipeline #3556 failed in 0 seconds
...@@ -135,6 +135,29 @@ export function useAuthBootstrap() { ...@@ -135,6 +135,29 @@ export function useAuthBootstrap() {
const codeTag = code ? code.slice(0, 8) : null; const codeTag = code ? code.slice(0, 8) : null;
const envInfo = envSnap(); // 存储/环境探针,一次算好,失败事件带上 const envInfo = envSnap(); // 存储/环境探针,一次算好,失败事件带上
/**
* 🔴 **code 一读到就立刻从地址栏剥掉**,⛔ 不能等 exchange 回来再剥。
*
* ── 2026-08-10 实测的卡死 ───────────────────────────────────────
* 带 `?code=` 打开 `/plans`,而 localStorage 里**已经有票**(第二次进来):
* ① store 反水合 → `isAuthenticated` 立刻为 true → AuthGate 放行 children
* ② `/plans` 的落地解析器跑起来 → 主管 → `router.replace('/supervisor')`
* ③ 这里的 `await exchangeCode()` 才回来,然后 `replaceState` 把地址写回 `/plans`
* ③ **把 ② 那次跳转冲掉了** —— Next 的路由树被拉回 `plans`,
* 而解析器已经 `return`,不会再试一次 ⇒ 页面永远停在「正在进入召回工作台…」。
* (localStorage 干净时不复现:那时 AuthGate 还在占位,解析器压根没挂载。)
*
* ⇒ 剥 code 这件事**与 exchange 成不成功无关**,提到 await 之前做:
* 本 effect 跑在**首次提交**上(那时 `mounted` 还是 false、children 尚未挂载),
* 所以任何页面的落地跳转都一定发生在这之后,不会再被回写覆盖。
* ⚠️ 第二个参数要传 `window.history.state` 而不是 `{}` ——
* App Router 的路由树就存在 history state 里,传空对象等于把它抹掉。
*/
if (code) {
url.searchParams.delete('code');
window.history.replaceState(window.history.state, '', url.toString());
}
// 入口快照:URL 有没有 code + 反水合后 store 有没有 token + 环境(嵌入首开路径起点证据) // 入口快照:URL 有没有 code + 反水合后 store 有没有 token + 环境(嵌入首开路径起点证据)
reportDiag({ ev: 'start', hadCode: Boolean(code), codeTag, ...storeSnap(), ...envInfo }); reportDiag({ ev: 'start', hadCode: Boolean(code), codeTag, ...storeSnap(), ...envInfo });
...@@ -145,15 +168,12 @@ export function useAuthBootstrap() { ...@@ -145,15 +168,12 @@ export function useAuthBootstrap() {
try { try {
const r = await exchangeCode(code); const r = await exchangeCode(code);
setTokens(r); setTokens(r);
url.searchParams.delete('code'); // ⚠️ 地址栏上面已经剥干净了 —— ⛔ 别在这里再 replaceState 一次(那正是卡死的成因)
window.history.replaceState({}, '', url.toString());
reportDiag({ ev: 'code-ok', codeTag }); reportDiag({ ev: 'code-ok', codeTag });
setStatus('ready'); setStatus('ready');
return; return;
} catch (e) { } catch (e) {
// code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效 // code 失效(过期 / 重放) — 不一定要崩,localStorage 可能还有效(地址栏已剥,同上)
url.searchParams.delete('code');
window.history.replaceState({}, '', url.toString());
// ⭐ 关键证据:换 code 失败瞬间,store 里到底有没有 token(有→能兜住;无→必弹登录已过期) // ⭐ 关键证据:换 code 失败瞬间,store 里到底有没有 token(有→能兜住;无→必弹登录已过期)
reportDiag({ reportDiag({
ev: 'code-fail', ev: 'code-fail',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment