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

补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 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

@@ -23,6 +23,7 @@ const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
@@ -45,6 +46,7 @@ const router = createBrowserRouter(
<Route errorElement={<AppRouteError />}>
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
<Route path="/forgot-password" element={<Suspense fallback={<AuthPageFallback />}><ForgotPasswordPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />

View File

@@ -400,6 +400,26 @@ export const api = {
method: 'POST',
body: JSON.stringify({ email }),
}),
sendResetEmailCode: (email: string) =>
request<{ message: string }>('/api/password-reset/email-code', {
method: 'POST',
body: JSON.stringify({ email }),
}),
resetPassword: (data: { email: string; emailCode: string; newPassword: string }) =>
request<{ message: string }>('/api/password-reset', {
method: 'POST',
body: JSON.stringify({
email: data.email,
email_code: data.emailCode,
new_password: data.newPassword,
}),
}),
searchUsers: (q: string, limit = 8) => {
const sp = new URLSearchParams({ q, limit: String(limit) });
return request<{ users: Array<{ id: number; username: string; nickname: string; avatar?: string }> }>(
`/api/users/search?${sp}`,
);
},
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),

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

View File

@@ -68,6 +68,10 @@ export default function MainLayout() {
const asideEverLoaded = useRef(false);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
const [searchAuthor, setSearchAuthor] = useState(params.get('author') || '');
const [searchTitleOnly, setSearchTitleOnly] = useState(params.get('title_only') === '1');
const [searchInBoard, setSearchInBoard] = useState(!!params.get('board') && !!params.get('keyword'));
const [searchAdvanced, setSearchAdvanced] = useState(false);
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
@@ -96,7 +100,12 @@ export default function MainLayout() {
});
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
useEffect(() => {
setKeyword(params.get('keyword') || '');
setSearchAuthor(params.get('author') || '');
setSearchTitleOnly(params.get('title_only') === '1');
setSearchInBoard(!!params.get('board') && (!!params.get('keyword') || !!params.get('author')));
}, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
@@ -226,24 +235,39 @@ export default function MainLayout() {
const doSearch = () => {
const kw = keyword.trim();
const active = (params.get('keyword') || '').trim();
if (!kw) {
// 输入已空:仅当 URL 仍带搜索时才回到全部帖子
if (active) navigateFeed(nav, '/');
const author = searchAuthor.trim();
const activeKw = (params.get('keyword') || '').trim();
const activeAuthor = (params.get('author') || '').trim();
const activeTitleOnly = params.get('title_only') === '1';
const activeBoard = Number(params.get('board')) || 0;
if (!kw && !author) {
if (activeKw || activeAuthor) navigateFeed(nav, '/');
return;
}
const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
return;
if (kw) {
const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
return;
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
const target = `/?keyword=${encodeURIComponent(kw)}`;
// 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航
if (active === kw && loc.pathname === '/') {
const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
const target = buildHomeUrl(scopeBoard, 'latest', {
keyword: kw,
author,
titleOnly: !!kw && searchTitleOnly,
});
const same =
loc.pathname === '/'
&& activeKw === kw
&& activeAuthor === author
&& activeTitleOnly === (!!kw && searchTitleOnly)
&& activeBoard === scopeBoard;
if (same) {
navigateFeed(nav, target);
return;
}
@@ -259,9 +283,10 @@ export default function MainLayout() {
const isFeedHome = loc.pathname === '/';
const outletKeyword = params.get('keyword') || '';
const outletTag = params.get('tag') || '';
const outletAuthor = params.get('author') || '';
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard =
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor
? -1
: boardId;
@@ -346,7 +371,7 @@ export default function MainLayout() {
{!isCompose && (!isMobile || searchExpanded) && (
<form
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}`}
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}${searchAdvanced ? ' header-search-wrap--advanced' : ''}`}
role="search"
onSubmit={e => {
e.preventDefault();
@@ -354,34 +379,80 @@ export default function MainLayout() {
if (isMobile) setSearchExpanded(false);
}}
>
<Search className="header-search-icon" size={16} aria-hidden />
<input
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子..."
aria-label="搜索帖子"
value={keyword}
onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search"
/>
{keyword && (
<div className="header-search-row">
<Search className="header-search-icon" size={16} aria-hidden />
<input
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子..."
aria-label="搜索帖子"
value={keyword}
onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search"
/>
<button
type="button"
className="header-search-clear"
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }}
aria-label="清除搜索"
>×</button>
)}
{isMobile && searchExpanded && (
<button
type="button"
className="header-search-cancel"
onClick={() => setSearchExpanded(false)}
className={cn('header-search-adv-toggle', searchAdvanced && 'active')}
onClick={() => setSearchAdvanced((v) => !v)}
aria-expanded={searchAdvanced}
title="高级搜索"
>
</button>
{(keyword || searchAuthor) && (
<button
type="button"
className="header-search-clear"
onClick={() => {
setKeyword('');
setSearchAuthor('');
setSearchTitleOnly(false);
navigateFeed(nav, '/');
}}
aria-label="清除搜索"
>×</button>
)}
{isMobile && searchExpanded && (
<button
type="button"
className="header-search-cancel"
onClick={() => setSearchExpanded(false)}
>
</button>
)}
</div>
{searchAdvanced && (
<div className="header-search-advanced">
<label className="header-search-opt">
<input
type="checkbox"
checked={searchTitleOnly}
onChange={(e) => setSearchTitleOnly(e.target.checked)}
/>
</label>
{boardId > 0 && (
<label className="header-search-opt">
<input
type="checkbox"
checked={searchInBoard}
onChange={(e) => setSearchInBoard(e.target.checked)}
/>
</label>
)}
<input
className="header-search-author"
type="text"
placeholder="作者用户名/昵称"
aria-label="作者"
value={searchAuthor}
onChange={(e) => setSearchAuthor(e.target.value)}
/>
</div>
)}
</form>
)}

View File

@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import AuthPasswordInput from '@/components/AuthPasswordInput';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import { loginPath } from '../utils/authRedirect';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import SiteBrandMark from '../components/SiteBrandMark';
const schema = (minLen: number, codeLen: number) => z.object({
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
email_code: z.string().regex(new RegExp(`^\\d{${codeLen}}$`), `请输入 ${codeLen} 位数字验证码`),
new_password: z.string().min(minLen, `密码至少 ${minLen}`),
});
type FormValues = z.infer<ReturnType<typeof schema>>;
export default function ForgotPasswordPage() {
const { limits } = useForumLimits();
const { branding } = useSiteBranding();
useNoIndexSEO('找回密码');
const nav = useNavigate();
const [loading, setLoading] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [mailReady, setMailReady] = useState<boolean | null>(null);
const codeLen = 6;
const form = useForm<FormValues>({
resolver: zodResolver(schema(limits.password_min_len, codeLen)),
defaultValues: { email: '', email_code: '', new_password: '' },
});
useEffect(() => {
api.registerConfig()
.then((c) => setMailReady(!!c.mail_ready))
.catch(() => setMailReady(false));
}, []);
useEffect(() => {
if (countdown <= 0) return;
const t = window.setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => window.clearTimeout(t);
}, [countdown]);
const sendCode = async () => {
const email = form.getValues('email');
const parsed = z.string().email().safeParse(email);
if (!parsed.success) {
form.setError('email', { message: '请先填写有效邮箱' });
return;
}
setSendingCode(true);
try {
const r = await api.sendResetEmailCode(email);
notify.success(r.message);
setCountdown(60);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '发送失败');
} finally {
setSendingCode(false);
}
};
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
const r = await api.resetPassword({
email: values.email,
emailCode: values.email_code,
newPassword: values.new_password,
});
notify.success(r.message);
nav(loginPath());
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '重置失败');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-box">
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
<SiteBrandMark branding={branding} className="logo-mark" />
</Link>
<h1></h1>
<p className="subtitle">
{mailReady === false
? '邮件服务未配置,请联系站长重置密码'
: '通过注册邮箱验证码设置新密码'}
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email_code"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<div className="auth-captcha-row">
<FormControl>
<Input
placeholder={`${codeLen} 位数字`}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={codeLen}
className="auth-email-code-input"
{...field}
onChange={(e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, codeLen);
field.onChange(digits);
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
className="auth-code-btn"
loading={sendingCode}
disabled={countdown > 0 || mailReady === false}
onClick={() => void sendCode()}
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<AuthPasswordInput placeholder="新密码" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading} disabled={mailReady === false}>
</Button>
</form>
</Form>
<p className="auth-footer">
<Link to={loginPath()}></Link>
</p>
<Link to="/" className="auth-back">
<ArrowLeft size={16} aria-hidden />
</Link>
</div>
</div>
);
}

View File

@@ -33,14 +33,16 @@ export default function HomePage() {
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const sort = parseFeedSort(params.get('sort'));
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
const isSiteHome = !boardId && !keyword && !tag;
const isSiteHome = !boardId && !keyword && !tag && !author;
const siteIntro = siteMetaDescription(branding);
const feedTitle = tag
? `标签:${tag}`
: keyword
? `搜索:${keyword}`
: keyword || author
? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
: (boardId && board ? board.name : '');
usePageSEO({
title: feedTitle || undefined,
@@ -67,7 +69,7 @@ export default function HomePage() {
const pageRef = useRef(1);
pageRef.current = page;
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
const feedSnapRef = useRef({ boardId, keyword, tag, sort, posts, postTotal, page });
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -96,6 +98,8 @@ export default function HomePage() {
board_id: boardId || '',
keyword: tag ? '' : keyword,
tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
@@ -114,7 +118,7 @@ export default function HomePage() {
loadingRef.current = false;
setLoading(false);
}
}, [boardId, keyword, tag, sort, pageSize]);
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
@@ -142,7 +146,7 @@ export default function HomePage() {
return;
}
const cached = getFeedCache(boardId, keyword, sort, tag);
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
if (cached && cached.posts.length > 0) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
@@ -163,6 +167,8 @@ export default function HomePage() {
boardId,
keyword,
tag,
author,
titleOnly,
sort,
location.key,
location.state,
@@ -175,15 +181,17 @@ export default function HomePage() {
feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag
&& feedSnapRef.current.author === author
&& feedSnapRef.current.titleOnly === titleOnly
&& feedSnapRef.current.sort === sort
) {
feedSnapRef.current = { boardId, keyword, tag, sort, posts, postTotal, page };
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
}
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps否则会用旧列表污染新 keyword
useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
feedSnapRef.current = { boardId, keyword, tag, sort, posts: [], postTotal: 0, page: 1 };
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
return () => {
if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current;
@@ -193,9 +201,9 @@ export default function HomePage() {
postTotal: snap.postTotal,
page: snap.page,
scrollTop: scrollTopRef.current,
}, snap.tag);
}, snap.tag, snap.author, snap.titleOnly);
};
}, [boardId, keyword, tag, sort]);
}, [boardId, keyword, tag, author, titleOnly, sort]);
useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
@@ -222,10 +230,10 @@ export default function HomePage() {
loadFirst();
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag }));
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly }));
};
const showSortBar = !keyword && !tag;
const showSortBar = !keyword && !tag && !author;
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
if ((loading || limitsLoading) && posts.length === 0) {
@@ -241,6 +249,8 @@ export default function HomePage() {
boardId={boardId}
keyword={keyword}
tag={tag}
author={author}
titleOnly={titleOnly}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
@@ -266,7 +276,7 @@ export default function HomePage() {
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag}
keyword={keyword || tag || author}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}

View File

@@ -92,6 +92,8 @@ export default function LoginPage() {
</form>
</Form>
<p className="auth-footer">
<Link to="/forgot-password"></Link>
<span className="auth-footer-sep" aria-hidden>·</span>
<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</p>
<Link to="/" className="auth-back">

View File

@@ -20,6 +20,7 @@ type MsgTab = 'dm' | 'notify';
const NOTIFY_KINDS = [
{ key: 'all', label: '全部' },
{ key: 'reply', label: '回复' },
{ key: 'mention', label: '@提及' },
{ key: 'moderation', label: '待审' },
{ key: 'reject', label: '拒帖' },
{ key: 'report_result', label: '举报' },
@@ -31,6 +32,7 @@ function kindLabel(kind: string) {
case 'reject': return '拒帖通知';
case 'report_result': return '举报结果';
case 'reply': return '回复提醒';
case 'mention': return '@提及';
case 'moderation': return '待审提醒';
case 'system': return '系统通知';
default: return '通知';

View File

@@ -380,14 +380,80 @@ img.site-brand-logo-img {
min-width: 0;
max-width: 420px;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
flex-direction: column;
justify-content: center;
gap: 0;
min-height: 36px;
padding: 0 10px 0 14px;
border-radius: 999px;
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, border-radius 0.15s;
}
.header-search-wrap--advanced {
border-radius: 14px;
padding-bottom: 8px;
max-width: 480px;
}
.header-search-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
}
.header-search-advanced {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 0 2px 2px 24px;
}
.header-search-opt {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-3, #64748b);
cursor: pointer;
white-space: nowrap;
}
.header-search-opt input {
margin: 0;
}
.header-search-author {
flex: 1;
min-width: 7rem;
height: 28px;
padding: 0 0.55rem;
border: 1px solid var(--j13-border-light);
border-radius: 0.35rem;
background: var(--j13-bg-surface, #fff);
font-size: 0.78rem;
color: var(--color-text-1);
font-family: inherit;
}
.header-search-adv-toggle {
flex-shrink: 0;
border: none;
background: transparent;
color: var(--color-text-3);
font-size: 0.75rem;
font-family: inherit;
padding: 0 2px;
cursor: pointer;
}
.header-search-adv-toggle.active,
.header-search-adv-toggle:hover {
color: var(--j13-green);
}
.header-search-wrap:focus-within {
@@ -3102,6 +3168,11 @@ a.post-title:visited {
color: var(--color-text-3);
}
.auth-footer-sep {
margin: 0 0.4rem;
color: var(--color-text-4, #94a3b8);
}
.post-detail-loading {
display: flex;
align-items: center;
@@ -5116,6 +5187,84 @@ a.post-title:visited {
transition: border-color 0.2s, background 0.2s;
}
.comment-mention-popup {
position: absolute;
left: 8px;
right: 48px;
bottom: calc(100% + 4px);
z-index: 20;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--j13-border, #e2e8f0);
border-radius: 0.45rem;
background: var(--j13-card, #fff);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.comment-mention-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.45rem 0.65rem;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
}
.comment-mention-item:hover,
.comment-mention-item.active {
background: color-mix(in srgb, var(--j13-green, #18a058) 10%, transparent);
}
.comment-mention-avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-bg-block-muted, #f1f5f9);
font-size: 0.7rem;
flex-shrink: 0;
}
.comment-mention-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-mention-meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.comment-mention-nick {
font-size: 0.85rem;
font-weight: 560;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-mention-user {
font-size: 0.72rem;
color: var(--color-text-4, #94a3b8);
}
.comment-mention-empty {
padding: 0.65rem;
font-size: 0.8rem;
color: var(--color-text-3, #64748b);
}
.comment-box-input-wrap:focus-within {
border-color: rgb(var(--primary-6));
}
@@ -5591,7 +5740,14 @@ a.waline-comment-author:hover {
.waline-comment { padding: 12px 14px; }
}
.mention { color: var(--j13-green); font-weight: 400; }
.mention {
color: var(--j13-green);
font-weight: 500;
cursor: pointer;
}
.mention:hover {
text-decoration: underline;
}
.quote-block {
border-left: 3px solid var(--j13-green);
@@ -6205,17 +6361,68 @@ a.user-link--avatar-only:focus-visible {
font-weight: 400;
}
.widget-item--comment {
cursor: default;
.widget-item--active {
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
padding: 0.5rem 0.65rem;
border-radius: 6px;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
}
/* 取消左右 padding 位移,避免悬停时日期被挤向前 */
/* 正在聊:保留悬停底色与内边距,取消左右位移与变色 */
.widget-item.widget-item--active:hover,
.widget-item.widget-item--active:focus-visible {
color: inherit;
padding-left: 0.65rem;
padding-right: 0.65rem;
margin-left: 0;
margin-right: 0;
background: var(--j13-bg-block-accent);
}
/* 标题单行省略,避免多行撑高区块 */
.widget-item--active .widget-item-title {
flex: none;
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
}
.widget-item-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.72rem;
color: var(--color-text-4, #94a3b8);
}
.widget-item-count {
flex-shrink: 0;
}
.widget-item--comment {
cursor: default;
padding-left: 0.65rem;
padding-right: 0.65rem;
border-radius: 6px;
box-sizing: border-box;
}
/* 最新评论:保留悬停底色与内边距,取消左右位移 */
.widget-item.widget-item--comment:hover,
.widget-item.widget-item--comment:focus-visible,
.widget-item.widget-item--comment:focus-within {
color: inherit;
padding-left: 0;
padding-right: 0;
padding-left: 0.65rem;
padding-right: 0.65rem;
margin-left: 0;
margin-right: 0;
background: var(--j13-bg-block-accent);

View File

@@ -9,10 +9,13 @@ function escapeWithBreaks(text: string): string {
.replace(/\n/g, '<br>');
}
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
/** @用户名 高亮(data-name 供点击跳转用户主页 */
export function highlightMentions(text: string): string {
return escapeWithBreaks(text)
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
.replace(
/@([\w\u4e00-\u9fa5_-]+)/g,
'<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
);
}
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前更早用具体日期 */

View File

@@ -15,18 +15,40 @@ export type FeedCache = {
/** 仅存内存SPA 内返回可恢复,浏览器刷新自动清空 */
const store = new Map<string, FeedCache>();
function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
return `${boardId}:${keyword}:${tag}:${sort}`;
function cacheKey(
boardId: number,
keyword: string,
sort: FeedSort,
tag = '',
author = '',
titleOnly = false,
) {
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
export function getFeedCache(
boardId: number,
keyword: string,
sort: FeedSort,
tag = '',
author = '',
titleOnly = false,
): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
}
/** 保存帖子列表缓存 */
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
store.set(cacheKey(boardId, keyword, sort, tag), data);
export function setFeedCache(
boardId: number,
keyword: string,
sort: FeedSort,
data: FeedCache,
tag = '',
author = '',
titleOnly = false,
) {
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
}
/** 清除所有帖子列表缓存 */