From 10185178e2bbb1860666b45eb08b08e1e27c5d80 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 7 Aug 2026 06:32:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=AE=BA=E5=9D=9B=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E8=83=BD=E5=8A=9B=EF=BC=9A=E8=AE=A8=E8=AE=BA=E9=94=81?= =?UTF-8?q?=E5=AE=9A=E3=80=81=E5=8F=91=E5=B8=96=E6=9C=AC=E5=9C=B0=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E3=80=81=E6=A0=87=E7=AD=BE=E7=B2=BE=E7=A1=AE=E7=AD=9B?= =?UTF-8?q?=E9=80=89=EF=BC=8C=E4=BB=A5=E5=8F=8A=E7=A7=81=E4=BF=A1=E4=B8=8E?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=88=86=E6=B5=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/src/api/client.ts | 19 +- frontend/src/api/types.ts | 2 + frontend/src/components/CommentThreadList.tsx | 2 +- frontend/src/components/FeedHeader.tsx | 22 +- frontend/src/components/FeedSortBar.tsx | 11 +- frontend/src/components/LevelEmblem.tsx | 127 +++++ frontend/src/components/PostListItem.tsx | 18 + frontend/src/components/RightPanel.tsx | 7 +- frontend/src/components/TagCloud.tsx | 2 +- frontend/src/components/UserBadges.tsx | 13 +- frontend/src/layouts/MainLayout.tsx | 11 +- frontend/src/pages/ComposePage.tsx | 76 ++- frontend/src/pages/HomePage.tsx | 45 +- frontend/src/pages/MessagesPage.tsx | 532 ++++++++++++------ frontend/src/pages/PostDetailPage.tsx | 58 +- frontend/src/pages/admin/AdminPostsPage.tsx | 25 +- frontend/src/styles/global.css | 225 +++++++- frontend/src/utils/composeDraft.ts | 59 ++ frontend/src/utils/feedCache.ts | 12 +- frontend/src/utils/userMeta.ts | 10 + handler/api.go | 23 + handler/message.go | 38 +- model/models.go | 5 +- router/router.go | 3 + service/comment.go | 5 + service/common.go | 1 + service/message.go | 53 ++ service/post.go | 27 + 28 files changed, 1165 insertions(+), 266 deletions(-) create mode 100644 frontend/src/components/LevelEmblem.tsx create mode 100644 frontend/src/utils/composeDraft.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a38e9a3..31eefa5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4045d44..543ef63 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/components/CommentThreadList.tsx b/frontend/src/components/CommentThreadList.tsx index acefcdc..f4b4665 100644 --- a/frontend/src/components/CommentThreadList.tsx +++ b/frontend/src/components/CommentThreadList.tsx @@ -330,7 +330,7 @@ function CommentItem({ {approving ? '通过中…' : '通过'} )} - {!hidden && !isEditing && ( + {!hidden && !isEditing && !!renderReplyBox && ( isReplying ? ( )} - {keyword && ( + {filtered && ( 共 {postTotal} 条 )} diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx index 4ded22b..6504654 100644 --- a/frontend/src/components/FeedSortBar.tsx +++ b/frontend/src/components/FeedSortBar.tsx @@ -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}` : '/'; diff --git a/frontend/src/components/LevelEmblem.tsx b/frontend/src/components/LevelEmblem.tsx new file mode 100644 index 0000000..a7203ff --- /dev/null +++ b/frontend/src/components/LevelEmblem.tsx @@ -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 ( + + {tone === 'sprout' && ( + <> + + + + + )} + {tone === 'leaf' && ( + <> + + + + + + )} + {tone === 'crest' && ( + <> + + + + + )} + {tone === 'crown' && ( + <> + + + + + + + + )} + + ); +} diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index 8fb60c3..b0edfa9 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -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) {
{post.board && } + {tagList.map(t => ( + + ))}
{hasImage && ( diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index f6fca63..503931c 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -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 三连重复 diff --git a/frontend/src/components/TagCloud.tsx b/frontend/src/components/TagCloud.tsx index 2d2d0c0..8ef740f 100644 --- a/frontend/src/components/TagCloud.tsx +++ b/frontend/src/components/TagCloud.tsx @@ -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)}`)} > {tag.name} {tier >= 3 && {tag.count}} diff --git a/frontend/src/components/UserBadges.tsx b/frontend/src/components/UserBadges.tsx index e7d44c0..fed5a8b 100644 --- a/frontend/src/components/UserBadges.tsx +++ b/frontend/src/components/UserBadges.tsx @@ -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 ( @@ -54,8 +55,12 @@ export default function UserBadges({ )} {showLevel && ( - - Lv.{level} + + + Lv.{level} )} {achievements.map(b => { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 79fd00a..825ef4a 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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() {
)}
-
- - -
- {!peerSelected || selectedPeer === null ? ( -
- -

选择左侧会话开始聊天

- 系统通知也会出现在会话列表中 -
- ) : ( - <> -
- - -
- {selectedPeer > 0 ? ( - {title} - ) : ( - {title} - )} - - {selectedPeer === 0 ? '审核与系统消息' : '私信对话'} - -
-
- -
{ - 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 ? ( -