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

补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 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 PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage')); const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage')); const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage')); const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage')); const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage')); const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
@@ -45,6 +46,7 @@ const router = createBrowserRouter(
<Route errorElement={<AppRouteError />}> <Route errorElement={<AppRouteError />}>
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} /> <Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></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="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}> <Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} /> <Route index element={<Navigate to="/admin/dashboard" replace />} />

View File

@@ -400,6 +400,26 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }), 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'), captcha: () => request<{ id: string; image: string }>('/api/captcha'),
logout: () => request('/api/logout', { method: 'POST' }), logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { 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 { Link } from 'react-router-dom';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { User, Comment } from '../api/types'; import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker'; import EmojiPicker from './EmojiPicker';
import { commentNick } from '../utils/comment'; import { commentNick } from '../utils/comment';
import { loginPath, registerPath } from '../utils/authRedirect'; import { loginPath, registerPath } from '../utils/authRedirect';
import { cn } from '@/lib/utils';
export interface CommentSubmitData { export interface CommentSubmitData {
content: string; content: string;
@@ -24,14 +26,22 @@ interface Props {
onCancelReply?: () => void; 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) { export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = 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 textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const owoRef = useRef<HTMLButtonElement>(null); const owoRef = useRef<HTMLButtonElement>(null);
const mentionTimer = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (inline && replyTo) { if (inline && replyTo) {
@@ -43,6 +53,8 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
setContent(''); setContent('');
setShowEmoji(false); setShowEmoji(false);
setIsPrivate(false); setIsPrivate(false);
setMentionQuery(null);
setMentionUsers([]);
}, [submitCount]); }, [submitCount]);
useEffect(() => { useEffect(() => {
@@ -67,21 +79,86 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
}; };
}, [showEmoji]); }, [showEmoji]);
const insertEmoji = (emoji: string) => { 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; const el = textareaRef.current;
if (el) { if (!el) {
const start = el.selectionStart ?? content.length; setContent((prev) => prev + insert);
const end = el.selectionEnd ?? content.length; return;
const next = content.slice(0, start) + emoji + content.slice(end); }
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); setContent(next);
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.focus(); el.focus();
const pos = start + emoji.length; const pos = start + insert.length;
el.setSelectionRange(pos, pos); el.setSelectionRange(pos, pos);
}); });
} else { };
setContent((prev) => prev + emoji);
} 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 = () => { const handleSubmit = () => {
@@ -96,6 +173,28 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { 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)) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
@@ -120,6 +219,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
const avatarInitial = user.nickname?.[0] || '?'; const avatarInitial = user.nickname?.[0] || '?';
const canSend = !!content.trim() && !submitting; const canSend = !!content.trim() && !submitting;
const showMentionPopup = mentionQuery !== null && mentionQuery.length > 0;
return ( return (
<div className="comment-box" ref={boxRef}> <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' : ''}`}> <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 <textarea
ref={textareaRef} ref={textareaRef}
className="comment-box-textarea" className="comment-box-textarea"
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'} placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧,可用 @ 提及用户'}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={handleChange}
onKeyDown={handleKeyDown} 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} rows={3}
/> />
<button <button

View File

@@ -1,17 +1,53 @@
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { highlightMentions } from '../utils/content'; import { highlightMentions } from '../utils/content';
import { userPath } from '../utils/userPath';
interface Props { interface Props {
content: string; 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 ( return (
<div <div
className="floor-body" 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={{ dangerouslySetInnerHTML={{
__html: highlightMentions(content, onMentionClick), __html: highlightMentions(content),
}} }}
/> />
); );

View File

@@ -6,6 +6,8 @@ interface Props {
boardId: number; boardId: number;
keyword: string; keyword: string;
tag?: string; tag?: string;
author?: string;
titleOnly?: boolean;
boards: Board[]; boards: Board[];
stats: ForumStats | null; stats: ForumStats | null;
postTotal: number; postTotal: number;
@@ -13,14 +15,32 @@ interface Props {
titleAs?: 'h1' | 'h2'; 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 nav = useNavigate();
const board = boards.find(b => b.id === boardId); const board = boards.find(b => b.id === boardId);
const filtered = !!(keyword || tag); const filtered = !!(keyword || tag || author);
const inBoard = !filtered && boardId > 0 && !!board; 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; const TitleTag = titleAs;
return ( return (

View File

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

View File

@@ -68,6 +68,10 @@ export default function MainLayout() {
const asideEverLoaded = useRef(false); const asideEverLoaded = useRef(false);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0); const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || ''); 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 feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits(); const { limits: forumLimits } = useForumLimits();
@@ -96,7 +100,12 @@ export default function MainLayout() {
}); });
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); 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(() => { useEffect(() => {
setAsideOpen(false); setAsideOpen(false);
setSidebarOpen(false); setSidebarOpen(false);
@@ -226,12 +235,16 @@ export default function MainLayout() {
const doSearch = () => { const doSearch = () => {
const kw = keyword.trim(); const kw = keyword.trim();
const active = (params.get('keyword') || '').trim(); const author = searchAuthor.trim();
if (!kw) { const activeKw = (params.get('keyword') || '').trim();
// 输入已空:仅当 URL 仍带搜索时才回到全部帖子 const activeAuthor = (params.get('author') || '').trim();
if (active) navigateFeed(nav, '/'); const activeTitleOnly = params.get('title_only') === '1';
const activeBoard = Number(params.get('board')) || 0;
if (!kw && !author) {
if (activeKw || activeAuthor) navigateFeed(nav, '/');
return; return;
} }
if (kw) {
const len = [...kw].length; const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) { if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`); notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
@@ -241,9 +254,20 @@ export default function MainLayout() {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`); notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return; return;
} }
const target = `/?keyword=${encodeURIComponent(kw)}`; }
// 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航 const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
if (active === kw && loc.pathname === '/') { 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); navigateFeed(nav, target);
return; return;
} }
@@ -259,9 +283,10 @@ export default function MainLayout() {
const isFeedHome = loc.pathname === '/'; const isFeedHome = loc.pathname === '/';
const outletKeyword = params.get('keyword') || ''; const outletKeyword = params.get('keyword') || '';
const outletTag = params.get('tag') || ''; const outletTag = params.get('tag') || '';
const outletAuthor = params.get('author') || '';
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」) // 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard = const mobileActiveBoard =
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor
? -1 ? -1
: boardId; : boardId;
@@ -346,7 +371,7 @@ export default function MainLayout() {
{!isCompose && (!isMobile || searchExpanded) && ( {!isCompose && (!isMobile || searchExpanded) && (
<form <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" role="search"
onSubmit={e => { onSubmit={e => {
e.preventDefault(); e.preventDefault();
@@ -354,6 +379,7 @@ export default function MainLayout() {
if (isMobile) setSearchExpanded(false); if (isMobile) setSearchExpanded(false);
}} }}
> >
<div className="header-search-row">
<Search className="header-search-icon" size={16} aria-hidden /> <Search className="header-search-icon" size={16} aria-hidden />
<input <input
ref={searchInputRef} ref={searchInputRef}
@@ -366,11 +392,25 @@ export default function MainLayout() {
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search" enterKeyHint="search"
/> />
{keyword && ( <button
type="button"
className={cn('header-search-adv-toggle', searchAdvanced && 'active')}
onClick={() => setSearchAdvanced((v) => !v)}
aria-expanded={searchAdvanced}
title="高级搜索"
>
</button>
{(keyword || searchAuthor) && (
<button <button
type="button" type="button"
className="header-search-clear" className="header-search-clear"
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }} onClick={() => {
setKeyword('');
setSearchAuthor('');
setSearchTitleOnly(false);
navigateFeed(nav, '/');
}}
aria-label="清除搜索" aria-label="清除搜索"
>×</button> >×</button>
)} )}
@@ -383,6 +423,37 @@ export default function MainLayout() {
</button> </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> </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 boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || ''; const keyword = params.get('keyword') || '';
const tag = params.get('tag') || ''; const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const sort = parseFeedSort(params.get('sort')); const sort = parseFeedSort(params.get('sort'));
const board = (ctx?.boards ?? []).find(b => b.id === boardId); 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 siteIntro = siteMetaDescription(branding);
const feedTitle = tag const feedTitle = tag
? `标签:${tag}` ? `标签:${tag}`
: keyword : keyword || author
? `搜索:${keyword}` ? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
: (boardId && board ? board.name : ''); : (boardId && board ? board.name : '');
usePageSEO({ usePageSEO({
title: feedTitle || undefined, title: feedTitle || undefined,
@@ -67,7 +69,7 @@ export default function HomePage() {
const pageRef = useRef(1); const pageRef = useRef(1);
pageRef.current = page; 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 totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0; const showPagination = totalPages > 1 && posts.length > 0;
@@ -96,6 +98,8 @@ export default function HomePage() {
board_id: boardId || '', board_id: boardId || '',
keyword: tag ? '' : keyword, keyword: tag ? '' : keyword,
tag: tag || '', tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort: sort === 'latest' ? '' : sort, sort: sort === 'latest' ? '' : sort,
}); });
const batch = Array.isArray(data.posts) ? data.posts : []; const batch = Array.isArray(data.posts) ? data.posts : [];
@@ -114,7 +118,7 @@ export default function HomePage() {
loadingRef.current = false; loadingRef.current = false;
setLoading(false); setLoading(false);
} }
}, [boardId, keyword, tag, sort, pageSize]); }, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]); const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
@@ -142,7 +146,7 @@ export default function HomePage() {
return; return;
} }
const cached = getFeedCache(boardId, keyword, sort, tag); const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
if (cached && cached.posts.length > 0) { if (cached && cached.posts.length > 0) {
setPosts(cached.posts); setPosts(cached.posts);
setPostTotal(cached.postTotal); setPostTotal(cached.postTotal);
@@ -163,6 +167,8 @@ export default function HomePage() {
boardId, boardId,
keyword, keyword,
tag, tag,
author,
titleOnly,
sort, sort,
location.key, location.key,
location.state, location.state,
@@ -175,15 +181,17 @@ export default function HomePage() {
feedSnapRef.current.boardId === boardId feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword && feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag && feedSnapRef.current.tag === tag
&& feedSnapRef.current.author === author
&& feedSnapRef.current.titleOnly === titleOnly
&& feedSnapRef.current.sort === sort && 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 // 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps否则会用旧列表污染新 keyword
useEffect(() => { useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位 // 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 () => { return () => {
if (skipCacheSaveRef.current) return; if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current; const snap = feedSnapRef.current;
@@ -193,9 +201,9 @@ export default function HomePage() {
postTotal: snap.postTotal, postTotal: snap.postTotal,
page: snap.page, page: snap.page,
scrollTop: scrollTopRef.current, scrollTop: scrollTopRef.current,
}, snap.tag); }, snap.tag, snap.author, snap.titleOnly);
}; };
}, [boardId, keyword, tag, sort]); }, [boardId, keyword, tag, author, titleOnly, sort]);
useEffect(() => { useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false; if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
@@ -222,10 +230,10 @@ export default function HomePage() {
loadFirst(); loadFirst();
return; 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) { if ((loading || limitsLoading) && posts.length === 0) {
@@ -241,6 +249,8 @@ export default function HomePage() {
boardId={boardId} boardId={boardId}
keyword={keyword} keyword={keyword}
tag={tag} tag={tag}
author={author}
titleOnly={titleOnly}
boards={ctx?.boards ?? []} boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null} stats={ctx?.stats ?? null}
postTotal={postTotal} postTotal={postTotal}
@@ -266,7 +276,7 @@ export default function HomePage() {
resetScrollKey={listResetKey} resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }} onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)} onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag} keyword={keyword || tag || author}
boardId={boardId} boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''} boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0} noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}

