补齐论坛核心能力:讨论锁定、发帖本地草稿、标签精确筛选,以及私信与通知分流。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user