diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index 03fe577..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
- 姜十三论坛 - 拾三一隅,自在交流
-
-
-
-
-
-
-
-
-
diff --git a/frontend/package.json b/frontend/package.json
index 1cd56f3..1446d94 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,9 +4,9 @@
"version": "1.0.0",
"type": "module",
"scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
+ "dev": "vike dev",
+ "build": "vike build",
+ "preview": "vike preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/frontend/pages/+config.ts b/frontend/pages/+config.ts
new file mode 100644
index 0000000..82cacc8
--- /dev/null
+++ b/frontend/pages/+config.ts
@@ -0,0 +1,6 @@
+import type { Config } from 'vike/types';
+
+export default {
+ // pageProps 透传给客户端(boot 数据经 +data 的 pageContext.data 自动序列化)
+ passToClient: ['pageProps'],
+} satisfies Config;
diff --git a/frontend/pages/feed/+Page.tsx b/frontend/pages/feed/+Page.tsx
new file mode 100644
index 0000000..d5c2722
--- /dev/null
+++ b/frontend/pages/feed/+Page.tsx
@@ -0,0 +1,10 @@
+import App from '../../src/App';
+import type { PageContext } from 'vike/types';
+
+export { Page };
+
+/** SSR feed 页:服务端渲染整棵 react-router 树,客户端 hydrate 后接管导航 */
+function Page(pageContext: PageContext) {
+ const { pathname, searchOriginal } = pageContext.urlParsed;
+ return ;
+}
diff --git a/frontend/pages/feed/+route.ts b/frontend/pages/feed/+route.ts
new file mode 100644
index 0000000..a3eb715
--- /dev/null
+++ b/frontend/pages/feed/+route.ts
@@ -0,0 +1,11 @@
+import type { PageContext } from 'vike/types';
+import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
+
+export { route };
+
+/** SSR 白名单:`/`、`/board/:id`、`/post/:id`(含 `.html` 伪静态) */
+function route(pageContext: PageContext) {
+ const match = matchFeedSsrPath(pageContext.urlPathname);
+ if (!match) return false;
+ return { routeParams: { kind: match.kind, id: match.id } };
+}
diff --git a/frontend/pages/spa/+Page.tsx b/frontend/pages/spa/+Page.tsx
new file mode 100644
index 0000000..ab5a33a
--- /dev/null
+++ b/frontend/pages/spa/+Page.tsx
@@ -0,0 +1,8 @@
+import App from '../../src/App';
+
+export { Page };
+
+/** SPA 壳页:客户端整树挂载 react-router(服务端不执行本组件) */
+function Page() {
+ return ;
+}
diff --git a/frontend/pages/spa/+route.ts b/frontend/pages/spa/+route.ts
new file mode 100644
index 0000000..050cc51
--- /dev/null
+++ b/frontend/pages/spa/+route.ts
@@ -0,0 +1,10 @@
+import type { PageContext } from 'vike/types';
+import { matchFeedSsrPath } from '../../src/ssr/feedPaths';
+
+export { route };
+
+/** catch-all:SSR feed 白名单之外的一切 URL */
+function route(pageContext: PageContext) {
+ if (matchFeedSsrPath(pageContext.urlPathname)) return false;
+ return { routeParams: {} };
+}
diff --git a/frontend/pages/spa/+ssr.ts b/frontend/pages/spa/+ssr.ts
new file mode 100644
index 0000000..10fb94a
--- /dev/null
+++ b/frontend/pages/spa/+ssr.ts
@@ -0,0 +1,4 @@
+export { ssr };
+
+/** SPA 壳:服务端只出空壳 HTML,客户端整树挂载 react-router */
+const ssr = false;
diff --git a/frontend/renderer/+onRenderClient.tsx b/frontend/renderer/+onRenderClient.tsx
new file mode 100644
index 0000000..5155163
--- /dev/null
+++ b/frontend/renderer/+onRenderClient.tsx
@@ -0,0 +1,90 @@
+import { StrictMode } from 'react';
+import type { ComponentType } from 'react';
+import { hydrateRoot, createRoot } from 'react-dom/client';
+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 { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
+
+export { onRenderClient };
+
+type PageComponent = ComponentType<{ url?: string }>;
+
+let root: ReturnType | null = null;
+
+function waitForStylesheets(): Promise {
+ const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
+ if (links.length === 0) return Promise.resolve();
+ return Promise.all(
+ links.map(
+ (node) =>
+ new Promise((resolve) => {
+ const link = node as HTMLLinkElement;
+ try {
+ if (link.sheet) {
+ resolve();
+ return;
+ }
+ } catch {
+ /* cross-origin */
+ }
+ const finish = () => resolve();
+ link.addEventListener('load', finish, { once: true });
+ link.addEventListener('error', finish, { once: true });
+ }),
+ ),
+ ).then(() => undefined);
+}
+
+async function onRenderClient(pageContext: PageContextClient) {
+ applyTheme(getStoredTheme());
+ consumeHomeBoot();
+
+ // Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
+ window.addEventListener('vite:preloadError', (event) => {
+ event.preventDefault();
+ reloadForStaleChunk();
+ });
+
+ const el = document.getElementById('root');
+ if (!el) return;
+
+ await waitForStylesheets();
+ try {
+ if (document.fonts?.ready) await document.fonts.ready;
+ } catch {
+ /* ignore */
+ }
+
+ const path = `${window.location.pathname}${window.location.search}`;
+ if (isMainLayoutPath(window.location.pathname)) {
+ await ensureColdBootReady(path);
+ }
+
+ // 有 SSR 正文 → hydrate 接管同一 DOM(首帧 hydrate 同构变体);SPA 壳 → 整树挂载
+ const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
+ if (hydrating) beginHomeHydrate();
+
+ const Page = pageContext.Page as PageComponent;
+ const app = (
+
+
+
+ );
+
+ if (hydrating) {
+ try {
+ hydrateRoot(el, app);
+ } catch (err) {
+ console.warn('[j13] hydrateRoot 失败,回退 createRoot', err);
+ el.innerHTML = '';
+ if (!root) root = createRoot(el);
+ root.render(app);
+ }
+ } else {
+ if (!root) root = createRoot(el);
+ root.render(app);
+ }
+}
diff --git a/frontend/renderer/+onRenderHtml.tsx b/frontend/renderer/+onRenderHtml.tsx
new file mode 100644
index 0000000..da932d4
--- /dev/null
+++ b/frontend/renderer/+onRenderHtml.tsx
@@ -0,0 +1,65 @@
+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';
+
+export { onRenderHtml };
+
+type PageComponent = ComponentType<{ url?: string }>;
+
+/** 首帧主题脚本(localStorage j13-theme),与旧 index.html 保持一致 */
+const THEME_INIT_SCRIPT
+ = "(function(){var t=localStorage.getItem('j13-theme')||'light';document.documentElement.classList.toggle('dark',t==='dark');document.documentElement.style.colorScheme=t;})();";
+
+/** 锁死手机/平板双指与手势缩放;头像裁剪 / 图片灯箱放行 pinch */
+const ZOOM_BLOCK_SCRIPT
+ = "(function(){function inZoomAllowArea(target){return !!(target&&target.closest&&target.closest('.avatar-crop-stage, .image-lightbox'));}function blockGesture(e){if(inZoomAllowArea(e.target))return;if(e.cancelable)e.preventDefault();}function blockMultiTouch(e){if(e.touches&&e.touches.length>1){if(inZoomAllowArea(e.target))return;if(e.cancelable)e.preventDefault();}}document.addEventListener('gesturestart',blockGesture,{passive:false});document.addEventListener('gesturechange',blockGesture,{passive:false});document.addEventListener('gestureend',blockGesture,{passive:false});document.addEventListener('touchstart',blockMultiTouch,{passive:false});document.addEventListener('touchmove',blockMultiTouch,{passive:false});})();";
+
+/** 等 lazy 页面全部渲染完成(allReady)后缓冲为完整 HTML,首字节语义与旧 Go 文档级 SSR 一致 */
+async function renderPageToString(element: ReactElement): Promise {
+ const stream = await renderToReadableStream(element, {
+ onError(err) {
+ console.error('[j13] SSR 渲染错误:', err);
+ },
+ });
+ await stream.allReady;
+ return new Response(stream).text();
+}
+
+async function onRenderHtml(pageContext: PageContextServer) {
+ const Page = pageContext.Page as PageComponent | undefined;
+
+ let pageHtml = '';
+ if (Page) {
+ // SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500
+ const scope = createRequestScope(createHomeStore());
+ scope.hydrating = true;
+ const { pathname, searchOriginal } = pageContext.urlParsed;
+ const url = pathname + (searchOriginal ?? '');
+ try {
+ pageHtml = await runWithScope(scope, () => renderPageToString());
+ } catch (err) {
+ console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
+ pageHtml = '';
+ }
+ }
+
+ return escapeInject`
+
+
+
+
+
+ 姜十三论坛 - 拾三一隅,自在交流
+
+
+
+
+ ${dangerouslySkipEscape(pageHtml)}
+
+`;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7d94557..9ad6885 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,114 +1,29 @@
-import { Suspense } from 'react';
-import {
- createBrowserRouter,
- createRoutesFromElements,
- RouterProvider,
- Route,
- Navigate,
-} from 'react-router';
-import './styles/global.css';
-import { AuthProvider } from './hooks/useAuth';
-import { ThemeProvider } from './hooks/useTheme';
-import MainLayout from './layouts/MainLayout';
-import AdminLayout from './layouts/AdminLayout';
-import ErrorBoundary from './components/ErrorBoundary';
-import AppRouteError from './components/AppRouteError';
-import PageLoader from './components/PageLoader';
-import AuthPageFallback from './components/AuthPageFallback';
-import { Toaster } from './components/ui/sonner';
-import PullToRefresh from './components/PullToRefresh';
-import TopProgressBar from './components/TopProgressBar';
-import { lazyWithRetry } from './utils/lazyWithRetry';
+import { RouterProvider, createBrowserRouter, createMemoryRouter } from 'react-router';
+import AppShell from './AppShell';
+import { appRoutes } from './AppRoutes';
-const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
-const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
-const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
-const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
-const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
-const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
-const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
-const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
-const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
-const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
-const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
-const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
-const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
-const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
-const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
-const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
-const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
-const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
-const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
-const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
-const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
-const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
-const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
-const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
-const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
-const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
-const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
-const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
-const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
+type AppRouter = ReturnType;
-const router = createBrowserRouter(
- createRoutesFromElements(
- }>
- }>} />
- }>} />
- }>} />
- } />
- }>
- } />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
- }>} />
-
- }>
- } />
- } />
- {/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- }>} />
- ,
- ),
-);
+/** 浏览器路由单例:整页加载创建一次,此后站内导航全部由 react-router 接管 */
+let browserRouter: AppRouter | null = null;
-export default function App() {
+function getAppRouter(url?: string): AppRouter {
+ if (import.meta.env.SSR) {
+ // 服务端:每请求新建 memory router(与浏览器同一棵路由树,保证 DOM 同构)
+ return createMemoryRouter(appRoutes, { initialEntries: [url ?? '/'] });
+ }
+ browserRouter ??= createBrowserRouter(appRoutes);
+ return browserRouter;
+}
+
+/**
+ * 应用根组件:Vike +Page 与客户端入口共用。
+ * SSR 时传入 `url`(pathname + search),客户端忽略。
+ */
+export default function App({ url }: { url?: string }) {
return (
-
-
-
-
-
-
-
-
-
-
+
+
+
);
}
diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx
new file mode 100644
index 0000000..f0060a7
--- /dev/null
+++ b/frontend/src/AppRoutes.tsx
@@ -0,0 +1,85 @@
+import { Suspense } from 'react';
+import { createRoutesFromElements, Route, Navigate } from 'react-router';
+import MainLayout from './layouts/MainLayout';
+import AdminLayout from './layouts/AdminLayout';
+import AppRouteError from './components/AppRouteError';
+import PageLoader from './components/PageLoader';
+import AuthPageFallback from './components/AuthPageFallback';
+import { lazyWithRetry } from './utils/lazyWithRetry';
+
+const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
+const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
+const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
+const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
+const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
+const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
+const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
+const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
+const UserProfilePage = lazyWithRetry(() => import('./pages/UserProfilePage'));
+const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
+const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
+const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
+const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
+const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
+const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
+const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
+const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
+const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsPage'));
+const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
+const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
+const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
+const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
+const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
+const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
+const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
+const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
+const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
+const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
+const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
+
+/** 全量 react-router 路由树(Vike 页面壳与浏览器共用同一棵树) */
+export const appRoutes = createRoutesFromElements(
+ }>
+ }>} />
+ }>} />
+ }>} />
+ } />
+ }>
+ } />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+ }>} />
+
+ }>
+ } />
+ } />
+ {/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ }>} />
+ ,
+);
diff --git a/frontend/src/AppShell.tsx b/frontend/src/AppShell.tsx
new file mode 100644
index 0000000..fd5bbe6
--- /dev/null
+++ b/frontend/src/AppShell.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from 'react';
+import './styles/global.css';
+import { AuthProvider } from './hooks/useAuth';
+import { ThemeProvider } from './hooks/useTheme';
+import ErrorBoundary from './components/ErrorBoundary';
+import { Toaster } from './components/ui/sonner';
+import PullToRefresh from './components/PullToRefresh';
+import TopProgressBar from './components/TopProgressBar';
+
+/** 与路由无关的应用壳:Provider + 全局挂件(SSR 服务端 / 客户端共用) */
+export default function AppShell({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/api/serverClient.ts b/frontend/src/api/serverClient.ts
new file mode 100644
index 0000000..45b07c8
--- /dev/null
+++ b/frontend/src/api/serverClient.ts
@@ -0,0 +1,28 @@
+/**
+ * 服务端 API 客户端:Vike +data 在 Node 侧调用 Go API 用(仅 SSR 打包引用)。
+ *
+ * - GO_API_URL 指向 Go 服务(dev 默认与 Vite proxy 一致;生产由环境变量给 :3001)
+ * - 转发浏览器 cookie 以透传登录态
+ * - 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 {
+ try {
+ const res = await fetch(`${GO_API_URL}${path}`, {
+ headers: {
+ ...(opts?.cookie ? { cookie: opts.cookie } : {}),
+ },
+ signal: AbortSignal.timeout(opts?.timeoutMs ?? 5000),
+ cache: 'no-store',
+ });
+ if (!res.ok) return null;
+ return (await res.json()) as T;
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
deleted file mode 100644
index 796c71e..0000000
--- a/frontend/src/main.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React from 'react';
-import { hydrateRoot, createRoot } from 'react-dom/client';
-import { applyTheme, getStoredTheme } from './utils/theme';
-import App from './App';
-import { consumeHomeBoot } from './utils/homeBoot';
-import { beginHomeHydrate } from './utils/homeHydrate';
-import { reloadForStaleChunk } from './utils/chunkLoad';
-import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
-
-applyTheme(getStoredTheme());
-consumeHomeBoot();
-
-// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
-window.addEventListener('vite:preloadError', (event) => {
- event.preventDefault();
- reloadForStaleChunk();
-});
-
-function hasSSRHome(): boolean {
- return !!document.querySelector('#root .ssr-home');
-}
-
-function waitForStylesheets(): Promise {
- const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
- if (links.length === 0) return Promise.resolve();
- return Promise.all(
- links.map(
- (node) =>
- new Promise((resolve) => {
- const link = node as HTMLLinkElement;
- try {
- if (link.sheet) {
- resolve();
- return;
- }
- } catch {
- /* cross-origin */
- }
- const finish = () => resolve();
- link.addEventListener('load', finish, { once: true });
- link.addEventListener('error', finish, { once: true });
- }),
- ),
- ).then(() => undefined);
-}
-
-function renderApp() {
- return (
-
-
-
- );
-}
-
-function mountCreateRoot(el: HTMLElement) {
- createRoot(el).render(renderApp());
-}
-
-/** 有 SSR 正文:hydrate 接管同一 DOM;失败则回退 createRoot */
-function mountHydrateHome(el: HTMLElement) {
- beginHomeHydrate();
- try {
- hydrateRoot(el, renderApp());
- } catch (err) {
- console.warn('[j13] hydrateRoot 失败,回退 createRoot', err);
- el.innerHTML = '';
- mountCreateRoot(el);
- }
-}
-
-async function boot() {
- const path = `${window.location.pathname}${window.location.search}`;
- const ssr = hasSSRHome();
-
- await waitForStylesheets();
- try {
- if (document.fonts?.ready) await document.fonts.ready;
- } catch {
- /* ignore */
- }
-
- if (isMainLayoutPath(window.location.pathname)) {
- await ensureColdBootReady(path);
- }
-
- const root = document.getElementById('root');
- if (!root) return;
-
- if (ssr) {
- mountHydrateHome(root);
- } else {
- mountCreateRoot(root);
- }
-}
-
-void boot();
diff --git a/frontend/src/ssr/feedPaths.ts b/frontend/src/ssr/feedPaths.ts
new file mode 100644
index 0000000..3b18379
--- /dev/null
+++ b/frontend/src/ssr/feedPaths.ts
@@ -0,0 +1,26 @@
+/**
+ * SSR feed 页路由匹配(Vike +route.ts 用)。
+ *
+ * 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`
+ * (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。
+ */
+
+export type FeedSsrMatch =
+ | { kind: 'home'; id: '' }
+ | { kind: 'board'; id: string }
+ | { kind: 'post'; 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] }) },
+];
+
+export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null {
+ for (const { re, pick } of FEED_PATTERNS) {
+ const m = urlPathname.match(re);
+ if (m) return pick(m);
+ }
+ return null;
+}
diff --git a/frontend/src/ssr/scopeTypes.ts b/frontend/src/ssr/scopeTypes.ts
index 42e754a..82c1ae1 100644
--- a/frontend/src/ssr/scopeTypes.ts
+++ b/frontend/src/ssr/scopeTypes.ts
@@ -25,6 +25,8 @@ export type RequestScope = {
checkIns: Map;
/** 主题(由 +data 从 cookie 解析) */
theme: 'light' | 'dark';
+ /** 本请求渲染是否采用「hydrate 首帧同构变体」(feed SSR 页恒为 true) */
+ hydrating: boolean;
};
export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | 'dark' = 'light'): RequestScope {
@@ -37,5 +39,6 @@ export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | '
pages: null,
checkIns: new Map(),
theme,
+ hydrating: false,
};
}
diff --git a/frontend/src/utils/homeHydrate.ts b/frontend/src/utils/homeHydrate.ts
index 48cba39..c128441 100644
--- a/frontend/src/utils/homeHydrate.ts
+++ b/frontend/src/utils/homeHydrate.ts
@@ -1,5 +1,7 @@
/** 首页 SSR hydrate 首帧同构标志(仅 / 与板块首页) */
+import { getScope } from '../ssr/scope';
+
declare global {
interface Window {
__J13_HYDRATING_HOME__?: boolean;
@@ -30,8 +32,10 @@ export function endHomeHydrate() {
}
}
-/** 首帧是否必须与 Go SSR DOM 同构 */
+/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块/窗口标志) */
export function isHomeHydrating(): boolean {
+ const scope = getScope();
+ if (scope) return scope.hydrating === true;
if (hydrating) return true;
try {
return !!window.__J13_HYDRATING_HOME__;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 7711e03..814e508 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -17,5 +17,5 @@
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
- "include": ["src"]
+ "include": ["src", "pages", "renderer", "vite.config.ts"]
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 6ae266f..0063672 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,40 +1,14 @@
import path from 'path';
-import { defineConfig, type Plugin } from 'vite';
+import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+import vike from 'vike/plugin';
// 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖
const apiPort = process.env.VITE_API_PORT || '3000';
const apiTarget = `http://localhost:${apiPort}`;
-/** 把 stylesheet 挪到 head 靠前(先于 module script),接近 Gitea 外链 CSS 顺序 */
-function htmlStylesheetsFirst(): Plugin {
- return {
- name: 'html-stylesheets-first',
- enforce: 'post',
- transformIndexHtml(html) {
- // 只处理真实标签,忽略注释里的示例文字
- const linkRe = /]*\/?>)[^>]*\brel=["']stylesheet["'][^>]*>\s*/gi;
- // 更稳妥:逐个找 link 标签
- const styles: string[] = [];
- const out = html.replace(/]*>/gi, (tag) => {
- if (/\brel=["']stylesheet["']/i.test(tag)) {
- styles.push(tag);
- return '';
- }
- return tag;
- });
- if (!styles.length) return html;
- const inject = `${styles.join('\n ')}\n `;
- if (/