From 1bb565c687c84380bda1262af053d69801339489 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 12 Sep 2026 04:59:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend+backend):=20=E5=AE=9E=E7=8E=B0SSR?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81=E4=BC=98=E5=8C=96=E4=B8=8E=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E8=87=AA=E6=89=98=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 后端调整refresh cookie路径与注释,完善认证cookie处理逻辑 2. 前端全局layout新增next/font自托管字体,消除FOUT闪动 3. 实现SSR直出登录态与未读通知数,避免页面加载闪动 4. 新增中间件处理access token静默轮转,保障SSR登录态 5. 优化登录、改密流程,添加页面刷新同步登录态 6. 重构通知铃铛组件支持初始未读数传入 --- backend/handler/auth.go | 10 +- frontend/app/globals.css | 14 +-- frontend/app/layout.tsx | 57 +++++++++-- frontend/app/login/page.tsx | 2 + frontend/app/settings/page.tsx | 2 + frontend/components/Header.tsx | 29 ++++-- frontend/components/NotificationBell.tsx | 5 +- frontend/lib/api.ts | 21 ++++ frontend/middleware.ts | 116 +++++++++++++++++++++++ 9 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 frontend/middleware.ts 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 && }