View File

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

View File

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

View File

@@ -380,14 +380,80 @@ img.site-brand-logo-img {
min-width: 0; min-width: 0;
max-width: 420px; max-width: 420px;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 8px; justify-content: center;
height: 36px; gap: 0;
padding: 0 14px; min-height: 36px;
padding: 0 10px 0 14px;
border-radius: 999px; border-radius: 999px;
background: var(--j13-bg-block-muted); background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light); 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 { .header-search-wrap:focus-within {
@@ -3102,6 +3168,11 @@ a.post-title:visited {
color: var(--color-text-3); color: var(--color-text-3);
} }
.auth-footer-sep {
margin: 0 0.4rem;
color: var(--color-text-4, #94a3b8);
}
.post-detail-loading { .post-detail-loading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5116,6 +5187,84 @@ a.post-title:visited {
transition: border-color 0.2s, background 0.2s; 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 { .comment-box-input-wrap:focus-within {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
} }
@@ -5591,7 +5740,14 @@ a.waline-comment-author:hover {
.waline-comment { padding: 12px 14px; } .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 { .quote-block {
border-left: 3px solid var(--j13-green); border-left: 3px solid var(--j13-green);
@@ -6205,17 +6361,68 @@ a.user-link--avatar-only:focus-visible {
font-weight: 400; font-weight: 400;
} }
.widget-item--comment { .widget-item--active {
cursor: default; 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:hover,
.widget-item.widget-item--comment:focus-visible, .widget-item.widget-item--comment:focus-visible,
.widget-item.widget-item--comment:focus-within { .widget-item.widget-item--comment:focus-within {
color: inherit; color: inherit;
padding-left: 0; padding-left: 0.65rem;
padding-right: 0; padding-right: 0.65rem;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
background: var(--j13-bg-block-accent); background: var(--j13-bg-block-accent);

View File

@@ -9,10 +9,13 @@ function escapeWithBreaks(text: string): string {
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
} }
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @ */ /** @用户名 高亮(data-name 供点击跳转用户主页 */
export function highlightMentions(text: string, _onClick?: (name: string) => void): string { export function highlightMentions(text: string): string {
return escapeWithBreaks(text) 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天前更早用具体日期 */ /** 相对时间:刚刚 / N分钟前 / N小时前 / N天前更早用具体日期 */

View File

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

View File

@@ -364,6 +364,7 @@ func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
if comment, err := h.Comment.GetByID(uint(id)); err == nil { if comment, err := h.Comment.GetByID(uint(id)); err == nil {
comment.Status = model.ContentStatusPublished comment.Status = model.ContentStatusPublished
h.Notify.AsyncNotifyCommentPublished(comment) h.Notify.AsyncNotifyCommentPublished(comment)
h.Notify.AsyncNotifyCommentMentions(comment)
} }
} }
c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished}) c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished})
@@ -933,6 +934,8 @@ func (h *Handlers) APIPosts(c *gin.Context) {
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64) userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
keyword := c.Query("keyword") keyword := c.Query("keyword")
tag := strings.TrimSpace(c.Query("tag")) tag := strings.TrimSpace(c.Query("tag"))
author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
q := service.PostListQuery{ q := service.PostListQuery{
BoardID: uint(boardID), BoardID: uint(boardID),
@@ -941,6 +944,8 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Size: size, Size: size,
Keyword: keyword, Keyword: keyword,
Tag: tag, Tag: tag,
Author: author,
TitleOnly: titleOnly,
Sort: c.DefaultQuery("sort", "latest"), Sort: c.DefaultQuery("sort", "latest"),
ViewerID: h.currentUserID(c), ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c), ViewerIsAdmin: h.isAdmin(c),
@@ -1063,7 +1068,7 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)}) c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
} }
// APIHotPosts 热门 TOP // APIHotPosts 近期活跃讨论(近 7 日有回复)
func (h *Handlers) APIHotPosts(c *gin.Context) { func (h *Handlers) APIHotPosts(c *gin.Context) {
items, err := h.Post.HotPosts(10) items, err := h.Post.HotPosts(10)
if err != nil { if err != nil {

View File

@@ -155,6 +155,77 @@ func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"}) c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
} }
// APISendResetEmailCode 发送重置密码验证码
func (h *Handlers) APISendResetEmailCode(c *gin.Context) {
var req struct {
Email string `json:"email" form:"email" binding:"required"`
}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
if err := h.EmailCode.SendResetCode(req.Email); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "若该邮箱已注册,验证码将发送到邮箱"})
}
// APIResetPassword 邮箱验证码重置密码
func (h *Handlers) APIResetPassword(c *gin.Context) {
var req struct {
Email string `json:"email" form:"email" binding:"required"`
EmailCode string `json:"email_code" form:"email_code" binding:"required"`
NewPassword string `json:"new_password" form:"new_password" binding:"required"`
}
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if !h.Settings.MailReady() {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
return
}
if !h.EmailCode.VerifyPurpose(service.EmailCodePurposeReset, req.Email, req.EmailCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
return
}
if err := h.User.ResetPasswordByEmail(req.Email, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码已重置,请使用新密码登录"})
}
// APISearchUsers 用户搜索(@补全)
func (h *Handlers) APISearchUsers(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusOK, gin.H{"users": []any{}})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
users, err := h.User.SearchUsersBrief(q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(users))
for _, u := range users {
out = append(out, gin.H{
"id": u.ID,
"username": u.Username,
"nickname": u.Nickname,
"avatar": u.Avatar,
})
}
c.JSON(http.StatusOK, gin.H{"users": out})
}
func (h *Handlers) APIRegister(c *gin.Context) { func (h *Handlers) APIRegister(c *gin.Context) {
var req struct { var req struct {
Username string `json:"username" form:"username" binding:"required"` Username string `json:"username" form:"username" binding:"required"`
@@ -468,6 +539,7 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
switch comment.Status { switch comment.Status {
case model.ContentStatusPublished: case model.ContentStatusPublished:
h.Notify.AsyncNotifyCommentPublished(comment) h.Notify.AsyncNotifyCommentPublished(comment)
h.Notify.AsyncNotifyCommentMentions(comment)
case model.ContentStatusPending: case model.ContentStatusPending:
msg = "评论已提交,审核通过后公开显示" msg = "评论已提交,审核通过后公开显示"
h.Notify.AsyncNotifyPendingComment(comment) h.Notify.AsyncNotifyPendingComment(comment)

View File

@@ -199,6 +199,7 @@ const (
MessageKindReject = "reject" // 帖子被拒/下架 MessageKindReject = "reject" // 帖子被拒/下架
MessageKindReportResult = "report_result" // 举报处理结果 MessageKindReportResult = "report_result" // 举报处理结果
MessageKindReply = "reply" // 帖子/评论被回复 MessageKindReply = "reply" // 帖子/评论被回复
MessageKindMention = "mention" // 被 @提及
MessageKindModeration = "moderation" // 新内容待审核(通知管理员) MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
) )

View File

@@ -117,10 +117,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/captcha", h.APICaptcha) pubAPI.GET("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig) pubAPI.GET("/register/config", h.APIRegisterConfig)
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode) pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
pubAPI.POST("/password-reset", middleware.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
pubAPI.GET("/posts", h.APIPosts) pubAPI.GET("/posts", h.APIPosts)
pubAPI.GET("/posts/hot", h.APIHotPosts) pubAPI.GET("/posts/hot", h.APIHotPosts)
pubAPI.GET("/tags", h.APITags) pubAPI.GET("/tags", h.APITags)
pubAPI.GET("/comments/recent", h.APIRecentComments) pubAPI.GET("/comments/recent", h.APIRecentComments)
// search 须在 :id 之前
pubAPI.GET("/users/search", h.APISearchUsers)
pubAPI.GET("/users/:id", h.APIUserPublic) pubAPI.GET("/users/:id", h.APIUserPublic)
pubAPI.GET("/posts/:id", h.APIPostDetail) pubAPI.GET("/posts/:id", h.APIPostDetail)
pubAPI.GET("/posts/:id/comments", h.APIPostComments) pubAPI.GET("/posts/:id/comments", h.APIPostComments)

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"crypto/rand" "crypto/rand"
"errors"
"math/big" "math/big"
"strings" "strings"
"sync" "sync"
@@ -14,9 +15,12 @@ const (
emailCodeLen = 6 emailCodeLen = 6
emailCodeTTL = 10 * time.Minute emailCodeTTL = 10 * time.Minute
emailCodeCooldown = 60 * time.Second emailCodeCooldown = 60 * time.Second
EmailCodePurposeRegister = "register"
EmailCodePurposeReset = "reset"
) )
// EmailCodeLen 注册邮箱验证码位数(供 API 告知前端) // EmailCodeLen 邮箱验证码位数(供 API 告知前端)
const EmailCodeLen = emailCodeLen const EmailCodeLen = emailCodeLen
type emailCodeEntry struct { type emailCodeEntry struct {
@@ -25,7 +29,7 @@ type emailCodeEntry struct {
sentAt time.Time sentAt time.Time
} }
// EmailCodeService 注册邮箱验证码 // EmailCodeService 邮箱验证码(按 purpose 隔离)
type EmailCodeService struct { type EmailCodeService struct {
mu sync.Mutex mu sync.Mutex
entries map[string]emailCodeEntry entries map[string]emailCodeEntry
@@ -41,20 +45,45 @@ func NewEmailCodeService(mail *MailService) *EmailCodeService {
return s return s
} }
// SendRegisterCode 向邮箱发送注册验证码 func emailCodeKey(purpose, email string) string {
return purpose + ":" + NormalizeEmail(email)
}
// SendRegisterCode 向邮箱发送注册验证码(邮箱须未注册)
func (s *EmailCodeService) SendRegisterCode(email string) error { func (s *EmailCodeService) SendRegisterCode(email string) error {
return s.sendCode(EmailCodePurposeRegister, email)
}
// SendResetCode 向邮箱发送重置密码验证码(邮箱须已注册;不存在时仍返回成功以防枚举)
func (s *EmailCodeService) SendResetCode(email string) error {
return s.sendCode(EmailCodePurposeReset, email)
}
func (s *EmailCodeService) sendCode(purpose, email string) error {
email = NormalizeEmail(email) email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil { if err := ValidateEmail(email); err != nil {
return err return err
} }
var exist model.User var exist model.User
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil { found := model.DB.Where("email = ?", email).First(&exist).Error == nil
switch purpose {
case EmailCodePurposeRegister:
if found {
return ErrEmailExists return ErrEmailExists
} }
case EmailCodePurposeReset:
if !found {
// 防邮箱枚举:假装已发送
return nil
}
default:
return errors.New("无效的验证码用途")
}
key := emailCodeKey(purpose, email)
s.mu.Lock() s.mu.Lock()
if prev, ok := s.entries[email]; ok && time.Since(prev.sentAt) < emailCodeCooldown { if prev, ok := s.entries[key]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
s.mu.Unlock() s.mu.Unlock()
return ErrEmailCodeCooldown return ErrEmailCodeCooldown
} }
@@ -69,13 +98,18 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
if s.mail != nil && s.mail.settings != nil { if s.mail != nil && s.mail.settings != nil {
siteName = s.mail.settings.SiteBranding().Name siteName = s.mail.settings.SiteBranding().Name
} }
subject, textBody, htmlBody := BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes())) var subject, textBody, htmlBody string
if purpose == EmailCodePurposeReset {
subject, textBody, htmlBody = BuildResetCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
} else {
subject, textBody, htmlBody = BuildRegisterCodeMail(siteName, code, int(emailCodeTTL.Minutes()))
}
if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil { if err := s.mail.SendHTML(email, subject, textBody, htmlBody); err != nil {
return err return err
} }
s.mu.Lock() s.mu.Lock()
s.entries[email] = emailCodeEntry{ s.entries[key] = emailCodeEntry{
code: code, code: code,
expiresAt: time.Now().Add(emailCodeTTL), expiresAt: time.Now().Add(emailCodeTTL),
sentAt: time.Now(), sentAt: time.Now(),
@@ -84,20 +118,26 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
return nil return nil
} }
// Verify 校验邮箱验证码(一次性) // Verify 校验邮箱验证码(一次性);兼容旧调用 Verify(email, code) 视为注册用途
func (s *EmailCodeService) Verify(email, code string) bool { func (s *EmailCodeService) Verify(email, code string) bool {
return s.VerifyPurpose(EmailCodePurposeRegister, email, code)
}
// VerifyPurpose 按用途校验验证码(一次性)
func (s *EmailCodeService) VerifyPurpose(purpose, email, code string) bool {
email = NormalizeEmail(email) email = NormalizeEmail(email)
code = strings.TrimSpace(code) code = strings.TrimSpace(code)
if email == "" || code == "" { if purpose == "" || email == "" || code == "" {
return false return false
} }
key := emailCodeKey(purpose, email)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
entry, ok := s.entries[email] entry, ok := s.entries[key]
if !ok { if !ok {
return false return false
} }
delete(s.entries, email) delete(s.entries, key)
if time.Now().After(entry.expiresAt) { if time.Now().After(entry.expiresAt) {
return false return false
} }
@@ -109,9 +149,9 @@ func (s *EmailCodeService) cleanup() {
for range ticker.C { for range ticker.C {
now := time.Now() now := time.Now()
s.mu.Lock() s.mu.Lock()
for email, entry := range s.entries { for key, entry := range s.entries {
if now.After(entry.expiresAt) { if now.After(entry.expiresAt) {
delete(s.entries, email) delete(s.entries, key)
} }
} }
s.mu.Unlock() s.mu.Unlock()

View File

@@ -93,6 +93,89 @@ func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, text
return subject, textBody, htmlBody return subject, textBody, htmlBody
} }
// BuildResetCodeMail 生成重置密码验证码邮件
func BuildResetCodeMail(siteName, code string, ttlMinutes int) (subject, textBody, htmlBody string) {
siteName = strings.TrimSpace(siteName)
if siteName == "" {
siteName = "姜十三论坛"
}
if ttlMinutes <= 0 {
ttlMinutes = 10
}
subject = fmt.Sprintf("【%s】重置密码验证码", siteName)
spaced := strings.Join(strings.Split(code, ""), " ")
textBody = fmt.Sprintf(
"你好,\n\n你正在重置 %s 的登录密码。请在页面填写以下验证码:\n\n%s\n\n共 %d 位数字)\n\n有效期%d 分钟。\n如非本人操作请忽略本邮件账号仍然安全。\n\n— %s\n",
siteName, spaced, len(code), ttlMinutes, siteName,
)
safeSite := html.EscapeString(siteName)
safeCode := html.EscapeString(code)
preheader := html.EscapeString(fmt.Sprintf("重置 %s 密码:请填写邮件中的验证码,有效期 %d 分钟。", siteName, ttlMinutes))
htmlBody = fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>%s</title>
</head>
<body style="margin:0;padding:0;background:#f5f7fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;color:#1f2937;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">%s</div>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f5f7fa;padding:28px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<tr>
<td style="padding:20px 28px;background:linear-gradient(135deg,#18a058,#138f4c);color:#ffffff;">
<div style="font-size:18px;font-weight:700;letter-spacing:0.02em;">%s</div>
<div style="margin-top:4px;font-size:13px;opacity:0.92;">重置密码验证</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<p style="margin:0 0 12px;font-size:15px;line-height:1.6;">你好,</p>
<p style="margin:0 0 20px;font-size:14px;line-height:1.7;color:#4b5563;">你正在重置 <strong style="color:#111827;">%s</strong> 的登录密码。请在页面输入下方验证码:</p>
<div style="margin:0 0 8px;text-align:center;font-size:12px;color:#6b7280;letter-spacing:0.08em;">验 证 码</div>
<div style="margin:0 auto 8px;max-width:280px;padding:16px 12px;text-align:center;background:#edfbf3;border:1px solid rgba(24,160,88,0.28);border-radius:10px;font-size:28px;font-weight:700;letter-spacing:0.35em;color:#138f4c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">
%s
</div>
<p style="margin:0 0 20px;text-align:center;font-size:12px;color:#9ca3af;">共 %d 位数字,请完整输入</p>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;background:#f8fafc;border-radius:8px;">
<tr>
<td style="padding:12px 14px;font-size:13px;line-height:1.6;color:#4b5563;">
<strong style="color:#111827;">有效期</strong>%d 分钟<br />
超时请返回页面重新获取验证码。
</td>
</tr>
</table>
<p style="margin:0;font-size:12px;line-height:1.6;color:#9ca3af;">如非本人操作,请忽略本邮件。请勿将验证码告知他人。</p>
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #f1f5f9;font-size:12px;color:#9ca3af;text-align:center;">
此邮件由 %s 自动发送,请勿直接回复
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
html.EscapeString(subject),
preheader,
safeSite,
safeSite,
safeCode,
len(code),
ttlMinutes,
safeSite,
)
return subject, textBody, htmlBody
}
// BuildReplyMail 生成「收到新回复」提醒邮件 // BuildReplyMail 生成「收到新回复」提醒邮件
// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。 // displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。
func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) { func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) {

65
service/mention.go Normal file
View File

@@ -0,0 +1,65 @@
package service
import (
"regexp"
"strings"
"git.iioio.com/freefire/jiang13-forum/model"
)
const maxMentionsPerContent = 10
// 与前端 highlightMentions 字符集对齐(字母数字下划线中文,兼容历史 -
// Go RE2 不支持 JS 的 \uXXXX需用 \x{HHHH}
var mentionPattern = regexp.MustCompile(`@([0-9A-Za-z_\x{4e00}-\x{9fa5}-]+)`)
// ExtractMentionNames 从纯文本提取 @提及名(去重、保序)
func ExtractMentionNames(text string) []string {
matches := mentionPattern.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
name := strings.TrimSpace(m[1])
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, name)
if len(out) >= maxMentionsPerContent {
break
}
}
return out
}
// ResolveMentionUserIDs 将提及名解析为用户 ID优先 username其次 nickname排除 excludeUserID
func ResolveMentionUserIDs(names []string, excludeUserID uint) []uint {
if len(names) == 0 {
return nil
}
ids := make([]uint, 0, len(names))
seen := make(map[uint]struct{}, len(names))
for _, name := range names {
var u model.User
err := model.DB.Select("id").Where("username = ?", name).First(&u).Error
if err != nil {
err = model.DB.Select("id").Where("nickname = ?", name).First(&u).Error
}
if err != nil || u.ID == 0 || u.ID == excludeUserID {
continue
}
if _, ok := seen[u.ID]; ok {
continue
}
seen[u.ID] = struct{}{}
ids = append(ids, u.ID)
}
return ids
}

14
service/mention_test.go Normal file
View File

@@ -0,0 +1,14 @@
package service
import (
"reflect"
"testing"
)
func TestExtractMentionNames(t *testing.T) {
got := ExtractMentionNames("hi @alice 和 @小明_x 以及 @bob-1")
want := []string{"alice", "小明_x", "bob-1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

View File

@@ -43,6 +43,15 @@ func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) {
s.goNotify(func() { s.NotifyCommentPublished(&cp) }) s.goNotify(func() { s.NotifyCommentPublished(&cp) })
} }
// AsyncNotifyCommentMentions 异步:评论公开后通知被 @ 的用户
func (s *NotifyService) AsyncNotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil {
return
}
cp := *comment
s.goNotify(func() { s.NotifyCommentMentions(&cp) })
}
// AsyncNotifyPendingPost 异步:待审帖通知管理员 // AsyncNotifyPendingPost 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) { func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) {
if s == nil || post == nil { if s == nil || post == nil {
@@ -92,6 +101,41 @@ func (s *NotifyService) NotifyCommentPublished(comment *model.Comment) {
s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content) s.sendReplyMail(toUserID, authorName, title, comment.PostID, displayFloor, isNested, comment.Content)
} }
// NotifyCommentMentions 评论公开后通知被 @提及的用户(跳过已收到回复通知的人)
func (s *NotifyService) NotifyCommentMentions(comment *model.Comment) {
if s == nil || comment == nil || comment.Status != model.ContentStatusPublished {
return
}
names := ExtractMentionNames(comment.Content)
ids := ResolveMentionUserIDs(names, comment.UserID)
if len(ids) == 0 {
return
}
post, err := s.loadPost(comment.PostID)
if err != nil {
return
}
// 已作为回复对象收到通知的用户不再重复发 mention
replyTo, _ := s.resolveReplyRecipient(comment, post)
authorName := s.commentAuthorName(comment)
title := post.Title
if title == "" {
title = "未知帖子"
}
displayFloor := s.resolveDisplayFloor(comment)
pid := comment.PostID
subject := "有人 @了你"
content := FormatMentionContent(authorName, title, displayFloor)
for _, uid := range ids {
if uid == 0 || uid == comment.UserID || uid == replyTo {
continue
}
_, _ = s.messages.SendSystem(uid, subject, content, model.MessageKindMention, &pid, nil)
}
}
// NotifyPendingPost 新帖进入待审时通知全部管理员 // NotifyPendingPost 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *model.Post) { func (s *NotifyService) NotifyPendingPost(post *model.Post) {
if s == nil || post == nil || post.Status != model.ContentStatusPending { if s == nil || post == nil || post.Status != model.ContentStatusPending {
@@ -295,6 +339,11 @@ func FormatReplyContent(authorName, postTitle string, displayFloor int, isNested
return fmt.Sprintf("%s 在《%s》发表了 #%d 楼。", authorName, postTitle, displayFloor) return fmt.Sprintf("%s 在《%s》发表了 #%d 楼。", authorName, postTitle, displayFloor)
} }
// FormatMentionContent @提及站内通知正文
func FormatMentionContent(authorName, postTitle string, displayFloor int) string {
return fmt.Sprintf("%s 在《%s》#%d 楼中提到了你。", authorName, postTitle, displayFloor)
}
// FormatPendingPostContent 待审帖站内私信正文 // FormatPendingPostContent 待审帖站内私信正文
func FormatPendingPostContent(authorName, postTitle string, postID uint) string { func FormatPendingPostContent(authorName, postTitle string, postID uint) string {
return fmt.Sprintf( return fmt.Sprintf(

View File

@@ -35,6 +35,8 @@ type PostListQuery struct {
Size int Size int
Keyword string Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE
Author string // 作者用户名或昵称(解析为 UserID
TitleOnly bool // 关键词仅匹配标题
Sort string // latest | reply | hot Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见) ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool ViewerIsAdmin bool
@@ -127,14 +129,29 @@ func parseSQLiteTime(s string) (time.Time, bool) {
return time.Time{}, false return time.Time{}, false
} }
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) { func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
} }
since := time.Now().Add(-7 * 24 * time.Hour)
var posts []model.Post var posts []model.Post
err := model.DB.Preload("User").Preload("Board"). err := model.DB.Preload("User").Preload("Board").
Where("status = ?", model.ContentStatusPublished). Where("status = ?", model.ContentStatusPublished).
Order("like_count desc, view_count desc").Limit(limit).Find(&posts).Error Where(`EXISTS (
SELECT 1 FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = ?
AND comments.created_at >= ?
)`, model.ContentStatusPublished, since).
Order(`(
SELECT MAX(created_at) FROM comments
WHERE comments.post_id = posts.id
AND comments.deleted_at IS NULL
AND comments.status = 'published'
) DESC`).
Limit(limit).Find(&posts).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -143,9 +160,14 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
ids[i] = p.ID ids[i] = p.ID
} }
countMap := s.commentCountMap(ids) countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
items := make([]PostListItem, len(posts)) items := make([]PostListItem, len(posts))
for i, p := range posts { for i, p := range posts {
items[i] = PostListItem{Post: p, CommentCount: countMap[p.ID]} items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
}
} }
return items, nil return items, nil
} }
@@ -260,6 +282,15 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
} }
q.Keyword = kw q.Keyword = kw
} }
if q.UserID == 0 {
if author := strings.TrimSpace(q.Author); author != "" {
if uid, ok := resolveAuthorUserID(author); ok {
q.UserID = uid
} else {
return []model.Post{}, 0, nil
}
}
}
db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board") db := model.DB.Model(&model.Post{}).Preload("User").Preload("Board")
db = applyPostVisibility(db, q) db = applyPostVisibility(db, q)
if q.BoardID > 0 { if q.BoardID > 0 {
@@ -270,8 +301,12 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
} }
if q.Keyword != "" { if q.Keyword != "" {
kw := "%" + q.Keyword + "%" kw := "%" + q.Keyword + "%"
if q.TitleOnly {
db = db.Where("title LIKE ?", kw)
} else {
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw) db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
} }
}
if tag := strings.TrimSpace(q.Tag); tag != "" { if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感 // 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
escaped := escapeLikePattern(strings.ToLower(tag)) escaped := escapeLikePattern(strings.ToLower(tag))
@@ -325,6 +360,22 @@ func escapeLikePattern(s string) string {
return s return s
} }
// resolveAuthorUserID 按用户名精确匹配,否则按昵称精确匹配(优先用户名)
func resolveAuthorUserID(author string) (uint, bool) {
author = strings.TrimSpace(author)
if author == "" {
return 0, false
}
var u model.User
if err := model.DB.Select("id").Where("username = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
if err := model.DB.Select("id").Where("nickname = ?", author).First(&u).Error; err == nil {
return u.ID, true
}
return 0, false
}
func (s *PostService) FindByID(id uint) (*model.Post, error) { func (s *PostService) FindByID(id uint) (*model.Post, error) {
var post model.Post var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error err := model.DB.Preload("User").Preload("Board").First(&post, id).Error

View File

@@ -72,6 +72,57 @@ func (s *UserService) GetByUsername(username string) (*model.User, error) {
return &user, nil return &user, nil
} }
// GetByEmail 按邮箱查询
func (s *UserService) GetByEmail(email string) (*model.User, error) {
email = NormalizeEmail(email)
var user model.User
if err := model.DB.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// ResetPasswordByEmail 通过邮箱重置密码(已通过验证码校验)
func (s *UserService) ResetPasswordByEmail(email, newPass string) error {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {
return err
}
user, err := s.GetByEmail(email)
if err != nil {
return errors.New("用户不存在")
}
hash, err := HashPassword(newPass)
if err != nil {
return err
}
return model.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("password", hash).Error
}
// SearchUsersBrief 公开用户搜索(@补全):匹配用户名/昵称,不含邮箱
func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return []model.User{}, nil
}
if limit <= 0 || limit > 20 {
limit = 8
}
like := "%" + keyword + "%"
var users []model.User
err := model.DB.Select("id", "username", "nickname", "avatar", "role", "verified").
Where("username LIKE ? OR nickname LIKE ?", like, like).
Order("username ASC").
Limit(limit).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []model.User{}
}
return users, nil
}
// UpdateNickname 修改昵称 // UpdateNickname 修改昵称
func (s *UserService) UpdateNickname(userID uint, nickname string) error { func (s *UserService) UpdateNickname(userID uint, nickname string) error {
nickname = strings.TrimSpace(nickname) nickname = strings.TrimSpace(nickname)