补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user