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

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