diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b064314..593fce4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,6 +34,7 @@ "autoprefixer": "^10.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "compression": "^1.8.1", "dayjs": "^1.11.13", "diff": "^9.0.0", "dompurify": "^3.4.10", @@ -4152,6 +4153,60 @@ "node": ">= 6" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -5731,6 +5786,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6480,6 +6544,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1446d94..896e6b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vike dev", "build": "vike build", - "preview": "vike preview" + "preview": "vike preview", + "start": "node server/index.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -35,6 +36,7 @@ "autoprefixer": "^10.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "compression": "^1.8.1", "dayjs": "^1.11.13", "diff": "^9.0.0", "dompurify": "^3.4.10", diff --git a/frontend/pages/+config.ts b/frontend/pages/+config.ts index 82cacc8..062c740 100644 --- a/frontend/pages/+config.ts +++ b/frontend/pages/+config.ts @@ -1,6 +1,12 @@ import type { Config } from 'vike/types'; export default { - // pageProps 透传给客户端(boot 数据经 +data 的 pageContext.data 自动序列化) - passToClient: ['pageProps'], + // pageProps / data(+data 返回的 boot / snapshot / head)透传给客户端供 hydrate seed + passToClient: ['pageProps', 'data'], + // 自定义集成(无 vike-react):声明 `ssr` 配置供页面 +ssr.ts 使用(SPA 壳页 ssr: false) + meta: { + ssr: { + env: { server: true }, + }, + }, } satisfies Config; diff --git a/frontend/pages/_error/+Page.tsx b/frontend/pages/_error/+Page.tsx new file mode 100644 index 0000000..b005bc8 --- /dev/null +++ b/frontend/pages/_error/+Page.tsx @@ -0,0 +1,10 @@ +import App from '../../src/App'; +import type { PageContext } from 'vike/types'; + +export { Page }; + +/** 错误页:复用 App 壳层与路由树 —— /post/:id 未找到渲染帖子自己的未找到态,未知路径渲染 catch-all NotFoundPage */ +function Page(pageContext: PageContext & { url?: string }) { + const url = pageContext.url ?? pageContext.urlOriginal ?? '/'; + return ; +} diff --git a/frontend/pages/_error/+data.ts b/frontend/pages/_error/+data.ts new file mode 100644 index 0000000..35c9a65 --- /dev/null +++ b/frontend/pages/_error/+data.ts @@ -0,0 +1,24 @@ +import type { PageContextServer } from 'vike/types'; +import type { FeedData } from '../../src/ssr/feedData'; + +export { data }; + +type NotFoundAbortReason = { + head?: FeedData['head']; + boot?: FeedData['boot']; +}; + +/** + * 错误页取数:feed +data 未找到(帖子/板块)时 throw render(404, { head, boot }), + * 壳层数据经 abortReason 带过来,保证 404 页也渲染完整站点壳与 noindex head。 + */ +async function data(pageContext: PageContextServer): Promise { + if (!import.meta.env.SSR) return { kind: 'notfound' }; + + const reason = pageContext.abortReason as NotFoundAbortReason | undefined; + return { + kind: 'notfound', + head: reason?.head, + boot: reason?.boot, + }; +} diff --git a/frontend/pages/feed/+Page.tsx b/frontend/pages/feed/+Page.tsx index d5c2722..fa3a3b4 100644 --- a/frontend/pages/feed/+Page.tsx +++ b/frontend/pages/feed/+Page.tsx @@ -4,7 +4,8 @@ import type { PageContext } from 'vike/types'; export { Page }; /** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */ -function Page(pageContext: PageContext) { - const { pathname, searchOriginal } = pageContext.urlParsed; - return ; +function Page(pageContext: PageContext & { url?: string }) { + // onRenderHtml 透传 url(pathname + search);urlOriginal 兜底(含 query) + const url = pageContext.url ?? pageContext.urlOriginal ?? '/'; + return ; } diff --git a/frontend/pages/feed/+data.ts b/frontend/pages/feed/+data.ts new file mode 100644 index 0000000..b54ac98 --- /dev/null +++ b/frontend/pages/feed/+data.ts @@ -0,0 +1,9 @@ +import type { PageContextServer } from 'vike/types'; +import { loadFeedData } from '../../src/ssr/feedData'; + +export { data }; + +// SSR 白名单页(/ 、/board/:id 、/post/:id)服务端取数 + head 构建 +async function data(pageContext: PageContextServer) { + return await loadFeedData(pageContext); +} diff --git a/frontend/renderer/+onRenderClient.tsx b/frontend/renderer/+onRenderClient.tsx index 5155163..3733ee2 100644 --- a/frontend/renderer/+onRenderClient.tsx +++ b/frontend/renderer/+onRenderClient.tsx @@ -5,7 +5,7 @@ import type { PageContextClient } from 'vike/types'; import { applyTheme, getStoredTheme } from '../src/utils/theme'; import { beginHomeHydrate } from '../src/utils/homeHydrate'; import { reloadForStaleChunk } from '../src/utils/chunkLoad'; -import { consumeHomeBoot } from '../src/utils/homeBoot'; +import { seedFeedData, type FeedData } from '../src/ssr/feedData'; import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute'; export { onRenderClient }; @@ -40,7 +40,10 @@ function waitForStylesheets(): Promise { async function onRenderClient(pageContext: PageContextClient) { applyTheme(getStoredTheme()); - consumeHomeBoot(); + // hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致) + if (pageContext.isHydration) { + seedFeedData((pageContext as { data?: FeedData }).data); + } // Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404) window.addEventListener('vite:preloadError', (event) => { diff --git a/frontend/renderer/+onRenderHtml.tsx b/frontend/renderer/+onRenderHtml.tsx index da932d4..83497dc 100644 --- a/frontend/renderer/+onRenderHtml.tsx +++ b/frontend/renderer/+onRenderHtml.tsx @@ -2,10 +2,15 @@ import type { ComponentType, ReactElement } from 'react'; import { renderToReadableStream } from 'react-dom/server'; import { escapeInject, dangerouslySkipEscape } from 'vike/server'; import type { PageContextServer } from 'vike/types'; -import App from '../src/App'; import { createHomeStore } from '../src/store/homeStore'; import { createRequestScope } from '../src/ssr/scopeTypes'; import { runWithScope } from '../src/ssr/scope'; +import { seedFeedData, type FeedData } from '../src/ssr/feedData'; +import { renderHeadTags } from '../src/ssr/head'; +import { installServerDom } from '../src/ssr/serverDom'; + +// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装 +installServerDom(); export { onRenderHtml }; @@ -32,6 +37,7 @@ async function renderPageToString(element: ReactElement): Promise { async function onRenderHtml(pageContext: PageContextServer) { const Page = pageContext.Page as PageComponent | undefined; + const data = (pageContext as { data?: FeedData }).data; let pageHtml = ''; if (Page) { @@ -41,20 +47,26 @@ async function onRenderHtml(pageContext: PageContextServer) { const { pathname, searchOriginal } = pageContext.urlParsed; const url = pathname + (searchOriginal ?? ''); try { - pageHtml = await runWithScope(scope, () => renderPageToString()); + pageHtml = await runWithScope(scope, () => { + // 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离) + seedFeedData(data); + return renderPageToString(); + }); } catch (err) { console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err); pageHtml = ''; } } + const headTags = renderHeadTags(data?.head); + + // 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理 return escapeInject` - - 姜十三论坛 - 拾三一隅,自在交流 + ${dangerouslySkipEscape(headTags)} diff --git a/frontend/src/api/serverClient.ts b/frontend/src/api/serverClient.ts index 45b07c8..88fc3ae 100644 --- a/frontend/src/api/serverClient.ts +++ b/frontend/src/api/serverClient.ts @@ -3,19 +3,26 @@ * * - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001) * - 转发浏览器 cookie 以透传登录态 + * - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等) * - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染 */ const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`; -export async function serverApi( - path: string, - opts?: { cookie?: string; timeoutMs?: number }, -): Promise { +export type ServerApiOptions = { + /** 浏览器请求的 cookie 原样转发 */ + cookie?: string; + /** 额外转发给 Go 的请求头(host / x-forwarded-* 等) */ + headers?: Record; + timeoutMs?: number; +}; + +export async function serverApi(path: string, opts?: ServerApiOptions): Promise { try { const res = await fetch(`${GO_API_URL}${path}`, { headers: { ...(opts?.cookie ? { cookie: opts.cookie } : {}), + ...(opts?.headers ?? {}), }, signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000), cache: 'no-store', diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index c984805..be26740 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -9,7 +9,7 @@ import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import Underline from '@tiptap/extension-underline'; import { TableKit } from '@tiptap/extension-table'; -import DOMPurify from 'dompurify'; +import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify'; import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote, List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole, @@ -121,7 +121,7 @@ function buildMarkdownTableSnippet(opts: TableInsertOptions): string { /** 净化编辑器 HTML,保留 members-only 自定义标签 */ function sanitizeHtml(html: string): string { - return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); + return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG); } /** 判断编辑器内容是否为空(纯贴纸/图片也算有内容) */ diff --git a/frontend/src/components/CommentEditor.tsx b/frontend/src/components/CommentEditor.tsx index ae40117..672af4b 100644 --- a/frontend/src/components/CommentEditor.tsx +++ b/frontend/src/components/CommentEditor.tsx @@ -6,7 +6,7 @@ import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import Underline from '@tiptap/extension-underline'; -import DOMPurify from 'dompurify'; +import { sanitizeHtml as sanitizeContentHtml } from '../utils/purify'; import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, List, ListOrdered, Code, Link as LinkIcon, Image as ImageIcon, @@ -36,7 +36,7 @@ interface Props { } function sanitizeHtml(html: string): string { - return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); + return sanitizeContentHtml(html, POST_CONTENT_PURIFY_CONFIG); } function isEditorEmpty(editor: Editor): boolean { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e186b77..7f78901 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -92,9 +92,9 @@ export default function MainLayout() { const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const [layoutRefreshTick, setLayoutRefreshTick] = useState(0); const asideEverLoaded = useRef(false); - /** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */ - const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured()); - const coldBootDone = useRef(isCompose || wasColdBootEnsured()); + /** 冷启动门闩:main.tsx 已预热则可同步放行;SSR/hydrate 首帧数据已由 +data seed,直接放行保持同构 */ + const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured() || isHomeHydrating()); + const coldBootDone = useRef(isCompose || wasColdBootEnsured() || isHomeHydrating()); const bootGen = useRef(0); const [boardId, setBoardId] = useState(() => { const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); diff --git a/frontend/src/ssr/feedData.ts b/frontend/src/ssr/feedData.ts new file mode 100644 index 0000000..f0752f5 --- /dev/null +++ b/frontend/src/ssr/feedData.ts @@ -0,0 +1,143 @@ +/** + * Vike +data 服务端取数(SSR 白名单页:/ 、/board/:id 、/post/:id)。 + * + * - feed(首页/板块):单次调用 Go /api/home-boot 聚合(对齐旧 homeBootPayload) + * - post:并行取 帖子详情 + 评论 + home-boot 壳层(侧栏/登录态 SSR 一致渲染) + * - 透传 cookie 登录态;转发 host / X-Forwarded-* 让 Go 推导站点 base URL + * - Go 不可达/取数失败:返回不含数据的 FeedData 降级为 SPA 壳,不阻断渲染 + * - head:移植 Go spa_meta.go/seo.go,由 onRenderHtml 注入 + */ +import { redirect, render } from 'vike/abort'; +import type { PageContextServer } from 'vike/types'; +import { serverApi } from '../api/serverClient'; +import type { Comment, PostDetailResponse } from '../api/types'; +import type { HomeBootPayload, PostDetailSnapshot } from '../utils/homeBoot'; +import { buildPostDetailSnapshot, postDetailCacheKey, seedHomeBoot } from '../utils/homeBoot'; +import { setSessionSnapshot } from '../utils/sessionPageCache'; +import { matchFeedSsrPath } from './feedPaths'; +import { buildFeedHead, buildNotFoundHead, buildPostHead, type PageHead } from './head'; + +export type FeedData = { + kind: 'home' | 'board' | 'post' | 'notfound'; + /** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */ + boot?: HomeBootPayload; + /** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */ + snapshot?: PostDetailSnapshot; + /** 元信息(服务端专用,onRenderHtml 消费) */ + head?: PageHead; +}; + +/** + * 将 +data 结果灌入当前缓存层。 + * 服务端在请求作用域内调用(onRenderHtml);客户端 hydrate 前调用(模块单例)—— + * 两侧均经 seedHomeBoot/setSessionSnapshot 的 scope 判断,逻辑同一份。 + */ +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); +} + +/** +data 入口(pages/feed/+data.ts 调用) */ +export async function loadFeedData(pageContext: PageContextServer): Promise { + // 客户端(Vike client router 触发的导航)不取数:SPA 内页面组件自行请求 + if (!import.meta.env.SSR) return { kind: 'home' }; + + const headers = pageContext.headers ?? {}; + const cookie = headers.cookie ?? ''; + const apiHeaders: Record = { + // Go requestOrigin() 读 X-Forwarded-Host/Proto 推导站点 base URL + 'x-forwarded-host': headers['x-forwarded-host'] || headers.host || '', + 'x-forwarded-proto': headers['x-forwarded-proto'] || 'http', + }; + + const match = matchFeedSsrPath(pageContext.urlPathname); + // +route 已保证只放行 feed 路由;兜底降级 SPA 壳 + if (!match) return { kind: 'home' }; + const { pathname } = pageContext.urlParsed; + const searchParams = new URLSearchParams(pageContext.urlParsed.searchOriginal ?? ''); + + if (match.kind === 'post') { + return loadPostData(match.id, pathname, { cookie, apiHeaders }); + } + return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders }); +} + +type ApiCtx = { cookie: string; apiHeaders: Record }; + +/** 帖子详情:详情 + 评论 + 壳层并行取数 */ +async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise { + const pid = /^\d+$/.test(id) ? Number(id) : NaN; + if (Number.isNaN(pid) || pid <= 0) { + throw render(404, { head: buildNotFoundHead(pathname) }); + } + + const [detail, commentsResp, boot] = await Promise.all([ + serverApi(`/api/posts/${pid}`, api), + serverApi<{ comments: Comment[]; total: number }>(`/api/posts/${pid}/comments`, api), + serverApi('/api/home-boot', api), + ]); + + const brand = boot?.branding ?? null; + const base = brand?.site_url?.trim() ?? ''; + + // 帖子不存在/无权限:壳层照常 seed(客户端渲染帖子自己的未找到态),HTTP 404 + noindex + if (!detail) { + throw render(404, { + head: buildNotFoundHead(pathname, brand, base), + boot: boot ?? undefined, + }); + } + + const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : []; + const snapshot = buildPostDetailSnapshot(detail, comments); + const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base); + return { kind: 'post', boot: boot ?? undefined, snapshot, head }; +} + +/** 首页/板块:单次 home-boot 聚合 */ +async function loadFeedListData( + kind: 'home' | 'board', + id: string, + pathname: string, + searchParams: URLSearchParams, + api: ApiCtx, +): Promise { + // 旧版 /?board=id → 301 规范板块路径(保留其余 query,与旧 Go ServePublicSPA 一致) + if (kind === 'home') { + const legacy = searchParams.get('board'); + if (legacy && /^\d+$/.test(legacy) && Number(legacy) > 0) { + searchParams.delete('board'); + const rest = searchParams.toString(); + throw redirect(`/board/${legacy}${rest ? `?${rest}` : ''}`, 301); + } + } + + const boardId = kind === 'board' && /^\d+$/.test(id) ? Number(id) : 0; + if (kind === 'board' && boardId <= 0) { + throw render(404, { head: buildNotFoundHead(pathname) }); + } + + const qs = new URLSearchParams(); + if (boardId > 0) qs.set('board_id', String(boardId)); + for (const key of ['sort', 'keyword', 'tag', 'author', 'title_only'] as const) { + const v = searchParams.get(key); + if (v) qs.set(key, v); + } + const query = qs.toString(); + const boot = await serverApi(`/api/home-boot${query ? `?${query}` : ''}`, api); + if (!boot) return { kind }; + + const brand = boot.branding ?? null; + const base = brand?.site_url?.trim() ?? ''; + + // 板块不存在:壳层数据随 abortReason 交给 _error 页(404 + noindex,对齐旧 Go serveNotFound) + if (kind === 'board' && !(boot.boards ?? []).some((b) => b.id === boardId)) { + throw render(404, { + head: buildNotFoundHead(pathname, brand, base), + boot, + }); + } + + return { kind, boot, head: buildFeedHead(boot, base) }; +} diff --git a/frontend/src/ssr/head.ts b/frontend/src/ssr/head.ts new file mode 100644 index 0000000..504caa4 --- /dev/null +++ b/frontend/src/ssr/head.ts @@ -0,0 +1,363 @@ +/** + * SEO head 构建:移植 Go 侧 embed_static/spa_meta.go + handler/seo.go 的 meta 生成逻辑。 + * + * 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 , + * 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。 + */ +import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types'; +import type { HomeBootPayload } from '../utils/homeBoot'; +import { normalizePermalinkExt } from '../utils/permalink'; + +export type PageHead = { + /** 完整 (已含站点名) */ + title: string; + description: string; + keywords?: string; + canonical?: string; + /** 默认 website */ + ogType?: string; + ogImage?: string; + /** og:site_name */ + siteName?: string; + /** 如 noindex,follow */ + robots?: string; + /** 站点 favicon(相对或绝对 URL) */ + favicon?: string; + /** 已序列化的 JSON-LD 对象(不含 script 标签) */ + jsonLd?: string; +}; + +export const SITE_NAME_FALLBACK = '姜十三论坛'; +/** 与 Go seoDescMax / seoPrerenderMax 对齐 */ +const DESC_MAX = 160; +const BODY_MAX = 4000; + +// —— 品牌字段派生(与 Go service/settings.go 方法对齐) —— + +export function siteNameOf(brand?: SiteBranding | null): string { + return brand?.name?.trim() || SITE_NAME_FALLBACK; +} + +/** DocumentTitle:站点名 - 标语 */ +export function documentTitleOf(brand?: SiteBranding | null): string { + const name = brand?.name?.trim() ?? ''; + const slogan = brand?.slogan?.trim() ?? ''; + if (!name) return SITE_NAME_FALLBACK; + return slogan ? `${name} - ${slogan}` : name; +} + +/** MetaDescription:优先简介,其次标语 */ +export function metaDescriptionOf(brand?: SiteBranding | null): string { + return brand?.description?.trim() || brand?.slogan?.trim() || ''; +} + +/** DefaultShareImage:专用 OG 图 → Logo → Favicon */ +export function defaultShareImageOf(brand?: SiteBranding | null): string { + return brand?.og_image?.trim() || brand?.logo?.trim() || brand?.favicon?.trim() || ''; +} + +/** pageTitle:页面名 - 站点名(与 Go handler/seo.go pageTitle 对齐) */ +export function pageTitle(page: string, siteName: string): string { + const p = page.trim(); + const s = siteName.trim(); + if (!p && !s) return SITE_NAME_FALLBACK; + if (!p) return s; + if (!s) return p; + return `${p} - ${s}`; +} + +// —— 文本工具(与 Go service/seo.go 对齐) —— + +/** AbsoluteURL:相对路径拼绝对 URL;base 为空或非 http(s) 时返回空串 */ +export function absoluteURL(base: string, pathOrURL: string): string { + const path = pathOrURL.trim(); + if (!path) return ''; + if (/^https?:\/\//i.test(path)) return path; + const b = base.trim().replace(/\/+$/, ''); + if (!b || !/^https?:\/\//i.test(b)) return ''; + return b + (path.startsWith('/') ? path : `/${path}`); +} + +/** TruncateRunes:按 Unicode code point 截断并加省略号 */ +export function truncateRunes(s: string, max: number): string { + const trimmed = s.trim(); + if (max <= 0 || !trimmed) return trimmed; + const runes = Array.from(trimmed); + if (runes.length <= max) return trimmed; + if (max < 2) return runes.slice(0, max).join(''); + return `${runes.slice(0, max - 1).join('')}…`; +} + +const styleOrScriptRe = /<(style|script)[^>]*>[\s\S]*?<\/\1>/gi; +const htmlTagRe = /<[^>]*>/g; + +/** StripHTMLForSearch:去标签取纯文本(与 Go service/content.go 对齐) */ +export function stripHTMLForSearch(html: string): string { + if (!html) return ''; + const text = html + .replace(styleOrScriptRe, ' ') + .replace(htmlTagRe, ' ') + .replace(/ /g, ' '); + return text.split(/\s+/).filter(Boolean).join(' '); +} + +const imgSrcRe = /<img[^>]+src=["']([^"']+)["']/i; + +/** FirstImageURL:正文中第一张图片的 src(忽略 data: 内联图) */ +export function firstImageURL(html: string): string { + const m = html.match(imgSrcRe); + const src = m?.[1]?.trim() ?? ''; + if (!src || src.startsWith('data:')) return ''; + return src; +} + +/** DisplayName:昵称优先,其次用户名 */ +export function displayNameOf(user?: User | null): string { + return user?.nickname?.trim() || user?.username?.trim() || ''; +} + +function joinKeywords(...parts: Array<string | undefined | null>): string { + const seen = new Set<string>(); + const out: string[] = []; + for (const part of parts) { + if (!part) continue; + for (const raw of part.replace(/[,、;;]/g, ',').split(',')) { + const p = raw.trim(); + if (!p || seen.has(p)) continue; + seen.add(p); + out.push(p); + } + } + return out.join(','); +} + +// —— 伪静态路径(显式传入 limits,避免读缓存) —— + +type PermOpts = { permalink_enabled?: boolean; permalink_ext?: string }; + +function permOpts(limits?: ForumLimitsPublic | null): PermOpts { + return { permalink_enabled: !!limits?.permalink_enabled, permalink_ext: limits?.permalink_ext ?? 'html' }; +} + +function suffixOf(limits?: ForumLimitsPublic | null): string { + if (!limits?.permalink_enabled) return ''; + return `.${normalizePermalinkExt(limits.permalink_ext)}`; +} + +function feedPostPath(id: number, limits?: ForumLimitsPublic | null): string { + return `/post/${id}${suffixOf(limits)}`; +} + +function feedBoardPath(id: number, limits?: ForumLimitsPublic | null): string { + return `/board/${id}${suffixOf(limits)}`; +} + +function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string { + return `/user/${id}${suffixOf(limits)}`; +} + +function toISO(time?: string): string { + if (!time) return ''; + const d = new Date(time); + return Number.isNaN(d.getTime()) ? time : d.toISOString(); +} + +// —— head 构建 —— + +/** 首页/板块页 head(移植 Go buildSPAPageMeta 首页分支 + ServePublicSPA 板块分支) */ +export function buildFeedHead(boot: HomeBootPayload, base: string): PageHead { + const brand = boot.branding ?? null; + const siteName = siteNameOf(brand); + const siteKeywords = brand?.keywords?.trim() ?? ''; + const ogImage = defaultShareImageOf(brand); + const limits = boot.limits ?? null; + + const board = boot.board_id > 0 + ? (boot.boards ?? []).find((b) => b.id === boot.board_id) ?? null + : null; + if (board) { + const desc = truncateRunes(board.description?.trim() || metaDescriptionOf(brand), DESC_MAX); + return { + title: pageTitle(board.name, siteName), + description: desc, + keywords: joinKeywords(board.name, siteKeywords), + canonical: absoluteURL(base, feedBoardPath(board.id, limits)) || undefined, + ogType: 'website', + ogImage: ogImage || undefined, + siteName, + favicon: brand?.favicon?.trim() || undefined, + }; + } + + // 站点首页(含搜索/标签/作者筛选:与旧 Go 一致 canonical 指向 /) + const desc = truncateRunes(metaDescriptionOf(brand), DESC_MAX); + const homeURL = absoluteURL(base, '/'); + const jsonLd = homeURL + ? JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'WebSite', + name: siteName, + description: desc, + url: homeURL, + }) + : undefined; + return { + title: documentTitleOf(brand), + description: desc, + keywords: siteKeywords || undefined, + canonical: homeURL || undefined, + ogType: 'website', + ogImage: ogImage || undefined, + siteName, + favicon: brand?.favicon?.trim() || undefined, + jsonLd, + }; +} + +/** 帖子详情页 head(移植 Go postPageMeta) */ +export function buildPostHead( + post: PostItem, + brand: SiteBranding | null, + limits: ForumLimitsPublic | null, + base: string, +): PageHead { + const siteName = siteNameOf(brand); + const content = post.content ?? ''; + const plain = stripHTMLForSearch(content); + const desc = truncateRunes(plain, DESC_MAX); + const canonical = absoluteURL(base, feedPostPath(post.id, limits)); + const defImage = defaultShareImageOf(brand); + + let ogImage = absoluteURL(base, firstImageURL(content)); + if (!ogImage) ogImage = absoluteURL(base, post.user?.avatar ?? ''); + if (!ogImage) ogImage = defImage; + + const jsonLd: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'DiscussionForumPosting', + headline: post.title, + description: desc, + datePublished: toISO(post.created_at), + dateModified: toISO(post.updated_at || post.created_at), + url: canonical, + mainEntityOfPage: canonical, + author: { + '@type': 'Person', + name: displayNameOf(post.user), + url: absoluteURL(base, feedUserPath(post.user_id, limits)), + }, + interactionStatistic: { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/ViewAction', + userInteractionCount: post.view_count ?? 0, + }, + }; + if (post.board?.name) jsonLd.articleSection = post.board.name; + if (ogImage) jsonLd.image = [ogImage]; + const body = truncateRunes(plain, BODY_MAX); + if (body) jsonLd.articleBody = body; + + return { + title: pageTitle(post.title, siteName), + description: desc, + canonical: canonical || undefined, + ogType: 'article', + ogImage: ogImage || undefined, + siteName, + jsonLd: JSON.stringify(jsonLd), + }; +} + +/** 404 页 head(移植 Go notFoundPageMeta) */ +export function buildNotFoundHead( + pathname: string, + brand?: SiteBranding | null, + base = '', +): PageHead { + return { + title: pageTitle('页面不存在', siteNameOf(brand)), + description: '您访问的页面不存在或已删除', + canonical: absoluteURL(base, pathname) || undefined, + ogType: 'website', + robots: 'noindex,follow', + siteName: siteNameOf(brand), + }; +} + +// —— HTML 渲染 —— + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function twitterCard(ogImage?: string): string { + return ogImage?.trim() ? 'summary_large_image' : 'summary'; +} + +function firstNonEmpty(...vals: Array<string | undefined>): string { + for (const v of vals) { + const s = v?.trim(); + if (s) return s; + } + return ''; +} + +/** + * 渲染 <head> 内的 SEO 节点(title / meta / OG / Twitter / JSON-LD / favicon)。 + * head 缺失时回退旧 SPA 入口的默认标题;返回值为自转义 HTML,调用方需经 + * dangerouslySkipEscape 注入。 + */ +export function renderHeadTags(head?: PageHead | null): string { + const h = head ?? null; + const title = firstNonEmpty(h?.title, `${SITE_NAME_FALLBACK} - 拾三一隅,自在交流`); + const description = h?.description?.trim() ?? ''; + const siteName = firstNonEmpty(h?.siteName, SITE_NAME_FALLBACK); + const ogType = firstNonEmpty(h?.ogType, 'website'); + const ogImage = h?.ogImage?.trim() ?? ''; + + const parts: string[] = []; + parts.push(`<title>${esc(title)}`); + if (description) parts.push(``); + const keywords = h?.keywords?.trim() ?? ''; + if (keywords) parts.push(``); + const canonical = h?.canonical?.trim() ?? ''; + if (canonical) parts.push(``); + const favicon = h?.favicon?.trim() ?? ''; + if (favicon) { + parts.push(``); + parts.push(``); + } + const robots = h?.robots?.trim() ?? ''; + if (robots) parts.push(``); + + const prop = (p: string, content: string) => { + if (content) parts.push(``); + }; + const metaName = (name: string, content: string) => { + if (content) parts.push(``); + }; + + prop('og:type', ogType); + prop('og:site_name', siteName); + prop('og:locale', 'zh_CN'); + prop('og:title', title); + prop('og:description', description); + prop('og:url', canonical); + prop('og:image', ogImage); + metaName('twitter:card', twitterCard(ogImage)); + metaName('twitter:title', title); + metaName('twitter:description', description); + metaName('twitter:image', ogImage); + + const jsonLd = h?.jsonLd?.trim() ?? ''; + if (jsonLd) { + // 仅转义 < 防止提前闭合 script(与 Go 侧处理一致) + parts.push(``); + } + return parts.join('\n'); +} diff --git a/frontend/src/ssr/scope.ts b/frontend/src/ssr/scope.ts index d4aba72..bfb7d6b 100644 --- a/frontend/src/ssr/scope.ts +++ b/frontend/src/ssr/scope.ts @@ -16,13 +16,13 @@ let als: AsyncLocalStorageLike | null = null; if (import.meta.env.SSR) { type NodeProcess = { - getBuiltinModule?: (id: string) => unknown; + getBuiltinModule?: (id: string) => Record | undefined; }; - const mod = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks') as - | (new () => AsyncLocalStorageLike) - | undefined; - if (mod) { - als = new mod(); + const hooks = (globalThis as { process?: NodeProcess }).process?.getBuiltinModule?.('node:async_hooks'); + // getBuiltinModule 返回模块命名空间对象,构造器在 AsyncLocalStorage 字段上 + const ALS = hooks?.AsyncLocalStorage as (new () => AsyncLocalStorageLike) | undefined; + if (ALS) { + als = new ALS(); } } diff --git a/frontend/src/ssr/serverDom.ts b/frontend/src/ssr/serverDom.ts new file mode 100644 index 0000000..9b7a603 --- /dev/null +++ b/frontend/src/ssr/serverDom.ts @@ -0,0 +1,33 @@ +/** + * 服务端 DOM polyfill(SSR 专用,只允许服务端入口导入,勿进客户端 bundle)。 + * + * 帖子/评论内容处理(postContent/content/seoText/postHeadings)依赖 + * DOMParser 与全局 document;服务端用 linkedom 提供同形实现: + * - DOMParser:new DOMParser().parseFromString(html, 'text/html') + * - document:空文档,供 document.createElement/createTreeWalker 等使用 + * - Element / HTMLElement:instanceof 判定(linkedom 的 HTML 元素全部继承 HTMLElement) + * - NodeFilter:createTreeWalker 的 SHOW_* 常量 + * + * DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回—— + * SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。 + */ +import { DOMParser as LinkedomDOMParser, HTMLElement as LinkedomHTMLElement, NodeFilter as LinkedomNodeFilter } from 'linkedom'; + +let installed = false; + +export function installServerDom(): void { + if (installed) return; + installed = true; + + const g = globalThis as unknown as Record; + const parser = new LinkedomDOMParser(); + const doc = parser.parseFromString('...', 'text/html'); + + g.DOMParser ??= LinkedomDOMParser; + g.NodeFilter ??= LinkedomNodeFilter; + g.HTMLElement ??= LinkedomHTMLElement; + // linkedom HTML 元素(含 unknown/custom 元素)统一继承 HTMLElement, + // 可同时充当 instanceof Element / instanceof HTMLElement 的判定目标 + g.Element ??= LinkedomHTMLElement; + g.document ??= doc; +} diff --git a/frontend/src/utils/authBoot.ts b/frontend/src/utils/authBoot.ts index 7155075..e184de8 100644 --- a/frontend/src/utils/authBoot.ts +++ b/frontend/src/utils/authBoot.ts @@ -1,7 +1,7 @@ import type { User } from '../api/types'; import { getScope } from '../ssr/scope'; -/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) +/** SSR boot 注入的鉴权种子(seedHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) * SSR 时写入请求作用域,客户端为模块级单例 */ let seededUser: User | null | undefined; let seededUnread = 0; diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts index 1a6e659..6b6320d 100644 --- a/frontend/src/utils/content.ts +++ b/frontend/src/utils/content.ts @@ -1,4 +1,4 @@ -import DOMPurify from 'dompurify'; +import { sanitizeHtml } from './purify'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { enhanceCodeBlocks } from './enhanceCodeBlocks'; @@ -64,7 +64,7 @@ function processMentionsInHtml(html: string): string { /** 渲染评论内容:HTML 净化 + @提及高亮 + 代码块阅读态,兼容旧版纯文本 */ export function renderCommentContent(content: string): string { if (isHtmlContent(content)) { - const sanitized = DOMPurify.sanitize(content, POST_CONTENT_PURIFY_CONFIG) as string; + const sanitized = sanitizeHtml(content, POST_CONTENT_PURIFY_CONFIG); const withMentions = processMentionsInHtml(sanitized); const doc = new DOMParser().parseFromString(`
${withMentions}
`, 'text/html'); const root = doc.getElementById('j13-comment-root') ?? doc.body; diff --git a/frontend/src/utils/homeBoot.ts b/frontend/src/utils/homeBoot.ts index 19fce32..e47f221 100644 --- a/frontend/src/utils/homeBoot.ts +++ b/frontend/src/utils/homeBoot.ts @@ -33,7 +33,7 @@ import { checkInCacheKey } from '../hooks/useCheckIn'; import { isTimeDiffSignificant } from './content'; import type { FeedSort } from '../components/FeedSortBar'; -/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */ +/** 与 Go homeBootPayload 对齐(boot 数据现经 Vike +data → pageContext 传递) */ export type HomeBootPayload = { board_id: number; sort: string; @@ -58,25 +58,6 @@ export type HomeBootPayload = { check_in?: CheckInStatus | null; }; -declare global { - interface Window { - __J13_HOME_BOOT__?: HomeBootPayload; - } -} - -/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */ -export function consumeHomeBoot(): HomeBootPayload | null { - if (typeof window === 'undefined') return null; - const boot = window.__J13_HOME_BOOT__; - try { - delete window.__J13_HOME_BOOT__; - } catch { - window.__J13_HOME_BOOT__ = undefined; - } - if (!boot || typeof boot !== 'object') return null; - return seedHomeBoot(boot); -} - /** 将 boot 数据灌入当前缓存层(SSR 时经请求作用域隔离;客户端为模块单例) */ export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload { if (boot.limits) seedForumLimitsCache(boot.limits); diff --git a/frontend/src/utils/markdownContent.ts b/frontend/src/utils/markdownContent.ts index 7934aef..3b72591 100644 --- a/frontend/src/utils/markdownContent.ts +++ b/frontend/src/utils/markdownContent.ts @@ -1,6 +1,6 @@ import TurndownService from 'turndown'; import { marked } from 'marked'; -import DOMPurify from 'dompurify'; +import { sanitizeHtml } from './purify'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions'; import { mapOutsideFences, wrapFencedCode } from './markdownFences'; @@ -280,7 +280,7 @@ marked.use({ /** 净化并保留 members-only 标签 */ function sanitizeContentHtml(html: string): string { - return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG); + return sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG); } /** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */ diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts index da9b718..2a1639e 100644 --- a/frontend/src/utils/postContent.ts +++ b/frontend/src/utils/postContent.ts @@ -1,4 +1,4 @@ -import DOMPurify from 'dompurify'; +import { sanitizeHtml } from './purify'; import type { Config } from 'dompurify'; import { enhanceCodeBlocks } from './enhanceCodeBlocks'; import { enhanceHeadingAnchors } from './postHeadings'; @@ -111,7 +111,7 @@ function extractGatedInnerHtml(el: Element, bodyClass: string, badgeClass: strin export function isHtmlEmpty(html: string): boolean { if (!html.trim()) return true; const doc = new DOMParser().parseFromString( - DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string, + sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG), 'text/html', ); // 有文本内容 @@ -130,7 +130,7 @@ export function renderPostContentHtml( if (!html.trim()) return ''; const doc = new DOMParser().parseFromString( - DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string, + sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG), 'text/html', ); diff --git a/frontend/src/utils/purify.ts b/frontend/src/utils/purify.ts new file mode 100644 index 0000000..20e5601 --- /dev/null +++ b/frontend/src/utils/purify.ts @@ -0,0 +1,15 @@ +/** + * 双端 sanitize 封装。 + * + * - 浏览器:DOMPurify 默认实例(绑定真实 window) + * - SSR:直接透传——内容均来自 Go API(服务端已消毒,与客户端 DOMPurify 输出一致); + * 且 DOMPurify 的 getGlobal 只认 `window`,Node 环境拿到的默认导出没有 sanitize, + * 注入 globalThis.window 又会翻转全项目 `typeof window` 守卫,故 SSR 走透传。 + */ +import DOMPurify from 'dompurify'; +import type { Config } from 'dompurify'; + +export function sanitizeHtml(html: string, config?: Config): string { + if (import.meta.env.SSR) return html; + return DOMPurify.sanitize(html, config) as string; +} diff --git a/frontend/src/utils/revisionDiff.ts b/frontend/src/utils/revisionDiff.ts index 7462d4c..c914f21 100644 --- a/frontend/src/utils/revisionDiff.ts +++ b/frontend/src/utils/revisionDiff.ts @@ -1,5 +1,5 @@ import { diffLines, diffWords } from 'diff'; -import DOMPurify from 'dompurify'; +import { sanitizeHtml } from './purify'; import { POST_CONTENT_PURIFY_CONFIG } from './postContent'; export interface PostSnapshot { @@ -25,7 +25,7 @@ export interface DiffPart { export function htmlToDiffText(html: string): string { if (!html.trim()) return ''; const doc = new DOMParser().parseFromString( - DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG), + sanitizeHtml(html, POST_CONTENT_PURIFY_CONFIG), 'text/html', ); doc.querySelectorAll('br').forEach(br => br.replaceWith('\n')); diff --git a/handler/home_boot.go b/handler/home_boot.go new file mode 100644 index 0000000..a549a6a --- /dev/null +++ b/handler/home_boot.go @@ -0,0 +1,288 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + "sync" + + "git.iioio.com/freefire/jiang13-forum/model" + "git.iioio.com/freefire/jiang13-forum/service" + "github.com/gin-gonic/gin" +) + +// homeBootPayload 与前端 HomeBootPayload 对齐(Vike +data 冷启动灌缓存)。 +type homeBootPayload struct { + BoardID uint `json:"board_id"` + Sort string `json:"sort"` + Keyword string `json:"keyword"` + Tag string `json:"tag"` + Author string `json:"author"` + TitleOnly bool `json:"title_only"` + Posts []service.PostListItem `json:"posts"` + PostTotal int64 `json:"post_total"` + Page int `json:"page"` + Boards []service.BoardWithStats `json:"boards"` + Stats homeBootStats `json:"stats"` + RecentComments []service.RecentCommentItem `json:"recent_comments"` + RecentUsers []service.RecentUserItem `json:"recent_users"` + Tags []service.TagCount `json:"tags"` + // omitempty:侧栏关闭或未拉取时不写字段,避免 SPA 把 [] 当成有效会话快照 + Showcase []service.CommunityShowcaseItem `json:"showcase,omitempty"` + Pages []service.SitePageSummary `json:"pages"` + Limits service.ForumLimitsPublic `json:"limits"` + Branding service.SiteBranding `json:"branding"` + User *model.UserSelf `json:"user"` + UnreadMessages int `json:"unread_messages"` + CheckIn *service.CheckInStatus `json:"check_in"` +} + +type homeBootStats struct { + Users int64 `json:"users"` + Posts int64 `json:"posts"` + Boards int64 `json:"boards"` + Comments int64 `json:"comments"` +} + +// APIHomeBoot 首页/板块/壳层聚合数据(Vike SSR +data 使用,与前端 HomeBootPayload 对齐)。 +// 并发聚合:帖子列表、板块、统计、侧栏 widget、登录态、签到等。 +func (h *Handlers) APIHomeBoot(c *gin.Context) { + boardID := uint(parseUintOrZero(c.Query("board_id"))) + boot, err := h.gatherHomeSSR(c, boardID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取数据失败"}) + return + } + c.JSON(http.StatusOK, boot) +} + +func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeBootPayload, error) { + limits := h.Settings.PublicLimits() + brand := h.Settings.SiteBranding() + brand.SiteURL = h.publicBaseURL(c) + defSort := h.Settings.DefaultFeedSort() + + sort := strings.TrimSpace(c.Query("sort")) + if sort == "" { + sort = defSort + } + keyword := strings.TrimSpace(c.Query("keyword")) + tag := strings.TrimSpace(c.Query("tag")) + author := strings.TrimSpace(c.Query("author")) + titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true") + if tag != "" { + keyword = "" + author = "" + titleOnly = false + } + + pageSize := h.Settings.PageSizeDefault() + + out := &homeBootPayload{ + BoardID: boardID, + Sort: sort, + Keyword: keyword, + Tag: tag, + Author: author, + TitleOnly: titleOnly, + Page: 1, + Posts: []service.PostListItem{}, + Boards: []service.BoardWithStats{}, + Pages: []service.SitePageSummary{}, + Limits: limits, + Branding: brand, + } + + var ( + postsErr, boardsErr error + wg sync.WaitGroup + ) + + wg.Add(1) + go func() { + defer wg.Done() + q := service.PostListQuery{ + BoardID: boardID, + Page: 1, + Size: pageSize, + Sort: sort, + Keyword: keyword, + Tag: tag, + Author: author, + TitleOnly: titleOnly, + ViewerID: h.currentUserID(c), + ViewerIsAdmin: h.isAdmin(c), + } + items, total, err := h.Post.ListItems(q) + if err != nil { + postsErr = err + return + } + if items == nil { + items = []service.PostListItem{} + } + if h.Badge != nil { + users := make([]*model.User, 0, len(items)) + for i := range items { + if items[i].User.ID > 0 { + users = append(users, &items[i].User) + } + } + h.Badge.AttachBadgeSummaries(users, 2) + } + out.Posts = items + out.PostTotal = total + }() + + wg.Add(1) + go func() { + defer wg.Done() + boards, err := h.Board.ListWithStats() + if err != nil { + boardsErr = err + return + } + if boards == nil { + boards = []service.BoardWithStats{} + } + out.Boards = boards + }() + + wg.Add(1) + go func() { + defer wg.Done() + var users, posts, boardsN, comments int64 + model.DB.Model(&model.User{}).Count(&users) + model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts) + model.DB.Model(&model.Board{}).Count(&boardsN) + model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&comments) + out.Stats = homeBootStats{Users: users, Posts: posts, Boards: boardsN, Comments: comments} + }() + + wg.Add(1) + go func() { + defer wg.Done() + pages, err := h.SitePage.ListPublished() + if err != nil || pages == nil { + pages = []service.SitePageSummary{} + } + out.Pages = pages + }() + + widgetEnabled := map[string]bool{} + for _, w := range limits.AsideWidgets { + widgetEnabled[w.ID] = w.Enabled + } + + if widgetEnabled[service.AsideWidgetRecentComments] { + wg.Add(1) + go func() { + defer wg.Done() + items, err := h.Comment.ListRecentPublic(8) + if err != nil || items == nil { + items = []service.RecentCommentItem{} + } + out.RecentComments = items + }() + } else { + out.RecentComments = []service.RecentCommentItem{} + } + + if widgetEnabled[service.AsideWidgetRecentUsers] { + wg.Add(1) + go func() { + defer wg.Done() + items, err := h.User.ListRecentRegistered(8) + if err != nil || items == nil { + items = []service.RecentUserItem{} + } + out.RecentUsers = items + }() + } else { + out.RecentUsers = []service.RecentUserItem{} + } + + if widgetEnabled[service.AsideWidgetTagCloud] { + wg.Add(1) + go func() { + defer wg.Done() + tags, err := h.Post.PopularTags(40) + if err != nil || tags == nil { + tags = []service.TagCount{} + } + out.Tags = tags + }() + } else { + out.Tags = []service.TagCount{} + } + + if widgetEnabled[service.AsideWidgetShowcase] && h.Community != nil { + wg.Add(1) + go func() { + defer wg.Done() + items, err := h.Community.ListShowcase(c.Request.Host) + if err != nil { + // 失败不写 showcase,让前端自行请求,避免假空粘死 + return + } + if items == nil { + items = []service.CommunityShowcaseItem{} + } + out.Showcase = items + }() + } + + uid := h.currentUserID(c) + if uid > 0 { + wg.Add(1) + go func() { + defer wg.Done() + user, err := h.User.GetByID(uid) + if err != nil || user == nil { + return + } + view := user.ToSelf() + if h.Badge != nil { + _ = h.Badge.EvaluateAuto(user.ID) + if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { + view.Badges = service.BadgeViews(badges, 0) + } + } + out.User = &view + }() + if h.Message != nil { + wg.Add(1) + go func() { + defer wg.Done() + total, _, _, err := h.Message.UnreadCounts(uid) + if err == nil { + out.UnreadMessages = int(total) + } + }() + } + if h.Points != nil { + wg.Add(1) + go func() { + defer wg.Done() + st, err := h.Points.GetCheckInStatus(uid) + if err == nil { + out.CheckIn = &st + } + }() + } + } + + wg.Wait() + if postsErr != nil { + return nil, postsErr + } + if boardsErr != nil { + return nil, boardsErr + } + return out, nil +} + +func parseUintOrZero(s string) uint64 { + n, _ := strconv.ParseUint(s, 10, 64) + return n +}