feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined: 抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫 - 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到 各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记 - 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false, useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出, head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块) - 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、 探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image - Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、 自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生 历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView + scroll-margin-top 适配 sticky 顶栏
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,5 +27,3 @@ tmp-cookie.txt
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
|
|
||||||
frontend/tmp-lucide-paths.json
|
|
||||||
|
|||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@@ -21,7 +21,6 @@
|
|||||||
"@svg-maps/china": "^2.0.0",
|
"@svg-maps/china": "^2.0.0",
|
||||||
"@svg-maps/world": "^2.0.0",
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
"@tiptap/extension-image": "^3.26.1",
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
@@ -3119,33 +3118,6 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
|
||||||
"version": "3.14.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
|
||||||
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/virtual-core": "3.17.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/virtual-core": {
|
|
||||||
"version": "3.17.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
|
|
||||||
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.26.1",
|
"version": "3.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"@svg-maps/china": "^2.0.0",
|
"@svg-maps/china": "^2.0.0",
|
||||||
"@svg-maps/world": "^2.0.0",
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
"@tiptap/extension-image": "^3.26.1",
|
"@tiptap/extension-image": "^3.26.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PageContextServer } from 'vike/types';
|
import type { PageContextServer } from 'vike/types';
|
||||||
import type { FeedData } from '../../src/ssr/feedData';
|
import type { FeedData } from '../../src/ssr/feedSeed';
|
||||||
|
|
||||||
export { data };
|
export { data };
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type { ComponentType } from 'react';
|
|||||||
import { hydrateRoot, createRoot } from 'react-dom/client';
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
||||||
import type { PageContextClient } from 'vike/types';
|
import type { PageContextClient } from 'vike/types';
|
||||||
import { applyTheme, getStoredTheme } from '../src/utils/theme';
|
import { applyTheme, getStoredTheme } from '../src/utils/theme';
|
||||||
|
import { installZoomGuard } from '../src/utils/zoomGuard';
|
||||||
import { beginHomeHydrate } from '../src/utils/homeHydrate';
|
import { beginHomeHydrate } from '../src/utils/homeHydrate';
|
||||||
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
|
import { reloadForStaleChunk } from '../src/utils/chunkLoad';
|
||||||
import { seedFeedData, type FeedData } from '../src/ssr/feedData';
|
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
|
||||||
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
|
import { ensureColdBootReady, isMainLayoutPath } from '../src/utils/prefetchRoute';
|
||||||
|
|
||||||
export { onRenderClient };
|
export { onRenderClient };
|
||||||
@@ -40,6 +41,7 @@ function waitForStylesheets(): Promise<void> {
|
|||||||
|
|
||||||
async function onRenderClient(pageContext: PageContextClient) {
|
async function onRenderClient(pageContext: PageContextClient) {
|
||||||
applyTheme(getStoredTheme());
|
applyTheme(getStoredTheme());
|
||||||
|
installZoomGuard();
|
||||||
// hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
|
// hydrate 前把 +data 结果 seed 进客户端缓存(与服务端渲染前 seed 同构,保证首帧一致)
|
||||||
if (pageContext.isHydration) {
|
if (pageContext.isHydration) {
|
||||||
seedFeedData((pageContext as { data?: FeedData }).data);
|
seedFeedData((pageContext as { data?: FeedData }).data);
|
||||||
@@ -54,6 +56,16 @@ async function onRenderClient(pageContext: PageContextClient) {
|
|||||||
const el = document.getElementById('root');
|
const el = document.getElementById('root');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
// 有 SSR 正文 → hydrate 接管同一 DOM(首帧数据已由 +data seed,无需冷启动预热);
|
||||||
|
// SPA 壳 / SSR 降级空壳 → createRoot 挂载,挂载前先冷启动预热,避免空白首帧
|
||||||
|
const hydrating = pageContext.isHydration && el.innerHTML.trim() !== '';
|
||||||
|
|
||||||
|
// 仅非 hydrate 路径需要冷启动:SSR 页 seed 已灌入缓存,冷启动只会重复请求并阻塞 hydrate
|
||||||
|
if (!hydrating && isMainLayoutPath(window.location.pathname)) {
|
||||||
|
const path = `${window.location.pathname}${window.location.search}`;
|
||||||
|
await ensureColdBootReady(path);
|
||||||
|
}
|
||||||
|
|
||||||
await waitForStylesheets();
|
await waitForStylesheets();
|
||||||
try {
|
try {
|
||||||
if (document.fonts?.ready) await document.fonts.ready;
|
if (document.fonts?.ready) await document.fonts.ready;
|
||||||
@@ -61,13 +73,6 @@ async function onRenderClient(pageContext: PageContextClient) {
|
|||||||
/* ignore */
|
/* 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();
|
if (hydrating) beginHomeHydrate();
|
||||||
|
|
||||||
const Page = pageContext.Page as PageComponent;
|
const Page = pageContext.Page as PageComponent;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { PageContextServer } from 'vike/types';
|
|||||||
import { createHomeStore } from '../src/store/homeStore';
|
import { createHomeStore } from '../src/store/homeStore';
|
||||||
import { createRequestScope } from '../src/ssr/scopeTypes';
|
import { createRequestScope } from '../src/ssr/scopeTypes';
|
||||||
import { runWithScope } from '../src/ssr/scope';
|
import { runWithScope } from '../src/ssr/scope';
|
||||||
import { seedFeedData, type FeedData } from '../src/ssr/feedData';
|
import { seedFeedData, type FeedData } from '../src/ssr/feedSeed';
|
||||||
import { renderHeadTags } from '../src/ssr/head';
|
import { renderHeadTags } from '../src/ssr/head';
|
||||||
import { installServerDom } from '../src/ssr/serverDom';
|
import { installServerDom } from '../src/ssr/serverDom';
|
||||||
|
import { readThemeFromCookie } from '../src/utils/theme';
|
||||||
|
import { createSsrApp } from '../src/App';
|
||||||
|
|
||||||
// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装
|
// 服务端 DOM polyfill:内容处理依赖 DOMParser/document,须在渲染前安装
|
||||||
installServerDom();
|
installServerDom();
|
||||||
@@ -16,14 +18,6 @@ export { onRenderHtml };
|
|||||||
|
|
||||||
type PageComponent = ComponentType<{ url?: string }>;
|
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 一致 */
|
/** 等 lazy 页面全部渲染完成(allReady)后缓冲为完整 HTML,首字节语义与旧 Go 文档级 SSR 一致 */
|
||||||
async function renderPageToString(element: ReactElement): Promise<string> {
|
async function renderPageToString(element: ReactElement): Promise<string> {
|
||||||
const stream = await renderToReadableStream(element, {
|
const stream = await renderToReadableStream(element, {
|
||||||
@@ -47,10 +41,13 @@ async function onRenderHtml(pageContext: PageContextServer) {
|
|||||||
const { pathname, searchOriginal } = pageContext.urlParsed;
|
const { pathname, searchOriginal } = pageContext.urlParsed;
|
||||||
const url = pathname + (searchOriginal ?? '');
|
const url = pathname + (searchOriginal ?? '');
|
||||||
try {
|
try {
|
||||||
pageHtml = await runWithScope(scope, () => {
|
pageHtml = await runWithScope(scope, async () => {
|
||||||
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
||||||
seedFeedData(data);
|
seedFeedData(data);
|
||||||
return renderPageToString(<Page url={url} />);
|
// 官方 SSR 路径:await 首导航后 StaticRouterProvider 同步渲染(避免 RouterProvider suspend
|
||||||
|
// 导致内容落入 <div hidden>,禁 JS 爬虫不可见)
|
||||||
|
const app = await createSsrApp(url);
|
||||||
|
return renderPageToString(app);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
|
console.error('[j13] SSR 渲染失败,降级 SPA 壳:', err);
|
||||||
@@ -60,15 +57,19 @@ async function onRenderHtml(pageContext: PageContextServer) {
|
|||||||
|
|
||||||
const headTags = renderHeadTags(data?.head);
|
const headTags = renderHeadTags(data?.head);
|
||||||
|
|
||||||
|
// 主题从请求 Cookie 读取,首帧直接渲染到 <html> 上(深色用户无白屏闪烁,无需内联脚本)
|
||||||
|
// headersOriginal 经 Vike 归一化为 pageContext.headers(键全小写)
|
||||||
|
const cookieHeader = (pageContext as { headers?: Record<string, string> | null }).headers?.cookie;
|
||||||
|
const dark = readThemeFromCookie(cookieHeader) === 'dark';
|
||||||
|
const htmlAttrs = dark ? ' class="dark" style="color-scheme:dark"' : '';
|
||||||
|
|
||||||
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
|
// 404/301 等状态码由 Vike abort 机制(throw render/redirect)原生处理
|
||||||
return escapeInject`<!DOCTYPE html>
|
return escapeInject`<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN"${dangerouslySkipEscape(htmlAttrs)}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
${dangerouslySkipEscape(headTags)}
|
${dangerouslySkipEscape(headTags)}
|
||||||
<script>${dangerouslySkipEscape(THEME_INIT_SCRIPT)}</script>
|
|
||||||
<script>${dangerouslySkipEscape(ZOOM_BLOCK_SCRIPT)}</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
|
<div id="root">${dangerouslySkipEscape(pageHtml)}</div>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const iconsDir = path.join('node_modules/lucide-react/dist/esm/icons');
|
|
||||||
const names = [
|
|
||||||
'search', 'house', 'star', 'folder-git-2', 'link-2', 'earth', 'file-text',
|
|
||||||
'message-circle', 'tags', 'moon', 'clock', 'badge-check', 'sliders-horizontal',
|
|
||||||
'plus', 'sun', 'mail', 'calendar-check', 'check', 'gift', 'panel-right',
|
|
||||||
'user-plus', 'layout-dashboard',
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
|
|
||||||
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
|
|
||||||
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
|
|
||||||
'folder', 'wrench', 'cpu',
|
|
||||||
];
|
|
||||||
|
|
||||||
function esc(v) {
|
|
||||||
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeToInner(nodes) {
|
|
||||||
return nodes.map(([tag, attrs]) => {
|
|
||||||
let s = `<${tag}`;
|
|
||||||
for (const [k, v] of Object.entries(attrs)) {
|
|
||||||
if (k === 'key') continue;
|
|
||||||
s += ` ${k}="${esc(v)}"`;
|
|
||||||
}
|
|
||||||
return `${s}/>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveIconFile(name) {
|
|
||||||
let f = path.join(iconsDir, `${name}.mjs`);
|
|
||||||
let src = fs.readFileSync(f, 'utf8');
|
|
||||||
const re = src.match(/export \{ default \} from '\.\/([^']+)';/);
|
|
||||||
if (re) {
|
|
||||||
f = path.join(iconsDir, re[1]);
|
|
||||||
src = fs.readFileSync(f, 'utf8');
|
|
||||||
}
|
|
||||||
return { f, src };
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = {};
|
|
||||||
for (const n of names) {
|
|
||||||
try {
|
|
||||||
const { src } = resolveIconFile(n);
|
|
||||||
const m = src.match(/const __iconNode = (\[[\s\S]*?\]);/);
|
|
||||||
if (!m) {
|
|
||||||
console.error('no node', n);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line no-eval
|
|
||||||
const nodes = eval(m[1]);
|
|
||||||
out[n] = nodeToInner(nodes);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('fail', n, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync('tmp-lucide-paths.json', JSON.stringify(out, null, 2));
|
|
||||||
console.log('wrote', Object.keys(out).length, 'icons');
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const paths = JSON.parse(fs.readFileSync('tmp-lucide-paths.json', 'utf8'));
|
|
||||||
|
|
||||||
function splitInner(inner) {
|
|
||||||
const tags = [];
|
|
||||||
const re = /<([a-z]+)([^>]*)\/>/g;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(inner))) {
|
|
||||||
tags.push(`<${m[1]}${m[2]}/>`);
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goFunc(name, size, key, className = '') {
|
|
||||||
const tags = splitInner(paths[key]);
|
|
||||||
const lines = tags.map((t) => `\t\t\`${t}\`,`).join('\n');
|
|
||||||
if (className) {
|
|
||||||
return `func ${name}() string {\n\treturn ssrSVGWithClass(${size}, "${className}",\n${lines}\n\t)\n}\n\n`;
|
|
||||||
}
|
|
||||||
return `func ${name}() string {\n\treturn ssrSVG(${size},\n${lines}\n\t)\n}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardKeys = [
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
|
|
||||||
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
|
|
||||||
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
|
|
||||||
'star', 'folder', 'wrench', 'cpu',
|
|
||||||
];
|
|
||||||
const defaults = [
|
|
||||||
'code-2', 'coffee', 'help-circle', 'message-square',
|
|
||||||
'lightbulb', 'book-open', 'gamepad-2', 'palette',
|
|
||||||
];
|
|
||||||
|
|
||||||
let out = `package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
\t"strconv"
|
|
||||||
\t"strings"
|
|
||||||
\t"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取)
|
|
||||||
|
|
||||||
func ssrSVG(size int, paths ...string) string {
|
|
||||||
\treturn ssrSVGWithClass(size, "", paths...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ssrSVGWithClass(size int, className string, paths ...string) string {
|
|
||||||
\tvar b strings.Builder
|
|
||||||
\tb.WriteString(\`<svg xmlns="http://www.w3.org/2000/svg" width="\`)
|
|
||||||
\tb.WriteString(strconv.Itoa(size))
|
|
||||||
\tb.WriteString(\`" height="\`)
|
|
||||||
\tb.WriteString(strconv.Itoa(size))
|
|
||||||
\tb.WriteString(\`" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\`)
|
|
||||||
\tif className != "" {
|
|
||||||
\t\tb.WriteString(\` class="\`)
|
|
||||||
\t\tb.WriteString(className)
|
|
||||||
\t\tb.WriteString(\`"\`)
|
|
||||||
\t}
|
|
||||||
\tb.WriteString(\` aria-hidden="true">\`)
|
|
||||||
\tfor _, p := range paths {
|
|
||||||
\t\tb.WriteString(p)
|
|
||||||
\t}
|
|
||||||
\tb.WriteString(\`</svg>\`)
|
|
||||||
\treturn b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
out += goFunc('ssrIconSearch', 16, 'search', 'header-search-icon');
|
|
||||||
out += goFunc('ssrIconSliders', 15, 'sliders-horizontal');
|
|
||||||
out += goFunc('ssrIconPlus', 16, 'plus');
|
|
||||||
out += goFunc('ssrIconMoon', 18, 'moon');
|
|
||||||
out += goFunc('ssrIconSun', 18, 'sun');
|
|
||||||
out += goFunc('ssrIconMail', 18, 'mail');
|
|
||||||
out += goFunc('ssrIconHome', 18, 'house');
|
|
||||||
out += goFunc('ssrIconStar', 18, 'star');
|
|
||||||
out += goFunc('ssrIconFolderGit', 18, 'folder-git-2');
|
|
||||||
out += goFunc('ssrIconLink2', 18, 'link-2');
|
|
||||||
out += goFunc('ssrIconEarth', 18, 'earth');
|
|
||||||
out += goFunc('ssrIconFileText', 18, 'file-text');
|
|
||||||
out += goFunc('ssrIconLayoutDashboard', 18, 'layout-dashboard');
|
|
||||||
out += goFunc('ssrIconMessageCircle', 16, 'message-circle');
|
|
||||||
out += goFunc('ssrIconClock', 16, 'clock');
|
|
||||||
out += goFunc('ssrIconBadgeCheck', 16, 'badge-check');
|
|
||||||
out += goFunc('ssrIconTags', 16, 'tags');
|
|
||||||
out += goFunc('ssrIconUserPlus', 16, 'user-plus');
|
|
||||||
out += goFunc('ssrIconCalendarCheck', 18, 'calendar-check');
|
|
||||||
out += goFunc('ssrIconCheck', 18, 'check');
|
|
||||||
out += goFunc('ssrIconGift', 15, 'gift');
|
|
||||||
out += goFunc('ssrIconPanelRight', 18, 'panel-right');
|
|
||||||
|
|
||||||
out += '// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons)\nvar ssrBoardIconInner = map[string]string{\n';
|
|
||||||
for (const k of boardKeys) {
|
|
||||||
out += `\t"${k}": \`${paths[k]}\`,\n`;
|
|
||||||
}
|
|
||||||
out += '}\n\n';
|
|
||||||
|
|
||||||
out += '// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)\nvar ssrBoardDefaultIcons = []string{\n';
|
|
||||||
for (const k of defaults) {
|
|
||||||
out += `\t"${k}",\n`;
|
|
||||||
}
|
|
||||||
out += '}\n\n';
|
|
||||||
|
|
||||||
out += `// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致)
|
|
||||||
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
|
|
||||||
\tkey := strings.TrimSpace(strings.ToLower(icon))
|
|
||||||
\tinner, ok := ssrBoardIconInner[key]
|
|
||||||
\tif !ok || inner == "" {
|
|
||||||
\t\tif themeIndex < 0 {
|
|
||||||
\t\t\tthemeIndex = 0
|
|
||||||
\t\t}
|
|
||||||
\t\tkey = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
|
|
||||||
\t\tinner = ssrBoardIconInner[key]
|
|
||||||
\t}
|
|
||||||
\treturn ssrSVGWithClass(18, className, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRRelativeTime 与前端 formatTime 同规则
|
|
||||||
func formatSSRRelativeTime(t time.Time) string {
|
|
||||||
\tif t.IsZero() {
|
|
||||||
\t\treturn ""
|
|
||||||
\t}
|
|
||||||
\tnow := time.Now()
|
|
||||||
\tdiffSec := now.Sub(t).Seconds()
|
|
||||||
\tif diffSec < 0 {
|
|
||||||
\t\tdiffSec = 0
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 60 {
|
|
||||||
\t\treturn "刚刚"
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 3600 {
|
|
||||||
\t\treturn strconv.Itoa(int(diffSec/60)) + "分钟前"
|
|
||||||
\t}
|
|
||||||
\tif diffSec < 86400 {
|
|
||||||
\t\treturn strconv.Itoa(int(diffSec/3600)) + "小时前"
|
|
||||||
\t}
|
|
||||||
\tdiffDay := int(diffSec / 86400)
|
|
||||||
\tif diffDay < 30 {
|
|
||||||
\t\treturn strconv.Itoa(diffDay) + "天前"
|
|
||||||
\t}
|
|
||||||
\tif t.Year() == now.Year() {
|
|
||||||
\t\treturn strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
\t}
|
|
||||||
\treturn strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm)
|
|
||||||
func formatSSRShortDateTime(t time.Time) string {
|
|
||||||
\tif t.IsZero() {
|
|
||||||
\t\treturn ""
|
|
||||||
\t}
|
|
||||||
\tlocal := t.Local()
|
|
||||||
\tpad := func(n int) string {
|
|
||||||
\t\tif n < 10 {
|
|
||||||
\t\t\treturn "0" + strconv.Itoa(n)
|
|
||||||
\t\t}
|
|
||||||
\t\treturn strconv.Itoa(n)
|
|
||||||
\t}
|
|
||||||
\treturn pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const outPath = path.join('..', 'handler', 'ssr_icons.go');
|
|
||||||
fs.writeFileSync(outPath, out);
|
|
||||||
console.log('wrote', outPath, Object.keys(paths).length, 'icons');
|
|
||||||
@@ -64,12 +64,16 @@ for (const prefix of GO_PROXY_PREFIXES) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端静态资源:dist/client(含 /assets 与 /stickers),assets 带 contenthash → immutable
|
// 前端静态资源:dist/client(含 /assets 与 /stickers),assets 带 contenthash → immutable;
|
||||||
|
// HTML 用 no-cache(每次协商),避免 no-store/max-age=0 导致浏览器丢弃历史滚动状态(F5 不恢复位置)
|
||||||
app.use(
|
app.use(
|
||||||
express.static(distClient, {
|
express.static(distClient, {
|
||||||
|
cacheControl: false, // 关闭 send 自动写入的 Cache-Control,统一由 setHeaders 决定
|
||||||
setHeaders(res, filePath) {
|
setHeaders(res, filePath) {
|
||||||
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
} else {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -88,6 +92,9 @@ app.use(async (req, res, next) => {
|
|||||||
const { statusCode, headers, body } = httpResponse;
|
const { statusCode, headers, body } = httpResponse;
|
||||||
res.statusCode = statusCode;
|
res.statusCode = statusCode;
|
||||||
for (const [name, value] of headers) res.setHeader(name, value);
|
for (const [name, value] of headers) res.setHeader(name, value);
|
||||||
|
// Vike 默认对 HTML 发 no-store:会使浏览器丢弃该历史条目的滚动恢复状态(F5 回到顶部)。
|
||||||
|
// 改为 no-cache:文档仍每次重新验证/渲染,但保留浏览器原生 scroll restoration。
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
if (typeof body === 'string') {
|
if (typeof body === 'string') {
|
||||||
res.send(body);
|
res.send(body);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
import { RouterProvider, createBrowserRouter, createMemoryRouter } from 'react-router';
|
import { BrowserRouter, StaticRouter, useRoutes } from 'react-router';
|
||||||
import AppShell from './AppShell';
|
import AppShell from './AppShell';
|
||||||
import { appRoutes } from './AppRoutes';
|
import { appRoutes } from './AppRoutes';
|
||||||
|
|
||||||
type AppRouter = ReturnType<typeof createMemoryRouter>;
|
/**
|
||||||
|
* 路由树:使用低层 router(useRoutes)而非 data router(RouterProvider)。
|
||||||
/** 浏览器路由单例:整页加载创建一次,此后站内导航全部由 react-router 接管 */
|
*
|
||||||
let browserRouter: AppRouter | null = null;
|
* 原因:data router 的 RouterProvider/StaticRouterProvider 内部订阅路由状态,
|
||||||
|
* 服务端 Fizz 渲染会产生 Suspense pending boundary(内容落入 <div hidden> +
|
||||||
function getAppRouter(url?: string): AppRouter {
|
* 脚本替换的流式格式,禁 JS 爬虫/SEO 工具看不到帖子)。本项目数据全部由
|
||||||
if (import.meta.env.SSR) {
|
* Vike +data 预取并 seed 进请求作用域,不使用 loader/action,低层路由首帧
|
||||||
// 服务端:每请求新建 memory router(与浏览器同一棵路由树,保证 DOM 同构)
|
* 即可同步渲染完整 HTML,SSR/CSR 行为一致。
|
||||||
return createMemoryRouter(appRoutes, { initialEntries: [url ?? '/'] });
|
*/
|
||||||
}
|
function AppRoutes() {
|
||||||
browserRouter ??= createBrowserRouter(appRoutes);
|
return useRoutes(appRoutes);
|
||||||
return browserRouter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用根组件:Vike +Page 与客户端入口共用。
|
* 应用根组件:Vike +Page 与客户端入口共用(浏览器侧)。
|
||||||
* SSR 时传入 `url`(pathname + search),客户端忽略。
|
|
||||||
*/
|
*/
|
||||||
export default function App({ url }: { url?: string }) {
|
export default function App(_props: { url?: string }) {
|
||||||
return (
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<RouterProvider router={getAppRouter(url)} />
|
<AppRoutes />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSR 应用工厂:低层 StaticRouter + useRoutes,location 一次性给定,
|
||||||
|
* 整棵树同步渲染(无首导航异步、无状态订阅 suspend)。
|
||||||
|
*/
|
||||||
|
export async function createSsrApp(url: string) {
|
||||||
|
return (
|
||||||
|
<StaticRouter location={url}>
|
||||||
|
<AppShell>
|
||||||
|
<AppRoutes />
|
||||||
|
</AppShell>
|
||||||
|
</StaticRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import AppRouteError from './components/AppRouteError';
|
|||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
import AuthPageFallback from './components/AuthPageFallback';
|
import AuthPageFallback from './components/AuthPageFallback';
|
||||||
import { lazyWithRetry } from './utils/lazyWithRetry';
|
import { lazyWithRetry } from './utils/lazyWithRetry';
|
||||||
|
// SSR 页面(/、/board/:id、/post/:id)必须静态导入:React.lazy 在服务端首帧必然
|
||||||
|
// suspend,会使内容落入 <div hidden> + 脚本替换的流式格式,禁 JS 爬虫/SEO 工具看不到内容。
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import PostDetailPage from './pages/PostDetailPage';
|
||||||
|
|
||||||
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
|
||||||
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
|
||||||
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||||
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||||
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
|
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
|
||||||
@@ -68,17 +70,19 @@ export const appRoutes = createRoutesFromElements(
|
|||||||
<Route path="/board/:id" element={<HomePage />} />
|
<Route path="/board/:id" element={<HomePage />} />
|
||||||
{/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
|
{/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
|
||||||
<Route path="/post/:id" element={<PostDetailPage />} />
|
<Route path="/post/:id" element={<PostDetailPage />} />
|
||||||
<Route path="/compose" element={<ComposePage />} />
|
{/* 懒加载页面各自包裹 Suspense:Suspense 边界不得包裹 SSR 静态内容
|
||||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
(React 19 会把边界内大体积/含 <img> 的内容 outlining 为 hidden chunk) */}
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/compose" element={<Suspense fallback={<PageLoader />}><ComposePage /></Suspense>} />
|
||||||
<Route path="/user/:id" element={<UserProfilePage />} />
|
<Route path="/post/:id/edit" element={<Suspense fallback={<PageLoader />}><ComposePage /></Suspense>} />
|
||||||
<Route path="/favorites" element={<FavoritesPage />} />
|
<Route path="/profile" element={<Suspense fallback={<PageLoader />}><ProfilePage /></Suspense>} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/user/:id" element={<Suspense fallback={<PageLoader />}><UserProfilePage /></Suspense>} />
|
||||||
<Route path="/links" element={<LinksPage />} />
|
<Route path="/favorites" element={<Suspense fallback={<PageLoader />}><FavoritesPage /></Suspense>} />
|
||||||
<Route path="/showcase" element={<Suspense fallback={null}><ShowcasePage /></Suspense>} />
|
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
||||||
<Route path="/messages" element={<MessagesPage />} />
|
<Route path="/links" element={<Suspense fallback={<PageLoader />}><LinksPage /></Suspense>} />
|
||||||
<Route path="/page/:slug" element={<Suspense fallback={null}><SitePageView /></Suspense>} />
|
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
|
||||||
<Route path="*" element={<Suspense fallback={null}><NotFoundPage /></Suspense>} />
|
<Route path="/messages" element={<Suspense fallback={<PageLoader />}><MessagesPage /></Suspense>} />
|
||||||
|
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
|
||||||
|
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||||
</Route>,
|
</Route>,
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
* - 转发浏览器 cookie 以透传登录态
|
* - 转发浏览器 cookie 以透传登录态
|
||||||
* - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等)
|
* - 转发 host / X-Forwarded-* 让 Go 推导正确的站点 base URL(canonical/og:url 等)
|
||||||
* - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染
|
* - 5s 超时;任何失败返回 null,调用方降级为 SPA 壳,不阻断渲染
|
||||||
|
*
|
||||||
|
* 本模块仅允许被 SSR 侧代码(pages 下的 +data、src/ssr/feedData)引用;
|
||||||
|
* 顶层 process.env 读取以 import.meta.env.SSR 守卫,防止误入客户端包时求值崩溃。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GO_API_URL = process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`;
|
const GO_API_URL = import.meta.env.SSR
|
||||||
|
? process.env.GO_API_URL || `http://127.0.0.1:${process.env.VITE_API_PORT || '3000'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
export type ServerApiOptions = {
|
export type ServerApiOptions = {
|
||||||
/** 浏览器请求的 cookie 原样转发 */
|
/** 浏览器请求的 cookie 原样转发 */
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ export interface PostItem {
|
|||||||
board_id: number;
|
board_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 完整正文;列表接口不下发(为空),仅帖子详情等场景有值 */
|
||||||
content?: string;
|
content?: string;
|
||||||
|
/** 列表接口预算的纯文本摘要(60 字),列表页直接使用无需解析 content */
|
||||||
|
excerpt?: string;
|
||||||
|
/** 列表接口预算的正文首图 URL(缩略图模式用) */
|
||||||
|
first_image?: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
/** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */
|
/** normal=讨论 | question=问答 | poll=投票 | bounty=悬赏 | lottery=抽奖 */
|
||||||
post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string;
|
post_type?: 'normal' | 'question' | 'poll' | 'bounty' | 'lottery' | string;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const BIND_RETRY_MS = 50;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 定位当前真正滚动的容器。
|
* 定位当前真正滚动的容器。
|
||||||
* 前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose
|
* Feed 文档滚动模式(.app-shell--doc-scroll):滚动根为窗口(document.scrollingElement)。
|
||||||
|
* 其余前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose
|
||||||
* 后台:.admin-main
|
* 后台:.admin-main
|
||||||
*/
|
*/
|
||||||
function pickScrollEl(scope: ParentNode): HTMLElement | null {
|
function pickScrollEl(scope: ParentNode): HTMLElement | null {
|
||||||
@@ -52,7 +53,7 @@ export default function BackToTop() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let bound: HTMLElement | null = null;
|
let bound: EventTarget | null = null;
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (!cancelled) syncVisible();
|
if (!cancelled) syncVisible();
|
||||||
@@ -80,6 +81,15 @@ export default function BackToTop() {
|
|||||||
|
|
||||||
const tryBind = () => {
|
const tryBind = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
// Feed 文档滚动模式:滚动根是窗口,scroll 事件落在 document 上
|
||||||
|
if (document.querySelector('.app-shell--doc-scroll')) {
|
||||||
|
unbind();
|
||||||
|
bound = document;
|
||||||
|
scrollElRef.current = document.scrollingElement as HTMLElement | null;
|
||||||
|
document.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scope = findScrollScope();
|
const scope = findScrollScope();
|
||||||
if (!scope) {
|
if (!scope) {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
|
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
|
||||||
const commentCount = post.comment_count ?? 0;
|
const commentCount = post.comment_count ?? 0;
|
||||||
const href = postPath(post.id);
|
const href = postPath(post.id);
|
||||||
const firstImage = firstImageFromHTML(post.content || '');
|
// 列表接口已预算摘要/首图且不下发正文;content 仅在详情等非列表场景有值时兜底
|
||||||
|
const firstImage = post.first_image || firstImageFromHTML(post.content || '');
|
||||||
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
|
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
|
||||||
const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : '';
|
const excerpt = showExcerpt ? (post.excerpt || excerptFromHTML(post.content || '', 60)) : '';
|
||||||
const showBoardBadge = !!post.board && boardId !== post.board.id;
|
const showBoardBadge = !!post.board && boardId !== post.board.id;
|
||||||
|
|
||||||
const openPost = () => onSelect(post.id);
|
const openPost = () => onSelect(post.id);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { ForumLimitsPublic } from '../api/types';
|
|
||||||
|
|
||||||
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
|
|
||||||
|
|
||||||
/** 虚拟列表行高预估值 */
|
|
||||||
export function feedListRowEstimate(style: FeedListStyle): number {
|
|
||||||
switch (style) {
|
|
||||||
case 'excerpt': return 68;
|
|
||||||
case 'thumbnail': return 72;
|
|
||||||
default: return 64;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,12 @@ function pickScrollEl(): HTMLElement | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feed 文档滚动模式(.app-shell--doc-scroll):滚动根是 document(html)。
|
||||||
|
// 触摸事件会冒泡到 html,scrollTop 即窗口滚动量。
|
||||||
|
if (document.querySelector('.app-shell--doc-scroll')) {
|
||||||
|
return document.scrollingElement as HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
|
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
|
||||||
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
|
||||||
if (mobileFeed) return mobileFeed;
|
if (mobileFeed) return mobileFeed;
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import type { PostItem } from '../api/types';
|
|
||||||
import type { FeedSort } from './FeedSortBar';
|
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
|
||||||
import { postPath } from '../utils/permalink';
|
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
|
||||||
|
|
||||||
import { formatTime } from '../utils/content';
|
|
||||||
import { MessageCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
/** 与 Go formatSSRRelativeTime / 前端 formatTime 对齐的列表时间 */
|
|
||||||
|
|
||||||
function stripHtmlPlain(html: string): string {
|
|
||||||
if (!html) return '';
|
|
||||||
const t = html
|
|
||||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
||||||
.replace(/<[^>]+>/g, ' ')
|
|
||||||
.replace(/ /gi, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateRunes(s: string, max: number): string {
|
|
||||||
const chars = Array.from(s);
|
|
||||||
if (chars.length <= max) return s;
|
|
||||||
return chars.slice(0, max).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstImageSrc(html: string): string {
|
|
||||||
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
||||||
const src = m?.[1]?.trim() || '';
|
|
||||||
if (!src || src.startsWith('data:')) return '';
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayName(post: PostItem): string {
|
|
||||||
const n = post.user?.nickname?.trim() || post.user?.username?.trim();
|
|
||||||
return n || '用户';
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
posts: PostItem[];
|
|
||||||
sort: FeedSort;
|
|
||||||
boardId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 与 Go writeSSRPostRow 同构的静态列表(hydrate 首帧专用) */
|
|
||||||
export default function StaticFeedList({ posts, sort, boardId }: Props) {
|
|
||||||
const { limits } = useForumLimits();
|
|
||||||
const style = limits.feed_list_style ?? 'title';
|
|
||||||
const titleOnly = style === 'title';
|
|
||||||
const needExcerpt = style === 'excerpt' || style === 'thumbnail';
|
|
||||||
const needThumb = style === 'thumbnail';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="post-list-scroll post-list-scroll--ssr">
|
|
||||||
<div className="content-surface content-surface--ssr">
|
|
||||||
{posts.length === 0 ? (
|
|
||||||
<div className="feed-empty">暂无帖子</div>
|
|
||||||
) : (
|
|
||||||
posts.map((post) => {
|
|
||||||
const href = postPath(post.id, limits);
|
|
||||||
const author = displayName(post);
|
|
||||||
const initial = Array.from(author)[0] || '?';
|
|
||||||
let excerpt = '';
|
|
||||||
let thumb = '';
|
|
||||||
if (needExcerpt || needThumb) {
|
|
||||||
excerpt = truncateRunes(stripHtmlPlain(post.content || ''), 120);
|
|
||||||
}
|
|
||||||
if (needThumb) thumb = firstImageSrc(post.content || '');
|
|
||||||
|
|
||||||
const rowClass = [
|
|
||||||
'post-row',
|
|
||||||
'post-row--v2',
|
|
||||||
titleOnly ? 'post-row--title-only' : '',
|
|
||||||
thumb ? 'post-row--has-thumb' : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
let timeLabel = formatTime(post.created_at);
|
|
||||||
if (sort === 'reply' && !post.last_reply_at) {
|
|
||||||
timeLabel = '暂无回复';
|
|
||||||
}
|
|
||||||
const lastReplyName = post.last_reply_user?.nickname?.trim()
|
|
||||||
|| post.last_reply_user?.username?.trim()
|
|
||||||
|| post.last_reply_guest_nick?.trim()
|
|
||||||
|| '';
|
|
||||||
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
|
|
||||||
const commentCount = post.comment_count ?? 0;
|
|
||||||
|
|
||||||
const hasTypeBadge = post.post_type === 'question'
|
|
||||||
|| post.post_type === 'poll'
|
|
||||||
|| post.post_type === 'lottery'
|
|
||||||
|| (post.post_type === 'bounty' && (
|
|
||||||
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|
|
||||||
|| post.bounty_status === 'awarded'
|
|
||||||
));
|
|
||||||
|
|
||||||
const titleRow = (
|
|
||||||
<div className="post-title-row">
|
|
||||||
{post.pinned > 0 ? (
|
|
||||||
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
|
||||||
) : null}
|
|
||||||
{(post.board_pinned ?? 0) > 0 ? (
|
|
||||||
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
|
||||||
) : null}
|
|
||||||
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
|
|
||||||
{post.status === 'pending' ? (
|
|
||||||
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
|
||||||
) : null}
|
|
||||||
{post.status === 'rejected' ? (
|
|
||||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
|
||||||
) : null}
|
|
||||||
<span className="post-title">{post.title}</span>
|
|
||||||
{hasTypeBadge ? (
|
|
||||||
<span className="post-title-type-badges">
|
|
||||||
{post.post_type === 'question' ? (
|
|
||||||
<span
|
|
||||||
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
|
|
||||||
title={post.question_resolved ? '已解决' : '未解决'}
|
|
||||||
>
|
|
||||||
{post.question_resolved ? '已解决' : '未解决'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'poll' ? (
|
|
||||||
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 ? (
|
|
||||||
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">
|
|
||||||
悬赏 {post.bounty_points}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'bounty' && post.bounty_status === 'awarded' ? (
|
|
||||||
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
|
|
||||||
) : null}
|
|
||||||
{post.post_type === 'lottery' ? (
|
|
||||||
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
|
|
||||||
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const metaLeft = (
|
|
||||||
<div className="post-meta-left">
|
|
||||||
{boardId === 0 && post.board?.id ? (
|
|
||||||
<span className="post-list-board-btn">
|
|
||||||
<span
|
|
||||||
className={`post-list-board-badge board-badge board-badge--${getBoardThemeIndex(post.board)}`}
|
|
||||||
>
|
|
||||||
{post.board.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="post-meta-author">{author}</span>
|
|
||||||
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
|
|
||||||
<span className="post-meta-time post-meta-time--created">{timeLabel}</span>
|
|
||||||
{showLastReply ? (
|
|
||||||
<span className="post-meta-last-reply">
|
|
||||||
<span className="post-meta-last-reply-arrow" aria-hidden>←</span>
|
|
||||||
{/* 外层是 <a class="post-row">,禁止嵌套 a */}
|
|
||||||
<span className="post-meta-last-reply-user">{lastReplyName}</span>
|
|
||||||
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = (
|
|
||||||
<div className="post-stats">
|
|
||||||
<span
|
|
||||||
className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}
|
|
||||||
title="评论"
|
|
||||||
>
|
|
||||||
<MessageCircle aria-hidden />
|
|
||||||
{commentCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a key={post.id} className={rowClass} href={href}>
|
|
||||||
{post.user?.avatar ? (
|
|
||||||
<span className="post-avatar user-link--avatar-only">
|
|
||||||
<img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="post-avatar user-link--avatar-only">{initial}</span>
|
|
||||||
)}
|
|
||||||
{thumb ? (
|
|
||||||
<div className="post-main post-main--with-thumb">
|
|
||||||
<div className="post-content">
|
|
||||||
{titleRow}
|
|
||||||
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
|
|
||||||
<div className="post-meta post-meta--inline">
|
|
||||||
{metaLeft}
|
|
||||||
{stats}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="post-aside">
|
|
||||||
<div className="post-thumb" aria-hidden>
|
|
||||||
<img src={thumb} alt="" loading="lazy" decoding="async" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="post-main">
|
|
||||||
<div className="post-text">
|
|
||||||
{titleRow}
|
|
||||||
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
|
|
||||||
</div>
|
|
||||||
<div className="post-meta">
|
|
||||||
{metaLeft}
|
|
||||||
{stats}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
import { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
||||||
import { Inbox, SearchX } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import PostListItem from './PostListItem';
|
|
||||||
import { feedListRowEstimate } from './PostListSkeleton';
|
|
||||||
import FeedPagination from './FeedPagination';
|
|
||||||
import { InFlowSiteFooter } from './SiteFooter';
|
|
||||||
import { useAuth } from '../hooks/useAuth';
|
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
|
||||||
import { useMediaQuery } from '../hooks/useTheme';
|
|
||||||
import { loginPath } from '../utils/authRedirect';
|
|
||||||
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
|
||||||
import type { PostItem } from '../api/types';
|
|
||||||
import type { FeedSort } from './FeedSortBar';
|
|
||||||
import { getDefaultFeedSortFromCache } from './FeedSortBar';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
posts: PostItem[];
|
|
||||||
sort?: FeedSort;
|
|
||||||
loading: boolean;
|
|
||||||
/** 当前页之后是否还有更多 */
|
|
||||||
hasMore: boolean;
|
|
||||||
/** 是否显示底部分页控件 */
|
|
||||||
showPagination: boolean;
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
postTotal: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
restoreScrollTop?: number | null;
|
|
||||||
resetScrollKey?: number;
|
|
||||||
onScrollTopChange?: (top: number) => void;
|
|
||||||
onScrollRestored?: () => void;
|
|
||||||
/** 搜索关键词(用于空态文案) */
|
|
||||||
keyword?: string;
|
|
||||||
/** 是否为帖子搜索(区别于标签筛选) */
|
|
||||||
isSearchMode?: boolean;
|
|
||||||
searchKeyword?: string;
|
|
||||||
searchAuthor?: string;
|
|
||||||
searchTitleOnly?: boolean;
|
|
||||||
searchScopeBoardId?: number;
|
|
||||||
onClearSearch?: () => void;
|
|
||||||
/** 当前板块 id,0 表示全部 */
|
|
||||||
boardId?: number;
|
|
||||||
/** 当前板块名 */
|
|
||||||
boardName?: string;
|
|
||||||
/** 站点是否尚无任何板块(全新安装) */
|
|
||||||
noBoards?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 手机 Feed 整栏滚动时的容器 */
|
|
||||||
function getMobileFeedScrollEl(): HTMLElement | null {
|
|
||||||
return document.querySelector('.main-content--feed-mobile-scroll');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VirtualPostList({
|
|
||||||
posts,
|
|
||||||
sort = getDefaultFeedSortFromCache(),
|
|
||||||
loading,
|
|
||||||
hasMore,
|
|
||||||
showPagination,
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
postTotal,
|
|
||||||
onPageChange,
|
|
||||||
onSelect,
|
|
||||||
restoreScrollTop,
|
|
||||||
resetScrollKey = 0,
|
|
||||||
onScrollTopChange,
|
|
||||||
onScrollRestored,
|
|
||||||
keyword = '',
|
|
||||||
isSearchMode = false,
|
|
||||||
searchKeyword = '',
|
|
||||||
searchAuthor = '',
|
|
||||||
searchTitleOnly = false,
|
|
||||||
searchScopeBoardId = 0,
|
|
||||||
onClearSearch,
|
|
||||||
boardId = 0,
|
|
||||||
boardName = '',
|
|
||||||
noBoards = false,
|
|
||||||
}: Props) {
|
|
||||||
const nav = useNavigate();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { limits } = useForumLimits();
|
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
||||||
const feedStyle = limits.feed_list_style ?? 'title';
|
|
||||||
const rowEstimate = feedListRowEstimate(feedStyle);
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const restoredRef = useRef(false);
|
|
||||||
const onScrollTopChangeRef = useRef(onScrollTopChange);
|
|
||||||
const onScrollRestoredRef = useRef(onScrollRestored);
|
|
||||||
onScrollTopChangeRef.current = onScrollTopChange;
|
|
||||||
onScrollRestoredRef.current = onScrollRestored;
|
|
||||||
|
|
||||||
const [scrollMargin, setScrollMargin] = useState(0);
|
|
||||||
|
|
||||||
const getScrollElement = useCallback(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
return getMobileFeedScrollEl() ?? parentRef.current;
|
|
||||||
}
|
|
||||||
return parentRef.current;
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
/** 列表相对整栏滚动容器顶部的偏移,供虚拟列表对齐 */
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isMobile) {
|
|
||||||
setScrollMargin(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const main = getMobileFeedScrollEl();
|
|
||||||
const list = parentRef.current;
|
|
||||||
if (!main || !list) return;
|
|
||||||
|
|
||||||
const update = () => {
|
|
||||||
// 相对 main 内容顶(含当前 scrollTop),避免 offsetParent 链不准
|
|
||||||
const next = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round(list.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop),
|
|
||||||
);
|
|
||||||
setScrollMargin(prev => (prev === next ? prev : next));
|
|
||||||
};
|
|
||||||
update();
|
|
||||||
|
|
||||||
const ro = new ResizeObserver(update);
|
|
||||||
ro.observe(main);
|
|
||||||
const boardBar = main.querySelector('.mobile-board-bar');
|
|
||||||
const feedTop = main.querySelector('.feed-top');
|
|
||||||
if (boardBar) ro.observe(boardBar);
|
|
||||||
if (feedTop) ro.observe(feedTop);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
}, [isMobile, posts.length, loading, keyword, boardId]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: posts.length,
|
|
||||||
getScrollElement,
|
|
||||||
estimateSize: () => rowEstimate,
|
|
||||||
overscan: 8,
|
|
||||||
scrollMargin: isMobile ? scrollMargin : 0,
|
|
||||||
measureElement:
|
|
||||||
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
|
|
||||||
? (el) => el.getBoundingClientRect().height
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
|
||||||
const isInitialLoad = loading && posts.length === 0;
|
|
||||||
const isEmpty = !loading && posts.length === 0;
|
|
||||||
const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim());
|
|
||||||
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
|
|
||||||
const isAdmin = user?.role === 'admin';
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (resetScrollKey <= 0) return;
|
|
||||||
const el = getScrollElement();
|
|
||||||
if (el) {
|
|
||||||
el.scrollTop = 0;
|
|
||||||
virtualizer.scrollToOffset(0);
|
|
||||||
}
|
|
||||||
restoredRef.current = true;
|
|
||||||
onScrollTopChangeRef.current?.(0);
|
|
||||||
}, [resetScrollKey, virtualizer, getScrollElement]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
// 收到新的恢复目标时允许再次 restore;null 表示已消费,勿动标记
|
|
||||||
if (restoreScrollTop == null) return;
|
|
||||||
restoredRef.current = false;
|
|
||||||
}, [restoreScrollTop]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
|
||||||
const el = getScrollElement();
|
|
||||||
if (el) {
|
|
||||||
el.scrollTop = restoreScrollTop;
|
|
||||||
} else {
|
|
||||||
virtualizer.scrollToOffset(restoreScrollTop);
|
|
||||||
}
|
|
||||||
restoredRef.current = true;
|
|
||||||
onScrollRestoredRef.current?.();
|
|
||||||
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = getScrollElement();
|
|
||||||
if (!el) return;
|
|
||||||
const onScroll = () => {
|
|
||||||
onScrollTopChangeRef.current?.(el.scrollTop);
|
|
||||||
};
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
|
||||||
}, [getScrollElement, isMobile]);
|
|
||||||
|
|
||||||
const searchSummaryParts: string[] = [];
|
|
||||||
if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}」`);
|
|
||||||
if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`);
|
|
||||||
if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题');
|
|
||||||
if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`);
|
|
||||||
|
|
||||||
const emptyActions = (
|
|
||||||
<div className="empty-feed-actions">
|
|
||||||
{noBoards ? (
|
|
||||||
isAdmin ? (
|
|
||||||
<Button type="button" size="sm" onClick={() => nav('/admin/boards')}>
|
|
||||||
去创建板块
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
|
||||||
刷新看看
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : isSearchEmpty ? (
|
|
||||||
<>
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={dispatchOpenPostSearch}>
|
|
||||||
修改搜索
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => (onClearSearch ? onClearSearch() : nav('/'))}>
|
|
||||||
清除筛选
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
|
||||||
返回全部帖子
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{boardId > 0 && (
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
|
||||||
看看其他板块
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{user ? (
|
|
||||||
<Button type="button" size="sm" onClick={() => nav(composeTarget)}>
|
|
||||||
{boardName ? `成为「${boardName}」第一帖` : '发第一帖'}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" size="sm" onClick={() => nav(loginPath(composeTarget))}>
|
|
||||||
登录后发帖
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="post-list-scroll" ref={parentRef}>
|
|
||||||
{isInitialLoad ? null : isEmpty ? (
|
|
||||||
<div className="empty-feed" role="status">
|
|
||||||
{isSearchEmpty
|
|
||||||
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
|
||||||
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
|
|
||||||
<p>
|
|
||||||
{noBoards
|
|
||||||
? '暂无板块'
|
|
||||||
: isSearchEmpty
|
|
||||||
? '没有匹配的帖子'
|
|
||||||
: '暂无帖子'}
|
|
||||||
</p>
|
|
||||||
<p className="empty-feed-hint">
|
|
||||||
{noBoards
|
|
||||||
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
|
|
||||||
: isSearchEmpty
|
|
||||||
? (searchSummaryParts.length > 0
|
|
||||||
? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
|
|
||||||
: '试试更短的关键词,或浏览标签云 / 板块')
|
|
||||||
: boardName
|
|
||||||
? `「${boardName}」还没有内容,来发第一篇吧`
|
|
||||||
: '换个板块看看,或发第一篇内容'}
|
|
||||||
</p>
|
|
||||||
{emptyActions}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
|
||||||
{virtualizer.getVirtualItems().map(vi => {
|
|
||||||
const post = posts[vi.index];
|
|
||||||
if (!post) return null;
|
|
||||||
// scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位
|
|
||||||
const offsetY = isMobile ? vi.start - scrollMargin : vi.start;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={vi.key}
|
|
||||||
data-index={vi.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
transform: `translateY(${offsetY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PostListItem post={post} sort={sort} boardId={boardId} onSelect={onSelect} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{showPagination && (
|
|
||||||
<div className="feed-list-footer feed-list-footer--pagination">
|
|
||||||
<FeedPagination
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
postTotal={postTotal}
|
|
||||||
loading={loading}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showEnd && (
|
|
||||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<InFlowSiteFooter />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useEffect, type RefObject } from 'react';
|
|
||||||
|
|
||||||
/** 判断元素是否可在指定方向继续滚动 */
|
|
||||||
function canElementScroll(el: HTMLElement, deltaY: number): boolean {
|
|
||||||
const { overflowY } = getComputedStyle(el);
|
|
||||||
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (el.scrollHeight <= el.clientHeight + 1) return false;
|
|
||||||
if (deltaY < 0) return el.scrollTop > 0;
|
|
||||||
return el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从目标节点向上查找首个可滚动容器 */
|
|
||||||
function findScrollable(start: HTMLElement | null, deltaY: number, boundary: HTMLElement): HTMLElement | null {
|
|
||||||
let el = start;
|
|
||||||
while (el && el !== boundary) {
|
|
||||||
if (canElementScroll(el, deltaY)) return el;
|
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文章页:鼠标在页面任意位置滚轮时,统一滚动主内容区。
|
|
||||||
* 保留 textarea、表情面板等主内容区内部可滚动元素的独立滚动。
|
|
||||||
*/
|
|
||||||
export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, enabled = true) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
const scrollEl = scrollRef.current;
|
|
||||||
if (!scrollEl) return;
|
|
||||||
|
|
||||||
const root = scrollEl.closest('.app-shell') as HTMLElement | null;
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
|
|
||||||
if (e.ctrlKey || e.metaKey) return;
|
|
||||||
|
|
||||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const inner = findScrollable(target, e.deltaY, root);
|
|
||||||
// 主内容区内部嵌套滚动(如 textarea、表情面板)保留原生行为
|
|
||||||
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
|
|
||||||
// 主内容区外的独立滚动区(如右侧目录)保留原生行为,避免滚轮被抢走
|
|
||||||
if (inner && !scrollEl.contains(inner)) return;
|
|
||||||
// 鼠标已在主滚动容器上时,交给浏览器原生处理
|
|
||||||
if (inner === scrollEl) return;
|
|
||||||
|
|
||||||
const prev = scrollEl.scrollTop;
|
|
||||||
scrollEl.scrollTop += e.deltaY;
|
|
||||||
if (scrollEl.scrollTop !== prev) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
root.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
return () => root.removeEventListener('wheel', onWheel);
|
|
||||||
}, [scrollRef, enabled]);
|
|
||||||
}
|
|
||||||
@@ -23,16 +23,23 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export const useTheme = () => useContext(ThemeContext);
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体查询 Hook(hydration 安全)。
|
||||||
|
*
|
||||||
|
* SSR 永远输出桌面三栏布局,因此服务端与 hydrate 首帧统一返回 false,
|
||||||
|
* 保证首帧 React 树与服务端 DOM 完全同构(窄屏若首帧就读 matchMedia,
|
||||||
|
* 会与 SSR 桌面布局不一致,导致 React 丢弃整棵 SSR DOM 全量重建)。
|
||||||
|
* hydration 提交后的 layout effect 在首帧绘制前同步真实值并监听变化,
|
||||||
|
* 窄屏用户无闪烁地切换到移动布局。
|
||||||
|
*/
|
||||||
export function useMediaQuery(query: string) {
|
export function useMediaQuery(query: string) {
|
||||||
const [match, setMatch] = useState(() => {
|
const [match, setMatch] = useState(false);
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return window.matchMedia(query).matches;
|
|
||||||
});
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const m = window.matchMedia(query);
|
const m = window.matchMedia(query);
|
||||||
const fn = () => setMatch(m.matches);
|
const sync = () => setMatch(m.matches);
|
||||||
m.addEventListener('change', fn);
|
sync();
|
||||||
return () => m.removeEventListener('change', fn);
|
m.addEventListener('change', sync);
|
||||||
|
return () => m.removeEventListener('change', sync);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router';
|
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router';
|
||||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -51,21 +51,18 @@ export default function MainLayout() {
|
|||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
useMonitorPageview();
|
useMonitorPageview();
|
||||||
const mqMobile = useMediaQuery('(max-width: 768px)');
|
// 响应式分支首帧恒为桌面(useMediaQuery hydration 安全),layout effect 绘制前同步真实值
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||||
/** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */
|
|
||||||
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
|
|
||||||
const isMobile = forceDesktop ? false : mqMobile;
|
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
||||||
|
|
||||||
|
/** hydrate 首帧提交后清除同构标志(shellReady/冷启动门闩在首帧渲染期读取) */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!forceDesktop) return;
|
|
||||||
endHomeHydrate();
|
endHomeHydrate();
|
||||||
startTransition(() => setForceDesktop(false));
|
}, []);
|
||||||
}, [forceDesktop]);
|
|
||||||
|
|
||||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||||
@@ -214,7 +211,11 @@ export default function MainLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshBoards();
|
// SSR +data 已 seed boards/stats(或 SPA 冷启动已预热)时跳过首拉,避免重复请求;
|
||||||
|
// 后台操作 / 手动刷新经 boards-refresh 事件强制重拉
|
||||||
|
if (getCachedBoards().length === 0 || getCachedStats() === null) {
|
||||||
|
void refreshBoards();
|
||||||
|
}
|
||||||
const onRefresh = () => refreshBoards();
|
const onRefresh = () => refreshBoards();
|
||||||
window.addEventListener('boards-refresh', onRefresh);
|
window.addEventListener('boards-refresh', onRefresh);
|
||||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||||
@@ -541,7 +542,8 @@ export default function MainLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('app-shell', forceDesktop && 'ssr-home')}>
|
// Feed / 帖子详情用文档滚动:F5 / 前进后退的滚动位置由浏览器原生历史恢复
|
||||||
|
<div className={cn('app-shell', !isCompose && (isFeedHome || isPostDetail) && 'app-shell--doc-scroll')}>
|
||||||
<div className="app-frame">
|
<div className="app-frame">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
@@ -820,11 +822,10 @@ export default function MainLayout() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shellReady ? (
|
{/* 不在此处包 Suspense:React 19 Fizz 会把 Suspense 边界内大体积/含 <img>
|
||||||
<Suspense fallback={null}>
|
的同步内容 outlining 为 <div hidden> 分块(禁 JS 爬虫不可见)。
|
||||||
<Outlet context={layoutCtx} />
|
懒加载路由的 Suspense 已在 AppRoutes 各路由 element 上就近声明。 */}
|
||||||
</Suspense>
|
{shellReady ? <Outlet context={layoutCtx} /> : null}
|
||||||
) : null}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useOutletContext,
|
useOutletContext,
|
||||||
@@ -7,17 +7,22 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
useNavigationType,
|
useNavigationType,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
import { Inbox, SearchX } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
import VirtualPostList from '../components/VirtualPostList';
|
import PostListItem from '../components/PostListItem';
|
||||||
import StaticFeedList from '../components/StaticFeedList';
|
import FeedPagination from '../components/FeedPagination';
|
||||||
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import FeedHeader from '../components/FeedHeader';
|
import FeedHeader from '../components/FeedHeader';
|
||||||
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||||
import {
|
import {
|
||||||
navigateFeed,
|
navigateFeed,
|
||||||
FEED_RESET_EVENT,
|
FEED_RESET_EVENT,
|
||||||
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
|||||||
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
import { startTransition, doneTransition } from '../utils/spaTransition';
|
import { startTransition, doneTransition } from '../utils/spaTransition';
|
||||||
import { isHomeHydrating } from '../utils/homeHydrate';
|
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||||
@@ -49,7 +53,6 @@ type FeedHydrate = {
|
|||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
page: number;
|
page: number;
|
||||||
scrollTop: number;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,11 +72,10 @@ function readHydrate(
|
|||||||
posts: cached.posts,
|
posts: cached.posts,
|
||||||
postTotal: cached.postTotal,
|
postTotal: cached.postTotal,
|
||||||
page: cached.page,
|
page: cached.page,
|
||||||
scrollTop: cached.scrollTop,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
|
return { posts: [], postTotal: 0, page: 1, loading: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -83,6 +85,7 @@ export default function HomePage() {
|
|||||||
const { id: boardRouteId } = useParams();
|
const { id: boardRouteId } = useParams();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const ctx = useOutletContext<LayoutCtx>();
|
const ctx = useOutletContext<LayoutCtx>();
|
||||||
|
const { user } = useAuth();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const { limits, loading: limitsLoading } = useForumLimits();
|
const { limits, loading: limitsLoading } = useForumLimits();
|
||||||
const postSearch = usePostSearch(limits);
|
const postSearch = usePostSearch(limits);
|
||||||
@@ -187,25 +190,14 @@ export default function HomePage() {
|
|||||||
titleOnly,
|
titleOnly,
|
||||||
});
|
});
|
||||||
const [listPending, setListPending] = useState(false);
|
const [listPending, setListPending] = useState(false);
|
||||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
/** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
|
||||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
|
||||||
);
|
|
||||||
const [listResetKey, setListResetKey] = useState(0);
|
const [listResetKey, setListResetKey] = useState(0);
|
||||||
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
|
|
||||||
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (useVirtualList) return;
|
|
||||||
reactStartTransition(() => setUseVirtualList(true));
|
|
||||||
}, [useVirtualList]);
|
|
||||||
|
|
||||||
const scrollTopRef = useRef(initial.scrollTop);
|
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const pageRef = useRef(initial.page);
|
const pageRef = useRef(initial.page);
|
||||||
const cacheKeyRef = useRef(cacheKey);
|
const cacheKeyRef = useRef(cacheKey);
|
||||||
const viewKeyRef = useRef(view.cacheKey);
|
const viewKeyRef = useRef(view.cacheKey);
|
||||||
const postsRef = useRef(posts);
|
const postsRef = useRef(posts);
|
||||||
const scrollRafRef = useRef(0);
|
|
||||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||||
const hydratedKeyRef = useRef<string | null>(null);
|
const hydratedKeyRef = useRef<string | null>(null);
|
||||||
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
||||||
@@ -223,8 +215,6 @@ export default function HomePage() {
|
|||||||
setPostTotal(next.postTotal);
|
setPostTotal(next.postTotal);
|
||||||
setPage(next.page);
|
setPage(next.page);
|
||||||
pageRef.current = next.page;
|
pageRef.current = next.page;
|
||||||
scrollTopRef.current = next.scrollTop;
|
|
||||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
|
||||||
setLoading(next.loading);
|
setLoading(next.loading);
|
||||||
setListPending(false);
|
setListPending(false);
|
||||||
setView(meta);
|
setView(meta);
|
||||||
@@ -254,10 +244,10 @@ export default function HomePage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
const showPagination = totalPages > 1 && posts.length > 0;
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
|
||||||
|
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||||
|
|
||||||
const resetFeedView = useCallback(() => {
|
const resetFeedView = useCallback(() => {
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -266,12 +256,12 @@ export default function HomePage() {
|
|||||||
resetFeedView();
|
resetFeedView();
|
||||||
}, [resetFeedView]);
|
}, [resetFeedView]);
|
||||||
|
|
||||||
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
|
/** 把当前列表写入 Zustand */
|
||||||
const persistFeed = useCallback((
|
const persistFeed = useCallback((
|
||||||
nextPosts: PostItem[],
|
nextPosts: PostItem[],
|
||||||
nextTotal: number,
|
nextTotal: number,
|
||||||
nextPage: number,
|
nextPage: number,
|
||||||
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
|
opts?: { touchFetchTime?: boolean; key?: string },
|
||||||
) => {
|
) => {
|
||||||
const key = opts?.key ?? cacheKeyRef.current;
|
const key = opts?.key ?? cacheKeyRef.current;
|
||||||
const prev = getHomeStoreState().getFeed(key);
|
const prev = getHomeStoreState().getFeed(key);
|
||||||
@@ -279,7 +269,6 @@ export default function HomePage() {
|
|||||||
posts: nextPosts,
|
posts: nextPosts,
|
||||||
postTotal: nextTotal,
|
postTotal: nextTotal,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
|
|
||||||
lastFetchTime: opts?.touchFetchTime === false
|
lastFetchTime: opts?.touchFetchTime === false
|
||||||
? (prev?.lastFetchTime ?? Date.now())
|
? (prev?.lastFetchTime ?? Date.now())
|
||||||
: Date.now(),
|
: Date.now(),
|
||||||
@@ -309,10 +298,7 @@ export default function HomePage() {
|
|||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
const total = data.total ?? 0;
|
const total = data.total ?? 0;
|
||||||
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
||||||
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
|
|
||||||
if (jumpTop) {
|
if (jumpTop) {
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
}
|
}
|
||||||
setPosts(batch);
|
setPosts(batch);
|
||||||
@@ -320,7 +306,7 @@ export default function HomePage() {
|
|||||||
setPage(p);
|
setPage(p);
|
||||||
pageRef.current = p;
|
pageRef.current = p;
|
||||||
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
|
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
|
persistFeed(batch, total, p, { touchFetchTime: true, key: requestKey });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (seq !== fetchSeqRef.current) return;
|
if (seq !== fetchSeqRef.current) return;
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
@@ -352,8 +338,6 @@ export default function HomePage() {
|
|||||||
}, [fetchPage, postTotal, pageSize]);
|
}, [fetchPage, postTotal, pageSize]);
|
||||||
|
|
||||||
const handleSelectPost = useCallback((id: number) => {
|
const handleSelectPost = useCallback((id: number) => {
|
||||||
// 离开前再写一次滚动,确保详情页返回可还原
|
|
||||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
|
||||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||||
}, [nav, limits.open_posts_in_new_tab]);
|
}, [nav, limits.open_posts_in_new_tab]);
|
||||||
|
|
||||||
@@ -399,7 +383,6 @@ export default function HomePage() {
|
|||||||
posts: cached.posts,
|
posts: cached.posts,
|
||||||
postTotal: cached.postTotal,
|
postTotal: cached.postTotal,
|
||||||
page: cached.page,
|
page: cached.page,
|
||||||
scrollTop: cached.scrollTop,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
} else {
|
} else {
|
||||||
@@ -415,10 +398,6 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hydratedKeyRef.current = cacheKey;
|
hydratedKeyRef.current = cacheKey;
|
||||||
if (postsRef.current.length === 0) {
|
|
||||||
setRestoreScrollTop(null);
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
}
|
|
||||||
loadFirst();
|
loadFirst();
|
||||||
}, [
|
}, [
|
||||||
limitsLoading,
|
limitsLoading,
|
||||||
@@ -466,7 +445,6 @@ export default function HomePage() {
|
|||||||
posts: warm.posts,
|
posts: warm.posts,
|
||||||
postTotal: warm.postTotal,
|
postTotal: warm.postTotal,
|
||||||
page: warm.page,
|
page: warm.page,
|
||||||
scrollTop: 0,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
setListResetKey((k) => k + 1);
|
setListResetKey((k) => k + 1);
|
||||||
@@ -495,20 +473,21 @@ export default function HomePage() {
|
|||||||
sort, boardId, keyword, tag, author, titleOnly,
|
sort, boardId, keyword, tag, author, titleOnly,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 卸载时取消未执行的 scroll rAF
|
// 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
|
||||||
useEffect(() => () => {
|
// - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
|
||||||
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
|
// - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
|
||||||
}, []);
|
// 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (listResetKey <= 0) return;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [listResetKey]);
|
||||||
|
|
||||||
const handleScrollTopChange = useCallback((top: number) => {
|
// PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
|
||||||
scrollTopRef.current = top;
|
// POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
|
||||||
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
|
useEffect(() => {
|
||||||
if (scrollRafRef.current) return;
|
if (navType === 'POP') return;
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
window.scrollTo(0, 0);
|
||||||
scrollRafRef.current = 0;
|
}, [cacheKey, navType]);
|
||||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSortChange = (next: FeedSort) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
||||||
@@ -527,6 +506,32 @@ export default function HomePage() {
|
|||||||
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
||||||
const isSearchActive = !!(view.keyword || view.author);
|
const isSearchActive = !!(view.keyword || view.author);
|
||||||
|
|
||||||
|
// 空态(加载中且无列表时已在上方 return null,此处只处理真空态)
|
||||||
|
const viewBoardName = ctx?.boards?.find(b => b.id === view.boardId)?.name || '';
|
||||||
|
const noBoards = !ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0;
|
||||||
|
const isSearchEmpty = posts.length === 0 && !loading
|
||||||
|
&& (isSearchActive || !!view.tag || !!(view.keyword || '').trim());
|
||||||
|
const composeTarget = view.boardId > 0 ? `/compose?board=${view.boardId}` : '/compose';
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const searchSummaryParts: string[] = [];
|
||||||
|
const kw = (view.keyword || view.tag || '').trim();
|
||||||
|
if (kw) searchSummaryParts.push(`关键词「${kw}」`);
|
||||||
|
if (view.author?.trim()) searchSummaryParts.push(`作者「${view.author.trim()}」`);
|
||||||
|
if (view.titleOnly) searchSummaryParts.push('仅标题');
|
||||||
|
if (searchFilters.scopeBoardId) {
|
||||||
|
const bn = ctx?.boards?.find(b => b.id === searchFilters.scopeBoardId)?.name || '';
|
||||||
|
if (bn) searchSummaryParts.push(`板块「${bn}」`);
|
||||||
|
}
|
||||||
|
const emptyHint = noBoards
|
||||||
|
? (isAdmin
|
||||||
|
? '网站还没有任何板块。你可以创建第一个板块,给帖子们一个归属。'
|
||||||
|
: '网站刚刚创建,管理员还没有添加板块。稍后再来看看吧。')
|
||||||
|
: isSearchEmpty
|
||||||
|
? `${searchSummaryParts.join(' · ')}${searchSummaryParts.length ? '。' : ''}换个关键词试试,或清除当前筛选条件。`
|
||||||
|
: (view.boardId > 0
|
||||||
|
? `板块「${viewBoardName}」还没有帖子。看看其他板块,或发表这个板块的第一帖。`
|
||||||
|
: '成为第一个发帖的人,开始讨论吧。');
|
||||||
|
|
||||||
if (isInvalidBoardRoute || isMissingBoard) {
|
if (isInvalidBoardRoute || isMissingBoard) {
|
||||||
return (
|
return (
|
||||||
<NotFoundPage
|
<NotFoundPage
|
||||||
@@ -546,17 +551,6 @@ export default function HomePage() {
|
|||||||
<div className="feed-panel">
|
<div className="feed-panel">
|
||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<div className="feed-top__bar">
|
||||||
{!useVirtualList ? (
|
|
||||||
(view.keyword || view.tag || view.author) ? (
|
|
||||||
<h1 className="feed-header-title">
|
|
||||||
{view.tag
|
|
||||||
? `#${view.tag}`
|
|
||||||
: view.keyword
|
|
||||||
? `搜索:${view.keyword}`
|
|
||||||
: `作者:${view.author}`}
|
|
||||||
</h1>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<FeedHeader
|
<FeedHeader
|
||||||
keyword={view.keyword}
|
keyword={view.keyword}
|
||||||
tag={view.tag}
|
tag={view.tag}
|
||||||
@@ -564,7 +558,6 @@ export default function HomePage() {
|
|||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{showSortBar && (
|
{showSortBar && (
|
||||||
<FeedSortBar
|
<FeedSortBar
|
||||||
value={view.sort}
|
value={view.sort}
|
||||||
@@ -583,36 +576,79 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!useVirtualList ? (
|
{/*
|
||||||
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
|
列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
|
||||||
|
不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
|
||||||
|
也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
|
||||||
|
*/}
|
||||||
|
<div className="post-list-scroll post-list-scroll--ssr">
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div className="empty-feed" role="status">
|
||||||
|
{isSearchEmpty
|
||||||
|
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
|
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
|
||||||
|
<p>{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
|
||||||
|
<p className="empty-feed-hint">{emptyHint}</p>
|
||||||
|
<div className="empty-feed-actions">
|
||||||
|
{noBoards ? (
|
||||||
|
isAdmin ? (
|
||||||
|
<Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
|
||||||
) : (
|
) : (
|
||||||
<VirtualPostList
|
<Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
|
||||||
posts={posts}
|
)
|
||||||
|
) : isSearchEmpty ? (
|
||||||
|
<>
|
||||||
|
<Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
|
||||||
|
<Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
|
||||||
|
<Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
|
||||||
|
</>
|
||||||
|
) : view.boardId > 0 ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => nav('/')}>看看其他板块</Button>
|
||||||
|
{user ? (
|
||||||
|
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : user ? (
|
||||||
|
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="content-surface content-surface--ssr">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostListItem
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
sort={view.sort}
|
sort={view.sort}
|
||||||
loading={listPending ? false : (loading || limitsLoading)}
|
boardId={view.boardId}
|
||||||
hasMore={hasMore}
|
onSelect={handleSelectPost}
|
||||||
showPagination={showPagination}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showPagination && (
|
||||||
|
<div className="feed-list-footer feed-list-footer--pagination">
|
||||||
|
<FeedPagination
|
||||||
page={page}
|
page={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
|
loading={loading}
|
||||||
onPageChange={goToPage}
|
onPageChange={goToPage}
|
||||||
onSelect={handleSelectPost}
|
|
||||||
restoreScrollTop={restoreScrollTop}
|
|
||||||
resetScrollKey={listResetKey}
|
|
||||||
onScrollTopChange={handleScrollTopChange}
|
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
|
||||||
keyword={view.keyword || view.tag || view.author}
|
|
||||||
isSearchMode={!!(view.keyword || view.author)}
|
|
||||||
searchKeyword={view.keyword}
|
|
||||||
searchAuthor={view.author}
|
|
||||||
searchTitleOnly={view.titleOnly}
|
|
||||||
searchScopeBoardId={searchFilters.scopeBoardId}
|
|
||||||
onClearSearch={postSearch.clearSearch}
|
|
||||||
boardId={view.boardId}
|
|
||||||
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
|
|
||||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showEnd && (
|
||||||
|
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||||
|
)}
|
||||||
|
<InFlowSiteFooter />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { loadMyCommentIds } from '../utils/guest';
|
|||||||
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||||
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||||
@@ -79,7 +78,6 @@ type PostDetailSnapshot = {
|
|||||||
bountyCanRefund: boolean;
|
bountyCanRefund: boolean;
|
||||||
bountyRefundBlockReason: string;
|
bountyRefundBlockReason: string;
|
||||||
bountyEligibleReplyCount: number;
|
bountyEligibleReplyCount: number;
|
||||||
scrollTop: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function postDetailCacheKey(id: number) {
|
function postDetailCacheKey(id: number) {
|
||||||
@@ -145,24 +143,15 @@ export default function PostDetailPage() {
|
|||||||
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
|
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
|
||||||
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
|
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
|
||||||
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
|
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
|
||||||
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */
|
|
||||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
|
|
||||||
if (!initialSnap) return null;
|
|
||||||
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageRef = useRef<HTMLDivElement>(null);
|
|
||||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const postRef = useRef<PostItem | null>(null);
|
const postRef = useRef<PostItem | null>(null);
|
||||||
const scrollTopRef = useRef(
|
|
||||||
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
|
|
||||||
);
|
|
||||||
postRef.current = post;
|
postRef.current = post;
|
||||||
|
|
||||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
// 滚动位置完全交给浏览器原生历史恢复(详情页为文档滚动,见 .app-shell--doc-scroll),
|
||||||
|
// 会话快照只缓存数据让 POP 后退时内容即时渲染、文档即刻够高,原生恢复才有正确落点。
|
||||||
|
|
||||||
// SPA 内跳转时纠正非规范伪静态路径
|
// SPA 内跳转时纠正非规范伪静态路径
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,7 +201,7 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
setPostOutline({
|
setPostOutline({
|
||||||
headings,
|
headings,
|
||||||
scrollRoot: pageRef.current,
|
scrollRoot: null,
|
||||||
title: '文章目录',
|
title: '文章目录',
|
||||||
author: post.user ?? null,
|
author: post.user ?? null,
|
||||||
publishedAt: post.created_at,
|
publishedAt: post.created_at,
|
||||||
@@ -238,15 +227,9 @@ export default function PostDetailPage() {
|
|||||||
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
||||||
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
||||||
setComments(snap.comments);
|
setComments(snap.comments);
|
||||||
if (opts?.restoreScroll) {
|
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
||||||
scrollTopRef.current = snap.scrollTop;
|
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
||||||
setRestoreScrollTop(snap.scrollTop);
|
if (!opts?.restoreScroll) window.scrollTo(0, 0);
|
||||||
} else {
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
|
|
||||||
const el = pageRef.current;
|
|
||||||
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
|
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
|
||||||
@@ -304,15 +287,9 @@ export default function PostDetailPage() {
|
|||||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
bountyCanRefund: detail.bounty_can_refund ?? true,
|
||||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
||||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
||||||
scrollTop: 0,
|
|
||||||
};
|
};
|
||||||
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
||||||
applySnapshot(snap, { restoreScroll: false });
|
applySnapshot(snap, { restoreScroll: false });
|
||||||
if (mode === 'force') {
|
|
||||||
const el = pageRef.current;
|
|
||||||
if (el) el.scrollTop = 0;
|
|
||||||
scrollTopRef.current = 0;
|
|
||||||
}
|
|
||||||
void refresh();
|
void refresh();
|
||||||
} catch {
|
} catch {
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
@@ -342,23 +319,6 @@ export default function PostDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [postId, applySnapshot]);
|
}, [postId, applySnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = pageRef.current;
|
|
||||||
if (!el || loading || !post) return;
|
|
||||||
const onScroll = () => {
|
|
||||||
scrollTopRef.current = el.scrollTop;
|
|
||||||
};
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
|
||||||
}, [loading, post]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
|
|
||||||
pageRef.current.scrollTop = restoreScrollTop;
|
|
||||||
scrollTopRef.current = restoreScrollTop;
|
|
||||||
setRestoreScrollTop(null);
|
|
||||||
}, [restoreScrollTop, loading, post]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!post || post.id !== postId || loading) return;
|
if (!post || post.id !== postId || loading) return;
|
||||||
setSessionSnapshot(postDetailCacheKey(post.id), {
|
setSessionSnapshot(postDetailCacheKey(post.id), {
|
||||||
@@ -375,7 +335,6 @@ export default function PostDetailPage() {
|
|||||||
bountyCanRefund,
|
bountyCanRefund,
|
||||||
bountyRefundBlockReason,
|
bountyRefundBlockReason,
|
||||||
bountyEligibleReplyCount,
|
bountyEligibleReplyCount,
|
||||||
scrollTop: scrollTopRef.current,
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
||||||
@@ -383,14 +342,6 @@ export default function PostDetailPage() {
|
|||||||
bountyEligibleReplyCount, postId, loading,
|
bountyEligibleReplyCount, postId, loading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
const p = postRef.current;
|
|
||||||
if (!p) return;
|
|
||||||
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
|
|
||||||
if (!prev) return;
|
|
||||||
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reloadComments = useCallback(async () => {
|
const reloadComments = useCallback(async () => {
|
||||||
const myIds = user ? [] : loadMyCommentIds();
|
const myIds = user ? [] : loadMyCommentIds();
|
||||||
const comm = await api.comments(postId, myIds);
|
const comm = await api.comments(postId, myIds);
|
||||||
@@ -460,102 +411,55 @@ export default function PostDetailPage() {
|
|||||||
return () => root.removeEventListener('focusout', onFocusOut);
|
return () => root.removeEventListener('focusout', onFocusOut);
|
||||||
}, [isMobile, composerOpen]);
|
}, [isMobile, composerOpen]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情页为窗口滚动:scroll-margin-top 预留 sticky 顶栏空间。
|
||||||
|
* 首屏图片/字体加载会持续改变布局,落点需短暂校正;用户手动滚动立即让位。
|
||||||
|
*/
|
||||||
|
const scrollAnchorIntoView = useCallback((el: HTMLElement) => {
|
||||||
|
el.scrollIntoView({ block: 'start' });
|
||||||
|
|
||||||
|
let interrupted = false;
|
||||||
|
const yieldToUser = () => { interrupted = true; };
|
||||||
|
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||||
|
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
(function correct() {
|
||||||
|
window.removeEventListener('wheel', yieldToUser);
|
||||||
|
window.removeEventListener('touchmove', yieldToUser);
|
||||||
|
if (interrupted || !el.isConnected || Date.now() - start > 1500) return;
|
||||||
|
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||||||
|
if (Math.abs(el.getBoundingClientRect().top - margin) > 4) {
|
||||||
|
el.scrollIntoView({ block: 'start' });
|
||||||
|
}
|
||||||
|
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||||
|
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||||
|
window.setTimeout(correct, 120);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const jumpToFloor = useCallback((floor: number) => {
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
const el = document.getElementById(`floor-${floor}`);
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
|
|
||||||
const root = pageRef.current;
|
scrollAnchorIntoView(el);
|
||||||
if (!root) return false;
|
|
||||||
|
|
||||||
// 直接计算目标滚动位置
|
|
||||||
const rootRect = root.getBoundingClientRect();
|
|
||||||
const elRect = el.getBoundingClientRect();
|
|
||||||
const relativeTop = elRect.top - rootRect.top;
|
|
||||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
|
||||||
const targetTop = root.scrollTop + relativeTop - 24;
|
|
||||||
|
|
||||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
|
||||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
|
||||||
|
|
||||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
|
||||||
|
|
||||||
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
|
|
||||||
root.scrollTop = clampedTop;
|
|
||||||
|
|
||||||
// 验证滚动结果,如果不准确则再次调整
|
|
||||||
// 注意:使用动态计算的 currentMaxScrollTop,因为评论可能在之后才渲染完成
|
|
||||||
const verifyAndFix = () => {
|
|
||||||
const currentElRect = el.getBoundingClientRect();
|
|
||||||
const currentRootRect = root.getBoundingClientRect();
|
|
||||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
|
||||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
|
||||||
|
|
||||||
// 如果元素在视口内但位置不准确,进行修正
|
|
||||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
|
|
||||||
const correction = currentRelativeTop - 24;
|
|
||||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
|
||||||
root.scrollTop = newTarget;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 在多个时间点验证和调整
|
|
||||||
requestAnimationFrame(verifyAndFix);
|
|
||||||
setTimeout(verifyAndFix, 50);
|
|
||||||
setTimeout(verifyAndFix, 150);
|
|
||||||
|
|
||||||
setHighlightFloor(floor);
|
setHighlightFloor(floor);
|
||||||
clearTimeout(highlightTimer.current);
|
clearTimeout(highlightTimer.current);
|
||||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, [scrollAnchorIntoView]);
|
||||||
|
|
||||||
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
|
/** 正文标题锚点:等正文进 DOM 后由调用方重试 */
|
||||||
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
const jumpToHeadingHash = useCallback((hash: string) => {
|
||||||
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
|
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
|
||||||
if (!id || /^floor-\d+$/.test(id)) return false;
|
if (!id || /^floor-\d+$/.test(id)) return false;
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
|
|
||||||
const root = pageRef.current;
|
scrollAnchorIntoView(el);
|
||||||
if (!root) return false;
|
|
||||||
|
|
||||||
// 直接计算目标滚动位置
|
|
||||||
const rootRect = root.getBoundingClientRect();
|
|
||||||
const elRect = el.getBoundingClientRect();
|
|
||||||
const relativeTop = elRect.top - rootRect.top;
|
|
||||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
|
||||||
const targetTop = root.scrollTop + relativeTop - 12;
|
|
||||||
|
|
||||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
|
||||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
|
||||||
|
|
||||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
|
||||||
|
|
||||||
// 直接设置滚动位置
|
|
||||||
root.scrollTop = clampedTop;
|
|
||||||
|
|
||||||
// 验证滚动结果,如果不准确则再次调整
|
|
||||||
const verifyAndFix = () => {
|
|
||||||
const currentElRect = el.getBoundingClientRect();
|
|
||||||
const currentRootRect = root.getBoundingClientRect();
|
|
||||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
|
||||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
|
||||||
|
|
||||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
|
|
||||||
const correction = currentRelativeTop - 12;
|
|
||||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
|
||||||
root.scrollTop = newTarget;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(verifyAndFix);
|
|
||||||
if (smooth) {
|
|
||||||
setTimeout(verifyAndFix, 50);
|
|
||||||
setTimeout(verifyAndFix, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, [scrollAnchorIntoView]);
|
||||||
|
|
||||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
|
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -597,7 +501,7 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const tryJump = () => {
|
const tryJump = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (jumpToHeadingHash(hash, false)) return;
|
if (jumpToHeadingHash(hash)) return;
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if (attempts < 30) {
|
if (attempts < 30) {
|
||||||
timer = window.setTimeout(tryJump, 50);
|
timer = window.setTimeout(tryJump, 50);
|
||||||
@@ -946,7 +850,7 @@ export default function PostDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
<article className="page-wrap post-detail-page">
|
||||||
<div className="post-detail-header">
|
<div className="post-detail-header">
|
||||||
<div className="post-detail-nav">
|
<div className="post-detail-nav">
|
||||||
<div className="post-detail-nav-left">
|
<div className="post-detail-nav-left">
|
||||||
@@ -1083,7 +987,6 @@ export default function PostDetailPage() {
|
|||||||
<summary>文章目录({headings.length})</summary>
|
<summary>文章目录({headings.length})</summary>
|
||||||
<ArticleOutline
|
<ArticleOutline
|
||||||
headings={headings}
|
headings={headings}
|
||||||
scrollRoot={pageRef.current}
|
|
||||||
title="目录"
|
title="目录"
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -11,32 +11,13 @@ import { redirect, render } from 'vike/abort';
|
|||||||
import type { PageContextServer } from 'vike/types';
|
import type { PageContextServer } from 'vike/types';
|
||||||
import { serverApi } from '../api/serverClient';
|
import { serverApi } from '../api/serverClient';
|
||||||
import type { Comment, PostDetailResponse } from '../api/types';
|
import type { Comment, PostDetailResponse } from '../api/types';
|
||||||
import type { HomeBootPayload, PostDetailSnapshot } from '../utils/homeBoot';
|
import type { HomeBootPayload } from '../utils/homeBoot';
|
||||||
import { buildPostDetailSnapshot, postDetailCacheKey, seedHomeBoot } from '../utils/homeBoot';
|
import { buildPostDetailSnapshot } from '../utils/homeBoot';
|
||||||
import { setSessionSnapshot } from '../utils/sessionPageCache';
|
|
||||||
import { matchFeedSsrPath } from './feedPaths';
|
import { matchFeedSsrPath } from './feedPaths';
|
||||||
import { buildFeedHead, buildNotFoundHead, buildPostHead, type PageHead } from './head';
|
import { buildFeedHead, buildNotFoundHead, buildPostHead } from './head';
|
||||||
|
import type { FeedData } from './feedSeed';
|
||||||
|
|
||||||
export type FeedData = {
|
export type { FeedData };
|
||||||
kind: 'home' | 'board' | 'post' | 'notfound';
|
|
||||||
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
|
|
||||||
boot?: HomeBootPayload;
|
|
||||||
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
|
|
||||||
snapshot?: PostDetailSnapshot;
|
|
||||||
/** <head> 元信息(服务端专用,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 调用) */
|
/** +data 入口(pages/feed/+data.ts 调用) */
|
||||||
export async function loadFeedData(pageContext: PageContextServer): Promise<FeedData> {
|
export async function loadFeedData(pageContext: PageContextServer): Promise<FeedData> {
|
||||||
|
|||||||
30
frontend/src/ssr/feedSeed.ts
Normal file
30
frontend/src/ssr/feedSeed.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* SSR 数据播种(双端安全模块)。
|
||||||
|
*
|
||||||
|
* onRenderHtml(服务端,请求作用域内)与 onRenderClient(hydrate 前,模块单例)
|
||||||
|
* 都会在渲染前把 +data 结果灌入缓存层;两侧均经 seedHomeBoot/setSessionSnapshot
|
||||||
|
* 内部的 scope 判断,逻辑同一份。
|
||||||
|
*
|
||||||
|
* 注意:本模块禁止引入仅服务端依赖(如 api/serverClient),否则会被打进客户端包。
|
||||||
|
*/
|
||||||
|
import type { HomeBootPayload, PostDetailSnapshot } from '../utils/homeBoot';
|
||||||
|
import { postDetailCacheKey, seedHomeBoot } from '../utils/homeBoot';
|
||||||
|
import { setSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
|
import type { PageHead } from './head';
|
||||||
|
|
||||||
|
export type FeedData = {
|
||||||
|
kind: 'home' | 'board' | 'post' | 'notfound';
|
||||||
|
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
|
||||||
|
boot?: HomeBootPayload;
|
||||||
|
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
|
||||||
|
snapshot?: PostDetailSnapshot;
|
||||||
|
/** <head> 元信息(服务端专用,onRenderHtml 消费) */
|
||||||
|
head?: PageHead;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将 +data 结果灌入当前缓存层(服务端请求作用域 / 客户端模块单例均可调用) */
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -7,11 +7,10 @@
|
|||||||
* - 客户端:scope 为 null,回落到原有模块单例行为
|
* - 客户端:scope 为 null,回落到原有模块单例行为
|
||||||
*/
|
*/
|
||||||
import type { User, SiteBranding, ForumLimitsPublic, SitePageSummary } from '../api/types';
|
import type { User, SiteBranding, ForumLimitsPublic, SitePageSummary } from '../api/types';
|
||||||
import type { CheckInStatus } from '../api/types';
|
|
||||||
import type { HomeStoreState } from '../store/homeStore';
|
import type { HomeStoreState } from '../store/homeStore';
|
||||||
|
|
||||||
export type RequestScope = {
|
export type RequestScope = {
|
||||||
/** layoutCache / sessionPageCache / 签到等统一 KV(键与 sessionStorage 键一致) */
|
/** layoutCache / sessionPageCache 等统一 KV(键与 sessionStorage 键一致) */
|
||||||
kv: Map<string, unknown>;
|
kv: Map<string, unknown>;
|
||||||
/** 本请求独立的 feed store(zustand 实例) */
|
/** 本请求独立的 feed store(zustand 实例) */
|
||||||
homeStore: HomeStoreState;
|
homeStore: HomeStoreState;
|
||||||
@@ -21,15 +20,11 @@ export type RequestScope = {
|
|||||||
branding: SiteBranding | null;
|
branding: SiteBranding | null;
|
||||||
limits: ForumLimitsPublic | null;
|
limits: ForumLimitsPublic | null;
|
||||||
pages: SitePageSummary[] | null;
|
pages: SitePageSummary[] | null;
|
||||||
/** 签到快照按 uid 存放 */
|
|
||||||
checkIns: Map<number, CheckInStatus>;
|
|
||||||
/** 主题(由 +data 从 cookie 解析) */
|
|
||||||
theme: 'light' | 'dark';
|
|
||||||
/** 本请求渲染是否采用「hydrate 首帧同构变体」(feed SSR 页恒为 true) */
|
/** 本请求渲染是否采用「hydrate 首帧同构变体」(feed SSR 页恒为 true) */
|
||||||
hydrating: boolean;
|
hydrating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | 'dark' = 'light'): RequestScope {
|
export function createRequestScope(homeStore: HomeStoreState): RequestScope {
|
||||||
return {
|
return {
|
||||||
kv: new Map(),
|
kv: new Map(),
|
||||||
homeStore,
|
homeStore,
|
||||||
@@ -37,8 +32,6 @@ export function createRequestScope(homeStore: HomeStoreState, theme: 'light' | '
|
|||||||
branding: null,
|
branding: null,
|
||||||
limits: null,
|
limits: null,
|
||||||
pages: null,
|
pages: null,
|
||||||
checkIns: new Map(),
|
|
||||||
theme,
|
|
||||||
hydrating: false,
|
hydrating: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type FeedCacheEntry = {
|
|||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
page: number;
|
page: number;
|
||||||
scrollTop: number;
|
|
||||||
/** 上次成功拉取 API 的时间戳(ms) */
|
/** 上次成功拉取 API 的时间戳(ms) */
|
||||||
lastFetchTime: number;
|
lastFetchTime: number;
|
||||||
};
|
};
|
||||||
@@ -45,7 +44,6 @@ type HomeStoreApiState = {
|
|||||||
feeds: Record<string, FeedCacheEntry>;
|
feeds: Record<string, FeedCacheEntry>;
|
||||||
getFeed: (key: string) => FeedCacheEntry | null;
|
getFeed: (key: string) => FeedCacheEntry | null;
|
||||||
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
|
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
|
||||||
patchScroll: (key: string, scrollTop: number) => void;
|
|
||||||
isStale: (key: string, now?: number) => boolean;
|
isStale: (key: string, now?: number) => boolean;
|
||||||
clearFeed: (key: string) => void;
|
clearFeed: (key: string) => void;
|
||||||
clearAll: () => void;
|
clearAll: () => void;
|
||||||
@@ -63,24 +61,11 @@ function createInitialState(set: (fn: (state: HomeStoreApiState) => Partial<Home
|
|||||||
posts: data.posts,
|
posts: data.posts,
|
||||||
postTotal: data.postTotal,
|
postTotal: data.postTotal,
|
||||||
page: data.page,
|
page: data.page,
|
||||||
scrollTop: data.scrollTop,
|
|
||||||
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
lastFetchTime: data.lastFetchTime ?? Date.now(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
patchScroll: (key, scrollTop) => {
|
|
||||||
const prev = get().feeds[key];
|
|
||||||
if (!prev) return;
|
|
||||||
// 数值未变则跳过,减少无意义的 store 更新
|
|
||||||
if (prev.scrollTop === scrollTop) return;
|
|
||||||
set((state) => ({
|
|
||||||
feeds: {
|
|
||||||
...state.feeds,
|
|
||||||
[key]: { ...prev, scrollTop },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
isStale: (key, now = Date.now()) => {
|
isStale: (key, now = Date.now()) => {
|
||||||
const entry = get().feeds[key];
|
const entry = get().feeds[key];
|
||||||
if (!entry) return true;
|
if (!entry) return true;
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ body {
|
|||||||
.app-body,
|
.app-body,
|
||||||
.content-workspace,
|
.content-workspace,
|
||||||
.main-content,
|
.main-content,
|
||||||
.virtual-list-wrap,
|
|
||||||
.post-list-scroll,
|
.post-list-scroll,
|
||||||
.list-page-panel,
|
.list-page-panel,
|
||||||
.article-editor-scroll,
|
.article-editor-scroll,
|
||||||
@@ -1766,8 +1765,13 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
color: var(--j13-green);
|
color: var(--j13-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 窗口滚动下顶栏 sticky 悬浮:锚点/楼层跳转预留顶栏高度 */
|
||||||
.post-detail-content .post-heading-anchor {
|
.post-detail-content .post-heading-anchor {
|
||||||
scroll-margin-top: 16px;
|
scroll-margin-top: calc(var(--j13-header-h) + 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waline-comment {
|
||||||
|
scroll-margin-top: calc(var(--j13-header-h) + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标题旁 #:hover 显现,点击复制锚点链接 */
|
/* 标题旁 #:hover 显现,点击复制锚点链接 */
|
||||||
@@ -2015,7 +2019,6 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
.post-detail-page,
|
.post-detail-page,
|
||||||
.page-wrap,
|
.page-wrap,
|
||||||
.post-list-scroll,
|
.post-list-scroll,
|
||||||
.virtual-list-wrap,
|
|
||||||
.aside-panel,
|
.aside-panel,
|
||||||
.aside-drawer-body,
|
.aside-drawer-body,
|
||||||
.widget-outline-body {
|
.widget-outline-body {
|
||||||
@@ -2028,7 +2031,6 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
.post-detail-page::-webkit-scrollbar,
|
.post-detail-page::-webkit-scrollbar,
|
||||||
.page-wrap::-webkit-scrollbar,
|
.page-wrap::-webkit-scrollbar,
|
||||||
.post-list-scroll::-webkit-scrollbar,
|
.post-list-scroll::-webkit-scrollbar,
|
||||||
.virtual-list-wrap::-webkit-scrollbar,
|
|
||||||
.aside-panel::-webkit-scrollbar,
|
.aside-panel::-webkit-scrollbar,
|
||||||
.aside-drawer-body::-webkit-scrollbar,
|
.aside-drawer-body::-webkit-scrollbar,
|
||||||
.widget-outline-body::-webkit-scrollbar {
|
.widget-outline-body::-webkit-scrollbar {
|
||||||
@@ -2039,7 +2041,6 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
.post-detail-page::-webkit-scrollbar-track,
|
.post-detail-page::-webkit-scrollbar-track,
|
||||||
.page-wrap::-webkit-scrollbar-track,
|
.page-wrap::-webkit-scrollbar-track,
|
||||||
.post-list-scroll::-webkit-scrollbar-track,
|
.post-list-scroll::-webkit-scrollbar-track,
|
||||||
.virtual-list-wrap::-webkit-scrollbar-track,
|
|
||||||
.aside-panel::-webkit-scrollbar-track,
|
.aside-panel::-webkit-scrollbar-track,
|
||||||
.aside-drawer-body::-webkit-scrollbar-track,
|
.aside-drawer-body::-webkit-scrollbar-track,
|
||||||
.widget-outline-body::-webkit-scrollbar-track {
|
.widget-outline-body::-webkit-scrollbar-track {
|
||||||
@@ -2050,7 +2051,6 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
.post-detail-page::-webkit-scrollbar-thumb,
|
.post-detail-page::-webkit-scrollbar-thumb,
|
||||||
.page-wrap::-webkit-scrollbar-thumb,
|
.page-wrap::-webkit-scrollbar-thumb,
|
||||||
.post-list-scroll::-webkit-scrollbar-thumb,
|
.post-list-scroll::-webkit-scrollbar-thumb,
|
||||||
.virtual-list-wrap::-webkit-scrollbar-thumb,
|
|
||||||
.aside-panel::-webkit-scrollbar-thumb,
|
.aside-panel::-webkit-scrollbar-thumb,
|
||||||
.aside-drawer-body::-webkit-scrollbar-thumb,
|
.aside-drawer-body::-webkit-scrollbar-thumb,
|
||||||
.widget-outline-body::-webkit-scrollbar-thumb {
|
.widget-outline-body::-webkit-scrollbar-thumb {
|
||||||
@@ -2064,7 +2064,6 @@ html.dark .ssr-theme-icon--sun {
|
|||||||
.post-detail-page::-webkit-scrollbar-thumb:hover,
|
.post-detail-page::-webkit-scrollbar-thumb:hover,
|
||||||
.page-wrap::-webkit-scrollbar-thumb:hover,
|
.page-wrap::-webkit-scrollbar-thumb:hover,
|
||||||
.post-list-scroll::-webkit-scrollbar-thumb:hover,
|
.post-list-scroll::-webkit-scrollbar-thumb:hover,
|
||||||
.virtual-list-wrap::-webkit-scrollbar-thumb:hover,
|
|
||||||
.aside-panel::-webkit-scrollbar-thumb:hover,
|
.aside-panel::-webkit-scrollbar-thumb:hover,
|
||||||
.aside-drawer-body::-webkit-scrollbar-thumb:hover,
|
.aside-drawer-body::-webkit-scrollbar-thumb:hover,
|
||||||
.widget-outline-body::-webkit-scrollbar-thumb:hover {
|
.widget-outline-body::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -2353,37 +2352,18 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-list-wrap,
|
|
||||||
.post-list-scroll {
|
.post-list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文档 SSR:静态列表(非 virtualizer absolute 行) */
|
/* 帖子列表:静态流式行(SSR 与 hydration 后同一份 DOM) */
|
||||||
.content-surface--ssr {
|
.content-surface--ssr {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
a.post-row {
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.feed-empty {
|
|
||||||
padding: 48px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.ssr-home .feed-header-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-list-footer {
|
.feed-list-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -3015,6 +2995,29 @@ a.post-row {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 文档滚动模式(.app-shell--doc-scroll,/ 、/board/* 与 /post/:id):
|
||||||
|
* 页面交给窗口滚动——Chrome 的原生历史滚动恢复(F5 / 前进后退)
|
||||||
|
* 只作用于文档级滚动条,不恢复内部嵌套容器。
|
||||||
|
* 顶栏 sticky;左右栏 sticky 定高、各自内滚;列表容器全部放开为自然文档流。
|
||||||
|
*/
|
||||||
|
/* overscroll-behavior 压掉浏览器原生下拉刷新,继续走站内自定义 PTR */
|
||||||
|
html:has(.app-shell--doc-scroll) { overflow-y: auto; overscroll-behavior-y: contain; }
|
||||||
|
body:has(.app-shell--doc-scroll) { overflow: visible; }
|
||||||
|
#root:has(> .app-shell--doc-scroll) { height: auto; min-height: 100%; }
|
||||||
|
.app-shell--doc-scroll { height: auto; max-height: none; overflow: visible; }
|
||||||
|
.app-shell--doc-scroll .app-frame { height: auto; min-height: 100%; overflow: visible; }
|
||||||
|
.app-shell--doc-scroll .app-header { position: sticky; top: 0; }
|
||||||
|
.app-shell--doc-scroll .app-body { overflow: visible; height: auto; }
|
||||||
|
.app-shell--doc-scroll .content-workspace { overflow: visible; height: auto; }
|
||||||
|
.app-shell--doc-scroll .main-content { overflow: visible; height: auto; }
|
||||||
|
.app-shell--doc-scroll .main-content--feed-mobile-scroll { overflow: visible; }
|
||||||
|
.app-shell--doc-scroll .page-wrap { overflow: visible; flex: 0 0 auto; height: auto; min-height: 0; }
|
||||||
|
.app-shell--doc-scroll .feed-panel { overflow: visible; flex: 0 0 auto; height: auto; min-height: 0; }
|
||||||
|
.app-shell--doc-scroll .post-list-scroll { overflow: visible; flex: 0 0 auto; height: auto; min-height: 0; }
|
||||||
|
.app-shell--doc-scroll .sidebar { position: sticky; top: var(--j13-header-h); height: calc(100dvh - var(--j13-header-h)); }
|
||||||
|
.app-shell--doc-scroll .aside-panel { position: sticky; top: var(--j13-header-h); height: calc(100dvh - var(--j13-header-h)); }
|
||||||
|
|
||||||
.post-list-bar {
|
.post-list-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ export function clearAuthSeed() {
|
|||||||
seededUnread = 0;
|
seededUnread = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated 改用 peekAuthSeed + clearAuthSeed;保留兼容 */
|
|
||||||
export function takeAuthSeed(): AuthSeed {
|
|
||||||
const s = peekAuthSeed();
|
|
||||||
if (s.hasSeed) clearAuthSeed();
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBootUnread(): number {
|
export function getBootUnread(): number {
|
||||||
return bootUnread;
|
return bootUnread;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { NavigateFunction } from 'react-router';
|
import type { NavigateFunction } from 'react-router';
|
||||||
import type { PostItem } from '../api/types';
|
import { getHomeStoreState } from '../store/homeStore';
|
||||||
import type { FeedSort } from '../components/FeedSortBar';
|
|
||||||
import {
|
|
||||||
feedCacheKey,
|
|
||||||
getHomeStoreState,
|
|
||||||
type FeedCacheEntry,
|
|
||||||
} from '../store/homeStore';
|
|
||||||
import { clearSessionSnapshots } from './sessionPageCache';
|
import { clearSessionSnapshots } from './sessionPageCache';
|
||||||
import { softRefreshCurrentPage } from './softRefresh';
|
import { softRefreshCurrentPage } from './softRefresh';
|
||||||
import { transitionTo } from './spaTransition';
|
import { transitionTo } from './spaTransition';
|
||||||
@@ -13,51 +7,6 @@ import { transitionTo } from './spaTransition';
|
|||||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||||
export type FeedNavState = { refreshFeed?: boolean };
|
export type FeedNavState = { refreshFeed?: boolean };
|
||||||
|
|
||||||
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime) */
|
|
||||||
export type FeedCache = {
|
|
||||||
posts: PostItem[];
|
|
||||||
postTotal: number;
|
|
||||||
page: number;
|
|
||||||
scrollTop: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function toPublic(entry: FeedCacheEntry): FeedCache {
|
|
||||||
return {
|
|
||||||
posts: entry.posts,
|
|
||||||
postTotal: entry.postTotal,
|
|
||||||
page: entry.page,
|
|
||||||
scrollTop: entry.scrollTop,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
|
||||||
export function getFeedCache(
|
|
||||||
boardId: number,
|
|
||||||
keyword: string,
|
|
||||||
sort: FeedSort,
|
|
||||||
tag = '',
|
|
||||||
author = '',
|
|
||||||
titleOnly = false,
|
|
||||||
): FeedCache | null {
|
|
||||||
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
|
||||||
const entry = getHomeStoreState().getFeed(key);
|
|
||||||
return entry ? toPublic(entry) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存帖子列表缓存(写入 Zustand,并记录拉取时间) */
|
|
||||||
export function setFeedCache(
|
|
||||||
boardId: number,
|
|
||||||
keyword: string,
|
|
||||||
sort: FeedSort,
|
|
||||||
data: FeedCache,
|
|
||||||
tag = '',
|
|
||||||
author = '',
|
|
||||||
titleOnly = false,
|
|
||||||
) {
|
|
||||||
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
|
|
||||||
getHomeStoreState().setFeed(key, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
|
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
|
||||||
export function clearAllFeedCache() {
|
export function clearAllFeedCache() {
|
||||||
getHomeStoreState().clearAll();
|
getHomeStoreState().clearAll();
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
|
|||||||
posts,
|
posts,
|
||||||
postTotal: boot.post_total ?? posts.length,
|
postTotal: boot.post_total ?? posts.length,
|
||||||
page: boot.page || 1,
|
page: boot.page || 1,
|
||||||
scrollTop: 0,
|
|
||||||
lastFetchTime: Date.now(),
|
lastFetchTime: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,44 +2,19 @@
|
|||||||
|
|
||||||
import { getScope } from '../ssr/scope';
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__J13_HYDRATING_HOME__?: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hydrating = false;
|
let hydrating = false;
|
||||||
|
|
||||||
export function beginHomeHydrate() {
|
export function beginHomeHydrate() {
|
||||||
hydrating = true;
|
hydrating = true;
|
||||||
try {
|
|
||||||
window.__J13_HYDRATING_HOME__ = true;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function endHomeHydrate() {
|
export function endHomeHydrate() {
|
||||||
hydrating = false;
|
hydrating = false;
|
||||||
try {
|
|
||||||
delete window.__J13_HYDRATING_HOME__;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
window.__J13_HYDRATING_HOME__ = undefined;
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块/窗口标志) */
|
/** 首帧是否必须与 SSR DOM 同构(SSR 渲染期读请求作用域,客户端读模块标志) */
|
||||||
export function isHomeHydrating(): boolean {
|
export function isHomeHydrating(): boolean {
|
||||||
const scope = getScope();
|
const scope = getScope();
|
||||||
if (scope) return scope.hydrating === true;
|
if (scope) return scope.hydrating === true;
|
||||||
if (hydrating) return true;
|
return hydrating;
|
||||||
try {
|
|
||||||
return !!window.__J13_HYDRATING_HOME__;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ async function prefetchFeed(url: URL, force: boolean): Promise<void> {
|
|||||||
posts: Array.isArray(data.posts) ? data.posts : [],
|
posts: Array.isArray(data.posts) ? data.posts : [],
|
||||||
postTotal: data.total ?? 0,
|
postTotal: data.total ?? 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
scrollTop: 0,
|
|
||||||
lastFetchTime: Date.now(),
|
lastFetchTime: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
export type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
const STORAGE_KEY = 'j13-theme';
|
const STORAGE_KEY = 'j13-theme';
|
||||||
|
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 年
|
||||||
|
|
||||||
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
|
/** 在 React 渲染前同步读取主题,避免首屏主题闪烁 */
|
||||||
export function getStoredTheme(): Theme {
|
export function getStoredTheme(): Theme {
|
||||||
if (typeof window === 'undefined') return 'light';
|
if (typeof window === 'undefined') return 'light';
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored === 'dark' ? 'dark' : 'light';
|
return stored === 'dark' ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSR 时从请求 Cookie 解析主题(onRenderHtml 用)。
|
||||||
|
* 主题同时写 localStorage 与 cookie:服务端读 cookie 直接渲染 <html class="dark">,
|
||||||
|
* 浏览器无需内联脚本即可在首帧拿到正确主题。
|
||||||
|
*/
|
||||||
|
export function readThemeFromCookie(cookieHeader: string | null | undefined): Theme {
|
||||||
|
if (!cookieHeader) return 'light';
|
||||||
|
const match = /(?:^|;\s*)j13-theme=(dark|light)(?:;|$)/.exec(cookieHeader);
|
||||||
|
return match?.[1] === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
export function applyTheme(theme: Theme) {
|
export function applyTheme(theme: Theme) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.toggle('dark', theme === 'dark');
|
root.classList.toggle('dark', theme === 'dark');
|
||||||
root.style.colorScheme = theme;
|
root.style.colorScheme = theme;
|
||||||
localStorage.setItem(STORAGE_KEY, theme);
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
// 同步 cookie 供下次 SSR 读取(path=/ 全站生效)
|
||||||
|
document.cookie = `${STORAGE_KEY}=${theme};path=/;max-age=${THEME_COOKIE_MAX_AGE};SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|||||||
33
frontend/src/utils/zoomGuard.ts
Normal file
33
frontend/src/utils/zoomGuard.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 手机/平板双指与手势缩放锁(头像裁剪、图片灯箱区域放行 pinch)。
|
||||||
|
*
|
||||||
|
* 只监听用户手势事件,而手势必然发生在页面加载与客户端 bundle 执行之后,
|
||||||
|
* 因此无需像主题脚本那样内联在 <head> 中,在客户端入口安装即可。
|
||||||
|
*/
|
||||||
|
export function installZoomGuard(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const inZoomAllowArea = (target: EventTarget | null): boolean =>
|
||||||
|
!!target
|
||||||
|
&& target instanceof Element
|
||||||
|
&& typeof target.closest === 'function'
|
||||||
|
&& !!target.closest('.avatar-crop-stage, .image-lightbox');
|
||||||
|
|
||||||
|
const blockGesture = (e: Event) => {
|
||||||
|
if (inZoomAllowArea(e.target)) return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockMultiTouch = (e: TouchEvent) => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -44,11 +44,11 @@ type homeBootStats struct {
|
|||||||
Comments int64 `json:"comments"`
|
Comments int64 `json:"comments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHomeBoot 首页/板块/壳层聚合数据(Vike SSR +data 使用,与前端 HomeBootPayload 对齐)。
|
// APIHomeBoot 首页/板块/壳层聚合数据(Vike +data 取数与 SPA 冷启动预热共用,与前端 HomeBootPayload 对齐)。
|
||||||
// 并发聚合:帖子列表、板块、统计、侧栏 widget、登录态、签到等。
|
// 并发聚合:帖子列表、板块、统计、侧栏 widget、登录态、签到等。
|
||||||
func (h *Handlers) APIHomeBoot(c *gin.Context) {
|
func (h *Handlers) APIHomeBoot(c *gin.Context) {
|
||||||
boardID := uint(parseUintOrZero(c.Query("board_id")))
|
boardID := uint(parseUintOrZero(c.Query("board_id")))
|
||||||
boot, err := h.gatherHomeSSR(c, boardID)
|
boot, err := h.gatherHomeBoot(c, boardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取数据失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取数据失败"})
|
||||||
return
|
return
|
||||||
@@ -56,7 +56,8 @@ func (h *Handlers) APIHomeBoot(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, boot)
|
c.JSON(http.StatusOK, boot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeBootPayload, error) {
|
// gatherHomeBoot 纯数据聚合(JSON),不做任何 HTML 渲染。
|
||||||
|
func (h *Handlers) gatherHomeBoot(c *gin.Context, boardID uint) (*homeBootPayload, error) {
|
||||||
limits := h.Settings.PublicLimits()
|
limits := h.Settings.PublicLimits()
|
||||||
brand := h.Settings.SiteBranding()
|
brand := h.Settings.SiteBranding()
|
||||||
brand.SiteURL = h.publicBaseURL(c)
|
brand.SiteURL = h.publicBaseURL(c)
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ type PostListItem struct {
|
|||||||
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
|
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
|
||||||
LastReplyUser *model.User `json:"last_reply_user,omitempty"`
|
LastReplyUser *model.User `json:"last_reply_user,omitempty"`
|
||||||
LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"`
|
LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"`
|
||||||
|
// 列表页不需要完整正文:服务端预算摘要/首图后清空 Content,
|
||||||
|
// 避免全文随列表接口与 SSR pageContext 下发(正文由帖子详情接口提供)
|
||||||
|
Excerpt string `json:"excerpt,omitempty"`
|
||||||
|
FirstImage string `json:"first_image,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type lastReplyInfo struct {
|
type lastReplyInfo struct {
|
||||||
@@ -93,9 +97,21 @@ func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error)
|
|||||||
LastReplyGuestNick: info.GuestNick,
|
LastReplyGuestNick: info.GuestNick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stripPostContent(items)
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stripPostContent 列表项不下发完整正文:预算 60 字摘要与首图(与前端
|
||||||
|
// excerptFromHTML(content, 60) / firstImageFromHTML 对齐)后清空 Content。
|
||||||
|
func stripPostContent(items []PostListItem) {
|
||||||
|
for i := range items {
|
||||||
|
html := items[i].Content
|
||||||
|
items[i].Excerpt = ExcerptFromHTML(html, 60)
|
||||||
|
items[i].FirstImage = FirstImageURL(html)
|
||||||
|
items[i].Content = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
|
func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
|
||||||
type row struct {
|
type row struct {
|
||||||
PostID uint
|
PostID uint
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,9 +69,10 @@ func TruncateRunes(s string, max int) string {
|
|||||||
return string(runes[:max-1]) + "…"
|
return string(runes[:max-1]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExcerptFromHTML 从 HTML 生成摘要(剥离标签)
|
// ExcerptFromHTML 从 HTML 生成摘要(剥离标签、解码实体)
|
||||||
func ExcerptFromHTML(htmlContent string, maxRunes int) string {
|
func ExcerptFromHTML(htmlContent string, maxRunes int) string {
|
||||||
plain := StripHTMLForSearch(htmlContent)
|
plain := StripHTMLForSearch(htmlContent)
|
||||||
|
plain = html.UnescapeString(plain)
|
||||||
return TruncateRunes(plain, maxRunes)
|
return TruncateRunes(plain, maxRunes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user