From 21c6233fc06c9b84fb162d8ec2b6d71e54173458 Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 1 Sep 2026 07:32:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=92=E5=BA=8F=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E5=88=B7=E6=96=B0=E3=80=81=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=E5=B8=96=E4=BB=85=20featured=EF=BC=8C=E8=BD=AF=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=90=8C=E6=AD=A5=20limits/=E5=93=81=E7=89=8C?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/src/components/FeedSortBar.tsx | 2 +- .../src/components/admin/FeedSortTabList.tsx | 2 +- frontend/src/hooks/useForumLimits.ts | 15 +++++-- frontend/src/hooks/useSiteBranding.ts | 23 ++++++++++ frontend/src/layouts/MainLayout.tsx | 1 - frontend/src/pages/HomePage.tsx | 43 ++++++++++--------- frontend/src/utils/prefetchRoute.ts | 11 ++++- service/post.go | 13 ++++-- service/settings.go | 2 +- 9 files changed, 79 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx index 3d9b979..578372c 100644 --- a/frontend/src/components/FeedSortBar.tsx +++ b/frontend/src/components/FeedSortBar.tsx @@ -16,7 +16,7 @@ export type FeedSort = 'latest' | 'reply' | 'hot'; const SORT_META: Record = { reply: { hint: '最近有人评论', icon: MessageCircle }, latest: { hint: '按发帖时间', icon: Clock }, - hot: { hint: '站内推荐', icon: BadgeCheck }, + hot: { hint: '仅展示推荐帖', icon: BadgeCheck }, }; interface Props { diff --git a/frontend/src/components/admin/FeedSortTabList.tsx b/frontend/src/components/admin/FeedSortTabList.tsx index 97c5ec1..38c8b76 100644 --- a/frontend/src/components/admin/FeedSortTabList.tsx +++ b/frontend/src/components/admin/FeedSortTabList.tsx @@ -7,7 +7,7 @@ import { normalizeFeedSortTabs } from '../../utils/feedSortTabs'; const TAB_META: Record = { reply: { hint: '按最后评论时间', placeholder: '新评论' }, latest: { hint: '按发帖时间', placeholder: '新帖子' }, - hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' }, + hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' }, }; type Props = { diff --git a/frontend/src/hooks/useForumLimits.ts b/frontend/src/hooks/useForumLimits.ts index 5933a4e..ed40b82 100644 --- a/frontend/src/hooks/useForumLimits.ts +++ b/frontend/src/hooks/useForumLimits.ts @@ -80,9 +80,18 @@ export function useForumLimits() { return { limits, loading }; } -/** 冷启动 / 预热:确保 limits 已写入模块缓存 */ -export function ensureForumLimitsLoaded(): Promise { - return fetchLimits(); +/** 冷启动 / 预热:确保 limits 已写入模块缓存;force 时重拉并在成功后通知 hook */ +export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promise { + if (opts?.force) { + cached = null; + inflight = null; + } + const limits = await fetchLimits(); + if (opts?.force) { + cacheEpoch += 1; + listeners.forEach(fn => fn()); + } + return limits; } /** 文档 SSR / 管理端:同步写入 limits 模块缓存 */ diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts index 793ed71..2019138 100644 --- a/frontend/src/hooks/useSiteBranding.ts +++ b/frontend/src/hooks/useSiteBranding.ts @@ -46,9 +46,15 @@ function readBootBranding(): SiteBranding | null { let cached: SiteBranding | null = readBootBranding(); let inflight: Promise | null = null; let cacheEpoch = 0; +/** force 预热刚写入后,hook 因 epoch 重跑时复用缓存,避免连打两次 API */ +let preferCacheOnce = false; const listeners = new Set<() => void>(); function fetchBranding(): Promise { + if (preferCacheOnce && cached) { + preferCacheOnce = false; + return Promise.resolve(cached); + } if (inflight) return inflight; // 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置 inflight = api.siteBranding() @@ -130,6 +136,23 @@ export function refetchSiteBranding() { listeners.forEach(fn => fn()); } +/** 冷启动 / 预热:确保品牌已写入模块缓存;force 时重拉并在成功后通知 hook */ +export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise { + if (!opts?.force && cached) return cached; + if (opts?.force) { + inflight = null; + preferCacheOnce = false; + } + const next = await fetchBranding(); + applyDocumentBrand(next); + if (opts?.force) { + preferCacheOnce = true; + cacheEpoch += 1; + listeners.forEach(fn => fn()); + } + return next; +} + /** 清除缓存并通知已挂载的 hook 重新拉取 */ export function invalidateSiteBrandingCache() { cached = null; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 4f8efe0..fa2ffab 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -341,7 +341,6 @@ export default function MainLayout() { setTagsLoading(false); setAsideLoading(false); asideEverLoaded.current = true; - refetchSiteBranding(); refreshUnreadMessages(); }; const onForce = () => { diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 74d7230..2a0c69b 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -208,6 +208,8 @@ export default function HomePage() { const scrollRafRef = useRef(0); /** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */ const hydratedKeyRef = useRef(null); + /** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */ + const refreshFetchKeyRef = useRef(null); pageRef.current = page; cacheKeyRef.current = cacheKey; viewKeyRef.current = view.cacheKey; @@ -338,7 +340,9 @@ export default function HomePage() { } }, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]); - const loadFirst = useCallback(() => fetchPage(1), [fetchPage]); + const loadFirst = useCallback((opts?: { resetScroll?: boolean }) => ( + fetchPage(1, { resetScroll: opts?.resetScroll }) + ), [fetchPage]); const goToPage = useCallback((p: number) => { const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize)); @@ -362,24 +366,14 @@ export default function HomePage() { if (forceRefresh && navType !== 'POP') { fetchSeqRef.current += 1; hydratedKeyRef.current = cacheKey; - // 预取已写入 store:整页替换,不卸成骨架 + refreshFetchKeyRef.current = cacheKey; + // 排序等强制刷新:丢弃该键旧分页/滚动,置顶重拉第 1 页 + getHomeStoreState().clearFeed(cacheKey); resetFeedView(); - const warm = getHomeStoreState().getFeed(cacheKey); - if (warm && warm.posts.length > 0) { - commitDisplayed({ - posts: warm.posts, - postTotal: warm.postTotal, - page: warm.page, - scrollTop: 0, - loading: false, - }, { cacheKey, sort, boardId, keyword, tag, author, titleOnly }); - } else { - // 预取失败时保留旧列表并重拉 - setListPending(postsRef.current.length > 0); - if (postsRef.current.length === 0) setLoading(true); - setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly }); - loadFirst(); - } + setListPending(postsRef.current.length > 0); + if (postsRef.current.length === 0) setLoading(true); + setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly }); + void loadFirst({ resetScroll: true }); // 消费后清掉 state,防止该 history 条目永远带着刷新标记 nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); return; @@ -390,6 +384,12 @@ export default function HomePage() { nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); } + // 刚强制刷新并清缓存:跳过随后因 state 清空触发的空缓存首拉 + if (refreshFetchKeyRef.current === cacheKey) { + refreshFetchKeyRef.current = null; + return; + } + const cached = getHomeStoreState().getFeed(cacheKey); if (cached && cached.posts.length > 0) { const needRestore = hydratedKeyRef.current !== cacheKey; @@ -511,13 +511,16 @@ export default function HomePage() { }, []); const handleSortChange = (next: FeedSort) => { + const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }); + // 排序标签:一律强制刷新到第 1 页顶部,不恢复浏览进度 if (next === sort) { const tid = startTransition(); + getHomeStoreState().clearFeed(cacheKeyRef.current); beginFeedRefresh(); - void Promise.resolve(loadFirst()).finally(() => doneTransition(tid)); + void Promise.resolve(loadFirst({ resetScroll: true })).finally(() => doneTransition(tid)); return; } - navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits })); + navigateFeed(nav, url, { refresh: true }); }; const showSortBar = !view.keyword && !view.tag && !view.author; diff --git a/frontend/src/utils/prefetchRoute.ts b/frontend/src/utils/prefetchRoute.ts index 5b63ccb..4fe8f1c 100644 --- a/frontend/src/utils/prefetchRoute.ts +++ b/frontend/src/utils/prefetchRoute.ts @@ -13,6 +13,7 @@ import type { import { parseFeedSort } from '../components/FeedSortBar'; import { checkInCacheKey } from '../hooks/useCheckIn'; import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits'; +import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding'; import { ensureSitePagesLoaded } from '../hooks/useSitePages'; import { feedCacheKey, getHomeStoreState } from '../store/homeStore'; import { resolveAsideWidgets } from './asideWidgets'; @@ -343,7 +344,8 @@ export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise /** 壳层数据:boards/stats/站点页/右栏(与冷启动、软刷新共用) */ export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise { - await ensureForumLimitsLoaded(); + const force = !!opts?.force; + await ensureForumLimitsLoaded({ force }); const limits = getCachedForumLimits(); const widgets = resolveAsideWidgets(limits); const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled); @@ -359,9 +361,14 @@ export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise { if (next) setCachedStats(next); }).catch(() => undefined), - ensureSitePagesLoaded({ force: !!opts?.force }), + ensureSitePagesLoaded({ force }), ]; + // 软刷新:与壳层同拍重拉站名 / 友链等品牌文案 + if (force) { + tasks.push(ensureSiteBrandingLoaded({ force: true }).catch(() => undefined)); + } + if (!hideAside) { if (showRecentComments) { tasks.push( diff --git a/service/post.go b/service/post.go index 560f7f8..e7bd22d 100644 --- a/service/post.go +++ b/service/post.go @@ -47,7 +47,7 @@ type PostListQuery struct { Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE) Author string // 作者用户名或昵称(解析为 UserID) TitleOnly bool // 关键词仅匹配标题 - Sort string // reply | latest | hot(hot=推荐优先) + Sort string // reply | latest | hot(hot=仅推荐帖) ViewerID uint // 当前查看者(用于 pending 仅作者可见) ViewerIsAdmin bool Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则 @@ -343,6 +343,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) { normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')" db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%") } + sortKey := normalizePostSort(q.Sort) + if sortKey == "hot" { + // 推荐帖:只展示人工推荐 + db = db.Where("featured = ?", true) + } var total int64 db.Count(&total) var posts []model.Post @@ -350,7 +355,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) { if q.BoardID > 0 { db = db.Order("board_pinned desc") } - switch normalizePostSort(q.Sort) { + switch sortKey { case "reply": // 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论) db = db.Order(`( @@ -365,8 +370,8 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) { ) DESC`) db = db.Order("posts.created_at DESC") case "hot": - // 推荐帖:人工推荐(featured)优先,再按互动 - db = db.Order("featured desc, like_count desc, view_count desc") + // 仅推荐帖:按互动再按 id + db = db.Order("like_count desc, view_count desc") default: db = db.Order("id desc") } diff --git a/service/settings.go b/service/settings.go index 21c3b5c..4ece551 100644 --- a/service/settings.go +++ b/service/settings.go @@ -188,7 +188,7 @@ const ( FeedSortReply = "reply" FeedSortLatest = "latest" - FeedSortHot = "hot" + FeedSortHot = "hot" // 仅推荐帖(featured) ) var asideWidgetDefaultOrder = []string{