feat: 排序标签强制刷新、推荐帖仅 featured,软刷新同步 limits/品牌文案
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,7 @@ export type FeedSort = 'latest' | 'reply' | 'hot';
|
|||||||
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
|
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
|
||||||
reply: { hint: '最近有人评论', icon: MessageCircle },
|
reply: { hint: '最近有人评论', icon: MessageCircle },
|
||||||
latest: { hint: '按发帖时间', icon: Clock },
|
latest: { hint: '按发帖时间', icon: Clock },
|
||||||
hot: { hint: '站内推荐', icon: BadgeCheck },
|
hot: { hint: '仅展示推荐帖', icon: BadgeCheck },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
|
|||||||
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
|
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
|
||||||
reply: { hint: '按最后评论时间', placeholder: '新评论' },
|
reply: { hint: '按最后评论时间', placeholder: '新评论' },
|
||||||
latest: { hint: '按发帖时间', placeholder: '新帖子' },
|
latest: { hint: '按发帖时间', placeholder: '新帖子' },
|
||||||
hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' },
|
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -80,9 +80,18 @@ export function useForumLimits() {
|
|||||||
return { limits, loading };
|
return { limits, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冷启动 / 预热:确保 limits 已写入模块缓存 */
|
/** 冷启动 / 预热:确保 limits 已写入模块缓存;force 时重拉并在成功后通知 hook */
|
||||||
export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
|
export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promise<ForumLimitsPublic> {
|
||||||
return fetchLimits();
|
if (opts?.force) {
|
||||||
|
cached = null;
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
const limits = await fetchLimits();
|
||||||
|
if (opts?.force) {
|
||||||
|
cacheEpoch += 1;
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
return limits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
||||||
|
|||||||
@@ -46,9 +46,15 @@ function readBootBranding(): SiteBranding | null {
|
|||||||
let cached: SiteBranding | null = readBootBranding();
|
let cached: SiteBranding | null = readBootBranding();
|
||||||
let inflight: Promise<SiteBranding> | null = null;
|
let inflight: Promise<SiteBranding> | null = null;
|
||||||
let cacheEpoch = 0;
|
let cacheEpoch = 0;
|
||||||
|
/** force 预热刚写入后,hook 因 epoch 重跑时复用缓存,避免连打两次 API */
|
||||||
|
let preferCacheOnce = false;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function fetchBranding(): Promise<SiteBranding> {
|
function fetchBranding(): Promise<SiteBranding> {
|
||||||
|
if (preferCacheOnce && cached) {
|
||||||
|
preferCacheOnce = false;
|
||||||
|
return Promise.resolve(cached);
|
||||||
|
}
|
||||||
if (inflight) return inflight;
|
if (inflight) return inflight;
|
||||||
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
|
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
|
||||||
inflight = api.siteBranding()
|
inflight = api.siteBranding()
|
||||||
@@ -130,6 +136,23 @@ export function refetchSiteBranding() {
|
|||||||
listeners.forEach(fn => fn());
|
listeners.forEach(fn => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 冷启动 / 预热:确保品牌已写入模块缓存;force 时重拉并在成功后通知 hook */
|
||||||
|
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
|
||||||
|
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 重新拉取 */
|
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||||
export function invalidateSiteBrandingCache() {
|
export function invalidateSiteBrandingCache() {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
|||||||
@@ -341,7 +341,6 @@ export default function MainLayout() {
|
|||||||
setTagsLoading(false);
|
setTagsLoading(false);
|
||||||
setAsideLoading(false);
|
setAsideLoading(false);
|
||||||
asideEverLoaded.current = true;
|
asideEverLoaded.current = true;
|
||||||
refetchSiteBranding();
|
|
||||||
refreshUnreadMessages();
|
refreshUnreadMessages();
|
||||||
};
|
};
|
||||||
const onForce = () => {
|
const onForce = () => {
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export default function HomePage() {
|
|||||||
const scrollRafRef = useRef(0);
|
const scrollRafRef = useRef(0);
|
||||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||||
const hydratedKeyRef = useRef<string | null>(null);
|
const hydratedKeyRef = useRef<string | null>(null);
|
||||||
|
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
||||||
|
const refreshFetchKeyRef = useRef<string | null>(null);
|
||||||
pageRef.current = page;
|
pageRef.current = page;
|
||||||
cacheKeyRef.current = cacheKey;
|
cacheKeyRef.current = cacheKey;
|
||||||
viewKeyRef.current = view.cacheKey;
|
viewKeyRef.current = view.cacheKey;
|
||||||
@@ -338,7 +340,9 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
|
}, [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 goToPage = useCallback((p: number) => {
|
||||||
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
@@ -362,24 +366,14 @@ export default function HomePage() {
|
|||||||
if (forceRefresh && navType !== 'POP') {
|
if (forceRefresh && navType !== 'POP') {
|
||||||
fetchSeqRef.current += 1;
|
fetchSeqRef.current += 1;
|
||||||
hydratedKeyRef.current = cacheKey;
|
hydratedKeyRef.current = cacheKey;
|
||||||
// 预取已写入 store:整页替换,不卸成骨架
|
refreshFetchKeyRef.current = cacheKey;
|
||||||
|
// 排序等强制刷新:丢弃该键旧分页/滚动,置顶重拉第 1 页
|
||||||
|
getHomeStoreState().clearFeed(cacheKey);
|
||||||
resetFeedView();
|
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);
|
setListPending(postsRef.current.length > 0);
|
||||||
if (postsRef.current.length === 0) setLoading(true);
|
if (postsRef.current.length === 0) setLoading(true);
|
||||||
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||||
loadFirst();
|
void loadFirst({ resetScroll: true });
|
||||||
}
|
|
||||||
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
|
||||||
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||||
return;
|
return;
|
||||||
@@ -390,6 +384,12 @@ export default function HomePage() {
|
|||||||
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
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);
|
const cached = getHomeStoreState().getFeed(cacheKey);
|
||||||
if (cached && cached.posts.length > 0) {
|
if (cached && cached.posts.length > 0) {
|
||||||
const needRestore = hydratedKeyRef.current !== cacheKey;
|
const needRestore = hydratedKeyRef.current !== cacheKey;
|
||||||
@@ -511,13 +511,16 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSortChange = (next: FeedSort) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
|
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
||||||
|
// 排序标签:一律强制刷新到第 1 页顶部,不恢复浏览进度
|
||||||
if (next === sort) {
|
if (next === sort) {
|
||||||
const tid = startTransition();
|
const tid = startTransition();
|
||||||
|
getHomeStoreState().clearFeed(cacheKeyRef.current);
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
void Promise.resolve(loadFirst()).finally(() => doneTransition(tid));
|
void Promise.resolve(loadFirst({ resetScroll: true })).finally(() => doneTransition(tid));
|
||||||
return;
|
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;
|
const showSortBar = !view.keyword && !view.tag && !view.author;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { parseFeedSort } from '../components/FeedSortBar';
|
import { parseFeedSort } from '../components/FeedSortBar';
|
||||||
import { checkInCacheKey } from '../hooks/useCheckIn';
|
import { checkInCacheKey } from '../hooks/useCheckIn';
|
||||||
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
|
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
|
||||||
|
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
|
||||||
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
|
||||||
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||||
import { resolveAsideWidgets } from './asideWidgets';
|
import { resolveAsideWidgets } from './asideWidgets';
|
||||||
@@ -343,7 +344,8 @@ export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise
|
|||||||
|
|
||||||
/** 壳层数据:boards/stats/站点页/右栏(与冷启动、软刷新共用) */
|
/** 壳层数据:boards/stats/站点页/右栏(与冷启动、软刷新共用) */
|
||||||
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
|
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
|
||||||
await ensureForumLimitsLoaded();
|
const force = !!opts?.force;
|
||||||
|
await ensureForumLimitsLoaded({ force });
|
||||||
const limits = getCachedForumLimits();
|
const limits = getCachedForumLimits();
|
||||||
const widgets = resolveAsideWidgets(limits);
|
const widgets = resolveAsideWidgets(limits);
|
||||||
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
|
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
|
||||||
@@ -359,9 +361,14 @@ export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<v
|
|||||||
api.stats().then((next) => {
|
api.stats().then((next) => {
|
||||||
if (next) setCachedStats(next);
|
if (next) setCachedStats(next);
|
||||||
}).catch(() => undefined),
|
}).catch(() => undefined),
|
||||||
ensureSitePagesLoaded({ force: !!opts?.force }),
|
ensureSitePagesLoaded({ force }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 软刷新:与壳层同拍重拉站名 / 友链等品牌文案
|
||||||
|
if (force) {
|
||||||
|
tasks.push(ensureSiteBrandingLoaded({ force: true }).catch(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
if (!hideAside) {
|
if (!hideAside) {
|
||||||
if (showRecentComments) {
|
if (showRecentComments) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type PostListQuery struct {
|
|||||||
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
|
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
|
||||||
Author string // 作者用户名或昵称(解析为 UserID)
|
Author string // 作者用户名或昵称(解析为 UserID)
|
||||||
TitleOnly bool // 关键词仅匹配标题
|
TitleOnly bool // 关键词仅匹配标题
|
||||||
Sort string // reply | latest | hot(hot=推荐优先)
|
Sort string // reply | latest | hot(hot=仅推荐帖)
|
||||||
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
||||||
ViewerIsAdmin bool
|
ViewerIsAdmin bool
|
||||||
Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则
|
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,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
|
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
|
||||||
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||||
}
|
}
|
||||||
|
sortKey := normalizePostSort(q.Sort)
|
||||||
|
if sortKey == "hot" {
|
||||||
|
// 推荐帖:只展示人工推荐
|
||||||
|
db = db.Where("featured = ?", true)
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
db.Count(&total)
|
db.Count(&total)
|
||||||
var posts []model.Post
|
var posts []model.Post
|
||||||
@@ -350,7 +355,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
if q.BoardID > 0 {
|
if q.BoardID > 0 {
|
||||||
db = db.Order("board_pinned desc")
|
db = db.Order("board_pinned desc")
|
||||||
}
|
}
|
||||||
switch normalizePostSort(q.Sort) {
|
switch sortKey {
|
||||||
case "reply":
|
case "reply":
|
||||||
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
|
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
|
||||||
db = db.Order(`(
|
db = db.Order(`(
|
||||||
@@ -365,8 +370,8 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
) DESC`)
|
) DESC`)
|
||||||
db = db.Order("posts.created_at DESC")
|
db = db.Order("posts.created_at DESC")
|
||||||
case "hot":
|
case "hot":
|
||||||
// 推荐帖:人工推荐(featured)优先,再按互动
|
// 仅推荐帖:按互动再按 id
|
||||||
db = db.Order("featured desc, like_count desc, view_count desc")
|
db = db.Order("like_count desc, view_count desc")
|
||||||
default:
|
default:
|
||||||
db = db.Order("id desc")
|
db = db.Order("id desc")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const (
|
|||||||
|
|
||||||
FeedSortReply = "reply"
|
FeedSortReply = "reply"
|
||||||
FeedSortLatest = "latest"
|
FeedSortLatest = "latest"
|
||||||
FeedSortHot = "hot"
|
FeedSortHot = "hot" // 仅推荐帖(featured)
|
||||||
)
|
)
|
||||||
|
|
||||||
var asideWidgetDefaultOrder = []string{
|
var asideWidgetDefaultOrder = []string{
|
||||||
|
|||||||
Reference in New Issue
Block a user