补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 06:32:06 +08:00
parent 0f83183620
commit 10185178e2
28 changed files with 1165 additions and 266 deletions

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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}` : '/';

View 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>
);
}

View File

@@ -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 && (

View File

@@ -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 三连重复

View File

@@ -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>}

View File

@@ -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 => {