From 45cc864971c5341d49f4b3adffe0c369989a5d79 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 10 Sep 2026 19:26:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssr):=20=E6=89=A9=E5=B1=95=20SSR=20?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=87=B3=20/page/:slug=20=E4=B8=8E=20/user/:?= =?UTF-8?q?id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feedPaths 白名单加入自定义单页与用户主页(兼容 .html 伪静态) - head.ts 新增 buildPageHead(ogType=article, WebPage JSON-LD) 与 buildUserHead(ogType=profile, Person JSON-LD) - feedData 新增 loadPageData / loadUserData,并行取页面/用户 数据 + 壳层,404 走 buildNotFoundHead + noindex - feedSeed 按 useSessionResource 的 key 约定 seed: sitepage:${slug}、user:${id}、user:${id}:posts:1:${size}, 使 SitePageView / UserProfilePage 首帧直接命中快照渲染 - MainLayout postOutline 首帧派生扩展 /page/:slug 分支, 文章目录随正文一同 SSR - 验证:两页 SSR HTML 含完整内容与 head,0 hydration 错误, hydration 后仅剩 pageview 请求;404 正确;已有页面无回归 --- frontend/src/layouts/MainLayout.tsx | 67 +++++++++++++-------- frontend/src/ssr/feedData.ts | 91 ++++++++++++++++++++++++++++- frontend/src/ssr/feedPaths.ts | 9 ++- frontend/src/ssr/feedSeed.ts | 21 ++++++- frontend/src/ssr/head.ts | 77 +++++++++++++++++++++++- 5 files changed, 236 insertions(+), 29 deletions(-) diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e4ca21e..0248b3b 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -12,7 +12,7 @@ import { useAuth } from '../hooks/useAuth'; import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { api } from '../api/client'; -import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User, UserActivityStats, UserPublic } from '../api/types'; +import type { Board, RecentComment, RecentUser, ForumStats, SitePage, TagCount, User, UserActivityStats, UserPublic } from '../api/types'; import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; import { renderPostContentHtml } from '../utils/postContent'; import { getSessionSnapshot } from '../utils/sessionPageCache'; @@ -80,28 +80,49 @@ export default function MainLayout() { // 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行, // 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。 const [postOutline, setPostOutline] = useState(() => { - const m = loc.pathname.match(/^\/post\/(\d+)/); - if (!m || /\/edit$/.test(loc.pathname)) return null; - const id = Number(m[1]); - if (!id) return null; - const snap = getSessionSnapshot(postDetailCacheKey(id)); - if (!snap?.post) return null; - const content = snap.post.content ?? ''; - const rendered = content.trim() - ? renderPostContentHtml(content, !!user, { - openLinksInNewTab: getCachedForumLimits()?.open_content_links_in_new_tab, - }) - : ''; - return { - headings: extractHeadingsFromHtml(rendered), - scrollRoot: null, - title: '文章目录', - author: snap.post.user ?? null, - publishedAt: snap.post.created_at, - viewCount: snap.post.view_count, - authorProfile: snap.authorProfile ?? null, - authorStats: snap.authorStats ?? null, - }; + const openLinks = getCachedForumLimits()?.open_content_links_in_new_tab; + // 帖子详情:从 post 快照派生作者卡 + 目录 + const postM = loc.pathname.match(/^\/post\/(\d+)/); + if (postM && !/\/edit$/.test(loc.pathname)) { + const id = Number(postM[1]); + if (id) { + const snap = getSessionSnapshot(postDetailCacheKey(id)); + if (snap?.post) { + const content = snap.post.content ?? ''; + const rendered = content.trim() + ? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks }) + : ''; + return { + headings: extractHeadingsFromHtml(rendered), + scrollRoot: null, + title: '文章目录', + author: snap.post.user ?? null, + publishedAt: snap.post.created_at, + viewCount: snap.post.view_count, + authorProfile: snap.authorProfile ?? null, + authorStats: snap.authorStats ?? null, + }; + } + } + } + // 自定义单页:从 sitepage 快照派生目录(无作者卡) + const pageM = loc.pathname.match(/^\/page\/([^/]+?)(?:\.html?)?$/); + if (pageM) { + const slug = pageM[1]; + const page = getSessionSnapshot(`sitepage:${slug}`); + if (page) { + const content = page.content ?? ''; + const rendered = content.trim() + ? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks }) + : ''; + return { + headings: extractHeadingsFromHtml(rendered), + scrollRoot: null, + title: '文章目录', + }; + } + } + return null; }); const [asideOpen, setAsideOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); diff --git a/frontend/src/ssr/feedData.ts b/frontend/src/ssr/feedData.ts index b09ac6d..d348817 100644 --- a/frontend/src/ssr/feedData.ts +++ b/frontend/src/ssr/feedData.ts @@ -10,11 +10,11 @@ import { redirect, render } from 'vike/abort'; import type { PageContextServer } from 'vike/types'; import { serverApi } from '../api/serverClient'; -import type { Comment, PostDetailResponse, UserActivityStats, UserPublic } from '../api/types'; +import type { Comment, PostDetailResponse, PostItem, SitePage, UserActivityStats, UserPublic } from '../api/types'; import type { HomeBootPayload } from '../utils/homeBoot'; import { buildPostDetailSnapshot } from '../utils/homeBoot'; import { matchFeedSsrPath } from './feedPaths'; -import { buildFeedHead, buildNotFoundHead, buildPostHead } from './head'; +import { buildFeedHead, buildNotFoundHead, buildPageHead, buildPostHead, buildUserHead } from './head'; import type { FeedData } from './feedSeed'; export type { FeedData }; @@ -41,6 +41,12 @@ export async function loadFeedData(pageContext: PageContextServer): Promise { + if (!slug) { + throw render(404, { head: buildNotFoundHead(pathname) }); + } + + const [pageResp, boot] = await Promise.all([ + serverApi<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`, api), + serverApi('/api/home-boot', api), + ]); + + const brand = boot?.branding ?? null; + const base = brand?.site_url?.trim() ?? ''; + const page = pageResp?.page; + + // 页面不存在或未发布:404 + noindex + if (!page || !page.published) { + throw render(404, { + head: buildNotFoundHead(pathname, brand, base), + boot: boot ?? undefined, + }); + } + + // slug 与页面组件 parsePermalinkSlug() 对齐(小写),保证 useSessionResource key 命中 + return { + kind: 'page', + boot: boot ?? undefined, + sitePage: { slug: page.slug.toLowerCase(), page }, + head: buildPageHead(page, brand, boot?.limits ?? null, base), + }; +} + +/** 用户主页:profile + 首屏帖子 + 壳层并行取数 */ +async function loadUserData(id: string, pathname: string, api: ApiCtx): Promise { + const uid = /^\d+$/.test(id) ? Number(id) : NaN; + if (Number.isNaN(uid) || uid <= 0) { + throw render(404, { head: buildNotFoundHead(pathname) }); + } + + const [profileResp, boot] = await Promise.all([ + serverApi<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${uid}`, api), + serverApi('/api/home-boot', api), + ]); + + const brand = boot?.branding ?? null; + const base = brand?.site_url?.trim() ?? ''; + const profile = profileResp?.user; + + // 用户不存在:404 + noindex + if (!profile) { + throw render(404, { + head: buildNotFoundHead(pathname, brand, base), + boot: boot ?? undefined, + }); + } + + // 首屏帖子列表(第 1 页,page_size 从 limits 取) + const limits = boot?.limits ?? null; + const pageSize = (limits?.page_size_default ?? 0) > 0 ? limits!.page_size_default : 20; + const postsResp = await serverApi<{ posts: PostItem[]; total: number }>( + `/api/posts?user_id=${uid}&page=1&size=${pageSize}&sort=latest`, + api, + ); + const posts = Array.isArray(postsResp?.posts) ? postsResp.posts : []; + const postTotal = postsResp?.total ?? 0; + + return { + kind: 'user', + boot: boot ?? undefined, + userProfile: { + id: uid, + profile, + stats: profileResp?.stats ?? null, + posts, + postTotal, + pageSize, + }, + head: buildUserHead(profile, brand, limits, base), + }; +} diff --git a/frontend/src/ssr/feedPaths.ts b/frontend/src/ssr/feedPaths.ts index 3b18379..3c642e6 100644 --- a/frontend/src/ssr/feedPaths.ts +++ b/frontend/src/ssr/feedPaths.ts @@ -1,20 +1,25 @@ /** * SSR feed 页路由匹配(Vike +route.ts 用)。 * - * 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id` + * 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`、 + * 自定义单页 `/page/:slug`、用户主页 `/user/:id` * (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。 */ export type FeedSsrMatch = | { kind: 'home'; id: '' } | { kind: 'board'; id: string } - | { kind: 'post'; id: string }; + | { kind: 'post'; id: string } + | { kind: 'page'; id: string } + | { kind: 'user'; id: string }; const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [ { re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) }, { re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) }, // 123 与 123.html(伪静态后缀由后台配置,两种形态都接住) { re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) }, + { re: /^\/page\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'page', id: m[1] }) }, + { re: /^\/user\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'user', id: m[1] }) }, ]; export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null { diff --git a/frontend/src/ssr/feedSeed.ts b/frontend/src/ssr/feedSeed.ts index a9bf097..f1c5004 100644 --- a/frontend/src/ssr/feedSeed.ts +++ b/frontend/src/ssr/feedSeed.ts @@ -13,11 +13,22 @@ import { setSessionSnapshot } from '../utils/sessionPageCache'; import type { PageHead } from './head'; export type FeedData = { - kind: 'home' | 'board' | 'post' | 'notfound'; + kind: 'home' | 'board' | 'post' | 'page' | 'user' | 'notfound'; /** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */ boot?: HomeBootPayload; /** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */ snapshot?: PostDetailSnapshot; + /** 自定义单页内容(SitePageView useSessionResource key=sitepage:${slug} 首帧命中) */ + sitePage?: { slug: string; page: import('../api/types').SitePage }; + /** 用户主页资料 + 首屏帖子列表(UserProfilePage 首帧命中) */ + userProfile?: { + id: number; + profile: import('../api/types').UserPublic; + stats: import('../api/types').UserActivityStats | null; + posts: import('../api/types').PostItem[]; + postTotal: number; + pageSize: number; + }; /** 元信息(服务端专用,onRenderHtml 消费) */ head?: PageHead; }; @@ -27,4 +38,12 @@ export function seedFeedData(data: FeedData | null | undefined): void { if (!data) return; if (data.boot) seedHomeBoot(data.boot); if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot); + if (data.sitePage) { + setSessionSnapshot(`sitepage:${data.sitePage.slug}`, data.sitePage.page); + } + if (data.userProfile) { + const { id, profile, stats, posts, postTotal, pageSize } = data.userProfile; + setSessionSnapshot(`user:${id}`, { profile, stats }); + setSessionSnapshot(`user:${id}:posts:1:${pageSize}`, { posts, total: postTotal }); + } } diff --git a/frontend/src/ssr/head.ts b/frontend/src/ssr/head.ts index 504caa4..32a773e 100644 --- a/frontend/src/ssr/head.ts +++ b/frontend/src/ssr/head.ts @@ -4,7 +4,7 @@ * 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 , * 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。 */ -import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types'; +import type { ForumLimitsPublic, PostItem, SiteBranding, SitePage, User, UserPublic } from '../api/types'; import type { HomeBootPayload } from '../utils/homeBoot'; import { normalizePermalinkExt } from '../utils/permalink'; @@ -156,6 +156,10 @@ function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string { return `/user/${id}${suffixOf(limits)}`; } +function feedPagePath(slug: string, limits?: ForumLimitsPublic | null): string { + return `/page/${slug}${suffixOf(limits)}`; +} + function toISO(time?: string): string { if (!time) return ''; const d = new Date(time); @@ -268,6 +272,77 @@ export function buildPostHead( }; } +/** 自定义单页 head(关于我们/版规等,ogType=article) */ +export function buildPageHead( + page: SitePage, + brand: SiteBranding | null, + limits: ForumLimitsPublic | null, + base: string, +): PageHead { + const siteName = siteNameOf(brand); + const content = page.content ?? ''; + const plain = stripHTMLForSearch(content); + const desc = truncateRunes(plain || page.title, DESC_MAX); + const canonical = absoluteURL(base, feedPagePath(page.slug, limits)); + const defImage = defaultShareImageOf(brand); + const ogImage = absoluteURL(base, firstImageURL(content)) || defImage; + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: page.title, + description: desc, + url: canonical, + datePublished: toISO(page.created_at), + dateModified: toISO(page.updated_at || page.created_at), + }; + if (ogImage) jsonLd.image = [ogImage]; + + return { + title: pageTitle(page.title, siteName), + description: desc, + canonical: canonical || undefined, + ogType: 'article', + ogImage: ogImage || undefined, + siteName, + jsonLd: JSON.stringify(jsonLd), + }; +} + +/** 用户主页 head(ogType=profile,Person JSON-LD) */ +export function buildUserHead( + profile: UserPublic, + brand: SiteBranding | null, + limits: ForumLimitsPublic | null, + base: string, +): PageHead { + const siteName = siteNameOf(brand); + const name = displayNameOf(profile); + const sig = (profile.signature ?? '').trim(); + const desc = truncateRunes(sig || `${name} 的主页`, DESC_MAX); + const canonical = absoluteURL(base, feedUserPath(profile.id, limits)); + const ogImage = absoluteURL(base, profile.avatar ?? '') || defaultShareImageOf(brand); + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'Person', + name, + description: desc, + url: canonical, + }; + if (ogImage) jsonLd.image = ogImage; + + return { + title: pageTitle(`${name} 的主页`, siteName), + description: desc, + canonical: canonical || undefined, + ogType: 'profile', + ogImage: ogImage || undefined, + siteName, + jsonLd: JSON.stringify(jsonLd), + }; +} + /** 404 页 head(移植 Go notFoundPageMeta) */ export function buildNotFoundHead( pathname: string,