fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录 - 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配 - 访问来源统计查询优化与测试、管理端 analytics sources 页面适配 - 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
This commit is contained in:
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user