补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
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`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminCommentsLockPost: (id: number, locked: boolean) =>
|
||||
request<{ message: string; comments_locked: boolean }>(`/api/admin/posts/${id}/comments-lock`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminRejectPost: (id: number, reason: string) =>
|
||||
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
|
||||
method: 'POST', body: JSON.stringify({ reason }),
|
||||
@@ -433,7 +437,20 @@ export const api = {
|
||||
},
|
||||
markConversationRead: (peerId: number) =>
|
||||
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
|
||||
messageUnreadCount: () => request<{ count: number }>('/api/messages/unread-count'),
|
||||
messageUnreadCount: () =>
|
||||
request<{ count: number; dm_count?: number; notify_count?: number }>('/api/messages/unread-count'),
|
||||
messageNotifications: (params?: { page?: number; size?: number; kind?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.size) q.set('size', String(params.size));
|
||||
if (params?.kind) q.set('kind', params.kind);
|
||||
const qs = q.toString();
|
||||
return request<{ notifications: PrivateMessage[]; total: number; page: number; kind: string }>(
|
||||
`/api/messages/notifications${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
markNotificationsRead: () =>
|
||||
request<{ message: string }>('/api/messages/notifications/read', { method: 'POST' }),
|
||||
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
|
||||
request<{ message: PrivateMessage }>('/api/messages', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface PostItem {
|
||||
board_pinned?: boolean;
|
||||
featured?: boolean;
|
||||
edit_locked?: boolean;
|
||||
/** 禁止新评论(结贴) */
|
||||
comments_locked?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
|
||||
@@ -330,7 +330,7 @@ function CommentItem({
|
||||
{approving ? '通过中…' : '通过'}
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && (
|
||||
{!hidden && !isEditing && !!renderReplyBox && (
|
||||
isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Board, ForumStats } from '../api/types';
|
||||
interface Props {
|
||||
boardId: number;
|
||||
keyword: string;
|
||||
tag?: string;
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
postTotal: number;
|
||||
@@ -12,20 +13,21 @@ interface Props {
|
||||
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 board = boards.find(b => b.id === boardId);
|
||||
|
||||
const inBoard = !keyword && boardId > 0 && !!board;
|
||||
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */
|
||||
const title = keyword ? `搜索:${keyword}` : '';
|
||||
const filtered = !!(keyword || tag);
|
||||
const inBoard = !filtered && boardId > 0 && !!board;
|
||||
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
|
||||
const title = tag ? `标签:${tag}` : (keyword ? `搜索:${keyword}` : '');
|
||||
const TitleTag = titleAs;
|
||||
|
||||
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">
|
||||
{title ? <TitleTag>{title}</TitleTag> : null}
|
||||
{!keyword && inBoard && (
|
||||
{!filtered && inBoard && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
@@ -38,7 +40,7 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!keyword && !inBoard && stats && (
|
||||
{!filtered && !inBoard && stats && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<Users aria-hidden />
|
||||
@@ -55,16 +57,16 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{keyword && (
|
||||
{filtered && (
|
||||
<button
|
||||
type="button"
|
||||
className="feed-head__clear"
|
||||
onClick={() => nav('/')}
|
||||
>
|
||||
清除搜索
|
||||
{tag ? '清除标签' : '清除搜索'}
|
||||
</button>
|
||||
)}
|
||||
{keyword && (
|
||||
{filtered && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,18 @@ export function parseFeedSort(raw: string | null): FeedSort {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
|
||||
export function buildHomeUrl(
|
||||
boardId: number,
|
||||
sort: FeedSort = 'latest',
|
||||
opts?: { keyword?: string; tag?: string },
|
||||
) {
|
||||
const p = new URLSearchParams();
|
||||
if (boardId) p.set('board', String(boardId));
|
||||
const tag = opts?.tag?.trim();
|
||||
const keyword = opts?.keyword?.trim();
|
||||
// 标签筛选与关键词搜索互斥:有 tag 时不带 keyword
|
||||
if (tag) p.set('tag', tag);
|
||||
else if (keyword) p.set('keyword', keyword);
|
||||
if (sort !== 'latest') p.set('sort', sort);
|
||||
const qs = p.toString();
|
||||
return qs ? `/?${qs}` : '/';
|
||||
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
@@ -8,6 +9,7 @@ import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { parseTags } from './TagInput';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
@@ -16,6 +18,7 @@ interface Props {
|
||||
}
|
||||
|
||||
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
const nav = useNavigate();
|
||||
const initial = post.user?.nickname?.[0] || '?';
|
||||
const timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
@@ -28,6 +31,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
const href = postPath(post.id);
|
||||
const excerpt = excerptFromHTML(post.content || '', 72);
|
||||
const hasImage = !!firstImageFromHTML(post.content || '');
|
||||
const tagList = parseTags(post.tags || '').slice(0, 3);
|
||||
|
||||
const openPost = () => onSelect(post.id);
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -113,6 +117,20 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
<div className="post-foot">
|
||||
<div className="post-foot-left">
|
||||
{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 className="post-stats">
|
||||
{hasImage && (
|
||||
|
||||
@@ -77,11 +77,14 @@ export default function RightPanel({
|
||||
const { branding } = useSiteBranding();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get('keyword') || '';
|
||||
const activeTag = params.get('tag') || '';
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||
// 站点首页:右侧品牌块承担唯一 h1;板块/搜索等页面由 Feed 标题作 h1
|
||||
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
|
||||
const isSiteHome = loc.pathname === '/'
|
||||
&& !params.get('board')
|
||||
&& !params.get('keyword')
|
||||
&& !params.get('tag');
|
||||
const description = branding.description?.trim() || '';
|
||||
const slogan = branding.slogan?.trim() || '';
|
||||
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
|
||||
active && 'active',
|
||||
)}
|
||||
title={`${tag.name} · ${tag.count} 篇`}
|
||||
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
||||
onClick={() => nav(active ? '/' : `/?tag=${encodeURIComponent(tag.name)}`)}
|
||||
>
|
||||
<span className="tag-cloud-item__name">{tag.name}</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 type { UserBadge } from '../api/types';
|
||||
import { badgeIcon } from '../utils/badgeIcons';
|
||||
import { resolveUserLevel } from '../utils/userMeta';
|
||||
import { levelToneFromLevel, resolveUserLevel } from '../utils/userMeta';
|
||||
import LevelEmblem from './LevelEmblem';
|
||||
|
||||
type BadgeUser = {
|
||||
role?: string;
|
||||
@@ -37,7 +38,7 @@ export default function UserBadges({
|
||||
|
||||
if (!isAdmin && !isVerified && !showLevel && achievements.length === 0) return null;
|
||||
|
||||
const levelTone = level >= 9 ? 'gold' : level >= 7 ? 'amber' : level >= 4 ? 'blue' : 'muted';
|
||||
const levelTone = levelToneFromLevel(level);
|
||||
|
||||
return (
|
||||
<span className={cn('user-badges', compact && 'user-badges--compact', className)}>
|
||||
@@ -54,8 +55,12 @@ export default function UserBadges({
|
||||
</span>
|
||||
)}
|
||||
{showLevel && (
|
||||
<span className={cn('user-badge user-badge--level', `user-badge--level-${levelTone}`)} title={`经验 ${user.exp ?? 0}`}>
|
||||
Lv.{level}
|
||||
<span
|
||||
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>
|
||||
)}
|
||||
{achievements.map(b => {
|
||||
|
||||
@@ -258,9 +258,10 @@ export default function MainLayout() {
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
const isFeedHome = loc.pathname === '/';
|
||||
const outletKeyword = params.get('keyword') || '';
|
||||
// 搜索结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
||||
const outletTag = params.get('tag') || '';
|
||||
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
||||
const mobileActiveBoard =
|
||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword
|
||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
|
||||
? -1
|
||||
: boardId;
|
||||
|
||||
@@ -434,8 +435,8 @@ export default function MainLayout() {
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn header-msg-btn"
|
||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读私信` : '站内私信'}
|
||||
aria-label={unreadMessages > 0 ? `站内私信,${unreadMessages} 条未读` : '站内私信'}
|
||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
|
||||
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
|
||||
onClick={() => nav('/messages')}
|
||||
>
|
||||
<Mail size={18} aria-hidden />
|
||||
@@ -461,7 +462,7 @@ export default function MainLayout() {
|
||||
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/messages')}>
|
||||
站内私信{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
||||
{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 { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -18,6 +18,12 @@ import { loginPath } from '../utils/authRedirect';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { parsePermalinkID, postPath } from '../utils/permalink';
|
||||
import { skipsModeration } from '../utils/userMeta';
|
||||
import {
|
||||
clearComposeDraft,
|
||||
composeDraftHasContent,
|
||||
loadComposeDraft,
|
||||
saveComposeDraft,
|
||||
} from '../utils/composeDraft';
|
||||
|
||||
interface ComposeBaseline {
|
||||
title: string;
|
||||
@@ -74,6 +80,9 @@ export default function ComposePage() {
|
||||
);
|
||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||
const [editWindowHint, setEditWindowHint] = useState('');
|
||||
const [draftHint, setDraftHint] = useState('');
|
||||
/** 新建帖:是否已处理过本地草稿恢复 */
|
||||
const draftHandledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
@@ -137,16 +146,43 @@ export default function ComposePage() {
|
||||
|
||||
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
||||
setBoards(list);
|
||||
if (!defaultBoard) setBoardId(initialBoardId);
|
||||
const boardForBaseline = defaultBoard || initialBoardId;
|
||||
setBoardId(prev => prev || boardForBaseline);
|
||||
setBaseline({
|
||||
const emptyBaseline: ComposeBaseline = {
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: boardForBaseline,
|
||||
postType: 'normal',
|
||||
});
|
||||
};
|
||||
|
||||
// 首次进入新建页:若有本地草稿则询问是否恢复
|
||||
if (!draftHandledRef.current) {
|
||||
draftHandledRef.current = true;
|
||||
const draft = loadComposeDraft();
|
||||
if (composeDraftHasContent(draft) && draft) {
|
||||
const when = new Date(draft.savedAt).toLocaleString('zh-CN', {
|
||||
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
const restore = window.confirm(`发现 ${when} 的未发帖草稿,是否恢复?\n选「取消」将丢弃该草稿。`);
|
||||
if (restore) {
|
||||
const boardIdOk = list.some(b => String(b.id) === draft.boardId)
|
||||
? draft.boardId
|
||||
: boardForBaseline;
|
||||
setBoardId(boardIdOk);
|
||||
setTitle(draft.title);
|
||||
setTags(draft.tags);
|
||||
setContent(draft.content);
|
||||
setPostType(draft.postType);
|
||||
setBaseline(emptyBaseline);
|
||||
setDraftHint('已恢复本地草稿,编辑中将自动保存');
|
||||
return;
|
||||
}
|
||||
clearComposeDraft();
|
||||
}
|
||||
}
|
||||
|
||||
setBoardId(prev => prev || boardForBaseline);
|
||||
setBaseline(emptyBaseline);
|
||||
};
|
||||
|
||||
const list = resolveBoards(layoutCtx?.boards);
|
||||
@@ -180,14 +216,36 @@ export default function ComposePage() {
|
||||
);
|
||||
}, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]);
|
||||
|
||||
// 新建帖:防抖写入本地草稿
|
||||
useEffect(() => {
|
||||
if (isEdit || !baseline) return;
|
||||
if (!isDirty) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
saveComposeDraft({
|
||||
title,
|
||||
tags: serializeTags(parseTags(tags)),
|
||||
content,
|
||||
boardId,
|
||||
postType,
|
||||
});
|
||||
setDraftHint('草稿已自动保存到本机');
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isEdit, baseline, isDirty, title, tags, content, boardId, postType]);
|
||||
|
||||
const {
|
||||
dialogOpen,
|
||||
stayOnPage,
|
||||
discardAndLeave,
|
||||
discardAndLeave: discardAndLeaveRaw,
|
||||
requestLeave,
|
||||
markSaved,
|
||||
} = useUnsavedChangesGuard({ isDirty });
|
||||
|
||||
const discardAndLeave = () => {
|
||||
if (!isEdit) clearComposeDraft();
|
||||
discardAndLeaveRaw();
|
||||
};
|
||||
|
||||
const leaveTo = (path: string) => {
|
||||
markSaved();
|
||||
nav(path);
|
||||
@@ -264,6 +322,7 @@ export default function ComposePage() {
|
||||
nav(postPath(editId!, limits));
|
||||
} else {
|
||||
const res = await api.createPost(payload);
|
||||
clearComposeDraft();
|
||||
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
|
||||
markSaved();
|
||||
nav(postPath(res.post_id, limits));
|
||||
@@ -298,6 +357,11 @@ export default function ComposePage() {
|
||||
{editWindowHint}
|
||||
</span>
|
||||
)}
|
||||
{!isEdit && draftHint && (
|
||||
<span className="compose-draft-hint" title={draftHint}>
|
||||
{draftHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="compose-header-actions">
|
||||
<button
|
||||
|
||||
@@ -32,18 +32,25 @@ export default function HomePage() {
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const tag = params.get('tag') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||
const isSiteHome = !boardId && !keyword;
|
||||
const isSiteHome = !boardId && !keyword && !tag;
|
||||
const siteIntro = siteMetaDescription(branding);
|
||||
const feedTitle = keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '');
|
||||
const feedTitle = tag
|
||||
? `标签:${tag}`
|
||||
: keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '');
|
||||
usePageSEO({
|
||||
title: feedTitle || undefined,
|
||||
description: board?.description?.trim() || siteIntro,
|
||||
keywords: joinSEOKeywords(board?.name, branding.keywords),
|
||||
canonicalPath: boardId ? `/?board=${boardId}` : '/',
|
||||
keywords: joinSEOKeywords(board?.name, tag, branding.keywords),
|
||||
canonicalPath: tag
|
||||
? `/?tag=${encodeURIComponent(tag)}`
|
||||
: boardId
|
||||
? `/?board=${boardId}`
|
||||
: '/',
|
||||
ogType: 'website',
|
||||
});
|
||||
|
||||
@@ -60,7 +67,7 @@ export default function HomePage() {
|
||||
const pageRef = useRef(1);
|
||||
pageRef.current = page;
|
||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
||||
const feedSnapRef = useRef({ boardId, keyword, sort, posts, postTotal, page });
|
||||
const feedSnapRef = useRef({ boardId, keyword, tag, sort, posts, postTotal, page });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
@@ -87,7 +94,8 @@ export default function HomePage() {
|
||||
page: p,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
keyword: tag ? '' : keyword,
|
||||
tag: tag || '',
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
@@ -106,7 +114,7 @@ export default function HomePage() {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
}, [boardId, keyword, tag, sort, pageSize]);
|
||||
|
||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||
|
||||
@@ -134,7 +142,7 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
const cached = getFeedCache(boardId, keyword, sort, tag);
|
||||
if (cached && cached.posts.length > 0) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
@@ -154,6 +162,7 @@ export default function HomePage() {
|
||||
pageSize,
|
||||
boardId,
|
||||
keyword,
|
||||
tag,
|
||||
sort,
|
||||
location.key,
|
||||
location.state,
|
||||
@@ -165,15 +174,16 @@ export default function HomePage() {
|
||||
if (
|
||||
feedSnapRef.current.boardId === boardId
|
||||
&& feedSnapRef.current.keyword === keyword
|
||||
&& feedSnapRef.current.tag === tag
|
||||
&& feedSnapRef.current.sort === sort
|
||||
) {
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts, postTotal, page };
|
||||
feedSnapRef.current = { boardId, keyword, tag, sort, posts, postTotal, page };
|
||||
}
|
||||
|
||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||
useEffect(() => {
|
||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts: [], postTotal: 0, page: 1 };
|
||||
feedSnapRef.current = { boardId, keyword, tag, sort, posts: [], postTotal: 0, page: 1 };
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current) return;
|
||||
const snap = feedSnapRef.current;
|
||||
@@ -183,9 +193,9 @@ export default function HomePage() {
|
||||
postTotal: snap.postTotal,
|
||||
page: snap.page,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
}, snap.tag);
|
||||
};
|
||||
}, [boardId, keyword, sort]);
|
||||
}, [boardId, keyword, tag, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
@@ -212,10 +222,10 @@ export default function HomePage() {
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag }));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
const showSortBar = !keyword && !tag;
|
||||
|
||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||
if ((loading || limitsLoading) && posts.length === 0) {
|
||||
@@ -230,6 +240,7 @@ export default function HomePage() {
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
tag={tag}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
@@ -255,7 +266,7 @@ export default function HomePage() {
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={keyword}
|
||||
keyword={keyword || tag}
|
||||
boardId={boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Bell, CheckCheck, Inbox, Send } from 'lucide-react';
|
||||
import { ArrowLeft, Bell, CheckCheck, Inbox, Mail, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -15,6 +15,17 @@ import { userPath } from '../utils/userPath';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MsgTab = 'dm' | 'notify';
|
||||
|
||||
const NOTIFY_KINDS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'reply', label: '回复' },
|
||||
{ key: 'moderation', label: '待审' },
|
||||
{ key: 'reject', label: '拒帖' },
|
||||
{ key: 'report_result', label: '举报' },
|
||||
{ key: 'system', label: '系统' },
|
||||
] as const;
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
switch (kind) {
|
||||
case 'reject': return '拒帖通知';
|
||||
@@ -22,7 +33,7 @@ function kindLabel(kind: string) {
|
||||
case 'reply': return '回复提醒';
|
||||
case 'moderation': return '待审提醒';
|
||||
case 'system': return '系统通知';
|
||||
default: return '';
|
||||
default: return '通知';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,17 +74,25 @@ function AvatarBubble({
|
||||
return <span className="pm-avatar pm-avatar--fallback">{peerInitial(name)}</span>;
|
||||
}
|
||||
|
||||
function parseTab(raw: string | null, peer: string | null): MsgTab {
|
||||
// 带 peer 时强制私信页(用户主页「发私信」入口)
|
||||
if (peer !== null && peer !== '') return 'dm';
|
||||
return raw === 'notify' ? 'notify' : 'dm';
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
useNoIndexSEO('站内私信');
|
||||
useNoIndexSEO('站内消息');
|
||||
|
||||
const peerParam = params.get('peer');
|
||||
const tab = parseTab(params.get('tab'), peerParam);
|
||||
const selectedPeer = peerParam === null || peerParam === ''
|
||||
? null
|
||||
: Number(peerParam);
|
||||
const peerSelected = selectedPeer !== null && !Number.isNaN(selectedPeer);
|
||||
const peerSelected = tab === 'dm' && selectedPeer !== null && !Number.isNaN(selectedPeer);
|
||||
const notifyKind = params.get('kind') || 'all';
|
||||
|
||||
const [conversations, setConversations] = useState<MessageConversation[]>([]);
|
||||
const [convTotal, setConvTotal] = useState(0);
|
||||
@@ -88,10 +107,32 @@ export default function MessagesPage() {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const [notifications, setNotifications] = useState<PrivateMessage[]>([]);
|
||||
const [notifyTotal, setNotifyTotal] = useState(0);
|
||||
const [notifyPage, setNotifyPage] = useState(1);
|
||||
const [notifyLoading, setNotifyLoading] = useState(false);
|
||||
const [notifyUnread, setNotifyUnread] = useState(0);
|
||||
const [dmUnread, setDmUnread] = useState(0);
|
||||
|
||||
const threadEndRef = useRef<HTMLDivElement>(null);
|
||||
const threadScrollRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
const dmConversations = useMemo(
|
||||
() => conversations.filter((c) => !c.is_system && c.peer_user_id > 0),
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const refreshUnreadSplit = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.messageUnreadCount();
|
||||
setDmUnread(r.dm_count ?? 0);
|
||||
setNotifyUnread(r.notify_count ?? 0);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadConversations = useCallback(async (page = 1, append = false) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
@@ -107,14 +148,46 @@ export default function MessagesPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => {
|
||||
setNotifyLoading(true);
|
||||
try {
|
||||
const r = await api.messageNotifications({
|
||||
page,
|
||||
size: 30,
|
||||
kind: kind === 'all' ? undefined : kind,
|
||||
});
|
||||
const next = r.notifications || [];
|
||||
setNotifyTotal(r.total || 0);
|
||||
setNotifyPage(r.page || page);
|
||||
// 打开通知页时标已读(首屏)
|
||||
if (!append && page === 1) {
|
||||
await api.markNotificationsRead().catch(() => undefined);
|
||||
setNotifications(next.map((m) => ({ ...m, is_read: true })));
|
||||
setNotifyUnread(0);
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
} else {
|
||||
setNotifications((prev) => (append ? [...prev, ...next] : next));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载通知失败');
|
||||
} finally {
|
||||
setNotifyLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
nav(loginPath('/messages'));
|
||||
return;
|
||||
}
|
||||
loadConversations(1);
|
||||
}, [user, authLoading, nav, loadConversations]);
|
||||
void refreshUnreadSplit();
|
||||
if (tab === 'dm') {
|
||||
loadConversations(1);
|
||||
} else {
|
||||
loadNotifications(1, false, notifyKind);
|
||||
}
|
||||
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -142,6 +215,7 @@ export default function MessagesPage() {
|
||||
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
||||
)));
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
void refreshUnreadSplit();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
||||
@@ -150,7 +224,7 @@ export default function MessagesPage() {
|
||||
if (!cancelled) setThreadLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [user, peerSelected, selectedPeer]);
|
||||
}, [user, peerSelected, selectedPeer, refreshUnreadSplit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadLoading && stickToBottomRef.current) {
|
||||
@@ -158,24 +232,54 @@ export default function MessagesPage() {
|
||||
}
|
||||
}, [messages, threadLoading, scrollToBottom]);
|
||||
|
||||
const setTab = (next: MsgTab) => {
|
||||
const p = new URLSearchParams();
|
||||
if (next === 'notify') {
|
||||
p.set('tab', 'notify');
|
||||
if (notifyKind !== 'all') p.set('kind', notifyKind);
|
||||
}
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const setKind = (kind: string) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('tab', 'notify');
|
||||
if (kind !== 'all') p.set('kind', kind);
|
||||
setParams(p, { replace: true });
|
||||
};
|
||||
|
||||
const openPeer = (peerId: number) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('tab', 'dm');
|
||||
p.set('peer', String(peerId));
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const closeThread = () => {
|
||||
setParams(new URLSearchParams(), { replace: true });
|
||||
const p = new URLSearchParams();
|
||||
p.set('tab', 'dm');
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.markAllMessagesRead();
|
||||
notify.success('已全部标为已读');
|
||||
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
||||
if (tab === 'notify') {
|
||||
await api.markNotificationsRead();
|
||||
setNotifications((prev) => prev.map((m) => ({ ...m, is_read: true })));
|
||||
setNotifyUnread(0);
|
||||
notify.success('通知已全部标为已读');
|
||||
} else {
|
||||
await api.markAllMessagesRead();
|
||||
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
||||
setDmUnread(0);
|
||||
setNotifyUnread(0);
|
||||
notify.success('已全部标为已读');
|
||||
}
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
void refreshUnreadSplit();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
@@ -239,7 +343,7 @@ export default function MessagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || (listLoading && conversations.length === 0 && !peerSelected)) {
|
||||
if (authLoading || (tab === 'dm' && listLoading && conversations.length === 0 && !peerSelected)) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user) return null;
|
||||
@@ -251,7 +355,7 @@ export default function MessagesPage() {
|
||||
? peerTitle(activeConv, peerUser, selectedPeer)
|
||||
: '';
|
||||
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
|
||||
const unreadTotal = conversations.reduce((n, c) => n + (c.unread_count || 0), 0);
|
||||
const unreadForTab = tab === 'notify' ? notifyUnread : dmUnread;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
@@ -263,203 +367,257 @@ export default function MessagesPage() {
|
||||
|
||||
<div className="pm-page-head">
|
||||
<div>
|
||||
<h1 className="page-title">站内私信</h1>
|
||||
<p className="page-desc">按会话查看,与用户即时沟通,并接收系统通知</p>
|
||||
<h1 className="page-title">站内消息</h1>
|
||||
<p className="page-desc">私信与系统通知分开查看,回复提醒可直达帖子</p>
|
||||
</div>
|
||||
{unreadTotal > 0 && (
|
||||
{unreadForTab > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={markAll}>
|
||||
<CheckCheck size={14} />
|
||||
全部已读
|
||||
{tab === 'notify' ? '通知全部已读' : '全部已读'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
||||
<aside className="pm-list" aria-label="会话列表">
|
||||
{listLoading && conversations.length === 0 ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="pm-tabs" role="tablist" aria-label="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'dm'}
|
||||
className={cn('pm-tab', tab === 'dm' && 'active')}
|
||||
onClick={() => setTab('dm')}
|
||||
>
|
||||
<Mail size={15} aria-hidden />
|
||||
私信
|
||||
{dmUnread > 0 && <span className="pm-tab__badge">{dmUnread > 99 ? '99+' : dmUnread}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'notify'}
|
||||
className={cn('pm-tab', tab === 'notify' && 'active')}
|
||||
onClick={() => setTab('notify')}
|
||||
>
|
||||
<Bell size={15} aria-hidden />
|
||||
通知
|
||||
{notifyUnread > 0 && <span className="pm-tab__badge">{notifyUnread > 99 ? '99+' : notifyUnread}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'notify' ? (
|
||||
<div className="pm-notify content-surface">
|
||||
<div className="pm-notify-filters" role="tablist" aria-label="通知类型">
|
||||
{NOTIFY_KINDS.map((k) => (
|
||||
<button
|
||||
key={k.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={notifyKind === k.key}
|
||||
className={cn('pm-notify-filter', notifyKind === k.key && 'active')}
|
||||
onClick={() => setKind(k.key)}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notifyLoading && notifications.length === 0 ? (
|
||||
<div className="flex justify-center py-16"><Spinner /></div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="pm-empty">
|
||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>还没有会话</p>
|
||||
<span>在用户主页点击「发私信」开始对话</span>
|
||||
<Bell size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>暂无通知</p>
|
||||
<span>有人回复你、审核结果等会出现在这里</span>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((c) => {
|
||||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||||
return (
|
||||
<button
|
||||
key={c.peer_user_id}
|
||||
type="button"
|
||||
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
|
||||
onClick={() => openPeer(c.peer_user_id)}
|
||||
>
|
||||
<AvatarBubble
|
||||
name={name}
|
||||
avatar={c.peer_user?.avatar}
|
||||
system={c.is_system || c.peer_user_id === 0}
|
||||
/>
|
||||
<div className="pm-conv-item__body">
|
||||
<div className="pm-conv-item__top">
|
||||
<span className="pm-conv-item__name">{name}</span>
|
||||
<span className="pm-conv-item__time">
|
||||
{formatTime(c.last_message?.created_at || c.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pm-conv-item__preview">
|
||||
<span>{previewText(c.last_message)}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="pm-conv-item__badge">
|
||||
{c.unread_count > 99 ? '99+' : c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="pm-notify-list">
|
||||
{notifications.map((m) => (
|
||||
<li key={m.id} className={cn('pm-notify-item', !m.is_read && 'unread')}>
|
||||
<div className="pm-notify-item__kind">{kindLabel(m.kind)}</div>
|
||||
{m.subject && <div className="pm-notify-item__subject">{m.subject}</div>}
|
||||
<div className="pm-notify-item__text">{m.content}</div>
|
||||
<div className="pm-notify-item__meta">
|
||||
<time>{formatTime(m.created_at)}</time>
|
||||
{m.related_post_id ? (
|
||||
<Link className="pm-notify-item__link" to={postPath(m.related_post_id)}>
|
||||
查看帖子
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{convTotal > conversations.length && (
|
||||
{notifyTotal > notifications.length && (
|
||||
<div className="pm-list-more">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={listLoading}
|
||||
onClick={() => loadConversations(convPage + 1, true)}
|
||||
disabled={notifyLoading}
|
||||
onClick={() => loadNotifications(notifyPage + 1, true, notifyKind)}
|
||||
>
|
||||
加载更多会话
|
||||
加载更多通知
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="pm-thread" aria-label="会话内容">
|
||||
{!peerSelected || selectedPeer === null ? (
|
||||
<div className="pm-empty pm-empty--thread">
|
||||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||||
<p>选择左侧会话开始聊天</p>
|
||||
<span>系统通知也会出现在会话列表中</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="pm-thread-head">
|
||||
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<AvatarBubble
|
||||
name={title}
|
||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||
system={selectedPeer === 0}
|
||||
/>
|
||||
<div className="pm-thread-head__meta">
|
||||
{selectedPeer > 0 ? (
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||
) : (
|
||||
<span className="pm-thread-head__name">{title}</span>
|
||||
)}
|
||||
<span className="pm-thread-head__sub">
|
||||
{selectedPeer === 0 ? '审核与系统消息' : '私信对话'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="pm-thread-scroll"
|
||||
ref={threadScrollRef}
|
||||
onScroll={(e) => {
|
||||
const t = e.currentTarget;
|
||||
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{threadLoading ? (
|
||||
<div className="flex justify-center py-16"><Spinner /></div>
|
||||
) : (
|
||||
<>
|
||||
{msgTotal > messages.length && (
|
||||
<div className="pm-thread-older">
|
||||
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
|
||||
查看更早消息
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
||||
<aside className="pm-list" aria-label="会话列表">
|
||||
{listLoading && dmConversations.length === 0 ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : dmConversations.length === 0 ? (
|
||||
<div className="pm-empty">
|
||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>还没有私信</p>
|
||||
<span>在用户主页点击「发私信」开始对话</span>
|
||||
</div>
|
||||
) : (
|
||||
dmConversations.map((c) => {
|
||||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||||
return (
|
||||
<button
|
||||
key={c.peer_user_id}
|
||||
type="button"
|
||||
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
|
||||
onClick={() => openPeer(c.peer_user_id)}
|
||||
>
|
||||
<AvatarBubble
|
||||
name={name}
|
||||
avatar={c.peer_user?.avatar}
|
||||
/>
|
||||
<div className="pm-conv-item__body">
|
||||
<div className="pm-conv-item__top">
|
||||
<span className="pm-conv-item__name">{name}</span>
|
||||
<span className="pm-conv-item__time">
|
||||
{formatTime(c.last_message?.created_at || c.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<div className="pm-empty">还没有消息,打个招呼吧</div>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const mine = m.from_user_id === user.id;
|
||||
const system = m.from_user_id === 0 || m.kind !== 'user';
|
||||
const label = kindLabel(m.kind);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn(
|
||||
'pm-bubble-row',
|
||||
mine && 'pm-bubble-row--mine',
|
||||
system && !mine && 'pm-bubble-row--system',
|
||||
)}
|
||||
>
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine', system && !mine && 'pm-bubble--system')}>
|
||||
{label && !mine && (
|
||||
<span className="pm-bubble__kind">{label}</span>
|
||||
)}
|
||||
{m.subject && m.kind !== 'user' && (
|
||||
<div className="pm-bubble__subject">{m.subject}</div>
|
||||
)}
|
||||
<div className="pm-bubble__text">{m.content}</div>
|
||||
{m.related_post_id ? (
|
||||
<Link className="pm-bubble__link" to={postPath(m.related_post_id)}>
|
||||
查看相关帖子 #{m.related_post_id}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="pm-bubble__meta">
|
||||
<time>{formatTime(m.created_at)}</time>
|
||||
<div className="pm-conv-item__preview">
|
||||
<span>{previewText(c.last_message)}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="pm-conv-item__badge">
|
||||
{c.unread_count > 99 ? '99+' : c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{convTotal > conversations.length && (
|
||||
<div className="pm-list-more">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={listLoading}
|
||||
onClick={() => loadConversations(convPage + 1, true)}
|
||||
>
|
||||
加载更多会话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="pm-thread" aria-label="会话内容">
|
||||
{!peerSelected || selectedPeer === null ? (
|
||||
<div className="pm-empty pm-empty--thread">
|
||||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||||
<p>选择左侧会话开始聊天</p>
|
||||
<span>系统通知请切换到「通知」页签</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="pm-thread-head">
|
||||
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<AvatarBubble
|
||||
name={title}
|
||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||
/>
|
||||
<div className="pm-thread-head__meta">
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||
<span className="pm-thread-head__sub">私信对话</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="pm-thread-scroll"
|
||||
ref={threadScrollRef}
|
||||
onScroll={(e) => {
|
||||
const t = e.currentTarget;
|
||||
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{threadLoading ? (
|
||||
<div className="flex justify-center py-16"><Spinner /></div>
|
||||
) : (
|
||||
<>
|
||||
{msgTotal > messages.length && (
|
||||
<div className="pm-thread-older">
|
||||
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
|
||||
查看更早消息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<div className="pm-empty">还没有消息,打个招呼吧</div>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const mine = m.from_user_id === user.id;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn('pm-bubble-row', mine && 'pm-bubble-row--mine')}
|
||||
>
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine')}>
|
||||
<div className="pm-bubble__text">{m.content}</div>
|
||||
<div className="pm-bubble__meta">
|
||||
<time>{formatTime(m.created_at)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={threadEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={threadEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCompose ? (
|
||||
<footer className="pm-composer">
|
||||
<textarea
|
||||
className="pm-composer__input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
placeholder={`发送给 ${title}…`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="pm-composer__send"
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send size={16} />
|
||||
发送
|
||||
</Button>
|
||||
</footer>
|
||||
) : (
|
||||
<footer className="pm-composer pm-composer--readonly">
|
||||
系统通知不可回复;如需联系管理员,请从用户主页发私信。
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{canCompose && (
|
||||
<footer className="pm-composer">
|
||||
<textarea
|
||||
className="pm-composer__input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
placeholder={`发送给 ${title}…`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="pm-composer__send"
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send size={16} />
|
||||
发送
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, LockOpen, MessageSquare, MessageSquareOff, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -587,6 +587,21 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentsLock = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminCommentsLockPost(postId, !post.comments_locked);
|
||||
setPost(p => p ? { ...p, comments_locked: r.comments_locked } : p);
|
||||
if (r.comments_locked) {
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(null);
|
||||
}
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<div className="post-detail-header">
|
||||
@@ -652,7 +667,12 @@ export default function PostDetailPage() {
|
||||
{' · '}{post.view_count} 次浏览
|
||||
{post.edit_locked && (
|
||||
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
|
||||
<Lock size={12} /> 已锁定
|
||||
<Lock size={12} /> 编辑锁定
|
||||
</span>
|
||||
)}
|
||||
{post.comments_locked && (
|
||||
<span className="post-detail-locked-tag" title="管理员已锁定讨论">
|
||||
<MessageSquareOff size={12} /> 讨论锁定
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -662,7 +682,16 @@ export default function PostDetailPage() {
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="post-detail-tags">
|
||||
{tags.map(t => <Badge key={t} variant="secondary">{t}</Badge>)}
|
||||
{tags.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className="post-detail-tag-btn"
|
||||
onClick={() => nav(`/?tag=${encodeURIComponent(t)}`)}
|
||||
>
|
||||
<Badge variant="secondary">{t}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -818,6 +847,10 @@ export default function PostDetailPage() {
|
||||
<Lock />
|
||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCommentsLock}>
|
||||
{post.comments_locked ? <LockOpen /> : <MessageSquareOff />}
|
||||
{post.comments_locked ? '开放讨论' : '锁定讨论'}
|
||||
</Button>
|
||||
{post.status !== 'rejected' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
||||
<Ban />
|
||||
@@ -908,7 +941,12 @@ export default function PostDetailPage() {
|
||||
<span className="comment-section-count">{comments.length} 条评论</span>
|
||||
</div>
|
||||
|
||||
{!replyTo && (
|
||||
{post.comments_locked ? (
|
||||
<div className="comment-locked-banner" role="status">
|
||||
<MessageSquareOff size={16} aria-hidden />
|
||||
该帖子已锁定讨论,暂不可发表新评论
|
||||
</div>
|
||||
) : !replyTo && (
|
||||
<div className="comment-box-wrap" ref={commentBoxRef}>
|
||||
<CommentBox {...commentBoxProps} />
|
||||
</div>
|
||||
@@ -918,16 +956,20 @@ export default function PostDetailPage() {
|
||||
{comments.length === 0 && !replyTo ? (
|
||||
<div className="comment-empty">
|
||||
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
||||
<p>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
|
||||
<p>
|
||||
{post.comments_locked
|
||||
? '暂无评论'
|
||||
: user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<CommentThreadList
|
||||
comments={comments}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyTo?.id ?? null}
|
||||
replyToId={post.comments_locked ? null : (replyTo?.id ?? null)}
|
||||
editingId={editingCommentId}
|
||||
currentUser={user}
|
||||
onReply={handleReplyTo}
|
||||
onReply={post.comments_locked ? () => undefined : handleReplyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onStartEdit={(c) => {
|
||||
setReplyTo(null);
|
||||
@@ -943,7 +985,7 @@ export default function PostDetailPage() {
|
||||
item.id === commentId ? { ...item, liked, like_count: likeCount } : item
|
||||
)));
|
||||
}}
|
||||
renderReplyBox={(c) => (
|
||||
renderReplyBox={post.comments_locked ? undefined : (c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
{...commentBoxProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Lock, LockOpen, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Search, Lock, LockOpen, MessageSquareOff, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -163,6 +163,16 @@ export default function AdminPostsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCommentsLock = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminCommentsLockPost(post.id, !post.comments_locked);
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await api.adminDeletePost(id);
|
||||
@@ -208,7 +218,7 @@ export default function AdminPostsPage() {
|
||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||
: tab === 'pending'
|
||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||
: '精华、全局置顶、板块置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||
: '精华、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +340,8 @@ export default function AdminPostsPage() {
|
||||
<th>精华</th>
|
||||
<th>全局置顶</th>
|
||||
<th>板块置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>编辑锁</th>
|
||||
<th>讨论锁</th>
|
||||
<th>点赞</th>
|
||||
<th>浏览</th>
|
||||
<th>时间</th>
|
||||
@@ -363,6 +374,7 @@ export default function AdminPostsPage() {
|
||||
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||
<td>{p.board_pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
<td className="text-sm whitespace-nowrap">
|
||||
@@ -393,7 +405,12 @@ export default function AdminPostsPage() {
|
||||
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁</> : <><Lock size={14} /> 锁定</>}
|
||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => toggleCommentsLock(p)}>
|
||||
{p.comments_locked
|
||||
? <><LockOpen size={14} /> 开放讨论</>
|
||||
: <><MessageSquareOff size={14} /> 锁定讨论</>}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
||||
@@ -2510,9 +2510,32 @@ a.post-title:visited {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3193,6 +3216,19 @@ a.post-title:visited {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
@@ -3749,6 +3785,18 @@ a.post-title:visited {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9420,6 +9468,136 @@ button.profile-stat:hover strong {
|
||||
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 {
|
||||
display: grid;
|
||||
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);
|
||||
color: var(--j13-green, #16a34a);
|
||||
}
|
||||
.user-badge--level-muted { color: #64748b; }
|
||||
.user-badge--level-blue {
|
||||
background: color-mix(in srgb, #2563eb 18%, transparent);
|
||||
color: #1d4ed8;
|
||||
.user-badge--level {
|
||||
gap: 0.2rem;
|
||||
padding-left: 0.3rem;
|
||||
border-left: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||
}
|
||||
.user-badge--level-amber {
|
||||
background: color-mix(in srgb, #d97706 20%, transparent);
|
||||
.user-badge__level-text {
|
||||
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;
|
||||
}
|
||||
.user-badge--level-gold {
|
||||
background: color-mix(in srgb, #ca8a04 22%, transparent);
|
||||
.user-badge--level-crown {
|
||||
background: color-mix(in srgb, #ca8a04 20%, transparent);
|
||||
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 {
|
||||
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 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
return `${boardId}:${keyword}:${sort}`;
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
|
||||
return `${boardId}:${keyword}:${tag}:${sort}`;
|
||||
}
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
|
||||
return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
|
||||
}
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
store.set(cacheKey(boardId, keyword, sort), data);
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 等级视觉档位(纹章 / 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 {
|
||||
return !!u && (u.role === 'admin' || !!u.verified);
|
||||
|
||||
Reference in New Issue
Block a user