diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go index ea3df21..ee72e51 100644 --- a/cmd/jiang13/program.go +++ b/cmd/jiang13/program.go @@ -12,6 +12,7 @@ import ( "github.com/kardianos/service" "git.iioio.com/freefire/jiang13-forum/config" + forumsvc "git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/router" ) @@ -78,6 +79,9 @@ func (p *program) setup() error { if err := model.InitDB(cfg.DBPath()); err != nil { return fmt.Errorf("数据库初始化失败: %w", err) } + if err := forumsvc.BackfillModerationNotifyRefs(); err != nil { + log.Printf("待审通知关联字段回填警告: %v", err) + } if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil { return fmt.Errorf("监控库初始化失败: %w", err) } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c2bfce6..b3fddd3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -664,6 +664,8 @@ export const api = { }, markNotificationsRead: () => request<{ message: string }>('/api/messages/notifications/read', { method: 'POST' }), + markMessageRead: (id: number) => + request<{ message: string }>(`/api/messages/${id}/read`, { method: 'POST' }), sendMessage: (body: { to_user_id: number; subject?: string; content: string }) => request<{ message: PrivateMessage }>('/api/messages', { method: 'POST', body: JSON.stringify(body), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 25a12cb..d99e195 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -717,9 +717,13 @@ export interface PrivateMessage { to_user_id: number; subject: string; content: string; - kind: 'user' | 'system' | 'reject' | 'report_result' | string; + kind: 'user' | 'system' | 'reject' | 'report_result' | 'reply' | 'mention' | 'moderation' | string; related_post_id?: number; related_report_id?: number; + related_comment_id?: number; + related_floor?: number; + /** 待审目标实时状态:pending|published|rejected|deleted */ + related_status?: string; is_read: boolean; created_at: string; from_user?: User; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a00862b..b27f928 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -72,6 +72,8 @@ export default function MainLayout() { const [recentComments, setRecentComments] = useState(() => getCachedRecentComments()); const [recentUsers, setRecentUsers] = useState(() => getCachedRecentUsers()); const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread()); + const [dmUnread, setDmUnread] = useState(0); + const [notifyUnread, setNotifyUnread] = useState(0); const [tags, setTags] = useState(() => getCachedTags()); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); const [postOutline, setPostOutline] = useState<{ @@ -309,13 +311,29 @@ export default function MainLayout() { const refreshUnreadMessages = useCallback(() => { if (!user) { setUnreadMessages(0); + setDmUnread(0); + setNotifyUnread(0); return; } api.messageUnreadCount() - .then((r) => setUnreadMessages(r.count || 0)) - .catch(() => setUnreadMessages(0)); + .then((r) => { + setUnreadMessages(r.count || 0); + setDmUnread(r.dm_count ?? 0); + setNotifyUnread(r.notify_count ?? 0); + }) + .catch(() => { + setUnreadMessages(0); + setDmUnread(0); + setNotifyUnread(0); + }); }, [user]); + /** 仅有通知未读时直达通知 Tab,避免先看到空私信列表 */ + const openMessages = useCallback(() => { + const path = notifyUnread > 0 && dmUnread === 0 ? '/messages?tab=notify' : '/messages'; + void transitionTo(nav, path); + }, [nav, notifyUnread, dmUnread]); + useEffect(() => { refreshUnreadMessages(); const onRefresh = () => refreshUnreadMessages(); @@ -667,7 +685,7 @@ export default function MainLayout() { className="header-icon-btn header-msg-btn" title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'} aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'} - onClick={() => void transitionTo(nav, '/messages')} + onClick={openMessages} > {unreadMessages > 0 && ( @@ -691,7 +709,7 @@ export default function MainLayout() { void transitionTo(nav, '/profile')}> 账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''} - void transitionTo(nav, '/messages')}> + 站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''} void transitionTo(nav, '/favorites')}>我的收藏 diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index 2364e1c..cb4424b 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -1,6 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Bell, CheckCheck, Inbox, Mail, Send } from 'lucide-react'; +import { + ArrowLeft, + AtSign, + Bell, + CheckCheck, + Flag, + Inbox, + Mail, + MessageCircleReply, + Send, + ShieldAlert, + XCircle, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { notify } from '@/lib/notify'; @@ -9,7 +21,7 @@ import type { MessageConversation, PrivateMessage, User } from '../api/types'; import { useAuth } from '../hooks/useAuth'; import { loginPath } from '../utils/authRedirect'; import { useNoIndexSEO } from '../hooks/usePageSEO'; -import { formatTime } from '../utils/content'; +import { formatDateTime, formatTime } from '../utils/content'; import { postPath } from '../utils/permalink'; import { userPath } from '../utils/userPath'; import { InFlowSiteFooter } from '../components/SiteFooter'; @@ -20,7 +32,6 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; type MsgTab = 'dm' | 'notify'; type ConvSnap = { conversations: MessageConversation[]; total: number; page: number }; -type NotifySnap = { notifications: PrivateMessage[]; total: number; page: number }; type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null }; const NOTIFY_KINDS = [ @@ -28,23 +39,91 @@ const NOTIFY_KINDS = [ { key: 'reply', label: '回复' }, { key: 'mention', label: '@提及' }, { key: 'moderation', label: '待审' }, - { key: 'reject', label: '拒帖' }, + { key: 'reject', label: '未通过' }, { key: 'report_result', label: '举报' }, { key: 'system', label: '系统' }, ] as const; -function kindLabel(kind: string) { +function kindLabel(kind: string, relatedStatus?: string) { + if (kind === 'moderation') { + if (relatedStatus && relatedStatus !== 'pending') return '审核'; + return '待审'; + } switch (kind) { - case 'reject': return '拒帖通知'; + case 'reject': return '未通过'; case 'report_result': return '举报结果'; case 'reply': return '回复提醒'; case 'mention': return '@提及'; - case 'moderation': return '待审提醒'; case 'system': return '系统通知'; default: return '通知'; } } +function statusLabel(status?: string) { + switch (status) { + case 'pending': return '待审'; + case 'published': return '已通过'; + case 'rejected': return '未通过'; + case 'deleted': return '已删除'; + default: return ''; + } +} + +/** 仍待处理的审核通知(无状态按待审处理,兼容未回填) */ +function isPendingModeration(m: PrivateMessage) { + return m.kind === 'moderation' && (!m.related_status || m.related_status === 'pending'); +} + +function KindIcon({ kind }: { kind: string }) { + const props = { size: 15, 'aria-hidden': true as const }; + switch (kind) { + case 'reply': return ; + case 'mention': return ; + case 'moderation': return ; + case 'reject': return ; + case 'report_result': return ; + default: return ; + } +} + +/** 按通知类型生成跳转目标与 CTA 文案(待审跳前台帖/楼层) */ +function notifyTarget(m: PrivateMessage): { to: string; label: string } | null { + const postID = m.related_post_id; + const commentID = m.related_comment_id; + const floor = m.related_floor && m.related_floor > 0 ? m.related_floor : 0; + const looksLikeComment = + (m.subject || '').includes('评论') || (m.content || '').includes('评论'); + + if (m.kind === 'moderation') { + if (!postID) return null; + const isComment = !!commentID || looksLikeComment; + const path = isComment && floor > 0 + ? `${postPath(postID)}#floor-${floor}` + : postPath(postID); + const pending = isPendingModeration(m); + if (isComment) { + return { to: path, label: pending ? '去审核评论' : '查看评论' }; + } + return { to: path, label: pending ? '去审核帖子' : '查看帖子' }; + } + + if (postID) { + const path = floor > 0 ? `${postPath(postID)}#floor-${floor}` : postPath(postID); + if (m.kind === 'reply' || m.kind === 'mention') { + return { to: path, label: floor > 0 ? '查看回复' : '查看帖子' }; + } + if (m.kind === 'reject' && (commentID || looksLikeComment)) { + return { to: path, label: '查看评论' }; + } + if (m.kind === 'report_result' && floor > 0) { + return { to: path, label: '查看评论' }; + } + return { to: path, label: '查看帖子' }; + } + + return null; +} + function peerTitle(conv: MessageConversation | null, peerUser: User | null | undefined, peerId: number) { if (peerId === 0 || conv?.is_system) return '系统通知'; return peerUser?.nickname || conv?.peer_user?.nickname || `用户 #${peerId}`; @@ -72,7 +151,7 @@ function AvatarBubble({ if (system) { return ( - + ); } @@ -83,7 +162,6 @@ function AvatarBubble({ } function parseTab(raw: string | null, peer: string | null): MsgTab { - // 带 peer 时强制私信页(用户主页「发私信」入口) if (peer !== null && peer !== '') return 'dm'; return raw === 'notify' ? 'notify' : 'dm'; } @@ -121,16 +199,23 @@ export default function MessagesPage() { const [notifyLoading, setNotifyLoading] = useState(false); const [notifyUnread, setNotifyUnread] = useState(0); const [dmUnread, setDmUnread] = useState(0); + const [unreadOnly, setUnreadOnly] = useState(false); const threadEndRef = useRef(null); const threadScrollRef = useRef(null); const stickToBottomRef = useRef(true); + const notifyLoadSeq = useRef(0); const dmConversations = useMemo( () => conversations.filter((c) => !c.is_system && c.peer_user_id > 0), [conversations], ); + const visibleNotifications = useMemo( + () => (unreadOnly ? notifications.filter((m) => !m.is_read) : notifications), + [notifications, unreadOnly], + ); + const refreshUnreadSplit = useCallback(async () => { try { const r = await api.messageUnreadCount(); @@ -173,18 +258,9 @@ export default function MessagesPage() { } }, []); + // 通知列表不走 session 短路,保证 related_status 实时 const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => { - const key = `messages:notify:${kind}:${page}`; - if (!append) { - const hit = getSessionSnapshot(key); - if (hit) { - setNotifications(hit.notifications); - setNotifyTotal(hit.total); - setNotifyPage(hit.page); - setNotifyLoading(false); - return; - } - } + const seq = ++notifyLoadSeq.current; setNotifyLoading(true); try { const r = await api.messageNotifications({ @@ -192,24 +268,16 @@ export default function MessagesPage() { size: 30, kind: kind === 'all' ? undefined : kind, }); + if (seq !== notifyLoadSeq.current) return; const next = r.notifications || []; setNotifyTotal(r.total || 0); setNotifyPage(r.page || page); - // 打开通知页时标已读(首屏) - if (!append && page === 1) { - await api.markNotificationsRead().catch(() => undefined); - const marked = next.map((m) => ({ ...m, is_read: true })); - setNotifications(marked); - setSessionSnapshot(key, { notifications: marked, total: r.total || 0, page: r.page || page }); - setNotifyUnread(0); - window.dispatchEvent(new Event('messages-unread-refresh')); - } else { - setNotifications((prev) => (append ? [...prev, ...next] : next)); - } + setNotifications((prev) => (append ? [...prev, ...next] : next)); } catch (e: unknown) { + if (seq !== notifyLoadSeq.current) return; notify.error(e instanceof Error ? e.message : '加载通知失败'); } finally { - setNotifyLoading(false); + if (seq === notifyLoadSeq.current) setNotifyLoading(false); } }, []); @@ -221,18 +289,16 @@ export default function MessagesPage() { nav(loginPath('/messages')); return; } + void refreshUnreadSplit(); if (tab === 'dm') { - if (!getSessionSnapshot('messages:conv:1')) void refreshUnreadSplit(); loadConversations(1); } else { - if (!getSessionSnapshot(`messages:notify:${notifyKind}:1`)) void refreshUnreadSplit(); loadNotifications(1, false, notifyKind); } }, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]); useEffect(() => { const onForce = () => { - // 下拉预热会话列表后直接重读;线程快照需作废以便重拉 void refreshUnreadSplit(); if (tab === 'dm') { void loadConversations(1); @@ -241,8 +307,6 @@ export default function MessagesPage() { setThreadEpoch(n => n + 1); } } else { - // 通知列表:预热未覆盖 kind,作废后重拉 - deleteSessionSnapshot(`messages:notify:${notifyKind}:1`); void loadNotifications(1, false, notifyKind); } }; @@ -365,6 +429,23 @@ export default function MessagesPage() { } }; + const openNotification = async (m: PrivateMessage) => { + const target = notifyTarget(m); + if (!m.is_read) { + setNotifications((prev) => prev.map((item) => (item.id === m.id ? { ...item, is_read: true } : item))); + setNotifyUnread((n) => Math.max(0, n - 1)); + api.markMessageRead(m.id) + .then(() => { + window.dispatchEvent(new Event('messages-unread-refresh')); + void refreshUnreadSplit(); + }) + .catch(() => undefined); + } + if (target) { + nav(target.to); + } + }; + const loadOlder = async () => { if (!peerSelected || selectedPeer === null || messages.length === 0) return; const oldest = messages[0]?.id; @@ -452,15 +533,10 @@ export default function MessagesPage() { return (
- -

站内消息

-

私信与系统通知分开查看,回复提醒可直达帖子

+

点开才标已读;待审可直达帖内位置

{unreadForTab > 0 && (
-
- - -
- - {tab === 'notify' ? ( -
-
- {NOTIFY_KINDS.map((k) => ( - - ))} -
- {notifyLoading && notifications.length === 0 ? ( -
- ) : notifications.length === 0 ? ( -
- -

暂无通知

- 有人回复你、审核结果等会出现在这里 -
- ) : ( -
    - {notifications.map((m) => ( -
  • -
    {kindLabel(m.kind)}
    - {m.subject &&
    {m.subject}
    } -
    {m.content}
    -
    - - {m.related_post_id ? ( - - 查看帖子 - - ) : null} -
    -
  • - ))} -
- )} - {notifyTotal > notifications.length && ( -
- -
- )} +
+
+ +
- ) : ( -
- - -
- {!peerSelected || selectedPeer === null ? ( -
- -

选择左侧会话开始聊天

- 系统通知请切换到「通知」页签 -
- ) : ( - <> -
- - -
- {title} - 私信对话 -
-
- -
{ - const t = e.currentTarget; - stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80; - }} - > - {threadLoading ? ( -
- ) : ( - <> - {msgTotal > messages.length && ( -
- +
+ ) : ( +
+ + +
+ {!peerSelected || selectedPeer === null ? ( +
+ +

选择左侧会话开始聊天

+ 系统通知请切换到「通知」页签 +
+ ) : ( + <> +
+ + +
+ {title} + 私信对话 +
+
+ +
{ + const t = e.currentTarget; + stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80; + }} + > + {threadLoading ? ( +
+ ) : ( + <> + {msgTotal > messages.length && ( +
+ +
+ )} + {messages.length === 0 ? ( +
还没有消息,打个招呼吧
+ ) : ( + messages.map((m) => { + const mine = m.from_user_id === user.id; + return ( +
+
+
{m.content}
+
+ +
-
- ); - }) - )} -
- - )} -
+ ); + }) + )} +
+ + )} +
- {canCompose && ( -