增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。

补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 07:39:33 +08:00
parent 10185178e2
commit 94f6a9666b
26 changed files with 1315 additions and 153 deletions

View File

@@ -1,13 +1,15 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker';
import { commentNick } from '../utils/comment';
import { loginPath, registerPath } from '../utils/authRedirect';
import { cn } from '@/lib/utils';
export interface CommentSubmitData {
content: string;
@@ -24,14 +26,22 @@ interface Props {
onCancelReply?: () => void;
}
/** 评论输入框:需登录后发表 */
type MentionUser = { id: number; username: string; nickname: string; avatar?: string };
/** 评论输入框:需登录后发表;支持 @ 用户补全 */
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const [content, setContent] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionStart, setMentionStart] = useState(-1);
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
const [mentionIndex, setMentionIndex] = useState(0);
const [mentionLoading, setMentionLoading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const owoRef = useRef<HTMLButtonElement>(null);
const mentionTimer = useRef<number | null>(null);
useEffect(() => {
if (inline && replyTo) {
@@ -43,6 +53,8 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
setContent('');
setShowEmoji(false);
setIsPrivate(false);
setMentionQuery(null);
setMentionUsers([]);
}, [submitCount]);
useEffect(() => {
@@ -67,21 +79,86 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
};
}, [showEmoji]);
const insertEmoji = (emoji: string) => {
const el = textareaRef.current;
if (el) {
const start = el.selectionStart ?? content.length;
const end = el.selectionEnd ?? content.length;
const next = content.slice(0, start) + emoji + content.slice(end);
setContent(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + emoji.length;
el.setSelectionRange(pos, pos);
});
} else {
setContent((prev) => prev + emoji);
const closeMention = useCallback(() => {
setMentionQuery(null);
setMentionUsers([]);
setMentionIndex(0);
setMentionStart(-1);
}, []);
const scanMention = useCallback((text: string, caret: number) => {
const before = text.slice(0, caret);
const m = before.match(/@([\w\u4e00-\u9fa5_-]*)$/);
if (!m) {
closeMention();
return;
}
const start = caret - m[0].length;
// @ 前须为行首或空白,避免邮箱等误触
if (start > 0 && !/\s/.test(text[start - 1])) {
closeMention();
return;
}
setMentionStart(start);
setMentionQuery(m[1] ?? '');
}, [closeMention]);
useEffect(() => {
if (mentionQuery === null) return;
if (mentionQuery.length === 0) {
setMentionUsers([]);
setMentionLoading(false);
return;
}
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
mentionTimer.current = window.setTimeout(() => {
setMentionLoading(true);
api.searchUsers(mentionQuery, 8)
.then((r) => {
setMentionUsers(r.users || []);
setMentionIndex(0);
})
.catch(() => setMentionUsers([]))
.finally(() => setMentionLoading(false));
}, 200);
return () => {
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
};
}, [mentionQuery]);
const insertAtCaret = (insert: string, replaceFrom?: number, replaceTo?: number) => {
const el = textareaRef.current;
if (!el) {
setContent((prev) => prev + insert);
return;
}
const start = replaceFrom ?? el.selectionStart ?? content.length;
const end = replaceTo ?? el.selectionEnd ?? content.length;
const next = content.slice(0, start) + insert + content.slice(end);
setContent(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + insert.length;
el.setSelectionRange(pos, pos);
});
};
const insertEmoji = (emoji: string) => {
insertAtCaret(emoji);
};
const pickMention = (u: MentionUser) => {
if (mentionStart < 0) return;
const el = textareaRef.current;
const caret = el?.selectionStart ?? content.length;
insertAtCaret(`@${u.username} `, mentionStart, caret);
closeMention();
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setContent(next);
scanMention(next, e.target.selectionStart ?? next.length);
};
const handleSubmit = () => {
@@ -96,6 +173,28 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (mentionQuery !== null && mentionUsers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((i) => (i + 1) % mentionUsers.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((i) => (i - 1 + mentionUsers.length) % mentionUsers.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
pickMention(mentionUsers[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeMention();
return;
}
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
@@ -120,6 +219,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
const avatarInitial = user.nickname?.[0] || '?';
const canSend = !!content.trim() && !submitting;
const showMentionPopup = mentionQuery !== null && mentionQuery.length > 0;
return (
<div className="comment-box" ref={boxRef}>
@@ -144,13 +244,52 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
)}
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
{showMentionPopup && (
<div className="comment-mention-popup" role="listbox" aria-label="提及用户">
{mentionLoading && mentionUsers.length === 0 ? (
<div className="comment-mention-empty"></div>
) : mentionUsers.length === 0 ? (
<div className="comment-mention-empty"></div>
) : (
mentionUsers.map((u, i) => (
<button
key={u.id}
type="button"
role="option"
aria-selected={i === mentionIndex}
className={cn('comment-mention-item', i === mentionIndex && 'active')}
onMouseDown={(e) => {
e.preventDefault();
pickMention(u);
}}
>
<span className="comment-mention-avatar" aria-hidden>
{u.avatar
? <img src={u.avatar} alt="" />
: (u.nickname?.[0] || u.username[0] || '?')}
</span>
<span className="comment-mention-meta">
<span className="comment-mention-nick">{u.nickname || u.username}</span>
<span className="comment-mention-user">@{u.username}</span>
</span>
</button>
))
)}
</div>
)}
<textarea
ref={textareaRef}
className="comment-box-textarea"
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧,可用 @ 提及用户'}
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={handleChange}
onKeyDown={handleKeyDown}
onClick={(e) => scanMention(content, e.currentTarget.selectionStart ?? content.length)}
onKeyUp={(e) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
scanMention(content, e.currentTarget.selectionStart ?? content.length);
}
}}
rows={3}
/>
<button

View File

@@ -1,17 +1,53 @@
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { highlightMentions } from '../utils/content';
import { userPath } from '../utils/userPath';
interface Props {
content: string;
onMentionClick?: (name: string) => void;
}
/** 渲染评论正文(支持正文内 @ 高亮) */
export default function CommentContent({ content, onMentionClick }: Props) {
/** 渲染评论正文(支持正文内 @ 高亮与点击跳转 */
export default function CommentContent({ content }: Props) {
const nav = useNavigate();
const openMention = async (name: string) => {
try {
const r = await api.searchUsers(name, 5);
const exact = (r.users || []).find(
(u) => u.username === name || u.nickname === name,
);
const user = exact || r.users?.[0];
if (user) {
nav(userPath(user.id));
}
} catch {
// 未登录或失败时忽略
}
};
return (
<div
className="floor-body"
onClick={(e) => {
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
if (!el) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const el = e.target as HTMLElement;
if (!el.classList.contains('mention')) return;
const name = el.getAttribute('data-name');
if (!name) return;
e.preventDefault();
void openMention(name);
}}
dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick),
__html: highlightMentions(content),
}}
/>
);

View File

@@ -6,6 +6,8 @@ interface Props {
boardId: number;
keyword: string;
tag?: string;
author?: string;
titleOnly?: boolean;
boards: Board[];
stats: ForumStats | null;
postTotal: number;
@@ -13,14 +15,32 @@ interface Props {
titleAs?: 'h1' | 'h2';
}
export default function FeedHeader({ boardId, keyword, tag = '', boards, stats, postTotal, titleAs = 'h1' }: Props) {
export default function FeedHeader({
boardId,
keyword,
tag = '',
author = '',
titleOnly = false,
boards,
stats,
postTotal,
titleAs = 'h1',
}: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
const filtered = !!(keyword || tag);
const filtered = !!(keyword || tag || author);
const inBoard = !filtered && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
const title = tag ? `标签:${tag}` : (keyword ? `搜索:${keyword}` : '');
let title = '';
if (tag) title = `标签:${tag}`;
else if (keyword || author) {
const parts: string[] = [];
if (keyword) parts.push(titleOnly ? `标题含「${keyword}` : `搜索:${keyword}`);
if (author) parts.push(`作者 ${author}`);
if (boardId && board) parts.push(`板块 ${board.name}`);
title = parts.join(' · ');
}
const TitleTag = titleAs;
return (

View File

@@ -30,15 +30,22 @@ export function parseFeedSort(raw: string | null): FeedSort {
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'latest',
opts?: { keyword?: string; tag?: string },
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean },
) {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
const author = opts?.author?.trim();
// 标签筛选与关键词搜索互斥:有 tag 时不带 keyword
if (tag) p.set('tag', tag);
else if (keyword) p.set('keyword', keyword);
else if (keyword) {
p.set('keyword', keyword);
if (opts?.titleOnly) p.set('title_only', '1');
if (author) p.set('author', author);
} else if (author) {
p.set('author', author);
}
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';

View File

@@ -1,10 +1,10 @@
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
import { ListTree, MessageCircle, MessagesSquare, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime } from '../utils/content';
import { formatShortDateTime, formatTime } from '../utils/content';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
@@ -31,20 +31,13 @@ interface Props {
postDetail?: PostDetailAside | null;
}
function hotRankClass(index: number): string {
if (index === 0) return 'widget-rank widget-rank--1';
if (index === 1) return 'widget-rank widget-rank--2';
if (index === 2) return 'widget-rank widget-rank--3';
return 'widget-rank';
}
function HotSkeleton() {
function ActiveSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
<div className="widget-skeleton" aria-busy="true" aria-label="正在聊加载中">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="widget-item widget-item--skeleton">
<Skeleton className="skeleton--widget-rank" />
<div key={i} className="widget-item widget-item--active widget-item--skeleton">
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
<Skeleton className="skeleton--widget-time" />
</div>
))}
</div>
@@ -84,14 +77,15 @@ export default function RightPanel({
const isSiteHome = loc.pathname === '/'
&& !params.get('board')
&& !params.get('keyword')
&& !params.get('tag');
&& !params.get('tag')
&& !params.get('author');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
const showHot = loading || hotList.length >= 4;
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
// 有近期讨论则展示「正在聊」;否则显示欢迎引导
const showActive = loading || hotList.length > 0;
const showWelcome = !loading && hotList.length === 0;
const isPostDetail = !!postDetail;
return (
@@ -137,28 +131,38 @@ export default function RightPanel({
</div>
)}
{!isPostDetail && showHot && (
{!isPostDetail && showActive && (
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
<MessagesSquare className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
<ActiveSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</button>
))}
<div className="widget-empty"> 7 </div>
) : hotList.map((item) => {
const replyLabel = item.last_reply_at
? `${formatTime(item.last_reply_at)}有人回`
: '近期有讨论';
const count = item.comment_count ?? 0;
return (
<button
key={item.id}
type="button"
className="widget-item widget-item--active"
onClick={() => onPostClick(item.id)}
title={item.title}
>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-meta">
<span className="widget-item-time">{replyLabel}</span>
{count > 0 && <span className="widget-item-count">{count} </span>}
</span>
</button>
);
})}
</div>
</div>
)}