Compare commits
3 Commits
2392768868
...
rebuild/vi
| Author | SHA1 | Date | |
|---|---|---|---|
| 20aee9c566 | |||
| 45cc864971 | |||
| 3c6d8cb891 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,11 +8,12 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
/embed_static/static/spa/
|
/.vite/
|
||||||
|
|
||||||
# Go 编译产物
|
# Go 编译产物
|
||||||
/dist/
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
|
/jiang13-*
|
||||||
|
|
||||||
# 临时文件
|
# 临时文件
|
||||||
tmp-cookie.txt
|
tmp-cookie.txt
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "de7e4cff",
|
|
||||||
"configHash": "9a7296da",
|
|
||||||
"lockfileHash": "e3b0c442",
|
|
||||||
"browserHash": "8c168d3c",
|
|
||||||
"optimized": {},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
@@ -36,10 +36,10 @@ async function onRenderHtml(pageContext: PageContextServer) {
|
|||||||
let pageHtml = '';
|
let pageHtml = '';
|
||||||
if (Page) {
|
if (Page) {
|
||||||
// SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500
|
// SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500
|
||||||
const scope = createRequestScope(createHomeStore());
|
|
||||||
scope.hydrating = true;
|
|
||||||
const { pathname, searchOriginal } = pageContext.urlParsed;
|
const { pathname, searchOriginal } = pageContext.urlParsed;
|
||||||
const url = pathname + (searchOriginal ?? '');
|
const url = pathname + (searchOriginal ?? '');
|
||||||
|
const scope = createRequestScope(createHomeStore(), url);
|
||||||
|
scope.hydrating = true;
|
||||||
try {
|
try {
|
||||||
pageHtml = await runWithScope(scope, async () => {
|
pageHtml = await runWithScope(scope, async () => {
|
||||||
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
// 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离)
|
||||||
|
|||||||
@@ -264,8 +264,6 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
postRevisions: (id: number) =>
|
postRevisions: (id: number) =>
|
||||||
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
||||||
postRevision: (id: number, revId: number) =>
|
|
||||||
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
|
|
||||||
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -582,7 +580,6 @@ export const api = {
|
|||||||
`/api/users/search?${sp}`,
|
`/api/users/search?${sp}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
|
||||||
logout: () => request('/api/logout', { method: 'POST' }),
|
logout: () => request('/api/logout', { method: 'POST' }),
|
||||||
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
||||||
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
|
likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ interface Props {
|
|||||||
author?: User | null;
|
author?: User | null;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
viewCount?: number;
|
viewCount?: number;
|
||||||
|
/** SSR/预热快照携带的作者主页资料与统计:首帧直接渲染,避免挂载后再拉 /api/users/:id */
|
||||||
|
initialProfile?: UserPublic | null;
|
||||||
|
initialStats?: UserActivityStats | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
|
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
|
||||||
@@ -22,11 +25,13 @@ export default function PostAuthorCard({
|
|||||||
author,
|
author,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
viewCount,
|
viewCount,
|
||||||
|
initialProfile = null,
|
||||||
|
initialStats = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const [profile, setProfile] = useState<UserPublic | null>(null);
|
const [profile, setProfile] = useState<UserPublic | null>(initialProfile);
|
||||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
const [stats, setStats] = useState<UserActivityStats | null>(initialStats);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!author?.id) {
|
if (!author?.id) {
|
||||||
@@ -34,6 +39,12 @@ export default function PostAuthorCard({
|
|||||||
setStats(null);
|
setStats(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 快照(SSR / 客户端预热 / 换帖)已带完整资料:同步即可,不再发请求
|
||||||
|
if (initialProfile && initialStats) {
|
||||||
|
setProfile(initialProfile);
|
||||||
|
setStats(initialStats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.userProfile(author.id)
|
api.userProfile(author.id)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -48,7 +59,7 @@ export default function PostAuthorCard({
|
|||||||
setStats(null);
|
setStats(null);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [author?.id]);
|
}, [author?.id, initialProfile, initialStats]);
|
||||||
|
|
||||||
if (!author?.id) {
|
if (!author?.id) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
|
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
|
||||||
import { useLocation, useSearchParams, useNavigate } from 'react-router';
|
import { useLocation, useSearchParams, useNavigate } from 'react-router';
|
||||||
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
|
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, UserActivityStats, UserPublic, ForumStats, FriendLink } from '../api/types';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { formatShortDateTime, formatTime } from '../utils/content';
|
import { formatShortDateTime, formatTime } from '../utils/content';
|
||||||
@@ -20,6 +20,9 @@ export type PostDetailAside = {
|
|||||||
headings: PostHeading[];
|
headings: PostHeading[];
|
||||||
scrollRoot?: HTMLElement | null;
|
scrollRoot?: HTMLElement | null;
|
||||||
outlineTitle?: string;
|
outlineTitle?: string;
|
||||||
|
/** SSR/预热快照携带的作者主页资料与统计(首帧直接渲染,缺省卡片自行拉取) */
|
||||||
|
authorProfile?: UserPublic | null;
|
||||||
|
authorStats?: UserActivityStats | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -237,6 +240,8 @@ export default function RightPanel({
|
|||||||
author={postDetail.author}
|
author={postDetail.author}
|
||||||
publishedAt={postDetail.publishedAt}
|
publishedAt={postDetail.publishedAt}
|
||||||
viewCount={postDetail.viewCount}
|
viewCount={postDetail.viewCount}
|
||||||
|
initialProfile={postDetail.authorProfile ?? null}
|
||||||
|
initialStats={postDetail.authorStats ?? null}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="widget-card widget-card--outline">
|
<div className="widget-card widget-card--outline">
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 有 SSR 种子时仍后台校验;无种子则首屏拉取
|
// SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求;
|
||||||
|
// 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。
|
||||||
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
|
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
|
||||||
useEffect(() => { void refresh(); }, [refresh]);
|
useEffect(() => {
|
||||||
|
if (peekAuthSeed().hasSeed) return;
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const prevUserId = useRef<number | null | 'init'>('init');
|
const prevUserId = useRef<number | null | 'init'>('init');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ function fetchBranding(): Promise<SiteBranding> {
|
|||||||
preferCacheOnce = false;
|
preferCacheOnce = false;
|
||||||
return Promise.resolve(cached);
|
return Promise.resolve(cached);
|
||||||
}
|
}
|
||||||
|
// SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求;
|
||||||
|
// 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
|
|
||||||
inflight = api.siteBranding()
|
inflight = api.siteBranding()
|
||||||
.then(b => {
|
.then(b => {
|
||||||
cached = { ...DEFAULT_BRANDING, ...b };
|
cached = { ...DEFAULT_BRANDING, ...b };
|
||||||
@@ -137,6 +139,7 @@ export function useSiteBranding() {
|
|||||||
|
|
||||||
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
|
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
|
||||||
export function refetchSiteBranding() {
|
export function refetchSiteBranding() {
|
||||||
|
cached = null;
|
||||||
inflight = null;
|
inflight = null;
|
||||||
cacheEpoch += 1;
|
cacheEpoch += 1;
|
||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
@@ -146,6 +149,7 @@ export function refetchSiteBranding() {
|
|||||||
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
|
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
|
||||||
if (!opts?.force && cached) return cached;
|
if (!opts?.force && cached) return cached;
|
||||||
if (opts?.force) {
|
if (opts?.force) {
|
||||||
|
cached = null;
|
||||||
inflight = null;
|
inflight = null;
|
||||||
preferCacheOnce = false;
|
preferCacheOnce = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||||
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types';
|
import type { Board, RecentComment, RecentUser, ForumStats, SitePage, TagCount, User, UserActivityStats, UserPublic } from '../api/types';
|
||||||
import type { PostHeading } from '../utils/postHeadings';
|
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||||
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
|
import { getSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
|
import { postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
|
||||||
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
|
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
|
||||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||||
import RightPanel from '../components/RightPanel';
|
import RightPanel from '../components/RightPanel';
|
||||||
import BackToTop from '../components/BackToTop';
|
import BackToTop from '../components/BackToTop';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
||||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||||
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
@@ -44,7 +47,7 @@ import { userPath } from '../utils/userPath';
|
|||||||
import { parsePermalinkID } from '../utils/permalink';
|
import { parsePermalinkID } from '../utils/permalink';
|
||||||
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
||||||
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
|
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
|
||||||
import { getBootUnread } from '../utils/authBoot';
|
import { getBootUnread, peekAuthSeed } from '../utils/authBoot';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
@@ -73,14 +76,54 @@ export default function MainLayout() {
|
|||||||
const [notifyUnread, setNotifyUnread] = useState(0);
|
const [notifyUnread, setNotifyUnread] = useState(0);
|
||||||
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
||||||
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
||||||
const [postOutline, setPostOutline] = useState<{
|
// SSR / hydrate 首帧:详情页快照已在渲染前 seed(onRenderHtml / onRenderClient),
|
||||||
headings: PostHeading[];
|
// 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行,
|
||||||
scrollRoot: HTMLElement | null;
|
// 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。
|
||||||
title?: string;
|
const [postOutline, setPostOutline] = useState<PostOutlineState>(() => {
|
||||||
author?: User | null;
|
const openLinks = getCachedForumLimits()?.open_content_links_in_new_tab;
|
||||||
publishedAt?: string;
|
// 帖子详情:从 post 快照派生作者卡 + 目录
|
||||||
viewCount?: number;
|
const postM = loc.pathname.match(/^\/post\/(\d+)/);
|
||||||
} | null>(null);
|
if (postM && !/\/edit$/.test(loc.pathname)) {
|
||||||
|
const id = Number(postM[1]);
|
||||||
|
if (id) {
|
||||||
|
const snap = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(id));
|
||||||
|
if (snap?.post) {
|
||||||
|
const content = snap.post.content ?? '';
|
||||||
|
const rendered = content.trim()
|
||||||
|
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
headings: extractHeadingsFromHtml(rendered),
|
||||||
|
scrollRoot: null,
|
||||||
|
title: '文章目录',
|
||||||
|
author: snap.post.user ?? null,
|
||||||
|
publishedAt: snap.post.created_at,
|
||||||
|
viewCount: snap.post.view_count,
|
||||||
|
authorProfile: snap.authorProfile ?? null,
|
||||||
|
authorStats: snap.authorStats ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自定义单页:从 sitepage 快照派生目录(无作者卡)
|
||||||
|
const pageM = loc.pathname.match(/^\/page\/([^/]+?)(?:\.html?)?$/);
|
||||||
|
if (pageM) {
|
||||||
|
const slug = pageM[1];
|
||||||
|
const page = getSessionSnapshot<SitePage>(`sitepage:${slug}`);
|
||||||
|
if (page) {
|
||||||
|
const content = page.content ?? '';
|
||||||
|
const rendered = content.trim()
|
||||||
|
? renderPostContentHtml(content, !!user, { openLinksInNewTab: openLinks })
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
headings: extractHeadingsFromHtml(rendered),
|
||||||
|
scrollRoot: null,
|
||||||
|
title: '文章目录',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
const [asideOpen, setAsideOpen] = useState(false);
|
const [asideOpen, setAsideOpen] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||||
@@ -335,8 +378,15 @@ export default function MainLayout() {
|
|||||||
void transitionTo(nav, path);
|
void transitionTo(nav, path);
|
||||||
}, [nav, notifyUnread, dmUnread]);
|
}, [nav, notifyUnread, dmUnread]);
|
||||||
|
|
||||||
|
// 仅首屏跳过一次:SSR/冷启动种子已带未读数(state 从 getBootUnread 初始化),不重复请求;
|
||||||
|
// 之后 user 变化(登录/退出)、事件刷新与 60s 轮询照常
|
||||||
|
const unreadImmediateSkipped = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshUnreadMessages();
|
if (!unreadImmediateSkipped.current && peekAuthSeed().hasSeed) {
|
||||||
|
unreadImmediateSkipped.current = true;
|
||||||
|
} else {
|
||||||
|
refreshUnreadMessages();
|
||||||
|
}
|
||||||
const onRefresh = () => refreshUnreadMessages();
|
const onRefresh = () => refreshUnreadMessages();
|
||||||
window.addEventListener('messages-unread-refresh', onRefresh);
|
window.addEventListener('messages-unread-refresh', onRefresh);
|
||||||
const timer = window.setInterval(refreshUnreadMessages, 60_000);
|
const timer = window.setInterval(refreshUnreadMessages, 60_000);
|
||||||
@@ -387,6 +437,13 @@ export default function MainLayout() {
|
|||||||
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompose || !showTagCloud) return;
|
if (isCompose || !showTagCloud) return;
|
||||||
|
// SSR +data / 冷启动预热已 seed 侧栏数据(含空数组)时首屏直接复用,不重复请求;
|
||||||
|
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setTags(getCachedTags());
|
||||||
|
setTagsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// 有 session 缓存则静默刷新(软刷新不卸空白)
|
// 有 session 缓存则静默刷新(软刷新不卸空白)
|
||||||
if (getCachedTags().length === 0) setTagsLoading(true);
|
if (getCachedTags().length === 0) setTagsLoading(true);
|
||||||
@@ -407,6 +464,14 @@ export default function MainLayout() {
|
|||||||
const needRecentComments = needAsideData && showRecentComments;
|
const needRecentComments = needAsideData && showRecentComments;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needRecentComments) return;
|
if (!needRecentComments) return;
|
||||||
|
// SSR +data / 冷启动预热已 seed:首屏同步缓存进 state,不重复请求;
|
||||||
|
// 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setRecentComments(getCachedRecentComments());
|
||||||
|
asideEverLoaded.current = true;
|
||||||
|
setAsideLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
||||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||||
@@ -433,6 +498,12 @@ export default function MainLayout() {
|
|||||||
const needRecentUsers = needAsideData && showRecentUsers;
|
const needRecentUsers = needAsideData && showRecentUsers;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needRecentUsers) return;
|
if (!needRecentUsers) return;
|
||||||
|
if (layoutRefreshTick === 0 && hasCachedAside()) {
|
||||||
|
setRecentUsers(getCachedRecentUsers());
|
||||||
|
asideEverLoaded.current = true;
|
||||||
|
setAsideLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||||
setAsideLoading(true);
|
setAsideLoading(true);
|
||||||
@@ -503,14 +574,7 @@ export default function MainLayout() {
|
|||||||
const isArticleAside = isPostDetail || isSitePage;
|
const isArticleAside = isPostDetail || isSitePage;
|
||||||
// 帖子有作者 →「作者与目录」;单页仅目录
|
// 帖子有作者 →「作者与目录」;单页仅目录
|
||||||
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
|
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
|
||||||
const setPostOutlineSafe = useCallback((outline: {
|
const setPostOutlineSafe = useCallback((outline: PostOutlineState) => {
|
||||||
headings: PostHeading[];
|
|
||||||
scrollRoot: HTMLElement | null;
|
|
||||||
title?: string;
|
|
||||||
author?: User | null;
|
|
||||||
publishedAt?: string;
|
|
||||||
viewCount?: number;
|
|
||||||
} | null) => {
|
|
||||||
setPostOutline(outline);
|
setPostOutline(outline);
|
||||||
}, []);
|
}, []);
|
||||||
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
||||||
@@ -848,6 +912,8 @@ export default function MainLayout() {
|
|||||||
headings: postOutline?.headings ?? [],
|
headings: postOutline?.headings ?? [],
|
||||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||||
outlineTitle: postOutline?.title,
|
outlineTitle: postOutline?.title,
|
||||||
|
authorProfile: postOutline?.authorProfile ?? null,
|
||||||
|
authorStats: postOutline?.authorStats ?? null,
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -967,6 +1033,8 @@ export default function MainLayout() {
|
|||||||
headings: postOutline?.headings ?? [],
|
headings: postOutline?.headings ?? [],
|
||||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||||
outlineTitle: postOutline?.title,
|
outlineTitle: postOutline?.title,
|
||||||
|
authorProfile: postOutline?.authorProfile ?? null,
|
||||||
|
authorStats: postOutline?.authorStats ?? null,
|
||||||
} : null}
|
} : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -979,6 +1047,19 @@ export default function MainLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 详情页/单页上报到右栏的文章目录与作者信息 */
|
||||||
|
export type PostOutlineState = {
|
||||||
|
headings: PostHeading[];
|
||||||
|
scrollRoot: HTMLElement | null;
|
||||||
|
title?: string;
|
||||||
|
author?: User | null;
|
||||||
|
publishedAt?: string;
|
||||||
|
viewCount?: number;
|
||||||
|
/** SSR/预热快照携带的作者主页资料与统计(作者卡首帧直接渲染,缺省由卡片自行拉取) */
|
||||||
|
authorProfile?: UserPublic | null;
|
||||||
|
authorStats?: UserActivityStats | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export type LayoutCtx = {
|
export type LayoutCtx = {
|
||||||
boardId: number;
|
boardId: number;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@@ -989,12 +1070,5 @@ export type LayoutCtx = {
|
|||||||
refreshBoards: () => void;
|
refreshBoards: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
/** 详情页上报作者与目录,供右侧栏展示 */
|
/** 详情页上报作者与目录,供右侧栏展示 */
|
||||||
setPostOutline: (outline: {
|
setPostOutline: (outline: PostOutlineState) => void;
|
||||||
headings: PostHeading[];
|
|
||||||
scrollRoot: HTMLElement | null;
|
|
||||||
title?: string;
|
|
||||||
author?: User | null;
|
|
||||||
publishedAt?: string;
|
|
||||||
viewCount?: number;
|
|
||||||
} | null) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types';
|
import type { PostItem, Comment, PollView, PostLotteryView, ReportReason } from '../api/types';
|
||||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||||
import CommentThreadList from '../components/CommentThreadList';
|
import CommentThreadList from '../components/CommentThreadList';
|
||||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||||
@@ -48,7 +48,7 @@ import ArticleOutline from '../components/ArticleOutline';
|
|||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
import { formatDateTime } from '../utils/content';
|
||||||
import { collectCommentSubtreeIds } from '../utils/comment';
|
import { collectCommentSubtreeIds } from '../utils/comment';
|
||||||
import { loadMyCommentIds } from '../utils/guest';
|
import { loadMyCommentIds } from '../utils/guest';
|
||||||
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||||
@@ -56,6 +56,7 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
|||||||
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||||
|
import { buildPostDetailSnapshot, postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
|
||||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
@@ -64,26 +65,6 @@ import { renderPostContentHtml } from '../utils/postContent';
|
|||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import NotFoundPage from './NotFoundPage';
|
import NotFoundPage from './NotFoundPage';
|
||||||
|
|
||||||
type PostDetailSnapshot = {
|
|
||||||
post: PostItem;
|
|
||||||
comments: Comment[];
|
|
||||||
poll: PollView | null;
|
|
||||||
lottery: PostLotteryView | null;
|
|
||||||
liked: boolean;
|
|
||||||
favorited: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
isEdited: boolean;
|
|
||||||
editBlockReason: string;
|
|
||||||
editWindowHours: number;
|
|
||||||
bountyCanRefund: boolean;
|
|
||||||
bountyRefundBlockReason: string;
|
|
||||||
bountyEligibleReplyCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function postDetailCacheKey(id: number) {
|
|
||||||
return `post:${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化剩余可编辑时间 */
|
/** 格式化剩余可编辑时间 */
|
||||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||||
if (windowHours <= 0) return '';
|
if (windowHours <= 0) return '';
|
||||||
@@ -143,6 +124,8 @@ 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 [authorProfile, setAuthorProfile] = useState<PostDetailSnapshot['authorProfile']>(initialSnap?.authorProfile ?? null);
|
||||||
|
const [authorStats, setAuthorStats] = useState<PostDetailSnapshot['authorStats']>(initialSnap?.authorStats ?? null);
|
||||||
|
|
||||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -206,9 +189,11 @@ export default function PostDetailPage() {
|
|||||||
author: post.user ?? null,
|
author: post.user ?? null,
|
||||||
publishedAt: post.created_at,
|
publishedAt: post.created_at,
|
||||||
viewCount: post.view_count,
|
viewCount: post.view_count,
|
||||||
|
authorProfile,
|
||||||
|
authorStats,
|
||||||
});
|
});
|
||||||
return () => setPostOutline(null);
|
return () => setPostOutline(null);
|
||||||
}, [headings, loading, post, setPostOutline]);
|
}, [headings, loading, post, authorProfile, authorStats, setPostOutline]);
|
||||||
|
|
||||||
const loadSeq = useRef(0);
|
const loadSeq = useRef(0);
|
||||||
const detailPath = postPath(postId, limits);
|
const detailPath = postPath(postId, limits);
|
||||||
@@ -226,6 +211,8 @@ export default function PostDetailPage() {
|
|||||||
setBountyCanRefund(snap.bountyCanRefund);
|
setBountyCanRefund(snap.bountyCanRefund);
|
||||||
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
||||||
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
||||||
|
setAuthorProfile(snap.authorProfile ?? null);
|
||||||
|
setAuthorStats(snap.authorStats ?? null);
|
||||||
setComments(snap.comments);
|
setComments(snap.comments);
|
||||||
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
||||||
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
||||||
@@ -273,21 +260,11 @@ export default function PostDetailPage() {
|
|||||||
]);
|
]);
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
|
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
|
||||||
const snap: PostDetailSnapshot = {
|
// 作者主页资料一并拉取(右栏作者卡首帧完整;失败降级为卡片自行拉取)
|
||||||
post: detail.post,
|
const authorResp = detail.post.user_id
|
||||||
comments: commentsList,
|
? await api.userProfile(detail.post.user_id).catch(() => null)
|
||||||
poll: detail.poll ?? null,
|
: null;
|
||||||
lottery: detail.lottery ?? null,
|
const snap = buildPostDetailSnapshot(detail, commentsList, authorResp);
|
||||||
liked: detail.liked,
|
|
||||||
favorited: detail.favorited,
|
|
||||||
canEdit: detail.can_edit ?? false,
|
|
||||||
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
|
|
||||||
editBlockReason: detail.edit_block_reason ?? '',
|
|
||||||
editWindowHours: detail.post_edit_window_hours ?? 0,
|
|
||||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
|
||||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
|
||||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
|
||||||
};
|
|
||||||
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
||||||
applySnapshot(snap, { restoreScroll: false });
|
applySnapshot(snap, { restoreScroll: false });
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -335,11 +312,14 @@ export default function PostDetailPage() {
|
|||||||
bountyCanRefund,
|
bountyCanRefund,
|
||||||
bountyRefundBlockReason,
|
bountyRefundBlockReason,
|
||||||
bountyEligibleReplyCount,
|
bountyEligibleReplyCount,
|
||||||
|
authorProfile,
|
||||||
|
authorStats,
|
||||||
|
scrollTop: 0,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
||||||
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
|
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
|
||||||
bountyEligibleReplyCount, postId, loading,
|
bountyEligibleReplyCount, authorProfile, authorStats, postId, loading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reloadComments = useCallback(async () => {
|
const reloadComments = useCallback(async () => {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
import { redirect, render } from 'vike/abort';
|
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, PostItem, SitePage, UserActivityStats, UserPublic } from '../api/types';
|
||||||
import type { HomeBootPayload } from '../utils/homeBoot';
|
import type { HomeBootPayload } from '../utils/homeBoot';
|
||||||
import { buildPostDetailSnapshot } from '../utils/homeBoot';
|
import { buildPostDetailSnapshot } from '../utils/homeBoot';
|
||||||
import { matchFeedSsrPath } from './feedPaths';
|
import { matchFeedSsrPath } from './feedPaths';
|
||||||
import { buildFeedHead, buildNotFoundHead, buildPostHead } from './head';
|
import { buildFeedHead, buildNotFoundHead, buildPageHead, buildPostHead, buildUserHead } from './head';
|
||||||
import type { FeedData } from './feedSeed';
|
import type { FeedData } from './feedSeed';
|
||||||
|
|
||||||
export type { FeedData };
|
export type { FeedData };
|
||||||
@@ -41,6 +41,12 @@ export async function loadFeedData(pageContext: PageContextServer): Promise<Feed
|
|||||||
if (match.kind === 'post') {
|
if (match.kind === 'post') {
|
||||||
return loadPostData(match.id, pathname, { cookie, apiHeaders });
|
return loadPostData(match.id, pathname, { cookie, apiHeaders });
|
||||||
}
|
}
|
||||||
|
if (match.kind === 'page') {
|
||||||
|
return loadPageData(match.id, pathname, { cookie, apiHeaders });
|
||||||
|
}
|
||||||
|
if (match.kind === 'user') {
|
||||||
|
return loadUserData(match.id, pathname, { cookie, apiHeaders });
|
||||||
|
}
|
||||||
return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders });
|
return loadFeedListData(match.kind, match.id, pathname, searchParams, { cookie, apiHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +76,14 @@ async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 作者主页资料(右栏作者卡首帧 SSR;失败降级为卡片自行拉取,不阻断渲染)
|
||||||
|
const authorId = detail.post.user_id;
|
||||||
|
const authorResp = authorId
|
||||||
|
? await serverApi<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${authorId}`, api)
|
||||||
|
: null;
|
||||||
|
|
||||||
const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : [];
|
const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : [];
|
||||||
const snapshot = buildPostDetailSnapshot(detail, comments);
|
const snapshot = buildPostDetailSnapshot(detail, comments, authorResp);
|
||||||
const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base);
|
const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base);
|
||||||
return { kind: 'post', boot: boot ?? undefined, snapshot, head };
|
return { kind: 'post', boot: boot ?? undefined, snapshot, head };
|
||||||
}
|
}
|
||||||
@@ -122,3 +134,84 @@ async function loadFeedListData(
|
|||||||
|
|
||||||
return { kind, boot, head: buildFeedHead(boot, base) };
|
return { kind, boot, head: buildFeedHead(boot, base) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自定义单页:page + 壳层并行取数 */
|
||||||
|
async function loadPageData(slug: string, pathname: string, api: ApiCtx): Promise<FeedData> {
|
||||||
|
if (!slug) {
|
||||||
|
throw render(404, { head: buildNotFoundHead(pathname) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pageResp, boot] = await Promise.all([
|
||||||
|
serverApi<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`, api),
|
||||||
|
serverApi<HomeBootPayload>('/api/home-boot', api),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const brand = boot?.branding ?? null;
|
||||||
|
const base = brand?.site_url?.trim() ?? '';
|
||||||
|
const page = pageResp?.page;
|
||||||
|
|
||||||
|
// 页面不存在或未发布:404 + noindex
|
||||||
|
if (!page || !page.published) {
|
||||||
|
throw render(404, {
|
||||||
|
head: buildNotFoundHead(pathname, brand, base),
|
||||||
|
boot: boot ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug 与页面组件 parsePermalinkSlug() 对齐(小写),保证 useSessionResource key 命中
|
||||||
|
return {
|
||||||
|
kind: 'page',
|
||||||
|
boot: boot ?? undefined,
|
||||||
|
sitePage: { slug: page.slug.toLowerCase(), page },
|
||||||
|
head: buildPageHead(page, brand, boot?.limits ?? null, base),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户主页:profile + 首屏帖子 + 壳层并行取数 */
|
||||||
|
async function loadUserData(id: string, pathname: string, api: ApiCtx): Promise<FeedData> {
|
||||||
|
const uid = /^\d+$/.test(id) ? Number(id) : NaN;
|
||||||
|
if (Number.isNaN(uid) || uid <= 0) {
|
||||||
|
throw render(404, { head: buildNotFoundHead(pathname) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [profileResp, boot] = await Promise.all([
|
||||||
|
serverApi<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${uid}`, api),
|
||||||
|
serverApi<HomeBootPayload>('/api/home-boot', api),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const brand = boot?.branding ?? null;
|
||||||
|
const base = brand?.site_url?.trim() ?? '';
|
||||||
|
const profile = profileResp?.user;
|
||||||
|
|
||||||
|
// 用户不存在:404 + noindex
|
||||||
|
if (!profile) {
|
||||||
|
throw render(404, {
|
||||||
|
head: buildNotFoundHead(pathname, brand, base),
|
||||||
|
boot: boot ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首屏帖子列表(第 1 页,page_size 从 limits 取)
|
||||||
|
const limits = boot?.limits ?? null;
|
||||||
|
const pageSize = (limits?.page_size_default ?? 0) > 0 ? limits!.page_size_default : 20;
|
||||||
|
const postsResp = await serverApi<{ posts: PostItem[]; total: number }>(
|
||||||
|
`/api/posts?user_id=${uid}&page=1&size=${pageSize}&sort=latest`,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const posts = Array.isArray(postsResp?.posts) ? postsResp.posts : [];
|
||||||
|
const postTotal = postsResp?.total ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'user',
|
||||||
|
boot: boot ?? undefined,
|
||||||
|
userProfile: {
|
||||||
|
id: uid,
|
||||||
|
profile,
|
||||||
|
stats: profileResp?.stats ?? null,
|
||||||
|
posts,
|
||||||
|
postTotal,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
head: buildUserHead(profile, brand, limits, base),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* SSR feed 页路由匹配(Vike +route.ts 用)。
|
* SSR feed 页路由匹配(Vike +route.ts 用)。
|
||||||
*
|
*
|
||||||
* 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`
|
* 真渲染白名单:首页 `/`、板块页 `/board/:id`、帖子详情 `/post/:id`、
|
||||||
|
* 自定义单页 `/page/:slug`、用户主页 `/user/:id`
|
||||||
* (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。
|
* (兼容 `123.html` 伪静态)。其余 URL 一律走 SPA 壳,后续扩展 SEO 页只改这里。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type FeedSsrMatch =
|
export type FeedSsrMatch =
|
||||||
| { kind: 'home'; id: '' }
|
| { kind: 'home'; id: '' }
|
||||||
| { kind: 'board'; id: string }
|
| { kind: 'board'; id: string }
|
||||||
| { kind: 'post'; id: string };
|
| { kind: 'post'; id: string }
|
||||||
|
| { kind: 'page'; id: string }
|
||||||
|
| { kind: 'user'; id: string };
|
||||||
|
|
||||||
const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [
|
const FEED_PATTERNS: Array<{ re: RegExp; pick: (m: RegExpMatchArray) => FeedSsrMatch }> = [
|
||||||
{ re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) },
|
{ re: /^\/$/, pick: () => ({ kind: 'home', id: '' }) },
|
||||||
{ re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) },
|
{ re: /^\/board\/([^/]+)$/, pick: (m) => ({ kind: 'board', id: m[1] }) },
|
||||||
// 123 与 123.html(伪静态后缀由后台配置,两种形态都接住)
|
// 123 与 123.html(伪静态后缀由后台配置,两种形态都接住)
|
||||||
{ re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) },
|
{ re: /^\/post\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'post', id: m[1] }) },
|
||||||
|
{ re: /^\/page\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'page', id: m[1] }) },
|
||||||
|
{ re: /^\/user\/([^/]+?)(?:\.html?)?$/, pick: (m) => ({ kind: 'user', id: m[1] }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null {
|
export function matchFeedSsrPath(urlPathname: string): FeedSsrMatch | null {
|
||||||
|
|||||||
@@ -13,11 +13,22 @@ import { setSessionSnapshot } from '../utils/sessionPageCache';
|
|||||||
import type { PageHead } from './head';
|
import type { PageHead } from './head';
|
||||||
|
|
||||||
export type FeedData = {
|
export type FeedData = {
|
||||||
kind: 'home' | 'board' | 'post' | 'notfound';
|
kind: 'home' | 'board' | 'post' | 'page' | 'user' | 'notfound';
|
||||||
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
|
/** feed 聚合(帖子列表 / 板块 / 统计 / 侧栏 widget / 登录态) */
|
||||||
boot?: HomeBootPayload;
|
boot?: HomeBootPayload;
|
||||||
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
|
/** 帖子详情会话快照(PostDetailPage navType=POP 时优先消费,不再二次请求) */
|
||||||
snapshot?: PostDetailSnapshot;
|
snapshot?: PostDetailSnapshot;
|
||||||
|
/** 自定义单页内容(SitePageView useSessionResource key=sitepage:${slug} 首帧命中) */
|
||||||
|
sitePage?: { slug: string; page: import('../api/types').SitePage };
|
||||||
|
/** 用户主页资料 + 首屏帖子列表(UserProfilePage 首帧命中) */
|
||||||
|
userProfile?: {
|
||||||
|
id: number;
|
||||||
|
profile: import('../api/types').UserPublic;
|
||||||
|
stats: import('../api/types').UserActivityStats | null;
|
||||||
|
posts: import('../api/types').PostItem[];
|
||||||
|
postTotal: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
/** <head> 元信息(服务端专用,onRenderHtml 消费) */
|
/** <head> 元信息(服务端专用,onRenderHtml 消费) */
|
||||||
head?: PageHead;
|
head?: PageHead;
|
||||||
};
|
};
|
||||||
@@ -27,4 +38,12 @@ export function seedFeedData(data: FeedData | null | undefined): void {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (data.boot) seedHomeBoot(data.boot);
|
if (data.boot) seedHomeBoot(data.boot);
|
||||||
if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot);
|
if (data.snapshot) setSessionSnapshot(postDetailCacheKey(data.snapshot.post.id), data.snapshot);
|
||||||
|
if (data.sitePage) {
|
||||||
|
setSessionSnapshot(`sitepage:${data.sitePage.slug}`, data.sitePage.page);
|
||||||
|
}
|
||||||
|
if (data.userProfile) {
|
||||||
|
const { id, profile, stats, posts, postTotal, pageSize } = data.userProfile;
|
||||||
|
setSessionSnapshot(`user:${id}`, { profile, stats });
|
||||||
|
setSessionSnapshot(`user:${id}:posts:1:${pageSize}`, { posts, total: postTotal });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>,
|
* 纯函数、两端安全(无 DOM / Node 依赖);renderHeadTags 产物由 onRenderHtml 注入 <head>,
|
||||||
* 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。
|
* 客户端路由切换时由 usePageSEO 接管(两者字段语义保持一致)。
|
||||||
*/
|
*/
|
||||||
import type { ForumLimitsPublic, PostItem, SiteBranding, User } from '../api/types';
|
import type { ForumLimitsPublic, PostItem, SiteBranding, SitePage, User, UserPublic } from '../api/types';
|
||||||
import type { HomeBootPayload } from '../utils/homeBoot';
|
import type { HomeBootPayload } from '../utils/homeBoot';
|
||||||
import { normalizePermalinkExt } from '../utils/permalink';
|
import { normalizePermalinkExt } from '../utils/permalink';
|
||||||
|
|
||||||
@@ -156,6 +156,10 @@ function feedUserPath(id: number, limits?: ForumLimitsPublic | null): string {
|
|||||||
return `/user/${id}${suffixOf(limits)}`;
|
return `/user/${id}${suffixOf(limits)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function feedPagePath(slug: string, limits?: ForumLimitsPublic | null): string {
|
||||||
|
return `/page/${slug}${suffixOf(limits)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toISO(time?: string): string {
|
function toISO(time?: string): string {
|
||||||
if (!time) return '';
|
if (!time) return '';
|
||||||
const d = new Date(time);
|
const d = new Date(time);
|
||||||
@@ -268,6 +272,77 @@ export function buildPostHead(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自定义单页 head(关于我们/版规等,ogType=article) */
|
||||||
|
export function buildPageHead(
|
||||||
|
page: SitePage,
|
||||||
|
brand: SiteBranding | null,
|
||||||
|
limits: ForumLimitsPublic | null,
|
||||||
|
base: string,
|
||||||
|
): PageHead {
|
||||||
|
const siteName = siteNameOf(brand);
|
||||||
|
const content = page.content ?? '';
|
||||||
|
const plain = stripHTMLForSearch(content);
|
||||||
|
const desc = truncateRunes(plain || page.title, DESC_MAX);
|
||||||
|
const canonical = absoluteURL(base, feedPagePath(page.slug, limits));
|
||||||
|
const defImage = defaultShareImageOf(brand);
|
||||||
|
const ogImage = absoluteURL(base, firstImageURL(content)) || defImage;
|
||||||
|
|
||||||
|
const jsonLd: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: page.title,
|
||||||
|
description: desc,
|
||||||
|
url: canonical,
|
||||||
|
datePublished: toISO(page.created_at),
|
||||||
|
dateModified: toISO(page.updated_at || page.created_at),
|
||||||
|
};
|
||||||
|
if (ogImage) jsonLd.image = [ogImage];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: pageTitle(page.title, siteName),
|
||||||
|
description: desc,
|
||||||
|
canonical: canonical || undefined,
|
||||||
|
ogType: 'article',
|
||||||
|
ogImage: ogImage || undefined,
|
||||||
|
siteName,
|
||||||
|
jsonLd: JSON.stringify(jsonLd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户主页 head(ogType=profile,Person JSON-LD) */
|
||||||
|
export function buildUserHead(
|
||||||
|
profile: UserPublic,
|
||||||
|
brand: SiteBranding | null,
|
||||||
|
limits: ForumLimitsPublic | null,
|
||||||
|
base: string,
|
||||||
|
): PageHead {
|
||||||
|
const siteName = siteNameOf(brand);
|
||||||
|
const name = displayNameOf(profile);
|
||||||
|
const sig = (profile.signature ?? '').trim();
|
||||||
|
const desc = truncateRunes(sig || `${name} 的主页`, DESC_MAX);
|
||||||
|
const canonical = absoluteURL(base, feedUserPath(profile.id, limits));
|
||||||
|
const ogImage = absoluteURL(base, profile.avatar ?? '') || defaultShareImageOf(brand);
|
||||||
|
|
||||||
|
const jsonLd: Record<string, unknown> = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name,
|
||||||
|
description: desc,
|
||||||
|
url: canonical,
|
||||||
|
};
|
||||||
|
if (ogImage) jsonLd.image = ogImage;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: pageTitle(`${name} 的主页`, siteName),
|
||||||
|
description: desc,
|
||||||
|
canonical: canonical || undefined,
|
||||||
|
ogType: 'profile',
|
||||||
|
ogImage: ogImage || undefined,
|
||||||
|
siteName,
|
||||||
|
jsonLd: JSON.stringify(jsonLd),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 404 页 head(移植 Go notFoundPageMeta) */
|
/** 404 页 head(移植 Go notFoundPageMeta) */
|
||||||
export function buildNotFoundHead(
|
export function buildNotFoundHead(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type { User, SiteBranding, ForumLimitsPublic, SitePageSummary } from '../
|
|||||||
import type { HomeStoreState } from '../store/homeStore';
|
import type { HomeStoreState } from '../store/homeStore';
|
||||||
|
|
||||||
export type RequestScope = {
|
export type RequestScope = {
|
||||||
|
/** 本请求的路径 + 查询串(不含 hash,服务端不可见),供双端共享模块读取「当前 URL」 */
|
||||||
|
url: string;
|
||||||
/** layoutCache / sessionPageCache 等统一 KV(键与 sessionStorage 键一致) */
|
/** layoutCache / sessionPageCache 等统一 KV(键与 sessionStorage 键一致) */
|
||||||
kv: Map<string, unknown>;
|
kv: Map<string, unknown>;
|
||||||
/** 本请求独立的 feed store(zustand 实例) */
|
/** 本请求独立的 feed store(zustand 实例) */
|
||||||
@@ -24,8 +26,9 @@ export type RequestScope = {
|
|||||||
hydrating: boolean;
|
hydrating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createRequestScope(homeStore: HomeStoreState): RequestScope {
|
export function createRequestScope(homeStore: HomeStoreState, url: string): RequestScope {
|
||||||
return {
|
return {
|
||||||
|
url,
|
||||||
kv: new Map(),
|
kv: new Map(),
|
||||||
homeStore,
|
homeStore,
|
||||||
auth: { hasSeed: false, user: null, unread: 0 },
|
auth: { hasSeed: false, user: null, unread: 0 },
|
||||||
|
|||||||
@@ -10,8 +10,106 @@
|
|||||||
*
|
*
|
||||||
* DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回——
|
* DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回——
|
||||||
* SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。
|
* SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。
|
||||||
|
*
|
||||||
|
* 注意 1:linkedom 的 DOMParser 解析「片段 HTML」时不会构造 <html>/<body>,
|
||||||
|
* 首枚元素会被当作 documentElement、其余内容挂在文档根上,doc.body 恒为空;
|
||||||
|
* 而内容处理工具统一读 doc.body / 在文档内 querySelectorAll,SSR 会渲染出空正文。
|
||||||
|
* ServerDOMParser 统一把片段包成完整文档,行为与浏览器 DOMParser 对齐。
|
||||||
|
*
|
||||||
|
* 注意 2:linkedom 对运行时「新增」的属性采用头插(setAttribute 新属性、
|
||||||
|
* classList.add 首次创建 class 时插到属性列表最前),浏览器是尾插;
|
||||||
|
* 而解析得到的属性两端都按源码顺序、已存在属性原地改值,行为一致。
|
||||||
|
* 属性顺序差异会使 dangerouslySetInnerHTML 的 SSR/客户端字符串不一致,
|
||||||
|
* 触发 hydration mismatch(#418/#425)。这里在两个新增入口记录属性的调用
|
||||||
|
* 顺序,并在 innerHTML/outerHTML 序列化读取前把「后加的属性」移回尾部,
|
||||||
|
* 使 linkedom 序列化结果与浏览器逐属性一致(重排幂等,可安全多次读取)。
|
||||||
|
*
|
||||||
|
* 注意 3:linkedom 序列化时对无子节点的 SVG/MathML 外发内容元素输出
|
||||||
|
* 自闭合(<path …/>),而浏览器的 HTML 片段序列化一律输出显式闭合
|
||||||
|
* (<path …></path>,仅 img/br 等 HTML void 元素省略尾标签)。序列化后
|
||||||
|
* 把这类自闭合展开为显式闭合(文本节点已转义,裸 <tag/> 只可能是真实标签)。
|
||||||
|
* 已知限制:linkedom 解析 HTML 时外发内容标签名也会被小写化(linearGradient
|
||||||
|
* → lineargradient);门控图标与现有数据仅使用全小写 SVG 标签,暂不处理。
|
||||||
*/
|
*/
|
||||||
import { DOMParser as LinkedomDOMParser, HTMLElement as LinkedomHTMLElement, NodeFilter as LinkedomNodeFilter } from 'linkedom';
|
import {
|
||||||
|
DOMParser as LinkedomDOMParser,
|
||||||
|
Element as LinkedomElement,
|
||||||
|
HTMLElement as LinkedomHTMLElement,
|
||||||
|
NodeFilter as LinkedomNodeFilter,
|
||||||
|
} from 'linkedom';
|
||||||
|
|
||||||
|
class ServerDOMParser {
|
||||||
|
parseFromString(markup: string, mimeType: string = 'text/html'): Document {
|
||||||
|
if (mimeType === 'text/html' && typeof markup === 'string' && !/<html[\s>]/i.test(markup)) {
|
||||||
|
markup = `<!DOCTYPE html><html><head></head><body>${markup}</body></html>`;
|
||||||
|
}
|
||||||
|
return new LinkedomDOMParser().parseFromString(markup, mimeType as 'text/html') as unknown as Document;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 元素上记录「运行时新增」属性名的 expando 键(按调用顺序) */
|
||||||
|
const ADDED_ATTRS = Symbol('ssrAddedAttrs');
|
||||||
|
/** 重排期间抑制记录,避免 remove + setAttribute 重写产生重复登记 */
|
||||||
|
let reordering = false;
|
||||||
|
|
||||||
|
function recordAddedAttribute(el: Element, name: string): void {
|
||||||
|
const store = el as unknown as Record<symbol, string[] | undefined>;
|
||||||
|
const list = store[ADDED_ATTRS];
|
||||||
|
if (list) {
|
||||||
|
if (!list.includes(name)) list.push(name);
|
||||||
|
} else {
|
||||||
|
store[ADDED_ATTRS] = [name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化前归一化属性顺序:解析属性(源码顺序)在前、运行时新增属性按调用
|
||||||
|
* 顺序在后——与浏览器尾插语义一致。linkedom 头插,重写时按期望顺序逆序写入。
|
||||||
|
*/
|
||||||
|
function normalizeAttributeOrder(root: Element): void {
|
||||||
|
const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
|
||||||
|
for (const el of elements) {
|
||||||
|
const store = el as unknown as Record<symbol, string[] | undefined>;
|
||||||
|
const added = store[ADDED_ATTRS];
|
||||||
|
if (!added || added.length === 0) continue;
|
||||||
|
const current = el.getAttributeNames();
|
||||||
|
if (current.length === 0) continue;
|
||||||
|
|
||||||
|
const addedSet = new Set(added);
|
||||||
|
const kept = current.filter(n => !addedSet.has(n));
|
||||||
|
const presentAdded = added.filter(n => current.includes(n));
|
||||||
|
const desired = [...kept, ...presentAdded];
|
||||||
|
if (desired.length === current.length && desired.every((n, i) => n === current[i])) {
|
||||||
|
continue; // 已是浏览器顺序(首次或重复读取)
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = new Map(current.map(n => [n, el.getAttribute(n) ?? '']));
|
||||||
|
reordering = true;
|
||||||
|
try {
|
||||||
|
for (const n of current) el.removeAttribute(n);
|
||||||
|
for (let i = desired.length - 1; i >= 0; i--) {
|
||||||
|
el.setAttribute(desired[i], values.get(desired[i]) ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reordering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* linkedom 对无子节点的外发内容(SVG/MathML)元素序列化为自闭合 <tag …/>,
|
||||||
|
* 浏览器的 HTML 片段序列化一律输出显式闭合 <tag …></tag>。读取 innerHTML/
|
||||||
|
* outerHTML 后展开(文本节点中 < 已转义为 <,裸 <tag/> 只可能是真实标签)。
|
||||||
|
*/
|
||||||
|
const FOREIGN_SELF_CLOSE = /<([a-zA-Z][\w:-]*)((?:\s[^<>"']*(?:"[^"]*"|'[^']*')[^<>"']*)*)?\s*\/>/g;
|
||||||
|
|
||||||
|
function expandForeignSelfClose(html: string): string {
|
||||||
|
return html.replace(
|
||||||
|
FOREIGN_SELF_CLOSE,
|
||||||
|
(_match, name: string, attrs: string | undefined) =>
|
||||||
|
`<${name}${(attrs ?? '').replace(/\s+$/, '')}></${name}>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
@@ -20,14 +118,65 @@ export function installServerDom(): void {
|
|||||||
installed = true;
|
installed = true;
|
||||||
|
|
||||||
const g = globalThis as unknown as Record<string, unknown>;
|
const g = globalThis as unknown as Record<string, unknown>;
|
||||||
const parser = new LinkedomDOMParser();
|
const parser = new ServerDOMParser();
|
||||||
const doc = parser.parseFromString('...', 'text/html');
|
const doc = parser.parseFromString('<!DOCTYPE html><html><head></head><body></body></html>', 'text/html');
|
||||||
|
|
||||||
g.DOMParser ??= LinkedomDOMParser;
|
g.DOMParser ??= ServerDOMParser;
|
||||||
g.NodeFilter ??= LinkedomNodeFilter;
|
g.NodeFilter ??= LinkedomNodeFilter;
|
||||||
g.HTMLElement ??= LinkedomHTMLElement;
|
g.HTMLElement ??= LinkedomHTMLElement;
|
||||||
// linkedom HTML 元素(含 unknown/custom 元素)统一继承 HTMLElement,
|
// linkedom HTML 元素(含 unknown/custom 元素)统一继承 HTMLElement,
|
||||||
// 可同时充当 instanceof Element / instanceof HTMLElement 的判定目标
|
// 可同时充当 instanceof Element / instanceof HTMLElement 的判定目标
|
||||||
g.Element ??= LinkedomHTMLElement;
|
g.Element ??= LinkedomHTMLElement;
|
||||||
g.document ??= doc;
|
g.document ??= doc;
|
||||||
|
|
||||||
|
installAttributeOrderPatch(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补丁 linkedom 属性插入顺序(仅原型方法,安装一次即对所有解析文档生效)。
|
||||||
|
*/
|
||||||
|
function installAttributeOrderPatch(doc: Document): void {
|
||||||
|
const elementProto = LinkedomElement.prototype as unknown as {
|
||||||
|
setAttribute(name: string, value: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) setAttribute:新增属性登记调用顺序(已存在属性 linkedom/浏览器均原地改值)
|
||||||
|
const origSetAttribute = elementProto.setAttribute;
|
||||||
|
elementProto.setAttribute = function (this: Element, name: string, value: string): void {
|
||||||
|
if (!reordering && !this.hasAttribute(name)) recordAddedAttribute(this, name);
|
||||||
|
origSetAttribute.call(this, name, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) DOMTokenList.add:class 属性的首次创建由此进入(className setter 也走 classList.add)
|
||||||
|
const probeEl = doc.createElement('div');
|
||||||
|
const tokenList = probeEl.classList as unknown as Record<symbol, unknown>;
|
||||||
|
const tokenProto = Object.getPrototypeOf(probeEl.classList) as {
|
||||||
|
add(this: unknown, ...tokens: string[]): void;
|
||||||
|
};
|
||||||
|
const ownerSymbol = Object.getOwnPropertySymbols(tokenList).find(
|
||||||
|
s => tokenList[s] === probeEl,
|
||||||
|
);
|
||||||
|
const origTokenAdd = tokenProto.add;
|
||||||
|
tokenProto.add = function (this: Record<symbol, Element | undefined>, ...tokens: string[]): void {
|
||||||
|
const owner = ownerSymbol ? this[ownerSymbol] : undefined;
|
||||||
|
if (!reordering && owner && !owner.hasAttribute('class')) recordAddedAttribute(owner, 'class');
|
||||||
|
origTokenAdd.apply(this, tokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3) innerHTML / outerHTML 读取(序列化)前,把属性顺序归一为浏览器顺序
|
||||||
|
const protoForDescriptors = LinkedomElement.prototype as unknown as Record<string, unknown>;
|
||||||
|
(['innerHTML', 'outerHTML'] as const).forEach(key => {
|
||||||
|
const desc = Object.getOwnPropertyDescriptor(protoForDescriptors, key);
|
||||||
|
if (!desc?.get) return;
|
||||||
|
const origGet = desc.get as (this: Element) => string;
|
||||||
|
Object.defineProperty(protoForDescriptors, key, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: desc.enumerable,
|
||||||
|
get(this: Element) {
|
||||||
|
normalizeAttributeOrder(this);
|
||||||
|
return expandForeignSelfClose(origGet.call(this));
|
||||||
|
},
|
||||||
|
set: desc.set as ((this: Element, html: string) => void) | undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getScope } from '../ssr/scope';
|
||||||
|
|
||||||
/** 构造带回跳的登录路径 */
|
/** 构造带回跳的登录路径 */
|
||||||
export function loginPath(from?: string): string {
|
export function loginPath(from?: string): string {
|
||||||
const path = sanitizeReturnPath(from ?? currentPath());
|
const path = sanitizeReturnPath(from ?? currentPath());
|
||||||
@@ -36,7 +38,10 @@ export function navigateAfterAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentPath(): string {
|
function currentPath(): string {
|
||||||
if (typeof window === 'undefined') return '/';
|
if (typeof window === 'undefined') {
|
||||||
|
// SSR:hash 不随请求上送,取请求作用域中的 pathname + search
|
||||||
|
return getScope()?.url ?? '/';
|
||||||
|
}
|
||||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
SitePageSummary,
|
SitePageSummary,
|
||||||
TagCount,
|
TagCount,
|
||||||
User,
|
User,
|
||||||
|
UserActivityStats,
|
||||||
|
UserPublic,
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
|
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
|
||||||
import { seedForumLimitsCache } from '../hooks/useForumLimits';
|
import { seedForumLimitsCache } from '../hooks/useForumLimits';
|
||||||
@@ -117,6 +119,9 @@ export type PostDetailSnapshot = {
|
|||||||
bountyCanRefund: boolean;
|
bountyCanRefund: boolean;
|
||||||
bountyRefundBlockReason: string;
|
bountyRefundBlockReason: string;
|
||||||
bountyEligibleReplyCount: number;
|
bountyEligibleReplyCount: number;
|
||||||
|
/** 作者主页资料(右栏作者卡 SSR/预热首帧直接渲染,缺省由卡片自行拉取) */
|
||||||
|
authorProfile: UserPublic | null;
|
||||||
|
authorStats: UserActivityStats | null;
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +130,11 @@ export function postDetailCacheKey(id: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */
|
/** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */
|
||||||
export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Comment[]): PostDetailSnapshot {
|
export function buildPostDetailSnapshot(
|
||||||
|
detail: PostDetailResponse,
|
||||||
|
comments: Comment[],
|
||||||
|
author?: { user: UserPublic; stats?: UserActivityStats | null } | null,
|
||||||
|
): PostDetailSnapshot {
|
||||||
return {
|
return {
|
||||||
post: detail.post,
|
post: detail.post,
|
||||||
comments: Array.isArray(comments) ? comments : [],
|
comments: Array.isArray(comments) ? comments : [],
|
||||||
@@ -141,6 +150,8 @@ export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Co
|
|||||||
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,
|
||||||
|
authorProfile: author?.user ?? null,
|
||||||
|
authorStats: author?.stats ?? null,
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,11 @@ async function prefetchPost(id: number, force: boolean): Promise<void> {
|
|||||||
api.post(id),
|
api.post(id),
|
||||||
api.comments(id, myIds),
|
api.comments(id, myIds),
|
||||||
]);
|
]);
|
||||||
const snap = buildPostDetailSnapshot(detail, comm.comments);
|
// 作者主页资料一并预热(右栏作者卡首帧直接渲染;失败降级为卡片自行拉取)
|
||||||
|
const author = detail.post.user_id
|
||||||
|
? await api.userProfile(detail.post.user_id).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const snap = buildPostDetailSnapshot(detail, comm.comments, author);
|
||||||
setSessionSnapshot(key, snap);
|
setSessionSnapshot(key, snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1127,16 +1127,6 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
|
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHotPosts 近期活跃讨论(近 7 日有回复)
|
|
||||||
func (h *Handlers) APIHotPosts(c *gin.Context) {
|
|
||||||
items, err := h.Post.HotPosts(10)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"posts": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
// APITags 标签云(按使用次数聚合)
|
// APITags 标签云(按使用次数聚合)
|
||||||
func (h *Handlers) APITags(c *gin.Context) {
|
func (h *Handlers) APITags(c *gin.Context) {
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
||||||
@@ -1211,29 +1201,6 @@ func (h *Handlers) APIPostRevisions(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIPostRevisionDetail 查看某个历史版本
|
|
||||||
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
|
|
||||||
post, err := h.Post.FindByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uid := h.currentUserID(c)
|
|
||||||
isAdmin := h.isAdmin(c)
|
|
||||||
if !isAdmin && post.UserID != uid {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rev, err := h.Post.GetRevision(uint(id), uint(revID))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isClientLimitError(err error) bool {
|
func isClientLimitError(err error) bool {
|
||||||
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
||||||
errors.Is(err, service.ErrSearchKeywordTooLong)
|
errors.Is(err, service.ErrSearchKeywordTooLong)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -33,7 +32,6 @@ type Handlers struct {
|
|||||||
Filter *service.SensitiveFilter
|
Filter *service.SensitiveFilter
|
||||||
Limiter *service.RateLimiter
|
Limiter *service.RateLimiter
|
||||||
Settings *service.ForumSettingsService
|
Settings *service.ForumSettingsService
|
||||||
Captcha *service.CaptchaService
|
|
||||||
Mail *service.MailService
|
Mail *service.MailService
|
||||||
EmailCode *service.EmailCodeService
|
EmailCode *service.EmailCodeService
|
||||||
OIDC *service.OIDCService
|
OIDC *service.OIDCService
|
||||||
@@ -114,18 +112,6 @@ func calcTotalPages(total int64, size int) int {
|
|||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
|
|
||||||
func (h *Handlers) APICaptcha(c *gin.Context) {
|
|
||||||
id, svg, err := h.Captcha.Generate()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"id": id,
|
|
||||||
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIRegisterConfig 注册页所需公开配置
|
// APIRegisterConfig 注册页所需公开配置
|
||||||
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||||
userCount := h.Auth.UserCount()
|
userCount := h.Auth.UserCount()
|
||||||
|
|||||||
Binary file not shown.
@@ -50,7 +50,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
|
reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc)
|
||||||
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
||||||
limiter := service.NewRateLimiter(settingsSvc)
|
limiter := service.NewRateLimiter(settingsSvc)
|
||||||
captchaSvc := service.NewCaptchaService()
|
|
||||||
mailSvc := service.NewMailService(settingsSvc)
|
mailSvc := service.NewMailService(settingsSvc)
|
||||||
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
||||||
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
|
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
|
||||||
@@ -82,7 +81,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
||||||
Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
|
Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
|
||||||
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||||
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||||
OIDC: oidcSvc, Gitea: giteaSvc,
|
OIDC: oidcSvc, Gitea: giteaSvc,
|
||||||
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
|
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
|
||||||
SitePage: service.NewSitePageService(filter),
|
SitePage: service.NewSitePageService(filter),
|
||||||
@@ -127,13 +126,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/site-branding", h.APISiteBranding)
|
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||||
pubAPI.GET("/pages", h.APIPages)
|
pubAPI.GET("/pages", h.APIPages)
|
||||||
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
||||||
pubAPI.GET("/captcha", h.APICaptcha)
|
|
||||||
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
||||||
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
||||||
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
||||||
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
||||||
pubAPI.GET("/posts", h.APIPosts)
|
pubAPI.GET("/posts", h.APIPosts)
|
||||||
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
|
||||||
pubAPI.GET("/tags", h.APITags)
|
pubAPI.GET("/tags", h.APITags)
|
||||||
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||||
// search / recent 须在 :id 之前
|
// search / recent 须在 :id 之前
|
||||||
@@ -164,7 +161,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.PUT("/posts/:id", h.APIUpdatePost)
|
api.PUT("/posts/:id", h.APIUpdatePost)
|
||||||
api.DELETE("/posts/:id", h.APIDeletePost)
|
api.DELETE("/posts/:id", h.APIDeletePost)
|
||||||
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
||||||
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
|
||||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
||||||
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
captchaLen = 4
|
|
||||||
captchaTTL = 5 * time.Minute
|
|
||||||
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
captchaWidth = 120
|
|
||||||
captchaHeight = 40
|
|
||||||
)
|
|
||||||
|
|
||||||
type captchaEntry struct {
|
|
||||||
answer string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptchaService 内存图形验证码
|
|
||||||
type CaptchaService struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
entries map[string]captchaEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaService() *CaptchaService {
|
|
||||||
s := &CaptchaService{entries: make(map[string]captchaEntry)}
|
|
||||||
go s.cleanup()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate 生成验证码,返回 id 与 SVG 图片
|
|
||||||
func (s *CaptchaService) Generate() (id, svg string, err error) {
|
|
||||||
answer, err := randomCaptchaText(captchaLen)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
rawID := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(rawID); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
id = hex.EncodeToString(rawID)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.entries[id] = captchaEntry{
|
|
||||||
answer: strings.ToUpper(answer),
|
|
||||||
expiresAt: time.Now().Add(captchaTTL),
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return id, renderCaptchaSVG(answer), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify 校验验证码(一次性,大小写不敏感)
|
|
||||||
func (s *CaptchaService) Verify(id, answer string) bool {
|
|
||||||
if id == "" || answer == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
entry, ok := s.entries[id]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delete(s.entries, id)
|
|
||||||
if time.Now().After(entry.expiresAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CaptchaService) cleanup() {
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
for range ticker.C {
|
|
||||||
now := time.Now()
|
|
||||||
s.mu.Lock()
|
|
||||||
for id, entry := range s.entries {
|
|
||||||
if now.After(entry.expiresAt) {
|
|
||||||
delete(s.entries, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomCaptchaText(n int) (string, error) {
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(n)
|
|
||||||
max := big.NewInt(int64(len(captchaChars)))
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
idx, err := rand.Int(rand.Reader, max)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
b.WriteByte(captchaChars[idx.Int64()])
|
|
||||||
}
|
|
||||||
return b.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCaptchaSVG(text string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
|
||||||
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
|
|
||||||
))
|
|
||||||
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
|
|
||||||
|
|
||||||
// 干扰线
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
x1, _ := randInt(0, captchaWidth)
|
|
||||||
y1, _ := randInt(0, captchaHeight)
|
|
||||||
x2, _ := randInt(0, captchaWidth)
|
|
||||||
y2, _ := randInt(0, captchaHeight)
|
|
||||||
color := noiseColor()
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
|
|
||||||
x1, y1, x2, y2, color,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
step := captchaWidth / (len(text) + 1)
|
|
||||||
for i, ch := range text {
|
|
||||||
x := step*(i+1) - 6
|
|
||||||
y, _ := randInt(26, 34)
|
|
||||||
rot, _ := randInt(-18, 18)
|
|
||||||
b.WriteString(fmt.Sprintf(
|
|
||||||
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
|
|
||||||
x, y, rot, x+6, y-6, ch,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(`</svg>`)
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func randInt(min, max int) (int, error) {
|
|
||||||
if max <= min {
|
|
||||||
return min, nil
|
|
||||||
}
|
|
||||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
|
||||||
if err != nil {
|
|
||||||
return min, err
|
|
||||||
}
|
|
||||||
return min + int(n.Int64()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func noiseColor() string {
|
|
||||||
r, _ := randInt(160, 210)
|
|
||||||
g, _ := randInt(170, 220)
|
|
||||||
b, _ := randInt(160, 210)
|
|
||||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@ var (
|
|||||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||||
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
|
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
|
||||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
|
||||||
ErrInvalidSetting = errors.New("无效的设置值")
|
ErrInvalidSetting = errors.New("无效的设置值")
|
||||||
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
||||||
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
||||||
@@ -35,7 +34,6 @@ var (
|
|||||||
ErrPostTagsTooLong = errors.New("标签过长")
|
ErrPostTagsTooLong = errors.New("标签过长")
|
||||||
ErrPostContentTooLong = errors.New("正文过长")
|
ErrPostContentTooLong = errors.New("正文过长")
|
||||||
ErrCommentTooLong = errors.New("评论内容过长")
|
ErrCommentTooLong = errors.New("评论内容过长")
|
||||||
ErrCaptchaInvalid = errors.New("验证码错误或已过期")
|
|
||||||
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
||||||
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
||||||
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
||||||
|
|||||||
@@ -172,52 +172,6 @@ func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
|
|
||||||
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-7 * 24 * time.Hour)
|
|
||||||
var posts []model.Post
|
|
||||||
err := model.DB.Preload("User").Preload("Board").
|
|
||||||
Where("status = ?", model.ContentStatusPublished).
|
|
||||||
Where(`EXISTS (
|
|
||||||
SELECT 1 FROM comments
|
|
||||||
WHERE comments.post_id = posts.id
|
|
||||||
AND comments.deleted_at IS NULL
|
|
||||||
AND comments.status = ?
|
|
||||||
AND comments.created_at >= ?
|
|
||||||
)`, model.ContentStatusPublished, since).
|
|
||||||
Order(`(
|
|
||||||
SELECT MAX(created_at) FROM comments
|
|
||||||
WHERE comments.post_id = posts.id
|
|
||||||
AND comments.deleted_at IS NULL
|
|
||||||
AND comments.status = 'published'
|
|
||||||
) DESC`).
|
|
||||||
Limit(limit).Find(&posts).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids := make([]uint, len(posts))
|
|
||||||
for i, p := range posts {
|
|
||||||
ids[i] = p.ID
|
|
||||||
}
|
|
||||||
countMap := s.commentCountMap(ids)
|
|
||||||
replyMap := s.lastReplyInfoMap(ids)
|
|
||||||
items := make([]PostListItem, len(posts))
|
|
||||||
for i, p := range posts {
|
|
||||||
info := replyMap[p.ID]
|
|
||||||
items[i] = PostListItem{
|
|
||||||
Post: p,
|
|
||||||
CommentCount: countMap[p.ID],
|
|
||||||
LastReplyAt: info.At,
|
|
||||||
LastReplyUser: info.User,
|
|
||||||
LastReplyGuestNick: info.GuestNick,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagCount 标签及其出现次数
|
// TagCount 标签及其出现次数
|
||||||
type TagCount struct {
|
type TagCount struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -680,16 +634,6 @@ func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
|
|||||||
return revs, nil
|
return revs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) {
|
|
||||||
var rev model.PostRevision
|
|
||||||
err := model.DB.Preload("Editor").
|
|
||||||
Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, ErrRevisionNotFound
|
|
||||||
}
|
|
||||||
return &rev, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。
|
// Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。
|
||||||
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
|
|||||||
Reference in New Issue
Block a user