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