diff --git a/backend/handler/auth.go b/backend/handler/auth.go
index 15ef5eb..eaf5e5e 100644
--- a/backend/handler/auth.go
+++ b/backend/handler/auth.go
@@ -16,7 +16,8 @@ var (
// setAuthCookies 设置认证 cookie:
// - j13_token: access token(HttpOnly,15min)
-// - j13_refresh: refresh token(HttpOnly,7天,仅 /api/auth 路径)
+// - j13_refresh: refresh token(HttpOnly,7天,Path=/,供 Next middleware 在
+// 页面/RSC 请求中读取并静默轮转;HttpOnly+SameSite=Strict+CSRF 校验已足够防护)
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
csrfToken := service.GenerateCSRFToken()
@@ -31,11 +32,12 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
- // Refresh token cookie(仅 /api/auth 路径使用,缩小攻击面)
+ // Refresh token cookie:Path=/ 以支持 SSR middleware 续期;
+ // 仅 /api/auth/refresh 端点消费,其他路径不读取
http.SetCookie(c.Writer, &http.Cookie{
Name: service.RefreshCookieName,
Value: refreshToken,
- Path: "/api/auth",
+ Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: true,
Secure: secure,
@@ -55,8 +57,10 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
// clearAuthCookies 清除所有认证 cookie
func clearAuthCookies(c *gin.Context) {
+ // refresh cookie 同时清除两个路径:Path=/api/auth 为旧版兼容
cookies := []struct{ name, path string }{
{service.CookieName, "/"},
+ {service.RefreshCookieName, "/"},
{service.RefreshCookieName, "/api/auth"},
{service.CSRFCookieName, "/"},
}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 015f513..c0f4890 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -36,12 +36,14 @@
--shadow: 0 2px 8px rgba(87, 66, 36, 0.05), 0 10px 28px rgba(87, 66, 36, 0.05);
--shadow-lg: 0 6px 16px rgba(87, 66, 36, 0.07), 0 20px 48px rgba(87, 66, 36, 0.1);
- --font-display: "Plus Jakarta Sans", "Noto Sans SC", -apple-system, "PingFang SC",
- "Microsoft YaHei", system-ui, sans-serif;
- --font-sans: "Plus Jakarta Sans", "Noto Sans SC", -apple-system, "PingFang SC",
- "Microsoft YaHei", system-ui, sans-serif;
- --font-mono: "JetBrains Mono", "Noto Sans SC", ui-monospace, "Cascadia Code",
- Consolas, monospace;
+ /* next/font 构建期自托管,变量由 layout.tsx 注入;
+ 本地系统字体仅作网络字体分片就绪前的回退(PingFang/雅黑与 Noto 观感接近) */
+ --font-display: var(--font-jakarta), var(--font-noto-sc), -apple-system,
+ "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+ --font-sans: var(--font-jakarta), var(--font-noto-sc), -apple-system,
+ "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains), var(--font-noto-sc), ui-monospace,
+ "Cascadia Code", Consolas, monospace;
--r: 18px;
--r-sm: 12px;
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 97bcd17..6861553 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,9 +1,33 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
+import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
+import { fetchMe, fetchUnreadCount, type User } from "@/lib/api";
+
+// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
+// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
+const jakarta = Plus_Jakarta_Sans({
+ subsets: ["latin"],
+ weight: ["400", "500", "600", "700", "800"],
+ variable: "--font-jakarta",
+ display: "swap",
+});
+
+const notoSC = Noto_Sans_SC({
+ weight: ["400", "500", "700", "900"],
+ variable: "--font-noto-sc",
+ display: "swap",
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ weight: ["400", "500", "700"],
+ variable: "--font-jetbrains",
+ display: "swap",
+});
export const metadata: Metadata = {
title: {
@@ -27,21 +51,34 @@ export default async function RootLayout({
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
+ // SSR 直出登录态(access 过期时 middleware 已先行轮转,见 middleware.ts),
+ // 保证 F5 初始 HTML 中的右上角就是正确的用户区,而非先闪"登录/注册"
+ const cookieHeader = cookieStore.toString();
+ let user: User | null = null;
+ let unread = 0;
+ if (cookieStore.get("j13_refresh")?.value) {
+ try {
+ const [me, count] = await Promise.all([
+ fetchMe(cookieHeader),
+ fetchUnreadCount(cookieHeader),
+ ]);
+ user = me.user;
+ unread = count;
+ } catch {
+ // 后端不可用时降级为游客视图,不阻塞页面渲染
+ }
+ }
+
return (
-
-
-
-
-
-
+
跳到主要内容
-
+
router.push("/login"), 1500);
} else {
setError(res.error || "修改失败");
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx
index 4e83767..df0078f 100644
--- a/frontend/components/Header.tsx
+++ b/frontend/components/Header.tsx
@@ -17,19 +17,33 @@ import {
Bell,
Home,
} from "lucide-react";
-import { apiMe, apiLogout, type User } from "@/lib/api";
+import { apiLogout, type User } from "@/lib/api";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
-export default function Header() {
+export default function Header({
+ initialUser,
+ initialUnread = 0,
+ initialTheme = "light",
+}: {
+ initialUser: User | null;
+ initialUnread?: number;
+ initialTheme?: "light" | "dark";
+}) {
const router = useRouter();
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
- const [theme, setTheme] = useState<"light" | "dark">("light");
- const [user, setUser] = useState(null);
+ const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
+ // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
+ const [user, setUser] = useState(initialUser);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef(null);
+ // 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
+ useEffect(() => {
+ setUser(initialUser);
+ }, [initialUser]);
+
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
@@ -45,9 +59,6 @@ export default function Header() {
document.documentElement.classList.toggle("dark", savedTheme === "dark");
setTheme(savedTheme);
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
- apiMe()
- .then((res) => setUser(res.user || null))
- .catch(() => setUser(null));
}, [pathname]);
// "/" 快速聚焦搜索
@@ -173,7 +184,7 @@ export default function Header() {
- {user && }
+ {user && }
{user ? (
<>
@@ -210,7 +221,7 @@ export default function Header() {
{/* 移动端按钮 */}
- {user && }
+ {user && }