Files
jiang13-bbs/frontend/app/admin/AdminSessionRecover.tsx
freefire 3e0689fe98 fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
2026-09-28 03:33:52 +08:00

142 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Loader2, RefreshCw, ShieldAlert } from "lucide-react";
import { apiMe, apiRefresh } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
/**
* 后台布局降级自愈:SSR 侧 /api/me 未识别出用户(access 过期轮转竞态、后端
* 瞬断等)时布局只能渲染无壳的降级卡片。本组件挂载后走客户端链路复判——
* apiMe 内部会在 401 时经 fetchWithRefresh 自动续期后重试:
* - 复判出用户 → router.refresh() 让服务端重出完整后台壳(顶栏/抽屉回归);
* - 仍是游客且凭据已被清理 → 会话确实失效,带回归地址去登录页;
* - 仍是游客但凭据还在(refresh 瞬态失败/后端暂不可用)→ 转入 offline 态,
* 有限次自动重试(间隔 5s),期间随时可手动重试/重新登录。
* refresh 成功但服务端仍识别失败时最多重试 3 次,超出转入手动重试,避免循环刷新。
*/
const MAX_AUTO_REFRESH = 3;
// offline 态自动重试次数(间隔 5s,共 50s):后端短暂抖动/重启场景无需用户手动干预
const MAX_AUTO_RETRY = 10;
const AUTO_RETRY_DELAY_MS = 5000;
export default function AdminSessionRecover() {
const router = useRouter();
const [state, setState] = useState<"recovering" | "offline">("recovering");
const autoRefreshLeft = useRef(MAX_AUTO_REFRESH);
const autoRetryLeft = useRef(MAX_AUTO_RETRY);
const recover = useCallback(() => {
setState("recovering");
apiMe()
.then(async (res) => {
if (res.user) {
if (autoRefreshLeft.current > 0) {
autoRefreshLeft.current -= 1;
router.refresh();
return;
}
setState("offline");
return;
}
// apiMe 内部的续期复判以 sessionSeen 为门(本页从未识别出用户时为 false,
// 不会触发),这里显式补一轮 refresh:access 竞态/瞬断多数在此恢复
const back = encodeURIComponent(location.pathname + location.search);
if (hasAuthCookieHint()) {
const refreshed = await apiRefresh();
if (refreshed.ok) {
const again = await apiMe();
if (again.user) {
if (autoRefreshLeft.current > 0) {
autoRefreshLeft.current -= 1;
router.refresh();
return;
}
setState("offline");
return;
}
setState("offline");
return;
}
if (refreshed.transient) {
// 后端暂不可用:无法证明登录态失效,留在原地可手动重试
setState("offline");
return;
}
// refresh 明确失效(后端同时下发清 cookie):会话确实终结,回跳登录
router.replace(`/login?redirect=${back}`);
return;
}
// 无任何凭据痕迹:直接回跳登录
router.replace(`/login?redirect=${back}`);
})
.catch(() => setState("offline"));
}, [router]);
useEffect(() => {
recover();
}, [recover]);
// offline 态有限次自动重试:后端短暂抖动/重启期间留在本页轮询恢复,
// 次数用尽后仅手动重试可再触发,避免无限轮询;手动重试会重置次数。
useEffect(() => {
if (state !== "offline") return;
if (autoRetryLeft.current <= 0) return;
autoRetryLeft.current -= 1;
const t = window.setTimeout(recover, AUTO_RETRY_DELAY_MS);
return () => window.clearTimeout(t);
}, [state, recover]);
const loginHref =
state === "offline" && typeof window !== "undefined"
? `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`
: "/login";
return (
<div className="max-w-md w-full panel p-8 text-center">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
正在恢复登录状态
</h1>
{state === "recovering" ? (
<p className="meta mt-2 text-[13px] inline-flex items-center justify-center gap-2">
<Loader2 size={14} className="animate-spin" aria-hidden />
正在确认会话,稍候将自动进入后台…
</p>
) : (
<>
<p className="meta mt-2 text-[13px]">暂时无法确认登录状态,请稍后重试。</p>
<div className="flex items-center justify-center gap-3 mt-6">
<button
type="button"
onClick={() => {
autoRefreshLeft.current = MAX_AUTO_REFRESH;
autoRetryLeft.current = MAX_AUTO_RETRY;
recover();
}}
className="btn btn-primary inline-flex items-center gap-2"
>
<RefreshCw size={14} aria-hidden /> 重试
</button>
<Link href={loginHref} className="btn btn-line">
重新登录
</Link>
</div>
</>
)}
<p className="mt-4">
<Link href="/" className="meta text-[13px]">
返回首页
</Link>
</p>
</div>
);
}