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

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

@@ -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 '通知';