补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 06:32:06 +08:00
parent 0f83183620
commit 10185178e2
28 changed files with 1165 additions and 266 deletions

View File

@@ -93,6 +93,10 @@ export const api = {
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminCommentsLockPost: (id: number, locked: boolean) =>
request<{ message: string; comments_locked: boolean }>(`/api/admin/posts/${id}/comments-lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminRejectPost: (id: number, reason: string) =>
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
method: 'POST', body: JSON.stringify({ reason }),
@@ -433,7 +437,20 @@ export const api = {
},
markConversationRead: (peerId: number) =>
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
messageUnreadCount: () => request<{ count: number }>('/api/messages/unread-count'),
messageUnreadCount: () =>
request<{ count: number; dm_count?: number; notify_count?: number }>('/api/messages/unread-count'),
messageNotifications: (params?: { page?: number; size?: number; kind?: string }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.size) q.set('size', String(params.size));
if (params?.kind) q.set('kind', params.kind);
const qs = q.toString();
return request<{ notifications: PrivateMessage[]; total: number; page: number; kind: string }>(
`/api/messages/notifications${qs ? `?${qs}` : ''}`,
);
},
markNotificationsRead: () =>
request<{ message: string }>('/api/messages/notifications/read', { method: 'POST' }),
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
request<{ message: PrivateMessage }>('/api/messages', {
method: 'POST', body: JSON.stringify(body),

View File

@@ -93,6 +93,8 @@ export interface PostItem {
board_pinned?: boolean;
featured?: boolean;
edit_locked?: boolean;
/** 禁止新评论(结贴) */
comments_locked?: boolean;
status?: 'pending' | 'published' | 'rejected' | string;
like_count: number;
view_count: number;

View File

@@ -330,7 +330,7 @@ function CommentItem({
{approving ? '通过中…' : '通过'}
</button>
)}
{!hidden && !isEditing && (
{!hidden && !isEditing && !!renderReplyBox && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />

View File

@@ -5,6 +5,7 @@ import type { Board, ForumStats } from '../api/types';
interface Props {
boardId: number;
keyword: string;
tag?: string;
boards: Board[];
stats: ForumStats | null;
postTotal: number;
@@ -12,20 +13,21 @@ interface Props {
titleAs?: 'h1' | 'h2';
}
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
export default function FeedHeader({ boardId, keyword, tag = '', boards, stats, postTotal, titleAs = 'h1' }: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
const inBoard = !keyword && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */
const title = keyword ? `搜索:${keyword}` : '';
const filtered = !!(keyword || tag);
const inBoard = !filtered && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
const title = tag ? `标签:${tag}` : (keyword ? `搜索:${keyword}` : '');
const TitleTag = titleAs;
return (
<div className={`feed-head${keyword ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
<div className="feed-head__title">
{title ? <TitleTag>{title}</TitleTag> : null}
{!keyword && inBoard && (
{!filtered && inBoard && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<FileText aria-hidden />
@@ -38,7 +40,7 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
)}
</div>
)}
{!keyword && !inBoard && stats && (
{!filtered && !inBoard && stats && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />
@@ -55,16 +57,16 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
</div>
)}
</div>
{keyword && (
{filtered && (
<button
type="button"
className="feed-head__clear"
onClick={() => nav('/')}
>
{tag ? '清除标签' : '清除搜索'}
</button>
)}
{keyword && (
{filtered && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>

View File

@@ -27,9 +27,18 @@ export function parseFeedSort(raw: string | null): FeedSort {
return 'latest';
}
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'latest',
opts?: { keyword?: string; tag?: string },
) {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
// 标签筛选与关键词搜索互斥:有 tag 时不带 keyword
if (tag) p.set('tag', tag);
else if (keyword) p.set('keyword', keyword);
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';

View File

@@ -0,0 +1,127 @@
import { cn } from '@/lib/utils';
import { levelToneFromLevel, type LevelTone } from '../utils/userMeta';
interface Props {
level: number;
size?: number;
className?: string;
tone?: LevelTone;
}
/** 等级迷你纹章:芽 / 叶 / 盾 / 冠线描currentColor */
export default function LevelEmblem({ level, size = 12, className, tone: toneProp }: Props) {
const tone = toneProp ?? levelToneFromLevel(level);
return (
<svg
className={cn('level-emblem', `level-emblem--${tone}`, className)}
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
{tone === 'sprout' && (
<>
<path
d="M8 13.5V8.2"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M8 9.2C8 9.2 5.2 8.6 4.2 6.4C3.4 4.6 4.6 3.2 6.4 3.6C7.6 3.9 8 5.2 8 5.2"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 7.6C8 7.6 9.8 6.4 11.2 7.2C12.6 8 12.4 9.8 10.8 10.4C9.6 10.85 8.4 10.2 8 9.6"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
{tone === 'leaf' && (
<>
<path
d="M8 13.2V7.5"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M8 8.2C5.6 8.2 3.8 6.5 3.5 4.4C3.3 2.9 4.5 2.2 5.8 2.6C7.1 3 8 4.4 8 4.4C8 4.4 8.9 3 10.2 2.6C11.5 2.2 12.7 2.9 12.5 4.4C12.2 6.5 10.4 8.2 8 8.2Z"
stroke="currentColor"
strokeWidth="1.25"
strokeLinejoin="round"
/>
<path
d="M8 4.4V8.2"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
opacity="0.7"
/>
<path
d="M5.2 11.4C5.8 10.2 6.8 9.5 8 9.5C9.2 9.5 10.2 10.2 10.8 11.4"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
</>
)}
{tone === 'crest' && (
<>
<path
d="M3.5 3.2H12.5V7.2C12.5 10.4 10.4 12.6 8 13.4C5.6 12.6 3.5 10.4 3.5 7.2V3.2Z"
stroke="currentColor"
strokeWidth="1.25"
strokeLinejoin="round"
/>
<path
d="M8 11.2V6.2"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
<path
d="M8 7C6.4 7 5.4 6 5.2 4.8C5.8 5.1 6.8 5.2 8 5.2C9.2 5.2 10.2 5.1 10.8 4.8C10.6 6 9.6 7 8 7Z"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</>
)}
{tone === 'crown' && (
<>
<path
d="M3.2 10.2C3.8 11.8 5.6 13 8 13.4C10.4 13 12.2 11.8 12.8 10.2"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M4.2 10C3.6 8.2 3.8 6.2 5.2 5C6.2 4.1 7.2 4.4 8 5.2C8.8 4.4 9.8 4.1 10.8 5C12.2 6.2 12.4 8.2 11.8 10"
stroke="currentColor"
strokeWidth="1.25"
strokeLinejoin="round"
/>
<path
d="M5.5 4.2L4.6 2.6M8 4.4V2.4M10.5 4.2L11.4 2.6"
stroke="currentColor"
strokeWidth="1.15"
strokeLinecap="round"
/>
<circle cx="4.5" cy="2.2" r="0.7" fill="currentColor" />
<circle cx="8" cy="2" r="0.75" fill="currentColor" />
<circle cx="11.5" cy="2.2" r="0.7" fill="currentColor" />
</>
)}
</svg>
);
}

View File

@@ -1,4 +1,5 @@
import { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon';
@@ -8,6 +9,7 @@ import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
import { postPath } from '../utils/permalink';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
import { parseTags } from './TagInput';
interface Props {
post: PostItem;
@@ -16,6 +18,7 @@ interface Props {
}
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const nav = useNavigate();
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
@@ -28,6 +31,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const href = postPath(post.id);
const excerpt = excerptFromHTML(post.content || '', 72);
const hasImage = !!firstImageFromHTML(post.content || '');
const tagList = parseTags(post.tags || '').slice(0, 3);
const openPost = () => onSelect(post.id);
const onKeyDown = (e: React.KeyboardEvent) => {
@@ -113,6 +117,20 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
<div className="post-foot">
<div className="post-foot-left">
{post.board && <BoardBadge board={post.board} />}
{tagList.map(t => (
<button
key={t}
type="button"
className="post-list-tag"
title={`筛选标签:${t}`}
onClick={(e) => {
e.stopPropagation();
nav(`/?tag=${encodeURIComponent(t)}`);
}}
>
{t}
</button>
))}
</div>
<div className="post-stats">
{hasImage && (

View File

@@ -77,11 +77,14 @@ export default function RightPanel({
const { branding } = useSiteBranding();
const loc = useLocation();
const [params] = useSearchParams();
const activeTag = params.get('keyword') || '';
const activeTag = params.get('tag') || '';
const hotList = hot?.slice(0, 8) ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
// 站点首页:右侧品牌块承担唯一 h1板块/搜索等页面由 Feed 标题作 h1
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
const isSiteHome = loc.pathname === '/'
&& !params.get('board')
&& !params.get('keyword')
&& !params.get('tag');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复

View File

@@ -102,7 +102,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
active && 'active',
)}
title={`${tag.name} · ${tag.count}`}
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
onClick={() => nav(active ? '/' : `/?tag=${encodeURIComponent(tag.name)}`)}
>
<span className="tag-cloud-item__name">{tag.name}</span>
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}

View File

@@ -2,7 +2,8 @@ import { BadgeCheck, Crown, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UserBadge } from '../api/types';
import { badgeIcon } from '../utils/badgeIcons';
import { resolveUserLevel } from '../utils/userMeta';
import { levelToneFromLevel, resolveUserLevel } from '../utils/userMeta';
import LevelEmblem from './LevelEmblem';
type BadgeUser = {
role?: string;
@@ -37,7 +38,7 @@ export default function UserBadges({
if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null;
const levelTone = level >= 9 ? 'gold' : level >= 7 ? 'amber' : level >= 4 ? 'blue' : 'muted';
const levelTone = levelToneFromLevel(level);
return (
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
@@ -54,8 +55,12 @@ export default function UserBadges({
</span>
)}
{showLevel && (
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
Lv.{level}
<span
className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)}
title={`经验 ${user.exp ?? 0}`}
>
<LevelEmblem level={level} tone={levelTone} size={compact ? 12 : 14} />
<span className="user-badge__level-text">Lv.{level}</span>
</span>
)}
{achievements.map(b => {

View File

@@ -258,9 +258,10 @@ export default function MainLayout() {
const userInitial = user?.nickname?.charAt(0) || '?';
const isFeedHome = loc.pathname === '/';
const outletKeyword = params.get('keyword') || '';
// 搜索结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const outletTag = params.get('tag') || '';
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard =
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
? -1
: boardId;
@@ -434,8 +435,8 @@ export default function MainLayout() {
<button
type="button"
className="header-icon-btn header-msg-btn"
title={unreadMessages > 0 ? `${unreadMessages} 条未读私信` : '站内私信'}
aria-label={unreadMessages > 0 ? `站内私信${unreadMessages} 条未读` : '站内私信'}
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
aria-label={unreadMessages > 0 ? `站内消息${unreadMessages} 条未读` : '站内消息'}
onClick={() => nav('/messages')}
>
<Mail size={18} aria-hidden />
@@ -461,7 +462,7 @@ export default function MainLayout() {
{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/messages')}>
{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{isMobile && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Send, Pencil } from 'lucide-react';
import { notify } from '@/lib/notify';
@@ -18,6 +18,12 @@ import { loginPath } from '../utils/authRedirect';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import { parsePermalinkID, postPath } from '../utils/permalink';
import { skipsModeration } from '../utils/userMeta';
import {
clearComposeDraft,
composeDraftHasContent,
loadComposeDraft,
saveComposeDraft,
} from '../utils/composeDraft';
interface ComposeBaseline {
title: string;
@@ -74,6 +80,9 @@ export default function ComposePage() {
);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
const [editWindowHint, setEditWindowHint] = useState('');
const [draftHint, setDraftHint] = useState('');
/** 新建帖:是否已处理过本地草稿恢复 */
const draftHandledRef = useRef(false);
useEffect(() => {
if (authLoading) return;
@@ -137,16 +146,43 @@ export default function ComposePage() {
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
setBoards(list);
if (!defaultBoard) setBoardId(initialBoardId);
const boardForBaseline = defaultBoard || initialBoardId;
setBoardId(prev => prev || boardForBaseline);
setBaseline({
const emptyBaseline: ComposeBaseline = {
title: '',
tags: '',
content: '',
boardId: boardForBaseline,
postType: 'normal',
};
// 首次进入新建页:若有本地草稿则询问是否恢复
if (!draftHandledRef.current) {
draftHandledRef.current = true;
const draft = loadComposeDraft();
if (composeDraftHasContent(draft) && draft) {
const when = new Date(draft.savedAt).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
const restore = window.confirm(`发现 ${when} 的未发帖草稿,是否恢复?\n选「取消」将丢弃该草稿。`);
if (restore) {
const boardIdOk = list.some(b => String(b.id) === draft.boardId)
? draft.boardId
: boardForBaseline;
setBoardId(boardIdOk);
setTitle(draft.title);
setTags(draft.tags);
setContent(draft.content);
setPostType(draft.postType);
setBaseline(emptyBaseline);
setDraftHint('已恢复本地草稿,编辑中将自动保存');
return;
}
clearComposeDraft();
}
}
setBoardId(prev => prev || boardForBaseline);
setBaseline(emptyBaseline);
};
const list = resolveBoards(layoutCtx?.boards);
@@ -180,14 +216,36 @@ export default function ComposePage() {
);
}, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]);
// 新建帖:防抖写入本地草稿
useEffect(() => {
if (isEdit || !baseline) return;
if (!isDirty) return;
const timer = window.setTimeout(() => {
saveComposeDraft({
title,
tags: serializeTags(parseTags(tags)),
content,
boardId,
postType,
});
setDraftHint('草稿已自动保存到本机');
}, 800);
return () => window.clearTimeout(timer);
}, [isEdit, baseline, isDirty, title, tags, content, boardId, postType]);
const {
dialogOpen,
stayOnPage,
discardAndLeave,
discardAndLeave: discardAndLeaveRaw,
requestLeave,
markSaved,
} = useUnsavedChangesGuard({ isDirty });
const discardAndLeave = () => {
if (!isEdit) clearComposeDraft();
discardAndLeaveRaw();
};
const leaveTo = (path: string) => {
markSaved();
nav(path);
@@ -264,6 +322,7 @@ export default function ComposePage() {
nav(postPath(editId!, limits));
} else {
const res = await api.createPost(payload);
clearComposeDraft();
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
markSaved();
nav(postPath(res.post_id, limits));
@@ -298,6 +357,11 @@ export default function ComposePage() {
{editWindowHint}
</span>
)}
{!isEdit && draftHint && (
<span className="compose-draft-hint" title={draftHint}>
{draftHint}
</span>
)}
</div>
<div className="compose-header-actions">
<button

View File

@@ -32,18 +32,25 @@ export default function HomePage() {
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const tag = params.get('tag') || '';
const sort = parseFeedSort(params.get('sort'));
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
const isSiteHome = !boardId && !keyword;
const isSiteHome = !boardId && !keyword && !tag;
const siteIntro = siteMetaDescription(branding);
const feedTitle = keyword
const feedTitle = tag
? `标签:${tag}`
: keyword
? `搜索:${keyword}`
: (boardId && board ? board.name : '');
usePageSEO({
title: feedTitle || undefined,
description: board?.description?.trim() || siteIntro,
keywords: joinSEOKeywords(board?.name, branding.keywords),
canonicalPath: boardId ? `/?board=${boardId}` : '/',
keywords: joinSEOKeywords(board?.name, tag, branding.keywords),
canonicalPath: tag
? `/?tag=${encodeURIComponent(tag)}`
: boardId
? `/?board=${boardId}`
: '/',
ogType: 'website',
});
@@ -60,7 +67,7 @@ export default function HomePage() {
const pageRef = useRef(1);
pageRef.current = page;
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
const feedSnapRef = useRef({ boardId, keyword, sort, posts, postTotal, page });
const feedSnapRef = useRef({ boardId, keyword, tag, sort, posts, postTotal, page });
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -87,7 +94,8 @@ export default function HomePage() {
page: p,
size: pageSize,
board_id: boardId || '',
keyword,
keyword: tag ? '' : keyword,
tag: tag || '',
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
@@ -106,7 +114,7 @@ export default function HomePage() {
loadingRef.current = false;
setLoading(false);
}
}, [boardId, keyword, sort, pageSize]);
}, [boardId, keyword, tag, sort, pageSize]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
@@ -134,7 +142,7 @@ export default function HomePage() {
return;
}
const cached = getFeedCache(boardId, keyword, sort);
const cached = getFeedCache(boardId, keyword, sort, tag);
if (cached && cached.posts.length > 0) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
@@ -154,6 +162,7 @@ export default function HomePage() {
pageSize,
boardId,
keyword,
tag,
sort,
location.key,
location.state,
@@ -165,15 +174,16 @@ export default function HomePage() {
if (
feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag
&& feedSnapRef.current.sort === sort
) {
feedSnapRef.current = { boardId, keyword, sort, posts, postTotal, page };
feedSnapRef.current = { boardId, keyword, tag, sort, posts, postTotal, page };
}
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps否则会用旧列表污染新 keyword
useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
feedSnapRef.current = { boardId, keyword, sort, posts: [], postTotal: 0, page: 1 };
feedSnapRef.current = { boardId, keyword, tag, sort, posts: [], postTotal: 0, page: 1 };
return () => {
if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current;
@@ -183,9 +193,9 @@ export default function HomePage() {
postTotal: snap.postTotal,
page: snap.page,
scrollTop: scrollTopRef.current,
});
}, snap.tag);
};
}, [boardId, keyword, sort]);
}, [boardId, keyword, tag, sort]);
useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
@@ -212,10 +222,10 @@ export default function HomePage() {
loadFirst();
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next));
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag }));
};
const showSortBar = !keyword;
const showSortBar = !keyword && !tag;
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
if ((loading || limitsLoading) && posts.length === 0) {
@@ -230,6 +240,7 @@ export default function HomePage() {
<FeedHeader
boardId={boardId}
keyword={keyword}
tag={tag}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
@@ -255,7 +266,7 @@ export default function HomePage() {
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword}
keyword={keyword || tag}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Bell, CheckCheck, Inbox, Send } from 'lucide-react';
import { ArrowLeft, Bell, CheckCheck, Inbox, Mail, Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
@@ -15,6 +15,17 @@ import { userPath } from '../utils/userPath';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { cn } from '@/lib/utils';
type MsgTab = 'dm' | 'notify';
const NOTIFY_KINDS = [
{ key: 'all', label: '全部' },
{ key: 'reply', label: '回复' },
{ key: 'moderation', label: '待审' },
{ key: 'reject', label: '拒帖' },
{ key: 'report_result', label: '举报' },
{ key: 'system', label: '系统' },
] as const;
function kindLabel(kind: string) {
switch (kind) {
case 'reject': return '拒帖通知';
@@ -22,7 +33,7 @@ function kindLabel(kind: string) {
case 'reply': return '回复提醒';
case 'moderation': return '待审提醒';
case 'system': return '系统通知';
default: return '';
default: return '通知';
}
}
@@ -63,17 +74,25 @@ function AvatarBubble({
return <span className="pm-avatar pm-avatar--fallback">{peerInitial(name)}</span>;
}
function parseTab(raw: string | null, peer: string | null): MsgTab {
// 带 peer 时强制私信页(用户主页「发私信」入口)
if (peer !== null && peer !== '') return 'dm';
return raw === 'notify' ? 'notify' : 'dm';
}
export default function MessagesPage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [params, setParams] = useSearchParams();
useNoIndexSEO('站内私信');
useNoIndexSEO('站内消息');
const peerParam = params.get('peer');
const tab = parseTab(params.get('tab'), peerParam);
const selectedPeer = peerParam === null || peerParam === ''
? null
: Number(peerParam);
const peerSelected = selectedPeer !== null && !Number.isNaN(selectedPeer);
const peerSelected = tab === 'dm' && selectedPeer !== null && !Number.isNaN(selectedPeer);
const notifyKind = params.get('kind') || 'all';
const [conversations, setConversations] = useState<MessageConversation[]>([]);
const [convTotal, setConvTotal] = useState(0);
@@ -88,10 +107,32 @@ export default function MessagesPage() {
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [notifications, setNotifications] = useState<PrivateMessage[]>([]);
const [notifyTotal, setNotifyTotal] = useState(0);
const [notifyPage, setNotifyPage] = useState(1);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifyUnread, setNotifyUnread] = useState(0);
const [dmUnread, setDmUnread] = useState(0);
const threadEndRef = useRef<HTMLDivElement>(null);
const threadScrollRef = useRef<HTMLDivElement>(null);
const stickToBottomRef = useRef(true);
const dmConversations = useMemo(
() => conversations.filter((c) => !c.is_system && c.peer_user_id > 0),
[conversations],
);
const refreshUnreadSplit = useCallback(async () => {
try {
const r = await api.messageUnreadCount();
setDmUnread(r.dm_count ?? 0);
setNotifyUnread(r.notify_count ?? 0);
} catch {
// ignore
}
}, []);
const loadConversations = useCallback(async (page = 1, append = false) => {
setListLoading(true);
try {
@@ -107,14 +148,46 @@ export default function MessagesPage() {
}
}, []);
const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => {
setNotifyLoading(true);
try {
const r = await api.messageNotifications({
page,
size: 30,
kind: kind === 'all' ? undefined : kind,
});
const next = r.notifications || [];
setNotifyTotal(r.total || 0);
setNotifyPage(r.page || page);
// 打开通知页时标已读(首屏)
if (!append && page === 1) {
await api.markNotificationsRead().catch(() => undefined);
setNotifications(next.map((m) => ({ ...m, is_read: true })));
setNotifyUnread(0);
window.dispatchEvent(new Event('messages-unread-refresh'));
} else {
setNotifications((prev) => (append ? [...prev, ...next] : next));
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载通知失败');
} finally {
setNotifyLoading(false);
}
}, []);
useEffect(() => {
if (authLoading) return;
if (!user) {
nav(loginPath('/messages'));
return;
}
void refreshUnreadSplit();
if (tab === 'dm') {
loadConversations(1);
}, [user, authLoading, nav, loadConversations]);
} else {
loadNotifications(1, false, notifyKind);
}
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
const scrollToBottom = useCallback((smooth = false) => {
requestAnimationFrame(() => {
@@ -142,6 +215,7 @@ export default function MessagesPage() {
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
)));
window.dispatchEvent(new Event('messages-unread-refresh'));
void refreshUnreadSplit();
})
.catch((e: unknown) => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
@@ -150,7 +224,7 @@ export default function MessagesPage() {
if (!cancelled) setThreadLoading(false);
});
return () => { cancelled = true; };
}, [user, peerSelected, selectedPeer]);
}, [user, peerSelected, selectedPeer, refreshUnreadSplit]);
useEffect(() => {
if (!threadLoading && stickToBottomRef.current) {
@@ -158,24 +232,54 @@ export default function MessagesPage() {
}
}, [messages, threadLoading, scrollToBottom]);
const setTab = (next: MsgTab) => {
const p = new URLSearchParams();
if (next === 'notify') {
p.set('tab', 'notify');
if (notifyKind !== 'all') p.set('kind', notifyKind);
}
setParams(p, { replace: true });
setDraft('');
};
const setKind = (kind: string) => {
const p = new URLSearchParams();
p.set('tab', 'notify');
if (kind !== 'all') p.set('kind', kind);
setParams(p, { replace: true });
};
const openPeer = (peerId: number) => {
const p = new URLSearchParams();
p.set('tab', 'dm');
p.set('peer', String(peerId));
setParams(p, { replace: true });
setDraft('');
};
const closeThread = () => {
setParams(new URLSearchParams(), { replace: true });
const p = new URLSearchParams();
p.set('tab', 'dm');
setParams(p, { replace: true });
setDraft('');
};
const markAll = async () => {
try {
if (tab === 'notify') {
await api.markNotificationsRead();
setNotifications((prev) => prev.map((m) => ({ ...m, is_read: true })));
setNotifyUnread(0);
notify.success('通知已全部标为已读');
} else {
await api.markAllMessagesRead();
notify.success('已全部标为已读');
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
setDmUnread(0);
setNotifyUnread(0);
notify.success('已全部标为已读');
}
window.dispatchEvent(new Event('messages-unread-refresh'));
void refreshUnreadSplit();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
@@ -239,7 +343,7 @@ export default function MessagesPage() {
}
};
if (authLoading || (listLoading && conversations.length === 0 && !peerSelected)) {
if (authLoading || (tab === 'dm' && listLoading && conversations.length === 0 && !peerSelected)) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user) return null;
@@ -251,7 +355,7 @@ export default function MessagesPage() {
? peerTitle(activeConv, peerUser, selectedPeer)
: '';
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
const unreadTotal = conversations.reduce((n, c) => n + (c.unread_count || 0), 0);
const unreadForTab = tab === 'notify' ? notifyUnread : dmUnread;
return (
<div className="page-wrap">
@@ -263,29 +367,111 @@ export default function MessagesPage() {
<div className="pm-page-head">
<div>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
</div>
{unreadTotal > 0 && (
{unreadForTab > 0 && (
<Button variant="outline" size="sm" onClick={markAll}>
<CheckCheck size={14} />
{tab === 'notify' ? '通知全部已读' : '全部已读'}
</Button>
)}
</div>
<div className="pm-tabs" role="tablist" aria-label="消息类型">
<button
type="button"
role="tab"
aria-selected={tab === 'dm'}
className={cn('pm-tab', tab === 'dm' && 'active')}
onClick={() => setTab('dm')}
>
<Mail size={15} aria-hidden />
{dmUnread > 0 && <span className="pm-tab__badge">{dmUnread > 99 ? '99+' : dmUnread}</span>}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'notify'}
className={cn('pm-tab', tab === 'notify' && 'active')}
onClick={() => setTab('notify')}
>
<Bell size={15} aria-hidden />
{notifyUnread > 0 && <span className="pm-tab__badge">{notifyUnread > 99 ? '99+' : notifyUnread}</span>}
</button>
</div>
{tab === 'notify' ? (
<div className="pm-notify content-surface">
<div className="pm-notify-filters" role="tablist" aria-label="通知类型">
{NOTIFY_KINDS.map((k) => (
<button
key={k.key}
type="button"
role="tab"
aria-selected={notifyKind === k.key}
className={cn('pm-notify-filter', notifyKind === k.key && 'active')}
onClick={() => setKind(k.key)}
>
{k.label}
</button>
))}
</div>
{notifyLoading && notifications.length === 0 ? (
<div className="flex justify-center py-16"><Spinner /></div>
) : notifications.length === 0 ? (
<div className="pm-empty">
<Bell size={28} strokeWidth={1.5} aria-hidden />
<p></p>
<span></span>
</div>
) : (
<ul className="pm-notify-list">
{notifications.map((m) => (
<li key={m.id} className={cn('pm-notify-item', !m.is_read && 'unread')}>
<div className="pm-notify-item__kind">{kindLabel(m.kind)}</div>
{m.subject && <div className="pm-notify-item__subject">{m.subject}</div>}
<div className="pm-notify-item__text">{m.content}</div>
<div className="pm-notify-item__meta">
<time>{formatTime(m.created_at)}</time>
{m.related_post_id ? (
<Link className="pm-notify-item__link" to={postPath(m.related_post_id)}>
</Link>
) : null}
</div>
</li>
))}
</ul>
)}
{notifyTotal > notifications.length && (
<div className="pm-list-more">
<Button
variant="ghost"
size="sm"
disabled={notifyLoading}
onClick={() => loadNotifications(notifyPage + 1, true, notifyKind)}
>
</Button>
</div>
)}
</div>
) : (
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
<aside className="pm-list" aria-label="会话列表">
{listLoading && conversations.length === 0 ? (
{listLoading && dmConversations.length === 0 ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : conversations.length === 0 ? (
) : dmConversations.length === 0 ? (
<div className="pm-empty">
<Inbox size={28} strokeWidth={1.5} aria-hidden />
<p></p>
<p></p>
<span></span>
</div>
) : (
conversations.map((c) => {
dmConversations.map((c) => {
const name = peerTitle(c, c.peer_user, c.peer_user_id);
const active = peerSelected && selectedPeer === c.peer_user_id;
return (
@@ -298,7 +484,6 @@ export default function MessagesPage() {
<AvatarBubble
name={name}
avatar={c.peer_user?.avatar}
system={c.is_system || c.peer_user_id === 0}
/>
<div className="pm-conv-item__body">
<div className="pm-conv-item__top">
@@ -339,7 +524,7 @@ export default function MessagesPage() {
<div className="pm-empty pm-empty--thread">
<Send size={32} strokeWidth={1.4} aria-hidden />
<p></p>
<span></span>
<span></span>
</div>
) : (
<>
@@ -350,17 +535,10 @@ export default function MessagesPage() {
<AvatarBubble
name={title}
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
system={selectedPeer === 0}
/>
<div className="pm-thread-head__meta">
{selectedPeer > 0 ? (
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
) : (
<span className="pm-thread-head__name">{title}</span>
)}
<span className="pm-thread-head__sub">
{selectedPeer === 0 ? '审核与系统消息' : '私信对话'}
</span>
<span className="pm-thread-head__sub"></span>
</div>
</header>
@@ -388,30 +566,13 @@ export default function MessagesPage() {
) : (
messages.map((m) => {
const mine = m.from_user_id === user.id;
const system = m.from_user_id === 0 || m.kind !== 'user';
const label = kindLabel(m.kind);
return (
<div
key={m.id}
className={cn(
'pm-bubble-row',
mine && 'pm-bubble-row--mine',
system && !mine && 'pm-bubble-row--system',
)}
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
>
<div className={cn('pm-bubble', mine && 'pm-bubble--mine', system && !mine && 'pm-bubble--system')}>
{label && !mine && (
<span className="pm-bubble__kind">{label}</span>
)}
{m.subject && m.kind !== 'user' && (
<div className="pm-bubble__subject">{m.subject}</div>
)}
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
<div className="pm-bubble__text">{m.content}</div>
{m.related_post_id ? (
<Link className="pm-bubble__link" to={postPath(m.related_post_id)}>
#{m.related_post_id}
</Link>
) : null}
<div className="pm-bubble__meta">
<time>{formatTime(m.created_at)}</time>
</div>
@@ -425,7 +586,7 @@ export default function MessagesPage() {
)}
</div>
{canCompose ? (
{canCompose && (
<footer className="pm-composer">
<textarea
className="pm-composer__input"
@@ -451,15 +612,12 @@ export default function MessagesPage() {
</Button>
</footer>
) : (
<footer className="pm-composer pm-composer--readonly">
</footer>
)}
</>
)}
</section>
</div>
)}
<InFlowSiteFooter />
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, LockOpen, MessageSquare, MessageSquareOff, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -587,6 +587,21 @@ export default function PostDetailPage() {
}
};
const handleCommentsLock = async () => {
if (!post) return;
try {
const r = await api.adminCommentsLockPost(postId, !post.comments_locked);
setPost(p => p ? { ...p, comments_locked: r.comments_locked } : p);
if (r.comments_locked) {
setReplyTo(null);
setEditingCommentId(null);
}
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
return (
<article className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
@@ -652,7 +667,12 @@ export default function PostDetailPage() {
{' · '}{post.view_count}
{post.edit_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
<Lock size={12} />
<Lock size={12} />
</span>
)}
{post.comments_locked && (
<span className="post-detail-locked-tag" title="管理员已锁定讨论">
<MessageSquareOff size={12} />
</span>
)}
</span>
@@ -662,7 +682,16 @@ export default function PostDetailPage() {
{tags.length > 0 && (
<div className="post-detail-tags">
{tags.map(t => <Badge key={t} variant="secondary">{t}</Badge>)}
{tags.map(t => (
<button
key={t}
type="button"
className="post-detail-tag-btn"
onClick={() => nav(`/?tag=${encodeURIComponent(t)}`)}
>
<Badge variant="secondary">{t}</Badge>
</button>
))}
</div>
)}
@@ -818,6 +847,10 @@ export default function PostDetailPage() {
<Lock />
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
</Button>
<Button variant="outline" size="sm" onClick={handleCommentsLock}>
{post.comments_locked ? <LockOpen /> : <MessageSquareOff />}
{post.comments_locked ? '开放讨论' : '锁定讨论'}
</Button>
{post.status !== 'rejected' && (
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
<Ban />
@@ -908,7 +941,12 @@ export default function PostDetailPage() {
<span className="comment-section-count">{comments.length} </span>
</div>
{!replyTo && (
{post.comments_locked ? (
<div className="comment-locked-banner" role="status">
<MessageSquareOff size={16} aria-hidden />
</div>
) : !replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}>
<CommentBox {...commentBoxProps} />
</div>
@@ -918,16 +956,20 @@ export default function PostDetailPage() {
{comments.length === 0 && !replyTo ? (
<div className="comment-empty">
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
<p>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
<p>
{post.comments_locked
? '暂无评论'
: user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}
</p>
</div>
) : (
<CommentThreadList
comments={comments}
highlightFloor={highlightFloor}
replyToId={replyTo?.id ?? null}
replyToId={post.comments_locked ? null : (replyTo?.id ?? null)}
editingId={editingCommentId}
currentUser={user}
onReply={handleReplyTo}
onReply={post.comments_locked ? () => undefined : handleReplyTo}
onCancelReply={() => setReplyTo(null)}
onStartEdit={(c) => {
setReplyTo(null);
@@ -943,7 +985,7 @@ export default function PostDetailPage() {
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
)));
}}
renderReplyBox={(c) => (
renderReplyBox={post.comments_locked ? undefined : (c) => (
<CommentBox
key={c.id}
{...commentBoxProps}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Lock, LockOpen, Trash2, RotateCcw } from 'lucide-react';
import { Search, Lock, LockOpen, MessageSquareOff, Trash2, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -163,6 +163,16 @@ export default function AdminPostsPage() {
}
};
const toggleCommentsLock = async (post: PostItem) => {
try {
const r = await api.adminCommentsLockPost(post.id, !post.comments_locked);
notify.success(r.message);
load(page);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const remove = async (id: number) => {
try {
await api.adminDeletePost(id);
@@ -208,7 +218,7 @@ export default function AdminPostsPage() {
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
: tab === 'pending'
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
: '精华、全局置顶、板块置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
: '精华、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
</p>
</div>
@@ -330,7 +340,8 @@ export default function AdminPostsPage() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@@ -363,6 +374,7 @@ export default function AdminPostsPage() {
<td>{p.pinned ? <Badge variant="green"></Badge> : '—'}</td>
<td>{p.board_pinned ? <Badge variant="green"></Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.comments_locked ? <Badge variant="destructive"></Badge> : '—'}</td>
<td>{p.like_count}</td>
<td>{p.view_count}</td>
<td className="text-sm whitespace-nowrap">
@@ -393,7 +405,12 @@ export default function AdminPostsPage() {
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> </> : <><Lock size={14} /> </>}
{p.edit_locked ? <><LockOpen size={14} /> </> : <><Lock size={14} /> </>}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleCommentsLock(p)}>
{p.comments_locked
? <><LockOpen size={14} /> </>
: <><MessageSquareOff size={14} /> </>}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>

View File

@@ -2510,9 +2510,32 @@ a.post-title:visited {
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.post-list-tag {
display: inline-flex;
align-items: center;
max-width: 7rem;
padding: 0.1rem 0.4rem;
border: 0;
border-radius: 0.25rem;
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 16%, transparent);
color: var(--color-text-3, #64748b);
font-size: 0.7rem;
line-height: 1.3;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-list-tag:hover {
color: var(--j13-green, #18a058);
background: color-mix(in srgb, var(--j13-green, #18a058) 12%, transparent);
}
.post-stats {
display: flex;
align-items: center;
@@ -3193,6 +3216,19 @@ a.post-title:visited {
font-size: 11px;
}
.comment-locked-banner {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0.75rem 0 1rem;
padding: 0.7rem 0.9rem;
border-radius: 0.4rem;
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 28%, transparent);
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
color: hsl(var(--destructive));
font-size: 0.875rem;
}
.post-detail-edit-hint {
font-size: 12px;
color: var(--color-text-3);
@@ -3749,6 +3785,18 @@ a.post-title:visited {
margin-bottom: 12px;
}
.post-detail-tag-btn {
display: inline-flex;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.post-detail-tag-btn:hover {
opacity: 0.85;
}
.post-detail-actions {
display: flex;
flex-direction: column;
@@ -9420,6 +9468,136 @@ button.profile-stat:hover strong {
margin-bottom: 12px;
}
.pm-tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 0.85rem;
}
.pm-tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
border: 1px solid var(--j13-border, #e2e8f0);
border-radius: 0.4rem;
background: transparent;
color: var(--color-text-2, #475569);
font-size: 0.875rem;
font-weight: 560;
cursor: pointer;
}
.pm-tab:hover {
border-color: color-mix(in srgb, var(--j13-green, #18a058) 40%, var(--j13-border, #e2e8f0));
}
.pm-tab.active {
border-color: color-mix(in srgb, var(--j13-green, #18a058) 45%, transparent);
background: color-mix(in srgb, var(--j13-green, #18a058) 10%, transparent);
color: var(--j13-green, #18a058);
}
.pm-tab__badge {
min-width: 1.15rem;
padding: 0 0.3rem;
border-radius: 999px;
background: hsl(var(--destructive));
color: #fff;
font-size: 0.68rem;
line-height: 1.15rem;
text-align: center;
}
.pm-notify {
padding: 0.85rem 1rem 1rem;
border-radius: 0.5rem;
}
.pm-notify-filters {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.85rem;
}
.pm-notify-filter {
padding: 0.25rem 0.65rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 14%, transparent);
color: var(--color-text-3, #64748b);
font-size: 0.78rem;
cursor: pointer;
}
.pm-notify-filter.active {
background: color-mix(in srgb, var(--j13-green, #18a058) 16%, transparent);
color: var(--j13-green, #18a058);
font-weight: 600;
}
.pm-notify-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.pm-notify-item {
padding: 0.75rem 0.85rem;
border: 1px solid var(--j13-border, #e2e8f0);
border-radius: 0.45rem;
background: var(--j13-card, #fff);
}
.pm-notify-item.unread {
border-color: color-mix(in srgb, var(--j13-green, #18a058) 35%, var(--j13-border, #e2e8f0));
background: color-mix(in srgb, var(--j13-green, #18a058) 5%, var(--j13-card, #fff));
}
.pm-notify-item__kind {
font-size: 0.72rem;
font-weight: 650;
color: var(--j13-green, #18a058);
margin-bottom: 0.25rem;
}
.pm-notify-item__subject {
font-weight: 650;
margin-bottom: 0.25rem;
}
.pm-notify-item__text {
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-2, #475569);
font-size: 0.9rem;
line-height: 1.5;
}
.pm-notify-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.55rem;
font-size: 0.75rem;
color: var(--color-text-4, #94a3b8);
}
.pm-notify-item__link {
color: var(--j13-green, #18a058);
text-decoration: none;
font-weight: 560;
}
.pm-notify-item__link:hover {
text-decoration: underline;
}
.pm-layout {
display: grid;
grid-template-columns: minmax(200px, 240px) 1fr;
@@ -9878,19 +10056,50 @@ a.pm-thread-head__name:hover {
background: color-mix(in srgb, var(--j13-green, #16a34a) 20%, transparent);
color: var(--j13-green, #16a34a);
}
.user-badge--level-muted { color: #64748b; }
.user-badge--level-blue {
background: color-mix(in srgb, #2563eb 18%, transparent);
color: #1d4ed8;
.user-badge--level {
gap: 0.2rem;
padding-left: 0.3rem;
border-left: 1px solid color-mix(in srgb, currentColor 28%, transparent);
}
.user-badge--level-amber {
background: color-mix(in srgb, #d97706 20%, transparent);
.user-badge__level-text {
letter-spacing: 0.01em;
}
.level-emblem {
flex-shrink: 0;
display: block;
}
.user-badge--level-sprout {
background: color-mix(in srgb, #6b8f71 16%, transparent);
color: #5a7260;
}
.user-badge--level-leaf {
background: color-mix(in srgb, var(--j13-green, #18a058) 16%, transparent);
color: var(--j13-green, #18a058);
}
.user-badge--level-crest {
background: color-mix(in srgb, #d97706 18%, transparent);
color: #b45309;
}
.user-badge--level-gold {
background: color-mix(in srgb, #ca8a04 22%, transparent);
.user-badge--level-crown {
background: color-mix(in srgb, #ca8a04 20%, transparent);
color: #a16207;
}
.dark .user-badge--level-sprout {
background: color-mix(in srgb, #86a789 18%, transparent);
color: #a3b8a6;
}
.dark .user-badge--level-leaf {
background: color-mix(in srgb, var(--j13-green, #23c36b) 18%, transparent);
color: var(--j13-green, #23c36b);
}
.dark .user-badge--level-crest {
background: color-mix(in srgb, #f59e0b 18%, transparent);
color: #fbbf24;
}
.dark .user-badge--level-crown {
background: color-mix(in srgb, #eab308 20%, transparent);
color: #facc15;
}
.user-badge--ach {
padding: 0.15rem;
}

View File

@@ -0,0 +1,59 @@
/** 新建帖本地草稿localStorage */
const STORAGE_KEY = 'j13-compose-draft-v1';
export type ComposeDraft = {
title: string;
tags: string;
content: string;
boardId: string;
postType: 'normal' | 'question';
savedAt: number;
};
export function loadComposeDraft(): ComposeDraft | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Partial<ComposeDraft>;
if (!data || typeof data !== 'object') return null;
return {
title: typeof data.title === 'string' ? data.title : '',
tags: typeof data.tags === 'string' ? data.tags : '',
content: typeof data.content === 'string' ? data.content : '',
boardId: typeof data.boardId === 'string' ? data.boardId : '',
postType: data.postType === 'question' ? 'question' : 'normal',
savedAt: typeof data.savedAt === 'number' ? data.savedAt : Date.now(),
};
} catch {
return null;
}
}
export function saveComposeDraft(draft: Omit<ComposeDraft, 'savedAt'>): void {
try {
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
// 配额满或隐私模式:忽略
}
}
export function clearComposeDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
/** 草稿是否有实质内容 */
export function composeDraftHasContent(d: ComposeDraft | null | undefined): boolean {
if (!d) return false;
return !!(
d.title.trim()
|| d.tags.trim()
|| d.content.trim()
|| (d.content && d.content.replace(/<[^>]*>/g, '').trim())
);
}

View File

@@ -15,18 +15,18 @@ export type FeedCache = {
/** 仅存内存SPA 内返回可恢复,浏览器刷新自动清空 */
const store = new Map<string, FeedCache>();
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
return `${boardId}:${keyword}:${sort}`;
function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
return `${boardId}:${keyword}:${tag}:${sort}`;
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
}
/** 保存帖子列表缓存 */
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
store.set(cacheKey(boardId, keyword, sort), data);
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
store.set(cacheKey(boardId, keyword, sort, tag), data);
}
/** 清除所有帖子列表缓存 */

View File

@@ -14,6 +14,16 @@ export function resolveUserLevel(u?: { level?: number; exp?: number } | null): n
return levelFromExp(u?.exp ?? 0);
}
/** 等级视觉档位(纹章 / CSS tone */
export type LevelTone = 'sprout' | 'leaf' | 'crest' | 'crown';
export function levelToneFromLevel(level: number): LevelTone {
if (level >= 9) return 'crown';
if (level >= 7) return 'crest';
if (level >= 4) return 'leaf';
return 'sprout';
}
/** 是否免审发帖/评论 */
export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean {
return !!u && (u.role === 'admin' || !!u.verified);

View File

@@ -198,6 +198,27 @@ func (h *Handlers) APIAdminLockPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
}
// APIAdminCommentsLockPost 锁定/解锁讨论(禁止新评论)
func (h *Handlers) APIAdminCommentsLockPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.Post.SetCommentsLocked(uint(id), req.Locked); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已开放讨论"
if req.Locked {
msg = "已锁定讨论"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked})
}
// APIAdminPinPost 全局置顶/取消全局置顶JSON
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -911,6 +932,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
keyword := c.Query("keyword")
tag := strings.TrimSpace(c.Query("tag"))
q := service.PostListQuery{
BoardID: uint(boardID),
@@ -918,6 +940,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Page: page,
Size: size,
Keyword: keyword,
Tag: tag,
Sort: c.DefaultQuery("sort", "latest"),
ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c),

View File

@@ -94,14 +94,46 @@ func (h *Handlers) APIMarkConversationRead(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "已标为已读"})
}
// APIMessageUnreadCount 未读私信数
// APIMessageUnreadCount 未读私信数(含私信/通知分项)
func (h *Handlers) APIMessageUnreadCount(c *gin.Context) {
n, err := h.Message.UnreadCount(h.currentUserID(c))
total, dm, notify, err := h.Message.UnreadCounts(h.currentUserID(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"count": n})
c.JSON(http.StatusOK, gin.H{
"count": total,
"dm_count": dm,
"notify_count": notify,
})
}
// APIMessageNotifications 系统通知列表
func (h *Handlers) APIMessageNotifications(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
kind := c.Query("kind")
uid := h.currentUserID(c)
list, total, err := h.Message.ListNotifications(uid, page, size, kind)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"notifications": list,
"total": total,
"page": page,
"kind": kind,
})
}
// APIMarkNotificationsRead 系统通知全部已读
func (h *Handlers) APIMarkNotificationsRead(c *gin.Context) {
if err := h.Message.MarkNotificationsRead(h.currentUserID(c)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "通知已全部标为已读"})
}
// APISendMessage 发送私信

View File

@@ -96,6 +96,7 @@ type Post struct {
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
EditLocked bool `gorm:"default:false" json:"edit_locked"`
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`

View File

@@ -151,6 +151,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
api.GET("/messages/notifications", h.APIMessageNotifications)
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
api.GET("/messages/conversations", h.APIMessageConversations)
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
@@ -197,6 +199,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)

View File

@@ -194,6 +194,11 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
return nil, errors.New("账号已被禁言")
}
// 讨论锁定:管理员亦不可强评(避免结贴后仍被顶楼)
if post.CommentsLocked {
return nil, ErrPostCommentsLocked
}
// 未公开帖仅作者/管理员可评论
if post.Status != model.ContentStatusPublished && post.Status != "" {
if user.Role != model.RoleAdmin && post.UserID != in.UserID {

View File

@@ -25,6 +25,7 @@ var (
ErrPermissionDenied = errors.New("无权操作")
ErrBoardNotFound = errors.New("板块不存在")
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
ErrPostEditExpired = errors.New("已超过可编辑时限")
ErrRevisionNotFound = errors.New("历史版本不存在")
ErrInvalidSetting = errors.New("无效的设置值")

View File

@@ -130,6 +130,59 @@ func (s *MessageService) UnreadCount(userID uint) (int64, error) {
return n, err
}
// UnreadCounts 未读总数,以及私信 / 系统通知分项
func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) {
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ?", userID, false).
Count(&total).Error
if err != nil {
return 0, 0, 0, err
}
err = model.DB.Model(&model.PrivateMessage{}).
Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false).
Count(&notify).Error
if err != nil {
return 0, 0, 0, err
}
dm = total - notify
if dm < 0 {
dm = 0
}
return total, dm, notify, nil
}
// ListNotifications 系统通知列表(按时间倒序,非聊天气泡)
func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) {
if page < 1 {
page = 1
}
size = s.settings.NormalizePageSize(size)
db := model.DB.Model(&model.PrivateMessage{}).
Where("from_user_id = 0 AND to_user_id = ?", userID)
kind = strings.TrimSpace(kind)
if kind != "" && kind != "all" {
db = db.Where("kind = ?", kind)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.PrivateMessage
err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error
if err != nil {
return nil, 0, err
}
if list == nil {
list = []model.PrivateMessage{}
}
return list, total, nil
}
// MarkNotificationsRead 将系统通知全部标为已读
func (s *MessageService) MarkNotificationsRead(userID uint) error {
return s.MarkConversationRead(userID, 0)
}
// MessageConversation 按对方聚合的会话摘要
type MessageConversation struct {
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知

View File

@@ -34,6 +34,7 @@ type PostListQuery struct {
Page int
Size int
Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE
Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
@@ -271,6 +272,12 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
escaped := escapeLikePattern(strings.ToLower(tag))
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), '', ','), ', ', ','), ' ,', ',') || ',')"
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
}
var total int64
db.Count(&total)
var posts []model.Post
@@ -310,6 +317,14 @@ func normalizePostSort(sort string) string {
}
}
// escapeLikePattern 转义 LIKE 通配符,配合 ESCAPE '\'
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
@@ -535,6 +550,18 @@ func (s *PostService) SetEditLocked(postID uint, locked bool) error {
return nil
}
// SetCommentsLocked 锁定/解锁讨论(禁止新评论)
func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("comments_locked", locked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrPostNotFound
}
return nil
}
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
var revs []model.PostRevision
err := model.DB.Preload("Editor").Where("post_id = ?", postID).