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

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

@@ -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
? `搜索:${keyword}`
: (boardId && board ? board.name : '');
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;
}
loadConversations(1);
}, [user, authLoading, nav, loadConversations]);
void refreshUnreadSplit();
if (tab === 'dm') {
loadConversations(1);
} 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 {
await api.markAllMessagesRead();
notify.success('已全部标为已读');
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
if (tab === 'notify') {
await api.markNotificationsRead();
setNotifications((prev) => prev.map((m) => ({ ...m, is_read: true })));
setNotifyUnread(0);
notify.success('通知已全部标为已读');
} else {
await api.markAllMessagesRead();
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,203 +367,257 @@ 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={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
<aside className="pm-list" aria-label="会话列表">
{listLoading && conversations.length === 0 ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : conversations.length === 0 ? (
<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">
<Inbox size={28} strokeWidth={1.5} aria-hidden />
<p>还没有会话</p>
<span>在用户主页点击「发私信」开始对话</span>
<Bell size={28} strokeWidth={1.5} aria-hidden />
<p>暂无通知</p>
<span>有人回复你、审核结果等会出现在这里</span>
</div>
) : (
conversations.map((c) => {
const name = peerTitle(c, c.peer_user, c.peer_user_id);
const active = peerSelected && selectedPeer === c.peer_user_id;
return (
<button
key={c.peer_user_id}
type="button"
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
onClick={() => openPeer(c.peer_user_id)}
>
<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">
<span className="pm-conv-item__name">{name}</span>
<span className="pm-conv-item__time">
{formatTime(c.last_message?.created_at || c.updated_at)}
</span>
</div>
<div className="pm-conv-item__preview">
<span>{previewText(c.last_message)}</span>
{c.unread_count > 0 && (
<span className="pm-conv-item__badge">
{c.unread_count > 99 ? '99+' : c.unread_count}
</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>
</button>
);
})
</li>
))}
</ul>
)}
{convTotal > conversations.length && (
{notifyTotal > notifications.length && (
<div className="pm-list-more">
<Button
variant="ghost"
size="sm"
disabled={listLoading}
onClick={() => loadConversations(convPage + 1, true)}
disabled={notifyLoading}
onClick={() => loadNotifications(notifyPage + 1, true, notifyKind)}
>
加载更多会话
加载更多通知
</Button>
</div>
)}
</aside>
<section className="pm-thread" aria-label="会话内容">
{!peerSelected || selectedPeer === null ? (
<div className="pm-empty pm-empty--thread">
<Send size={32} strokeWidth={1.4} aria-hidden />
<p>选择左侧会话开始聊天</p>
<span>系统通知也会出现在会话列表中</span>
</div>
) : (
<>
<header className="pm-thread-head">
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
<ArrowLeft size={18} />
</button>
<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>
</div>
</header>
<div
className="pm-thread-scroll"
ref={threadScrollRef}
onScroll={(e) => {
const t = e.currentTarget;
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
}}
>
{threadLoading ? (
<div className="flex justify-center py-16"><Spinner /></div>
) : (
<>
{msgTotal > messages.length && (
<div className="pm-thread-older">
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
查看更早消息
</Button>
</div>
) : (
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
<aside className="pm-list" aria-label="会话列表">
{listLoading && dmConversations.length === 0 ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : dmConversations.length === 0 ? (
<div className="pm-empty">
<Inbox size={28} strokeWidth={1.5} aria-hidden />
<p>还没有私信</p>
<span>在用户主页点击「发私信」开始对话</span>
</div>
) : (
dmConversations.map((c) => {
const name = peerTitle(c, c.peer_user, c.peer_user_id);
const active = peerSelected && selectedPeer === c.peer_user_id;
return (
<button
key={c.peer_user_id}
type="button"
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
onClick={() => openPeer(c.peer_user_id)}
>
<AvatarBubble
name={name}
avatar={c.peer_user?.avatar}
/>
<div className="pm-conv-item__body">
<div className="pm-conv-item__top">
<span className="pm-conv-item__name">{name}</span>
<span className="pm-conv-item__time">
{formatTime(c.last_message?.created_at || c.updated_at)}
</span>
</div>
)}
{messages.length === 0 ? (
<div className="pm-empty">还没有消息,打个招呼吧</div>
) : (
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',
)}
>
<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="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 className="pm-conv-item__preview">
<span>{previewText(c.last_message)}</span>
{c.unread_count > 0 && (
<span className="pm-conv-item__badge">
{c.unread_count > 99 ? '99+' : c.unread_count}
</span>
)}
</div>
</div>
</button>
);
})
)}
{convTotal > conversations.length && (
<div className="pm-list-more">
<Button
variant="ghost"
size="sm"
disabled={listLoading}
onClick={() => loadConversations(convPage + 1, true)}
>
加载更多会话
</Button>
</div>
)}
</aside>
<section className="pm-thread" aria-label="会话内容">
{!peerSelected || selectedPeer === null ? (
<div className="pm-empty pm-empty--thread">
<Send size={32} strokeWidth={1.4} aria-hidden />
<p>选择左侧会话开始聊天</p>
<span>系统通知请切换到「通知」页签</span>
</div>
) : (
<>
<header className="pm-thread-head">
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
<ArrowLeft size={18} />
</button>
<AvatarBubble
name={title}
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
/>
<div className="pm-thread-head__meta">
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
<span className="pm-thread-head__sub">私信对话</span>
</div>
</header>
<div
className="pm-thread-scroll"
ref={threadScrollRef}
onScroll={(e) => {
const t = e.currentTarget;
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
}}
>
{threadLoading ? (
<div className="flex justify-center py-16"><Spinner /></div>
) : (
<>
{msgTotal > messages.length && (
<div className="pm-thread-older">
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
查看更早消息
</Button>
</div>
)}
{messages.length === 0 ? (
<div className="pm-empty">还没有消息,打个招呼吧</div>
) : (
messages.map((m) => {
const mine = m.from_user_id === user.id;
return (
<div
key={m.id}
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
>
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
<div className="pm-bubble__text">{m.content}</div>
<div className="pm-bubble__meta">
<time>{formatTime(m.created_at)}</time>
</div>
</div>
</div>
</div>
);
})
)}
<div ref={threadEndRef} />
</>
)}
</div>
);
})
)}
<div ref={threadEndRef} />
</>
)}
</div>
{canCompose ? (
<footer className="pm-composer">
<textarea
className="pm-composer__input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
maxLength={4000}
placeholder={`发送给 ${title}…`}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button
className="pm-composer__send"
loading={sending}
disabled={!draft.trim()}
onClick={() => void send()}
>
<Send size={16} />
发送
</Button>
</footer>
) : (
<footer className="pm-composer pm-composer--readonly">
系统通知不可回复;如需联系管理员,请从用户主页发私信。
</footer>
)}
</>
)}
</section>
</div>
{canCompose && (
<footer className="pm-composer">
<textarea
className="pm-composer__input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
maxLength={4000}
placeholder={`发送给 ${title}…`}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button
className="pm-composer__send"
loading={sending}
disabled={!draft.trim()}
onClick={() => void send()}
>
<Send size={16} />
发送
</Button>
</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>