补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -93,6 +93,10 @@ export const api = {
|
|||||||
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
||||||
method: 'POST', body: JSON.stringify({ locked }),
|
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) =>
|
adminRejectPost: (id: number, reason: string) =>
|
||||||
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
|
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
|
||||||
method: 'POST', body: JSON.stringify({ reason }),
|
method: 'POST', body: JSON.stringify({ reason }),
|
||||||
@@ -433,7 +437,20 @@ export const api = {
|
|||||||
},
|
},
|
||||||
markConversationRead: (peerId: number) =>
|
markConversationRead: (peerId: number) =>
|
||||||
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
|
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 }) =>
|
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
|
||||||
request<{ message: PrivateMessage }>('/api/messages', {
|
request<{ message: PrivateMessage }>('/api/messages', {
|
||||||
method: 'POST', body: JSON.stringify(body),
|
method: 'POST', body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export interface PostItem {
|
|||||||
board_pinned?: boolean;
|
board_pinned?: boolean;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
edit_locked?: boolean;
|
edit_locked?: boolean;
|
||||||
|
/** 禁止新评论(结贴) */
|
||||||
|
comments_locked?: boolean;
|
||||||
status?: 'pending' | 'published' | 'rejected' | string;
|
status?: 'pending' | 'published' | 'rejected' | string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
view_count: number;
|
view_count: number;
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ function CommentItem({
|
|||||||
{approving ? '通过中…' : '通过'}
|
{approving ? '通过中…' : '通过'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!hidden && !isEditing && (
|
{!hidden && !isEditing && !!renderReplyBox && (
|
||||||
isReplying ? (
|
isReplying ? (
|
||||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Board, ForumStats } from '../api/types';
|
|||||||
interface Props {
|
interface Props {
|
||||||
boardId: number;
|
boardId: number;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
tag?: string;
|
||||||
boards: Board[];
|
boards: Board[];
|
||||||
stats: ForumStats | null;
|
stats: ForumStats | null;
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
@@ -12,20 +13,21 @@ interface Props {
|
|||||||
titleAs?: 'h1' | 'h2';
|
titleAs?: 'h1' | 'h2';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
|
export default function FeedHeader({ boardId, keyword, tag = '', boards, stats, postTotal, titleAs = 'h1' }: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const board = boards.find(b => b.id === boardId);
|
const board = boards.find(b => b.id === boardId);
|
||||||
|
|
||||||
const inBoard = !keyword && boardId > 0 && !!board;
|
const filtered = !!(keyword || tag);
|
||||||
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */
|
const inBoard = !filtered && boardId > 0 && !!board;
|
||||||
const title = keyword ? `搜索:${keyword}` : '';
|
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
|
||||||
|
const title = tag ? `标签:${tag}` : (keyword ? `搜索:${keyword}` : '');
|
||||||
const TitleTag = titleAs;
|
const TitleTag = titleAs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
||||||
<div className="feed-head__title">
|
<div className="feed-head__title">
|
||||||
{title ? <TitleTag>{title}</TitleTag> : null}
|
{title ? <TitleTag>{title}</TitleTag> : null}
|
||||||
{!keyword && inBoard && (
|
{!filtered && inBoard && (
|
||||||
<div className="feed-head__stats">
|
<div className="feed-head__stats">
|
||||||
<span className="feed-stat-chip">
|
<span className="feed-stat-chip">
|
||||||
<FileText aria-hidden />
|
<FileText aria-hidden />
|
||||||
@@ -38,7 +40,7 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!keyword && !inBoard && stats && (
|
{!filtered && !inBoard && stats && (
|
||||||
<div className="feed-head__stats">
|
<div className="feed-head__stats">
|
||||||
<span className="feed-stat-chip">
|
<span className="feed-stat-chip">
|
||||||
<Users aria-hidden />
|
<Users aria-hidden />
|
||||||
@@ -55,16 +57,16 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{keyword && (
|
{filtered && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="feed-head__clear"
|
className="feed-head__clear"
|
||||||
onClick={() => nav('/')}
|
onClick={() => nav('/')}
|
||||||
>
|
>
|
||||||
清除搜索
|
{tag ? '清除标签' : '清除搜索'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{keyword && (
|
{filtered && (
|
||||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ export function parseFeedSort(raw: string | null): FeedSort {
|
|||||||
return 'latest';
|
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();
|
const p = new URLSearchParams();
|
||||||
if (boardId) p.set('board', String(boardId));
|
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);
|
if (sort !== 'latest') p.set('sort', sort);
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return qs ? `/?${qs}` : '/';
|
return qs ? `/?${qs}` : '/';
|
||||||
|
|||||||
127
frontend/src/components/LevelEmblem.tsx
Normal file
127
frontend/src/components/LevelEmblem.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { levelToneFromLevel, type LevelTone } from '../utils/userMeta';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
level: number;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
tone?: LevelTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级迷你纹章:芽 / 叶 / 盾 / 冠(线描,currentColor) */
|
||||||
|
export default function LevelEmblem({ level, size = 12, className, tone: toneProp }: Props) {
|
||||||
|
const tone = toneProp ?? levelToneFromLevel(level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn('level-emblem', `level-emblem--${tone}`, className)}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{tone === 'sprout' && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M8 13.5V8.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 9.2C8 9.2 5.2 8.6 4.2 6.4C3.4 4.6 4.6 3.2 6.4 3.6C7.6 3.9 8 5.2 8 5.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 7.6C8 7.6 9.8 6.4 11.2 7.2C12.6 8 12.4 9.8 10.8 10.4C9.6 10.85 8.4 10.2 8 9.6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tone === 'leaf' && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M8 13.2V7.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 8.2C5.6 8.2 3.8 6.5 3.5 4.4C3.3 2.9 4.5 2.2 5.8 2.6C7.1 3 8 4.4 8 4.4C8 4.4 8.9 3 10.2 2.6C11.5 2.2 12.7 2.9 12.5 4.4C12.2 6.5 10.4 8.2 8 8.2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 4.4V8.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.2 11.4C5.8 10.2 6.8 9.5 8 9.5C9.2 9.5 10.2 10.2 10.8 11.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tone === 'crest' && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M3.5 3.2H12.5V7.2C12.5 10.4 10.4 12.6 8 13.4C5.6 12.6 3.5 10.4 3.5 7.2V3.2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 11.2V6.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 7C6.4 7 5.4 6 5.2 4.8C5.8 5.1 6.8 5.2 8 5.2C9.2 5.2 10.2 5.1 10.8 4.8C10.6 6 9.6 7 8 7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.1"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tone === 'crown' && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d="M3.2 10.2C3.8 11.8 5.6 13 8 13.4C10.4 13 12.2 11.8 12.8 10.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.2 10C3.6 8.2 3.8 6.2 5.2 5C6.2 4.1 7.2 4.4 8 5.2C8.8 4.4 9.8 4.1 10.8 5C12.2 6.2 12.4 8.2 11.8 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.5 4.2L4.6 2.6M8 4.4V2.4M10.5 4.2L11.4 2.6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="4.5" cy="2.2" r="0.7" fill="currentColor" />
|
||||||
|
<circle cx="8" cy="2" r="0.75" fill="currentColor" />
|
||||||
|
<circle cx="11.5" cy="2.2" r="0.7" fill="currentColor" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
||||||
import BoardBadge from '@/components/BoardBadge';
|
import BoardBadge from '@/components/BoardBadge';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
@@ -8,6 +9,7 @@ import type { FeedSort } from './FeedSortBar';
|
|||||||
import { formatTime } from '../utils/content';
|
import { formatTime } from '../utils/content';
|
||||||
import { postPath } from '../utils/permalink';
|
import { postPath } from '../utils/permalink';
|
||||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||||
|
import { parseTags } from './TagInput';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
post: PostItem;
|
post: PostItem;
|
||||||
@@ -16,6 +18,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||||
|
const nav = useNavigate();
|
||||||
const initial = post.user?.nickname?.[0] || '?';
|
const initial = post.user?.nickname?.[0] || '?';
|
||||||
const timeLabel = sort === 'reply'
|
const timeLabel = sort === 'reply'
|
||||||
? (post.last_reply_at
|
? (post.last_reply_at
|
||||||
@@ -28,6 +31,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
const href = postPath(post.id);
|
const href = postPath(post.id);
|
||||||
const excerpt = excerptFromHTML(post.content || '', 72);
|
const excerpt = excerptFromHTML(post.content || '', 72);
|
||||||
const hasImage = !!firstImageFromHTML(post.content || '');
|
const hasImage = !!firstImageFromHTML(post.content || '');
|
||||||
|
const tagList = parseTags(post.tags || '').slice(0, 3);
|
||||||
|
|
||||||
const openPost = () => onSelect(post.id);
|
const openPost = () => onSelect(post.id);
|
||||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -113,6 +117,20 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
<div className="post-foot">
|
<div className="post-foot">
|
||||||
<div className="post-foot-left">
|
<div className="post-foot-left">
|
||||||
{post.board && <BoardBadge board={post.board} />}
|
{post.board && <BoardBadge board={post.board} />}
|
||||||
|
{tagList.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
className="post-list-tag"
|
||||||
|
title={`筛选标签:${t}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
nav(`/?tag=${encodeURIComponent(t)}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="post-stats">
|
<div className="post-stats">
|
||||||
{hasImage && (
|
{hasImage && (
|
||||||
|
|||||||
@@ -77,11 +77,14 @@ export default function RightPanel({
|
|||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const activeTag = params.get('keyword') || '';
|
const activeTag = params.get('tag') || '';
|
||||||
const hotList = hot?.slice(0, 8) ?? [];
|
const hotList = hot?.slice(0, 8) ?? [];
|
||||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||||
// 站点首页:右侧品牌块承担唯一 h1;板块/搜索等页面由 Feed 标题作 h1
|
// 站点首页:右侧品牌块承担唯一 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 description = branding.description?.trim() || '';
|
||||||
const slogan = branding.slogan?.trim() || '';
|
const slogan = branding.slogan?.trim() || '';
|
||||||
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
|
|||||||
active && 'active',
|
active && 'active',
|
||||||
)}
|
)}
|
||||||
title={`${tag.name} · ${tag.count} 篇`}
|
title={`${tag.name} · ${tag.count} 篇`}
|
||||||
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
onClick={() => nav(active ? '/' : `/?tag=${encodeURIComponent(tag.name)}`)}
|
||||||
>
|
>
|
||||||
<span className="tag-cloud-item__name">{tag.name}</span>
|
<span className="tag-cloud-item__name">{tag.name}</span>
|
||||||
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { BadgeCheck, Crown, type LucideIcon } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { UserBadge } from '../api/types';
|
import type { UserBadge } from '../api/types';
|
||||||
import { badgeIcon } from '../utils/badgeIcons';
|
import { badgeIcon } from '../utils/badgeIcons';
|
||||||
import { resolveUserLevel } from '../utils/userMeta';
|
import { levelToneFromLevel, resolveUserLevel } from '../utils/userMeta';
|
||||||
|
import LevelEmblem from './LevelEmblem';
|
||||||
|
|
||||||
type BadgeUser = {
|
type BadgeUser = {
|
||||||
role?: string;
|
role?: string;
|
||||||
@@ -37,7 +38,7 @@ export default function UserBadges({
|
|||||||
|
|
||||||
if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null;
|
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 (
|
return (
|
||||||
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
|
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
|
||||||
@@ -54,8 +55,12 @@ export default function UserBadges({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showLevel && (
|
{showLevel && (
|
||||||
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
|
<span
|
||||||
Lv.{level}
|
className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)}
|
||||||
|
title={`经验 ${user.exp ?? 0}`}
|
||||||
|
>
|
||||||
|
<LevelEmblem level={level} tone={levelTone} size={compact ? 12 : 14} />
|
||||||
|
<span className="user-badge__level-text">Lv.{level}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{achievements.map(b => {
|
{achievements.map(b => {
|
||||||
|
|||||||
@@ -258,9 +258,10 @@ export default function MainLayout() {
|
|||||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||||
const isFeedHome = loc.pathname === '/';
|
const isFeedHome = loc.pathname === '/';
|
||||||
const outletKeyword = params.get('keyword') || '';
|
const outletKeyword = params.get('keyword') || '';
|
||||||
// 搜索结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
const outletTag = params.get('tag') || '';
|
||||||
|
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
||||||
const mobileActiveBoard =
|
const mobileActiveBoard =
|
||||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword
|
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
|
||||||
? -1
|
? -1
|
||||||
: boardId;
|
: boardId;
|
||||||
|
|
||||||
@@ -434,8 +435,8 @@ export default function MainLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-icon-btn header-msg-btn"
|
className="header-icon-btn header-msg-btn"
|
||||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读私信` : '站内私信'}
|
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
||||||
aria-label={unreadMessages > 0 ? `站内私信,${unreadMessages} 条未读` : '站内私信'}
|
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
||||||
onClick={() => nav('/messages')}
|
onClick={() => nav('/messages')}
|
||||||
>
|
>
|
||||||
<Mail size={18} aria-hidden />
|
<Mail size={18} aria-hidden />
|
||||||
@@ -461,7 +462,7 @@ export default function MainLayout() {
|
|||||||
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => nav('/messages')}>
|
<DropdownMenuItem onClick={() => nav('/messages')}>
|
||||||
站内私信{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
@@ -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 { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
@@ -18,6 +18,12 @@ import { loginPath } from '../utils/authRedirect';
|
|||||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||||
import { parsePermalinkID, postPath } from '../utils/permalink';
|
import { parsePermalinkID, postPath } from '../utils/permalink';
|
||||||
import { skipsModeration } from '../utils/userMeta';
|
import { skipsModeration } from '../utils/userMeta';
|
||||||
|
import {
|
||||||
|
clearComposeDraft,
|
||||||
|
composeDraftHasContent,
|
||||||
|
loadComposeDraft,
|
||||||
|
saveComposeDraft,
|
||||||
|
} from '../utils/composeDraft';
|
||||||
|
|
||||||
interface ComposeBaseline {
|
interface ComposeBaseline {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -74,6 +80,9 @@ export default function ComposePage() {
|
|||||||
);
|
);
|
||||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||||
const [editWindowHint, setEditWindowHint] = useState('');
|
const [editWindowHint, setEditWindowHint] = useState('');
|
||||||
|
const [draftHint, setDraftHint] = useState('');
|
||||||
|
/** 新建帖:是否已处理过本地草稿恢复 */
|
||||||
|
const draftHandledRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
@@ -137,16 +146,43 @@ export default function ComposePage() {
|
|||||||
|
|
||||||
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
||||||
setBoards(list);
|
setBoards(list);
|
||||||
if (!defaultBoard) setBoardId(initialBoardId);
|
|
||||||
const boardForBaseline = defaultBoard || initialBoardId;
|
const boardForBaseline = defaultBoard || initialBoardId;
|
||||||
setBoardId(prev => prev || boardForBaseline);
|
const emptyBaseline: ComposeBaseline = {
|
||||||
setBaseline({
|
|
||||||
title: '',
|
title: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
content: '',
|
content: '',
|
||||||
boardId: boardForBaseline,
|
boardId: boardForBaseline,
|
||||||
postType: 'normal',
|
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);
|
const list = resolveBoards(layoutCtx?.boards);
|
||||||
@@ -180,14 +216,36 @@ export default function ComposePage() {
|
|||||||
);
|
);
|
||||||
}, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]);
|
}, [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 {
|
const {
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
stayOnPage,
|
stayOnPage,
|
||||||
discardAndLeave,
|
discardAndLeave: discardAndLeaveRaw,
|
||||||
requestLeave,
|
requestLeave,
|
||||||
markSaved,
|
markSaved,
|
||||||
} = useUnsavedChangesGuard({ isDirty });
|
} = useUnsavedChangesGuard({ isDirty });
|
||||||
|
|
||||||
|
const discardAndLeave = () => {
|
||||||
|
if (!isEdit) clearComposeDraft();
|
||||||
|
discardAndLeaveRaw();
|
||||||
|
};
|
||||||
|
|
||||||
const leaveTo = (path: string) => {
|
const leaveTo = (path: string) => {
|
||||||
markSaved();
|
markSaved();
|
||||||
nav(path);
|
nav(path);
|
||||||
@@ -264,6 +322,7 @@ export default function ComposePage() {
|
|||||||
nav(postPath(editId!, limits));
|
nav(postPath(editId!, limits));
|
||||||
} else {
|
} else {
|
||||||
const res = await api.createPost(payload);
|
const res = await api.createPost(payload);
|
||||||
|
clearComposeDraft();
|
||||||
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
|
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
|
||||||
markSaved();
|
markSaved();
|
||||||
nav(postPath(res.post_id, limits));
|
nav(postPath(res.post_id, limits));
|
||||||
@@ -298,6 +357,11 @@ export default function ComposePage() {
|
|||||||
{editWindowHint}
|
{editWindowHint}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!isEdit && draftHint && (
|
||||||
|
<span className="compose-draft-hint" title={draftHint}>
|
||||||
|
{draftHint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="compose-header-actions">
|
<div className="compose-header-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -32,18 +32,25 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||||
const keyword = params.get('keyword') || '';
|
const keyword = params.get('keyword') || '';
|
||||||
|
const tag = params.get('tag') || '';
|
||||||
const sort = parseFeedSort(params.get('sort'));
|
const sort = parseFeedSort(params.get('sort'));
|
||||||
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||||
const isSiteHome = !boardId && !keyword;
|
const isSiteHome = !boardId && !keyword && !tag;
|
||||||
const siteIntro = siteMetaDescription(branding);
|
const siteIntro = siteMetaDescription(branding);
|
||||||
const feedTitle = keyword
|
const feedTitle = tag
|
||||||
|
? `标签:${tag}`
|
||||||
|
: keyword
|
||||||
? `搜索:${keyword}`
|
? `搜索:${keyword}`
|
||||||
: (boardId && board ? board.name : '');
|
: (boardId && board ? board.name : '');
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
title: feedTitle || undefined,
|
title: feedTitle || undefined,
|
||||||
description: board?.description?.trim() || siteIntro,
|
description: board?.description?.trim() || siteIntro,
|
||||||
keywords: joinSEOKeywords(board?.name, branding.keywords),
|
keywords: joinSEOKeywords(board?.name, tag, branding.keywords),
|
||||||
canonicalPath: boardId ? `/?board=${boardId}` : '/',
|
canonicalPath: tag
|
||||||
|
? `/?tag=${encodeURIComponent(tag)}`
|
||||||
|
: boardId
|
||||||
|
? `/?board=${boardId}`
|
||||||
|
: '/',
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ export default function HomePage() {
|
|||||||
const pageRef = useRef(1);
|
const pageRef = useRef(1);
|
||||||
pageRef.current = page;
|
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 totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
const showPagination = totalPages > 1 && posts.length > 0;
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
@@ -87,7 +94,8 @@ export default function HomePage() {
|
|||||||
page: p,
|
page: p,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
board_id: boardId || '',
|
board_id: boardId || '',
|
||||||
keyword,
|
keyword: tag ? '' : keyword,
|
||||||
|
tag: tag || '',
|
||||||
sort: sort === 'latest' ? '' : sort,
|
sort: sort === 'latest' ? '' : sort,
|
||||||
});
|
});
|
||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
@@ -106,7 +114,7 @@ export default function HomePage() {
|
|||||||
loadingRef.current = false;
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [boardId, keyword, sort, pageSize]);
|
}, [boardId, keyword, tag, sort, pageSize]);
|
||||||
|
|
||||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||||
|
|
||||||
@@ -134,7 +142,7 @@ export default function HomePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = getFeedCache(boardId, keyword, sort);
|
const cached = getFeedCache(boardId, keyword, sort, tag);
|
||||||
if (cached && cached.posts.length > 0) {
|
if (cached && cached.posts.length > 0) {
|
||||||
setPosts(cached.posts);
|
setPosts(cached.posts);
|
||||||
setPostTotal(cached.postTotal);
|
setPostTotal(cached.postTotal);
|
||||||
@@ -154,6 +162,7 @@ export default function HomePage() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
boardId,
|
boardId,
|
||||||
keyword,
|
keyword,
|
||||||
|
tag,
|
||||||
sort,
|
sort,
|
||||||
location.key,
|
location.key,
|
||||||
location.state,
|
location.state,
|
||||||
@@ -165,15 +174,16 @@ export default function HomePage() {
|
|||||||
if (
|
if (
|
||||||
feedSnapRef.current.boardId === boardId
|
feedSnapRef.current.boardId === boardId
|
||||||
&& feedSnapRef.current.keyword === keyword
|
&& feedSnapRef.current.keyword === keyword
|
||||||
|
&& feedSnapRef.current.tag === tag
|
||||||
&& feedSnapRef.current.sort === sort
|
&& feedSnapRef.current.sort === sort
|
||||||
) {
|
) {
|
||||||
feedSnapRef.current = { boardId, keyword, sort, posts, postTotal, page };
|
feedSnapRef.current = { boardId, keyword, tag, sort, posts, postTotal, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||||
feedSnapRef.current = { boardId, keyword, sort, posts: [], postTotal: 0, page: 1 };
|
feedSnapRef.current = { boardId, keyword, tag, sort, posts: [], postTotal: 0, page: 1 };
|
||||||
return () => {
|
return () => {
|
||||||
if (skipCacheSaveRef.current) return;
|
if (skipCacheSaveRef.current) return;
|
||||||
const snap = feedSnapRef.current;
|
const snap = feedSnapRef.current;
|
||||||
@@ -183,9 +193,9 @@ export default function HomePage() {
|
|||||||
postTotal: snap.postTotal,
|
postTotal: snap.postTotal,
|
||||||
page: snap.page,
|
page: snap.page,
|
||||||
scrollTop: scrollTopRef.current,
|
scrollTop: scrollTopRef.current,
|
||||||
});
|
}, snap.tag);
|
||||||
};
|
};
|
||||||
}, [boardId, keyword, sort]);
|
}, [boardId, keyword, tag, sort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||||
@@ -212,10 +222,10 @@ export default function HomePage() {
|
|||||||
loadFirst();
|
loadFirst();
|
||||||
return;
|
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) {
|
if ((loading || limitsLoading) && posts.length === 0) {
|
||||||
@@ -230,6 +240,7 @@ export default function HomePage() {
|
|||||||
<FeedHeader
|
<FeedHeader
|
||||||
boardId={boardId}
|
boardId={boardId}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
|
tag={tag}
|
||||||
boards={ctx?.boards ?? []}
|
boards={ctx?.boards ?? []}
|
||||||
stats={ctx?.stats ?? null}
|
stats={ctx?.stats ?? null}
|
||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
@@ -255,7 +266,7 @@ export default function HomePage() {
|
|||||||
resetScrollKey={listResetKey}
|
resetScrollKey={listResetKey}
|
||||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||||
keyword={keyword}
|
keyword={keyword || tag}
|
||||||
boardId={boardId}
|
boardId={boardId}
|
||||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
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 { 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 { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
@@ -15,6 +15,17 @@ import { userPath } from '../utils/userPath';
|
|||||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
import { cn } from '@/lib/utils';
|
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) {
|
function kindLabel(kind: string) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'reject': return '拒帖通知';
|
case 'reject': return '拒帖通知';
|
||||||
@@ -22,7 +33,7 @@ function kindLabel(kind: string) {
|
|||||||
case 'reply': return '回复提醒';
|
case 'reply': return '回复提醒';
|
||||||
case 'moderation': return '待审提醒';
|
case 'moderation': return '待审提醒';
|
||||||
case 'system': 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>;
|
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() {
|
export default function MessagesPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
useNoIndexSEO('站内私信');
|
useNoIndexSEO('站内消息');
|
||||||
|
|
||||||
const peerParam = params.get('peer');
|
const peerParam = params.get('peer');
|
||||||
|
const tab = parseTab(params.get('tab'), peerParam);
|
||||||
const selectedPeer = peerParam === null || peerParam === ''
|
const selectedPeer = peerParam === null || peerParam === ''
|
||||||
? null
|
? null
|
||||||
: Number(peerParam);
|
: 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 [conversations, setConversations] = useState<MessageConversation[]>([]);
|
||||||
const [convTotal, setConvTotal] = useState(0);
|
const [convTotal, setConvTotal] = useState(0);
|
||||||
@@ -88,10 +107,32 @@ export default function MessagesPage() {
|
|||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
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 threadEndRef = useRef<HTMLDivElement>(null);
|
||||||
const threadScrollRef = useRef<HTMLDivElement>(null);
|
const threadScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const stickToBottomRef = useRef(true);
|
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) => {
|
const loadConversations = useCallback(async (page = 1, append = false) => {
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
nav(loginPath('/messages'));
|
nav(loginPath('/messages'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
void refreshUnreadSplit();
|
||||||
|
if (tab === 'dm') {
|
||||||
loadConversations(1);
|
loadConversations(1);
|
||||||
}, [user, authLoading, nav, loadConversations]);
|
} else {
|
||||||
|
loadNotifications(1, false, notifyKind);
|
||||||
|
}
|
||||||
|
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -142,6 +215,7 @@ export default function MessagesPage() {
|
|||||||
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
||||||
)));
|
)));
|
||||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||||
|
void refreshUnreadSplit();
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
||||||
@@ -150,7 +224,7 @@ export default function MessagesPage() {
|
|||||||
if (!cancelled) setThreadLoading(false);
|
if (!cancelled) setThreadLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [user, peerSelected, selectedPeer]);
|
}, [user, peerSelected, selectedPeer, refreshUnreadSplit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!threadLoading && stickToBottomRef.current) {
|
if (!threadLoading && stickToBottomRef.current) {
|
||||||
@@ -158,24 +232,54 @@ export default function MessagesPage() {
|
|||||||
}
|
}
|
||||||
}, [messages, threadLoading, scrollToBottom]);
|
}, [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 openPeer = (peerId: number) => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
|
p.set('tab', 'dm');
|
||||||
p.set('peer', String(peerId));
|
p.set('peer', String(peerId));
|
||||||
setParams(p, { replace: true });
|
setParams(p, { replace: true });
|
||||||
setDraft('');
|
setDraft('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeThread = () => {
|
const closeThread = () => {
|
||||||
setParams(new URLSearchParams(), { replace: true });
|
const p = new URLSearchParams();
|
||||||
|
p.set('tab', 'dm');
|
||||||
|
setParams(p, { replace: true });
|
||||||
setDraft('');
|
setDraft('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAll = async () => {
|
const markAll = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (tab === 'notify') {
|
||||||
|
await api.markNotificationsRead();
|
||||||
|
setNotifications((prev) => prev.map((m) => ({ ...m, is_read: true })));
|
||||||
|
setNotifyUnread(0);
|
||||||
|
notify.success('通知已全部标为已读');
|
||||||
|
} else {
|
||||||
await api.markAllMessagesRead();
|
await api.markAllMessagesRead();
|
||||||
notify.success('已全部标为已读');
|
|
||||||
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
||||||
|
setDmUnread(0);
|
||||||
|
setNotifyUnread(0);
|
||||||
|
notify.success('已全部标为已读');
|
||||||
|
}
|
||||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||||
|
void refreshUnreadSplit();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
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>;
|
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||||
}
|
}
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -251,7 +355,7 @@ export default function MessagesPage() {
|
|||||||
? peerTitle(activeConv, peerUser, selectedPeer)
|
? peerTitle(activeConv, peerUser, selectedPeer)
|
||||||
: '';
|
: '';
|
||||||
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
|
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 (
|
return (
|
||||||
<div className="page-wrap">
|
<div className="page-wrap">
|
||||||
@@ -263,29 +367,111 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
<div className="pm-page-head">
|
<div className="pm-page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">站内私信</h1>
|
<h1 className="page-title">站内消息</h1>
|
||||||
<p className="page-desc">按会话查看,与用户即时沟通,并接收系统通知</p>
|
<p className="page-desc">私信与系统通知分开查看,回复提醒可直达帖子</p>
|
||||||
</div>
|
</div>
|
||||||
{unreadTotal > 0 && (
|
{unreadForTab > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={markAll}>
|
<Button variant="outline" size="sm" onClick={markAll}>
|
||||||
<CheckCheck size={14} />
|
<CheckCheck size={14} />
|
||||||
全部已读
|
{tab === 'notify' ? '通知全部已读' : '全部已读'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pm-tabs" role="tablist" aria-label="消息类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'dm'}
|
||||||
|
className={cn('pm-tab', tab === 'dm' && 'active')}
|
||||||
|
onClick={() => setTab('dm')}
|
||||||
|
>
|
||||||
|
<Mail size={15} aria-hidden />
|
||||||
|
私信
|
||||||
|
{dmUnread > 0 && <span className="pm-tab__badge">{dmUnread > 99 ? '99+' : dmUnread}</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'notify'}
|
||||||
|
className={cn('pm-tab', tab === 'notify' && 'active')}
|
||||||
|
onClick={() => setTab('notify')}
|
||||||
|
>
|
||||||
|
<Bell size={15} aria-hidden />
|
||||||
|
通知
|
||||||
|
{notifyUnread > 0 && <span className="pm-tab__badge">{notifyUnread > 99 ? '99+' : notifyUnread}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'notify' ? (
|
||||||
|
<div className="pm-notify content-surface">
|
||||||
|
<div className="pm-notify-filters" role="tablist" aria-label="通知类型">
|
||||||
|
{NOTIFY_KINDS.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={notifyKind === k.key}
|
||||||
|
className={cn('pm-notify-filter', notifyKind === k.key && 'active')}
|
||||||
|
onClick={() => setKind(k.key)}
|
||||||
|
>
|
||||||
|
{k.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{notifyLoading && notifications.length === 0 ? (
|
||||||
|
<div className="flex justify-center py-16"><Spinner /></div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="pm-empty">
|
||||||
|
<Bell size={28} strokeWidth={1.5} aria-hidden />
|
||||||
|
<p>暂无通知</p>
|
||||||
|
<span>有人回复你、审核结果等会出现在这里</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="pm-notify-list">
|
||||||
|
{notifications.map((m) => (
|
||||||
|
<li key={m.id} className={cn('pm-notify-item', !m.is_read && 'unread')}>
|
||||||
|
<div className="pm-notify-item__kind">{kindLabel(m.kind)}</div>
|
||||||
|
{m.subject && <div className="pm-notify-item__subject">{m.subject}</div>}
|
||||||
|
<div className="pm-notify-item__text">{m.content}</div>
|
||||||
|
<div className="pm-notify-item__meta">
|
||||||
|
<time>{formatTime(m.created_at)}</time>
|
||||||
|
{m.related_post_id ? (
|
||||||
|
<Link className="pm-notify-item__link" to={postPath(m.related_post_id)}>
|
||||||
|
查看帖子
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{notifyTotal > notifications.length && (
|
||||||
|
<div className="pm-list-more">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={notifyLoading}
|
||||||
|
onClick={() => loadNotifications(notifyPage + 1, true, notifyKind)}
|
||||||
|
>
|
||||||
|
加载更多通知
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
||||||
<aside className="pm-list" aria-label="会话列表">
|
<aside className="pm-list" aria-label="会话列表">
|
||||||
{listLoading && conversations.length === 0 ? (
|
{listLoading && dmConversations.length === 0 ? (
|
||||||
<div className="flex justify-center py-10"><Spinner /></div>
|
<div className="flex justify-center py-10"><Spinner /></div>
|
||||||
) : conversations.length === 0 ? (
|
) : dmConversations.length === 0 ? (
|
||||||
<div className="pm-empty">
|
<div className="pm-empty">
|
||||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||||
<p>还没有会话</p>
|
<p>还没有私信</p>
|
||||||
<span>在用户主页点击「发私信」开始对话</span>
|
<span>在用户主页点击「发私信」开始对话</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
conversations.map((c) => {
|
dmConversations.map((c) => {
|
||||||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||||||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||||||
return (
|
return (
|
||||||
@@ -298,7 +484,6 @@ export default function MessagesPage() {
|
|||||||
<AvatarBubble
|
<AvatarBubble
|
||||||
name={name}
|
name={name}
|
||||||
avatar={c.peer_user?.avatar}
|
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__body">
|
||||||
<div className="pm-conv-item__top">
|
<div className="pm-conv-item__top">
|
||||||
@@ -339,7 +524,7 @@ export default function MessagesPage() {
|
|||||||
<div className="pm-empty pm-empty--thread">
|
<div className="pm-empty pm-empty--thread">
|
||||||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||||||
<p>选择左侧会话开始聊天</p>
|
<p>选择左侧会话开始聊天</p>
|
||||||
<span>系统通知也会出现在会话列表中</span>
|
<span>系统通知请切换到「通知」页签</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -350,17 +535,10 @@ export default function MessagesPage() {
|
|||||||
<AvatarBubble
|
<AvatarBubble
|
||||||
name={title}
|
name={title}
|
||||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||||
system={selectedPeer === 0}
|
|
||||||
/>
|
/>
|
||||||
<div className="pm-thread-head__meta">
|
<div className="pm-thread-head__meta">
|
||||||
{selectedPeer > 0 ? (
|
|
||||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||||
) : (
|
<span className="pm-thread-head__sub">私信对话</span>
|
||||||
<span className="pm-thread-head__name">{title}</span>
|
|
||||||
)}
|
|
||||||
<span className="pm-thread-head__sub">
|
|
||||||
{selectedPeer === 0 ? '审核与系统消息' : '私信对话'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -388,30 +566,13 @@ export default function MessagesPage() {
|
|||||||
) : (
|
) : (
|
||||||
messages.map((m) => {
|
messages.map((m) => {
|
||||||
const mine = m.from_user_id === user.id;
|
const mine = m.from_user_id === user.id;
|
||||||
const system = m.from_user_id === 0 || m.kind !== 'user';
|
|
||||||
const label = kindLabel(m.kind);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={cn(
|
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
|
||||||
'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')}>
|
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||||||
{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>
|
<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">
|
<div className="pm-bubble__meta">
|
||||||
<time>{formatTime(m.created_at)}</time>
|
<time>{formatTime(m.created_at)}</time>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +586,7 @@ export default function MessagesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCompose ? (
|
{canCompose && (
|
||||||
<footer className="pm-composer">
|
<footer className="pm-composer">
|
||||||
<textarea
|
<textarea
|
||||||
className="pm-composer__input"
|
className="pm-composer__input"
|
||||||
@@ -451,15 +612,12 @@ export default function MessagesPage() {
|
|||||||
发送
|
发送
|
||||||
</Button>
|
</Button>
|
||||||
</footer>
|
</footer>
|
||||||
) : (
|
|
||||||
<footer className="pm-composer pm-composer--readonly">
|
|
||||||
系统通知不可回复;如需联系管理员,请从用户主页发私信。
|
|
||||||
</footer>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<InFlowSiteFooter />
|
<InFlowSiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
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 FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 (
|
return (
|
||||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||||
<div className="post-detail-header">
|
<div className="post-detail-header">
|
||||||
@@ -652,7 +667,12 @@ export default function PostDetailPage() {
|
|||||||
{' · '}{post.view_count} 次浏览
|
{' · '}{post.view_count} 次浏览
|
||||||
{post.edit_locked && (
|
{post.edit_locked && (
|
||||||
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
|
<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>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -662,7 +682,16 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="post-detail-tags">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -818,6 +847,10 @@ export default function PostDetailPage() {
|
|||||||
<Lock />
|
<Lock />
|
||||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCommentsLock}>
|
||||||
|
{post.comments_locked ? <LockOpen /> : <MessageSquareOff />}
|
||||||
|
{post.comments_locked ? '开放讨论' : '锁定讨论'}
|
||||||
|
</Button>
|
||||||
{post.status !== 'rejected' && (
|
{post.status !== 'rejected' && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
||||||
<Ban />
|
<Ban />
|
||||||
@@ -908,7 +941,12 @@ export default function PostDetailPage() {
|
|||||||
<span className="comment-section-count">{comments.length} 条评论</span>
|
<span className="comment-section-count">{comments.length} 条评论</span>
|
||||||
</div>
|
</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}>
|
<div className="comment-box-wrap" ref={commentBoxRef}>
|
||||||
<CommentBox {...commentBoxProps} />
|
<CommentBox {...commentBoxProps} />
|
||||||
</div>
|
</div>
|
||||||
@@ -918,16 +956,20 @@ export default function PostDetailPage() {
|
|||||||
{comments.length === 0 && !replyTo ? (
|
{comments.length === 0 && !replyTo ? (
|
||||||
<div className="comment-empty">
|
<div className="comment-empty">
|
||||||
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
||||||
<p>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
|
<p>
|
||||||
|
{post.comments_locked
|
||||||
|
? '暂无评论'
|
||||||
|
: user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CommentThreadList
|
<CommentThreadList
|
||||||
comments={comments}
|
comments={comments}
|
||||||
highlightFloor={highlightFloor}
|
highlightFloor={highlightFloor}
|
||||||
replyToId={replyTo?.id ?? null}
|
replyToId={post.comments_locked ? null : (replyTo?.id ?? null)}
|
||||||
editingId={editingCommentId}
|
editingId={editingCommentId}
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
onReply={handleReplyTo}
|
onReply={post.comments_locked ? () => undefined : handleReplyTo}
|
||||||
onCancelReply={() => setReplyTo(null)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
onStartEdit={(c) => {
|
onStartEdit={(c) => {
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
@@ -943,7 +985,7 @@ export default function PostDetailPage() {
|
|||||||
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
|
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
|
||||||
)));
|
)));
|
||||||
}}
|
}}
|
||||||
renderReplyBox={(c) => (
|
renderReplyBox={post.comments_locked ? undefined : (c) => (
|
||||||
<CommentBox
|
<CommentBox
|
||||||
key={c.id}
|
key={c.id}
|
||||||
{...commentBoxProps}
|
{...commentBoxProps}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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) => {
|
const remove = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.adminDeletePost(id);
|
await api.adminDeletePost(id);
|
||||||
@@ -208,7 +218,7 @@ export default function AdminPostsPage() {
|
|||||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||||
: tab === 'pending'
|
: tab === 'pending'
|
||||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||||
: '精华、全局置顶、板块置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
: '精华、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<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.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||||
<td>{p.board_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.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||||
|
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||||
<td>{p.like_count}</td>
|
<td>{p.like_count}</td>
|
||||||
<td>{p.view_count}</td>
|
<td>{p.view_count}</td>
|
||||||
<td className="text-sm whitespace-nowrap">
|
<td className="text-sm whitespace-nowrap">
|
||||||
@@ -393,7 +405,12 @@ export default function AdminPostsPage() {
|
|||||||
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
|
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
<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>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
|
|||||||
@@ -2510,9 +2510,32 @@ a.post-title:visited {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-list-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 16%, transparent);
|
||||||
|
color: var(--color-text-3, #64748b);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list-tag:hover {
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #18a058) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.post-stats {
|
.post-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3193,6 +3216,19 @@ a.post-title:visited {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-locked-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 28%, transparent);
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-edit-hint {
|
.post-detail-edit-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
@@ -3749,6 +3785,18 @@ a.post-title:visited {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-detail-tag-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-tag-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-actions {
|
.post-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -9420,6 +9468,136 @@ button.profile-stat:hover strong {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pm-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border: 1px solid var(--j13-border, #e2e8f0);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-2, #475569);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 560;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tab:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green, #18a058) 40%, var(--j13-border, #e2e8f0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tab.active {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green, #18a058) 45%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #18a058) 10%, transparent);
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tab__badge {
|
||||||
|
min-width: 1.15rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--destructive));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.15rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify {
|
||||||
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-filter {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--j13-muted, #94a3b8) 14%, transparent);
|
||||||
|
color: var(--color-text-3, #64748b);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-filter.active {
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #18a058) 16%, transparent);
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item {
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid var(--j13-border, #e2e8f0);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: var(--j13-card, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item.unread {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green, #18a058) 35%, var(--j13-border, #e2e8f0));
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #18a058) 5%, var(--j13-card, #fff));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__kind {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__subject {
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--color-text-2, #475569);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-4, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__link {
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-notify-item__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.pm-layout {
|
.pm-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(200px, 240px) 1fr;
|
grid-template-columns: minmax(200px, 240px) 1fr;
|
||||||
@@ -9878,19 +10056,50 @@ a.pm-thread-head__name:hover {
|
|||||||
background: color-mix(in srgb, var(--j13-green, #16a34a) 20%, transparent);
|
background: color-mix(in srgb, var(--j13-green, #16a34a) 20%, transparent);
|
||||||
color: var(--j13-green, #16a34a);
|
color: var(--j13-green, #16a34a);
|
||||||
}
|
}
|
||||||
.user-badge--level-muted { color: #64748b; }
|
.user-badge--level {
|
||||||
.user-badge--level-blue {
|
gap: 0.2rem;
|
||||||
background: color-mix(in srgb, #2563eb 18%, transparent);
|
padding-left: 0.3rem;
|
||||||
color: #1d4ed8;
|
border-left: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||||
}
|
}
|
||||||
.user-badge--level-amber {
|
.user-badge__level-text {
|
||||||
background: color-mix(in srgb, #d97706 20%, transparent);
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.level-emblem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.user-badge--level-sprout {
|
||||||
|
background: color-mix(in srgb, #6b8f71 16%, transparent);
|
||||||
|
color: #5a7260;
|
||||||
|
}
|
||||||
|
.user-badge--level-leaf {
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #18a058) 16%, transparent);
|
||||||
|
color: var(--j13-green, #18a058);
|
||||||
|
}
|
||||||
|
.user-badge--level-crest {
|
||||||
|
background: color-mix(in srgb, #d97706 18%, transparent);
|
||||||
color: #b45309;
|
color: #b45309;
|
||||||
}
|
}
|
||||||
.user-badge--level-gold {
|
.user-badge--level-crown {
|
||||||
background: color-mix(in srgb, #ca8a04 22%, transparent);
|
background: color-mix(in srgb, #ca8a04 20%, transparent);
|
||||||
color: #a16207;
|
color: #a16207;
|
||||||
}
|
}
|
||||||
|
.dark .user-badge--level-sprout {
|
||||||
|
background: color-mix(in srgb, #86a789 18%, transparent);
|
||||||
|
color: #a3b8a6;
|
||||||
|
}
|
||||||
|
.dark .user-badge--level-leaf {
|
||||||
|
background: color-mix(in srgb, var(--j13-green, #23c36b) 18%, transparent);
|
||||||
|
color: var(--j13-green, #23c36b);
|
||||||
|
}
|
||||||
|
.dark .user-badge--level-crest {
|
||||||
|
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.dark .user-badge--level-crown {
|
||||||
|
background: color-mix(in srgb, #eab308 20%, transparent);
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
.user-badge--ach {
|
.user-badge--ach {
|
||||||
padding: 0.15rem;
|
padding: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|||||||
59
frontend/src/utils/composeDraft.ts
Normal file
59
frontend/src/utils/composeDraft.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** 新建帖本地草稿(localStorage) */
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'j13-compose-draft-v1';
|
||||||
|
|
||||||
|
export type ComposeDraft = {
|
||||||
|
title: string;
|
||||||
|
tags: string;
|
||||||
|
content: string;
|
||||||
|
boardId: string;
|
||||||
|
postType: 'normal' | 'question';
|
||||||
|
savedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadComposeDraft(): ComposeDraft | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw) as Partial<ComposeDraft>;
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
return {
|
||||||
|
title: typeof data.title === 'string' ? data.title : '',
|
||||||
|
tags: typeof data.tags === 'string' ? data.tags : '',
|
||||||
|
content: typeof data.content === 'string' ? data.content : '',
|
||||||
|
boardId: typeof data.boardId === 'string' ? data.boardId : '',
|
||||||
|
postType: data.postType === 'question' ? 'question' : 'normal',
|
||||||
|
savedAt: typeof data.savedAt === 'number' ? data.savedAt : Date.now(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveComposeDraft(draft: Omit<ComposeDraft, 'savedAt'>): void {
|
||||||
|
try {
|
||||||
|
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// 配额满或隐私模式:忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearComposeDraft(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 草稿是否有实质内容 */
|
||||||
|
export function composeDraftHasContent(d: ComposeDraft | null | undefined): boolean {
|
||||||
|
if (!d) return false;
|
||||||
|
return !!(
|
||||||
|
d.title.trim()
|
||||||
|
|| d.tags.trim()
|
||||||
|
|| d.content.trim()
|
||||||
|
|| (d.content && d.content.replace(/<[^>]*>/g, '').trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,18 +15,18 @@ export type FeedCache = {
|
|||||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||||
const store = new Map<string, FeedCache>();
|
const store = new Map<string, FeedCache>();
|
||||||
|
|
||||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
|
||||||
return `${boardId}:${keyword}:${sort}`;
|
return `${boardId}:${keyword}:${tag}:${sort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
|
||||||
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存帖子列表缓存 */
|
/** 保存帖子列表缓存 */
|
||||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
|
||||||
store.set(cacheKey(boardId, keyword, sort), data);
|
store.set(cacheKey(boardId, keyword, sort, tag), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除所有帖子列表缓存 */
|
/** 清除所有帖子列表缓存 */
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export function resolveUserLevel(u?: { level?: number; exp?: number } | null): n
|
|||||||
return levelFromExp(u?.exp ?? 0);
|
return levelFromExp(u?.exp ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 等级视觉档位(纹章 / CSS tone) */
|
||||||
|
export type LevelTone = 'sprout' | 'leaf' | 'crest' | 'crown';
|
||||||
|
|
||||||
|
export function levelToneFromLevel(level: number): LevelTone {
|
||||||
|
if (level >= 9) return 'crown';
|
||||||
|
if (level >= 7) return 'crest';
|
||||||
|
if (level >= 4) return 'leaf';
|
||||||
|
return 'sprout';
|
||||||
|
}
|
||||||
|
|
||||||
/** 是否免审发帖/评论 */
|
/** 是否免审发帖/评论 */
|
||||||
export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean {
|
export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean {
|
||||||
return !!u && (u.role === 'admin' || !!u.verified);
|
return !!u && (u.role === 'admin' || !!u.verified);
|
||||||
|
|||||||
@@ -198,6 +198,27 @@ func (h *Handlers) APIAdminLockPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIAdminCommentsLockPost 锁定/解锁讨论(禁止新评论)
|
||||||
|
func (h *Handlers) APIAdminCommentsLockPost(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
var req struct {
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Post.SetCommentsLocked(uint(id), req.Locked); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "已开放讨论"
|
||||||
|
if req.Locked {
|
||||||
|
msg = "已锁定讨论"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked})
|
||||||
|
}
|
||||||
|
|
||||||
// APIAdminPinPost 全局置顶/取消全局置顶(JSON)
|
// APIAdminPinPost 全局置顶/取消全局置顶(JSON)
|
||||||
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
@@ -911,6 +932,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||||
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
|
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
tag := strings.TrimSpace(c.Query("tag"))
|
||||||
|
|
||||||
q := service.PostListQuery{
|
q := service.PostListQuery{
|
||||||
BoardID: uint(boardID),
|
BoardID: uint(boardID),
|
||||||
@@ -918,6 +940,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
|
Tag: tag,
|
||||||
Sort: c.DefaultQuery("sort", "latest"),
|
Sort: c.DefaultQuery("sort", "latest"),
|
||||||
ViewerID: h.currentUserID(c),
|
ViewerID: h.currentUserID(c),
|
||||||
ViewerIsAdmin: h.isAdmin(c),
|
ViewerIsAdmin: h.isAdmin(c),
|
||||||
|
|||||||
@@ -94,14 +94,46 @@ func (h *Handlers) APIMarkConversationRead(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "已标为已读"})
|
c.JSON(http.StatusOK, gin.H{"message": "已标为已读"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIMessageUnreadCount 未读私信数
|
// APIMessageUnreadCount 未读私信数(含私信/通知分项)
|
||||||
func (h *Handlers) APIMessageUnreadCount(c *gin.Context) {
|
func (h *Handlers) APIMessageUnreadCount(c *gin.Context) {
|
||||||
n, err := h.Message.UnreadCount(h.currentUserID(c))
|
total, dm, notify, err := h.Message.UnreadCounts(h.currentUserID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"count": n})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"count": total,
|
||||||
|
"dm_count": dm,
|
||||||
|
"notify_count": notify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIMessageNotifications 系统通知列表
|
||||||
|
func (h *Handlers) APIMessageNotifications(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||||
|
kind := c.Query("kind")
|
||||||
|
uid := h.currentUserID(c)
|
||||||
|
list, total, err := h.Message.ListNotifications(uid, page, size, kind)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"notifications": list,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"kind": kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIMarkNotificationsRead 系统通知全部已读
|
||||||
|
func (h *Handlers) APIMarkNotificationsRead(c *gin.Context) {
|
||||||
|
if err := h.Message.MarkNotificationsRead(h.currentUserID(c)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "通知已全部标为已读"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APISendMessage 发送私信
|
// APISendMessage 发送私信
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ type Post struct {
|
|||||||
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
||||||
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
||||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||||
|
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
||||||
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
|
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
|
||||||
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
||||||
|
api.GET("/messages/notifications", h.APIMessageNotifications)
|
||||||
|
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
|
||||||
api.GET("/messages/conversations", h.APIMessageConversations)
|
api.GET("/messages/conversations", h.APIMessageConversations)
|
||||||
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
|
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
|
||||||
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
|
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
|
||||||
@@ -197,6 +199,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
|
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
|
||||||
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
||||||
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
||||||
|
adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
|
||||||
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
||||||
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
|
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
|
||||||
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
|
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
|
|||||||
return nil, errors.New("账号已被禁言")
|
return nil, errors.New("账号已被禁言")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 讨论锁定:管理员亦不可强评(避免结贴后仍被顶楼)
|
||||||
|
if post.CommentsLocked {
|
||||||
|
return nil, ErrPostCommentsLocked
|
||||||
|
}
|
||||||
|
|
||||||
// 未公开帖仅作者/管理员可评论
|
// 未公开帖仅作者/管理员可评论
|
||||||
if post.Status != model.ContentStatusPublished && post.Status != "" {
|
if post.Status != model.ContentStatusPublished && post.Status != "" {
|
||||||
if user.Role != model.RoleAdmin && post.UserID != in.UserID {
|
if user.Role != model.RoleAdmin && post.UserID != in.UserID {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var (
|
|||||||
ErrPermissionDenied = errors.New("无权操作")
|
ErrPermissionDenied = errors.New("无权操作")
|
||||||
ErrBoardNotFound = errors.New("板块不存在")
|
ErrBoardNotFound = errors.New("板块不存在")
|
||||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||||
|
ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论")
|
||||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||||
ErrInvalidSetting = errors.New("无效的设置值")
|
ErrInvalidSetting = errors.New("无效的设置值")
|
||||||
|
|||||||
@@ -130,6 +130,59 @@ func (s *MessageService) UnreadCount(userID uint) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnreadCounts 未读总数,以及私信 / 系统通知分项
|
||||||
|
func (s *MessageService) UnreadCounts(userID uint) (total, dm, notify int64, err error) {
|
||||||
|
err = model.DB.Model(&model.PrivateMessage{}).
|
||||||
|
Where("to_user_id = ? AND is_read = ?", userID, false).
|
||||||
|
Count(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
err = model.DB.Model(&model.PrivateMessage{}).
|
||||||
|
Where("to_user_id = ? AND is_read = ? AND from_user_id = 0", userID, false).
|
||||||
|
Count(¬ify).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
dm = total - notify
|
||||||
|
if dm < 0 {
|
||||||
|
dm = 0
|
||||||
|
}
|
||||||
|
return total, dm, notify, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNotifications 系统通知列表(按时间倒序,非聊天气泡)
|
||||||
|
func (s *MessageService) ListNotifications(userID uint, page, size int, kind string) ([]model.PrivateMessage, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size = s.settings.NormalizePageSize(size)
|
||||||
|
db := model.DB.Model(&model.PrivateMessage{}).
|
||||||
|
Where("from_user_id = 0 AND to_user_id = ?", userID)
|
||||||
|
kind = strings.TrimSpace(kind)
|
||||||
|
if kind != "" && kind != "all" {
|
||||||
|
db = db.Where("kind = ?", kind)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var list []model.PrivateMessage
|
||||||
|
err := db.Order("id desc").Offset((page - 1) * size).Limit(size).Find(&list).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []model.PrivateMessage{}
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationsRead 将系统通知全部标为已读
|
||||||
|
func (s *MessageService) MarkNotificationsRead(userID uint) error {
|
||||||
|
return s.MarkConversationRead(userID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// MessageConversation 按对方聚合的会话摘要
|
// MessageConversation 按对方聚合的会话摘要
|
||||||
type MessageConversation struct {
|
type MessageConversation struct {
|
||||||
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知
|
PeerUserID uint `json:"peer_user_id"` // 0 = 系统通知
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type PostListQuery struct {
|
|||||||
Page int
|
Page int
|
||||||
Size int
|
Size int
|
||||||
Keyword string
|
Keyword string
|
||||||
|
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
|
||||||
Sort string // latest | reply | hot
|
Sort string // latest | reply | hot
|
||||||
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
||||||
ViewerIsAdmin bool
|
ViewerIsAdmin bool
|
||||||
@@ -271,6 +272,12 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
kw := "%" + q.Keyword + "%"
|
kw := "%" + q.Keyword + "%"
|
||||||
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
|
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
|
||||||
}
|
}
|
||||||
|
if tag := strings.TrimSpace(q.Tag); tag != "" {
|
||||||
|
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
|
||||||
|
escaped := escapeLikePattern(strings.ToLower(tag))
|
||||||
|
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
|
||||||
|
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
|
||||||
|
}
|
||||||
var total int64
|
var total int64
|
||||||
db.Count(&total)
|
db.Count(&total)
|
||||||
var posts []model.Post
|
var posts []model.Post
|
||||||
@@ -310,6 +317,14 @@ func normalizePostSort(sort string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeLikePattern 转义 LIKE 通配符,配合 ESCAPE '\'
|
||||||
|
func escapeLikePattern(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||||
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) FindByID(id uint) (*model.Post, error) {
|
func (s *PostService) FindByID(id uint) (*model.Post, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
|
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
|
||||||
@@ -535,6 +550,18 @@ func (s *PostService) SetEditLocked(postID uint, locked bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCommentsLocked 锁定/解锁讨论(禁止新评论)
|
||||||
|
func (s *PostService) SetCommentsLocked(postID uint, locked bool) error {
|
||||||
|
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("comments_locked", locked)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
|
func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) {
|
||||||
var revs []model.PostRevision
|
var revs []model.PostRevision
|
||||||
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
|
err := model.DB.Preload("Editor").Where("post_id = ?", postID).
|
||||||
|
|||||||
Reference in New Issue
Block a user