fix: 管理会话自动恢复与访问来源统计优化

- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
This commit is contained in:
2026-09-28 03:33:52 +08:00
parent 0c3b3a710c
commit 3e0689fe98
15 changed files with 548 additions and 85 deletions

View 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>
);
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
import { apiStatsSources, type VisitSources } from "@/lib/api";
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
import { RangeSegmented, StatsState, TableEmpty, formatInt, refKindLabel } from "../ui";
/** 来源按日表:固定展示四类主要来源列 */
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
@@ -49,11 +49,25 @@ export default function SourcesPage() {
</AdminChartCard>
<div className="grid gap-4 lg:grid-cols-2">
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
{data.search_hosts.length ? (
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
<AdminChartCard title="站内搜索词 Top 10" hint="搜索结果页(/?q=)浏览量 Top 10">
{data.search_terms.length ? (
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
{data.search_terms.map((t, i) => (
<li key={`${i}-${t.key}`} className="py-2 flex items-center gap-3 text-[13px]">
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
{i + 1}
</span>
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={t.key}>
{t.key}
</span>
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
{formatInt(t.count)}
</span>
</li>
))}
</ul>
) : (
<TableEmpty text="暂无搜索引擎来源" />
<TableEmpty text="暂无站内搜索" />
)}
</AdminChartCard>
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">

View File

@@ -1,11 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import AdminShell from "@/components/admin/AdminShell";
import AdminSessionRecover from "./AdminSessionRecover";
// 整个 /admin 树不进入索引(子页面无需重复声明)
export const metadata: Metadata = {
@@ -25,6 +27,48 @@ export default async function AdminLayout({ children }: { children: React.ReactN
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
if (!allowed || !me.user) {
// 已登录但无后台权限:保留提示卡片
if (me.user) {
return (
<div
data-admin-viewport
id="main"
tabIndex={-1}
className="min-h-screen flex items-center justify-center px-4 outline-none"
>
<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>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
</div>
);
}
// SSR 侧未识别出用户(/api/me 瞬断、access 过期轮转竞态等)时,原实现直接
// 落到无壳的权限卡片——手机端表现为「首次进入后台整块导航 header 消失」,
// 无法切换管理板块,再进一次才恢复。这里按凭据状态分流入修复:
// ① 完全无凭据:直达登录页并回跳目标地址,不再给无导航的死胡同卡片;
// ② 仍带凭据:客户端经 fetchWithRefresh 自动续期后 router.refresh 自愈。
const hasAuthHint = !!(
cookieStore.get(TOKEN_COOKIE)?.value ||
cookieStore.get(REFRESH_COOKIE)?.value ||
cookieStore.get(CSRF_COOKIE)?.value
);
if (!hasAuthHint) {
const pathname = (await headers()).get("x-pathname") || "/admin";
redirect(`/login?redirect=${encodeURIComponent(pathname)}`);
}
return (
<div
data-admin-viewport
@@ -32,21 +76,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
tabIndex={-1}
className="min-h-screen flex items-center justify-center px-4 outline-none"
>
<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>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
<AdminSessionRecover />
</div>
);
}

View File

@@ -3177,6 +3177,9 @@ body:has([data-compose-viewport]) #main > [data-compose-viewport] {
/* 聊天页:整页锁在视口内,避免「页面滚动 + 对话区滚动」双滚动条 */
body:has([data-chat-viewport]) {
height: 100dvh;
/* 覆盖 body 上的 min-h-screen(100vh):min-height 优先级高于 height/max-height,
否则移动端 body 实际高 100vh,比可见视口高出地址栏高度,输入框会被推出屏幕 */
min-height: 0;
max-height: 100dvh;
overflow: hidden;
}

View File

@@ -152,7 +152,7 @@ export default function AccountGuard() {
id="session-ended-dialog-desc"
className="meta text-[13px] leading-relaxed text-center"
>
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
你的登录会话已失效(在安全设置中剔除、会话数超出上限被淘汰,或登录有效期已过)。
请重新登录后继续使用。
</p>
</Modal>

View File

@@ -38,6 +38,11 @@ import { useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
// 接近上限时输入框内显示 n/100 计数帮助用户感知
const SEARCH_MAX_LEN = 100;
const SEARCH_COUNT_FROM = 80;
export default function Header({
initialUser,
initialUnread = 0,
@@ -280,18 +285,30 @@ export default function Header({
ref={searchInputRef}
type="text"
value={searchQuery}
maxLength={SEARCH_MAX_LEN}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
placeholder="搜索帖子..."
aria-label="搜索帖子"
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
className={`j13-search-field w-full pl-10 py-2.5 text-sm rounded-full ${
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-10"
}`}
/>
<kbd
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
>
/
</kbd>
{searchQuery.length >= SEARCH_COUNT_FROM ? (
<span
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
>
{searchQuery.length}/{SEARCH_MAX_LEN}
</span>
) : (
<kbd
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
>
/
</kbd>
)}
</div>
</form>
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
@@ -365,6 +382,7 @@ export default function Header({
autoFocus
type="text"
value={searchQuery}
maxLength={SEARCH_MAX_LEN}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
@@ -374,8 +392,18 @@ export default function Header({
}}
aria-label="搜索帖子"
placeholder="搜索帖子..."
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
className={`j13-search-field w-full pl-10 py-3 rounded-full text-sm ${
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-4"
}`}
/>
{searchQuery.length >= SEARCH_COUNT_FROM && (
<span
className="absolute right-4 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
>
{searchQuery.length}/{SEARCH_MAX_LEN}
</span>
)}
</form>
</div>
)}

View File

@@ -1,26 +1,37 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { apiTelemetryPageView } from "@/lib/api";
/**
* 公开站页面访问埋点:同 path 30s 内合并;跳过 /admin。
*/
export default function VisitBeacon() {
function BeaconInner() {
const pathname = usePathname();
const search = useSearchParams().toString();
const lastRef = useRef<{ path: string; at: number }>({ path: "", at: 0 });
useEffect(() => {
if (!pathname || pathname === "/admin" || pathname.startsWith("/admin/")) return;
// 完整 URL(含 query):query 变化也上报,站内搜索 /?q=… 才能被记录
const full = pathname + (search ? `?${search}` : "");
const now = Date.now();
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
lastRef.current = { path: pathname, at: now };
// 带 query 完整路径 + 来源页,供后台来源分析
const full = pathname + (typeof window !== "undefined" ? window.location.search : "");
if (lastRef.current.path === full && now - lastRef.current.at < 30_000) return;
lastRef.current = { path: full, at: now };
// 来源页,供后台来源分析
const ref = typeof document !== "undefined" ? document.referrer : "";
void apiTelemetryPageView(full, ref);
}, [pathname]);
}, [pathname, search]);
return null;
}
/**
* 公开站页面访问埋点:同 URL 30s 内合并;跳过 /admin。
* useSearchParams 需 Suspense 边界(与 FooterTiming 同模式)。
*/
export default function VisitBeacon() {
return (
<Suspense fallback={null}>
<BeaconInner />
</Suspense>
);
}

View File

@@ -726,7 +726,7 @@ function detectBannedBody(res: Response): void {
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
export async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
@@ -1748,7 +1748,7 @@ export interface VisitSourcesKindDay {
export interface VisitSources {
range_days: number;
kinds: VisitNamedCount[];
search_hosts: VisitNamedCount[];
search_terms: VisitNamedCount[];
external: VisitNamedCount[];
trend: VisitSourcesKindDay[];
}