增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}` : '/';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user