{
+ const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
+ if (!el) return;
+ const name = el.getAttribute('data-name');
+ if (!name) return;
+ e.preventDefault();
+ void openMention(name);
+ }}
+ onKeyDown={(e) => {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ const el = e.target as HTMLElement;
+ if (!el.classList.contains('mention')) return;
+ const name = el.getAttribute('data-name');
+ if (!name) return;
+ e.preventDefault();
+ void openMention(name);
+ }}
dangerouslySetInnerHTML={{
- __html: highlightMentions(content, onMentionClick),
+ __html: highlightMentions(content),
}}
/>
);
diff --git a/frontend/src/components/FeedHeader.tsx b/frontend/src/components/FeedHeader.tsx
index 1df4276..fbbed12 100644
--- a/frontend/src/components/FeedHeader.tsx
+++ b/frontend/src/components/FeedHeader.tsx
@@ -6,6 +6,8 @@ interface Props {
boardId: number;
keyword: string;
tag?: string;
+ author?: string;
+ titleOnly?: boolean;
boards: Board[];
stats: ForumStats | null;
postTotal: number;
@@ -13,14 +15,32 @@ interface Props {
titleAs?: 'h1' | 'h2';
}
-export default function FeedHeader({ boardId, keyword, tag = '', boards, stats, postTotal, titleAs = 'h1' }: Props) {
+export default function FeedHeader({
+ boardId,
+ keyword,
+ tag = '',
+ author = '',
+ titleOnly = false,
+ boards,
+ stats,
+ postTotal,
+ titleAs = 'h1',
+}: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
- const filtered = !!(keyword || tag);
+ const filtered = !!(keyword || tag || author);
const inBoard = !filtered && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
- const title = tag ? `标签:${tag}` : (keyword ? `搜索:${keyword}` : '');
+ let title = '';
+ if (tag) title = `标签:${tag}`;
+ else if (keyword || author) {
+ const parts: string[] = [];
+ if (keyword) parts.push(titleOnly ? `标题含「${keyword}」` : `搜索:${keyword}`);
+ if (author) parts.push(`作者 ${author}`);
+ if (boardId && board) parts.push(`板块 ${board.name}`);
+ title = parts.join(' · ');
+ }
const TitleTag = titleAs;
return (
diff --git a/frontend/src/components/FeedSortBar.tsx b/frontend/src/components/FeedSortBar.tsx
index 6504654..340d2db 100644
--- a/frontend/src/components/FeedSortBar.tsx
+++ b/frontend/src/components/FeedSortBar.tsx
@@ -30,15 +30,22 @@ export function parseFeedSort(raw: string | null): FeedSort {
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'latest',
- opts?: { keyword?: string; tag?: string },
+ opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean },
) {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
+ const author = opts?.author?.trim();
// 标签筛选与关键词搜索互斥:有 tag 时不带 keyword
if (tag) p.set('tag', tag);
- else if (keyword) p.set('keyword', keyword);
+ else if (keyword) {
+ p.set('keyword', keyword);
+ if (opts?.titleOnly) p.set('title_only', '1');
+ if (author) p.set('author', author);
+ } else if (author) {
+ p.set('author', author);
+ }
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
return qs ? `/?${qs}` : '/';
diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx
index 503931c..7f85f16 100644
--- a/frontend/src/components/RightPanel.tsx
+++ b/frontend/src/components/RightPanel.tsx
@@ -1,10 +1,10 @@
-import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
+import { ListTree, MessageCircle, MessagesSquare, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
-import { formatShortDateTime } from '../utils/content';
+import { formatShortDateTime, formatTime } from '../utils/content';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
@@ -31,20 +31,13 @@ interface Props {
postDetail?: PostDetailAside | null;
}
-function hotRankClass(index: number): string {
- if (index === 0) return 'widget-rank widget-rank--1';
- if (index === 1) return 'widget-rank widget-rank--2';
- if (index === 2) return 'widget-rank widget-rank--3';
- return 'widget-rank';
-}
-
-function HotSkeleton() {
+function ActiveSkeleton() {
return (
-
+
{Array.from({ length: 6 }, (_, i) => (
-
@@ -84,14 +77,15 @@ export default function RightPanel({
const isSiteHome = loc.pathname === '/'
&& !params.get('board')
&& !params.get('keyword')
- && !params.get('tag');
+ && !params.get('tag')
+ && !params.get('author');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
- // 帖子很少时热门几乎等于主列表,改显示欢迎引导
- const showHot = loading || hotList.length >= 4;
- const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
+ // 有近期讨论则展示「正在聊」;否则显示欢迎引导
+ const showActive = loading || hotList.length > 0;
+ const showWelcome = !loading && hotList.length === 0;
const isPostDetail = !!postDetail;
return (
@@ -137,28 +131,38 @@ export default function RightPanel({
)}
- {!isPostDetail && showHot && (
+ {!isPostDetail && showActive && (
-
- 热门帖子
+
+ 正在聊
{loading && hotList.length === 0 ? (
-
+
) : hotList.length === 0 ? (
-
暂无数据
- ) : hotList.map((item, i) => (
-
- ))}
+
近 7 日暂无新回复
+ ) : hotList.map((item) => {
+ const replyLabel = item.last_reply_at
+ ? `${formatTime(item.last_reply_at)}有人回`
+ : '近期有讨论';
+ const count = item.comment_count ?? 0;
+ return (
+
+ );
+ })}
)}
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index 825ef4a..a8c881a 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -68,6 +68,10 @@ export default function MainLayout() {
const asideEverLoaded = useRef(false);
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
const [keyword, setKeyword] = useState(params.get('keyword') || '');
+ const [searchAuthor, setSearchAuthor] = useState(params.get('author') || '');
+ const [searchTitleOnly, setSearchTitleOnly] = useState(params.get('title_only') === '1');
+ const [searchInBoard, setSearchInBoard] = useState(!!params.get('board') && !!params.get('keyword'));
+ const [searchAdvanced, setSearchAdvanced] = useState(false);
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
@@ -96,7 +100,12 @@ export default function MainLayout() {
});
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
- useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
+ useEffect(() => {
+ setKeyword(params.get('keyword') || '');
+ setSearchAuthor(params.get('author') || '');
+ setSearchTitleOnly(params.get('title_only') === '1');
+ setSearchInBoard(!!params.get('board') && (!!params.get('keyword') || !!params.get('author')));
+ }, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
@@ -226,24 +235,39 @@ export default function MainLayout() {
const doSearch = () => {
const kw = keyword.trim();
- const active = (params.get('keyword') || '').trim();
- if (!kw) {
- // 输入已空:仅当 URL 仍带搜索时才回到全部帖子
- if (active) navigateFeed(nav, '/');
+ const author = searchAuthor.trim();
+ const activeKw = (params.get('keyword') || '').trim();
+ const activeAuthor = (params.get('author') || '').trim();
+ const activeTitleOnly = params.get('title_only') === '1';
+ const activeBoard = Number(params.get('board')) || 0;
+ if (!kw && !author) {
+ if (activeKw || activeAuthor) navigateFeed(nav, '/');
return;
}
- const len = [...kw].length;
- if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
- notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
- return;
+ if (kw) {
+ const len = [...kw].length;
+ if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
+ notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
+ return;
+ }
+ if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
+ notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
+ return;
+ }
}
- if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
- notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
- return;
- }
- const target = `/?keyword=${encodeURIComponent(kw)}`;
- // 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航
- if (active === kw && loc.pathname === '/') {
+ const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
+ const target = buildHomeUrl(scopeBoard, 'latest', {
+ keyword: kw,
+ author,
+ titleOnly: !!kw && searchTitleOnly,
+ });
+ const same =
+ loc.pathname === '/'
+ && activeKw === kw
+ && activeAuthor === author
+ && activeTitleOnly === (!!kw && searchTitleOnly)
+ && activeBoard === scopeBoard;
+ if (same) {
navigateFeed(nav, target);
return;
}
@@ -259,9 +283,10 @@ export default function MainLayout() {
const isFeedHome = loc.pathname === '/';
const outletKeyword = params.get('keyword') || '';
const outletTag = params.get('tag') || '';
+ const outletAuthor = params.get('author') || '';
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard =
- isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
+ isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor
? -1
: boardId;
@@ -346,7 +371,7 @@ export default function MainLayout() {
{!isCompose && (!isMobile || searchExpanded) && (
)}
diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..d49e0d8
--- /dev/null
+++ b/frontend/src/pages/ForgotPasswordPage.tsx
@@ -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
>;
+
+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(null);
+ const codeLen = 6;
+
+ const form = useForm({
+ 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 (
+
+
+
+
+
+
找回密码
+
+ {mailReady === false
+ ? '邮件服务未配置,请联系站长重置密码'
+ : '通过注册邮箱验证码设置新密码'}
+
+
+
+
+ 想起密码了?返回登录
+
+
+
+ 返回论坛
+
+
+
+ );
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index d8d5492..ce93a82 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -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}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 5ae3f7d..fe3776c 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -92,6 +92,8 @@ export default function LoginPage() {
+ 忘记密码
+ ·
没有账号?注册
diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx
index 8bb944d..32bb46a 100644
--- a/frontend/src/pages/MessagesPage.tsx
+++ b/frontend/src/pages/MessagesPage.tsx
@@ -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 '通知';
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 0f9ec81..1ab7e22 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -380,14 +380,80 @@ img.site-brand-logo-img {
min-width: 0;
max-width: 420px;
display: flex;
- align-items: center;
- gap: 8px;
- height: 36px;
- padding: 0 14px;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0;
+ min-height: 36px;
+ padding: 0 10px 0 14px;
border-radius: 999px;
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
- transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
+ transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, border-radius 0.15s;
+}
+
+.header-search-wrap--advanced {
+ border-radius: 14px;
+ padding-bottom: 8px;
+ max-width: 480px;
+}
+
+.header-search-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 36px;
+}
+
+.header-search-advanced {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ padding: 0 2px 2px 24px;
+}
+
+.header-search-opt {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.75rem;
+ color: var(--color-text-3, #64748b);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.header-search-opt input {
+ margin: 0;
+}
+
+.header-search-author {
+ flex: 1;
+ min-width: 7rem;
+ height: 28px;
+ padding: 0 0.55rem;
+ border: 1px solid var(--j13-border-light);
+ border-radius: 0.35rem;
+ background: var(--j13-bg-surface, #fff);
+ font-size: 0.78rem;
+ color: var(--color-text-1);
+ font-family: inherit;
+}
+
+.header-search-adv-toggle {
+ flex-shrink: 0;
+ border: none;
+ background: transparent;
+ color: var(--color-text-3);
+ font-size: 0.75rem;
+ font-family: inherit;
+ padding: 0 2px;
+ cursor: pointer;
+}
+
+.header-search-adv-toggle.active,
+.header-search-adv-toggle:hover {
+ color: var(--j13-green);
}
.header-search-wrap:focus-within {
@@ -3102,6 +3168,11 @@ a.post-title:visited {
color: var(--color-text-3);
}
+.auth-footer-sep {
+ margin: 0 0.4rem;
+ color: var(--color-text-4, #94a3b8);
+}
+
.post-detail-loading {
display: flex;
align-items: center;
@@ -5116,6 +5187,84 @@ a.post-title:visited {
transition: border-color 0.2s, background 0.2s;
}
+.comment-mention-popup {
+ position: absolute;
+ left: 8px;
+ right: 48px;
+ bottom: calc(100% + 4px);
+ z-index: 20;
+ max-height: 220px;
+ overflow-y: auto;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.45rem;
+ background: var(--j13-card, #fff);
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
+}
+
+.comment-mention-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.45rem 0.65rem;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+}
+
+.comment-mention-item:hover,
+.comment-mention-item.active {
+ background: color-mix(in srgb, var(--j13-green, #18a058) 10%, transparent);
+}
+
+.comment-mention-avatar {
+ width: 1.5rem;
+ height: 1.5rem;
+ border-radius: 999px;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--j13-bg-block-muted, #f1f5f9);
+ font-size: 0.7rem;
+ flex-shrink: 0;
+}
+
+.comment-mention-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.comment-mention-meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ line-height: 1.25;
+}
+
+.comment-mention-nick {
+ font-size: 0.85rem;
+ font-weight: 560;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.comment-mention-user {
+ font-size: 0.72rem;
+ color: var(--color-text-4, #94a3b8);
+}
+
+.comment-mention-empty {
+ padding: 0.65rem;
+ font-size: 0.8rem;
+ color: var(--color-text-3, #64748b);
+}
+
.comment-box-input-wrap:focus-within {
border-color: rgb(var(--primary-6));
}
@@ -5591,7 +5740,14 @@ a.waline-comment-author:hover {
.waline-comment { padding: 12px 14px; }
}
-.mention { color: var(--j13-green); font-weight: 400; }
+.mention {
+ color: var(--j13-green);
+ font-weight: 500;
+ cursor: pointer;
+}
+.mention:hover {
+ text-decoration: underline;
+}
.quote-block {
border-left: 3px solid var(--j13-green);
@@ -6205,17 +6361,68 @@ a.user-link--avatar-only:focus-visible {
font-weight: 400;
}
-.widget-item--comment {
- cursor: default;
+.widget-item--active {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.2rem;
+ padding: 0.5rem 0.65rem;
+ border-radius: 6px;
+ box-sizing: border-box;
+ min-width: 0;
+ overflow: hidden;
}
-/* 取消左右 padding 位移,避免悬停时日期被挤向前 */
+/* 正在聊:保留悬停底色与内边距,取消左右位移与变色 */
+.widget-item.widget-item--active:hover,
+.widget-item.widget-item--active:focus-visible {
+ color: inherit;
+ padding-left: 0.65rem;
+ padding-right: 0.65rem;
+ margin-left: 0;
+ margin-right: 0;
+ background: var(--j13-bg-block-accent);
+}
+
+/* 标题单行省略,避免多行撑高区块 */
+.widget-item--active .widget-item-title {
+ flex: none;
+ display: block;
+ max-width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.35;
+}
+
+.widget-item-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.72rem;
+ color: var(--color-text-4, #94a3b8);
+}
+
+.widget-item-count {
+ flex-shrink: 0;
+}
+
+.widget-item--comment {
+ cursor: default;
+ padding-left: 0.65rem;
+ padding-right: 0.65rem;
+ border-radius: 6px;
+ box-sizing: border-box;
+}
+
+/* 最新评论:保留悬停底色与内边距,取消左右位移 */
.widget-item.widget-item--comment:hover,
.widget-item.widget-item--comment:focus-visible,
.widget-item.widget-item--comment:focus-within {
color: inherit;
- padding-left: 0;
- padding-right: 0;
+ padding-left: 0.65rem;
+ padding-right: 0.65rem;
margin-left: 0;
margin-right: 0;
background: var(--j13-bg-block-accent);
diff --git a/frontend/src/utils/content.ts b/frontend/src/utils/content.ts
index 6344393..cb821b3 100644
--- a/frontend/src/utils/content.ts
+++ b/frontend/src/utils/content.ts
@@ -9,10 +9,13 @@ function escapeWithBreaks(text: string): string {
.replace(/\n/g, '
');
}
-/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
-export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
+/** @用户名 高亮(data-name 供点击跳转用户主页) */
+export function highlightMentions(text: string): string {
return escapeWithBreaks(text)
- .replace(/@([\w\u4e00-\u9fa5_-]+)/g, '@$1');
+ .replace(
+ /@([\w\u4e00-\u9fa5_-]+)/g,
+ '@$1',
+ );
}
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前;更早用具体日期 */
diff --git a/frontend/src/utils/feedCache.ts b/frontend/src/utils/feedCache.ts
index 2b1962e..12ef832 100644
--- a/frontend/src/utils/feedCache.ts
+++ b/frontend/src/utils/feedCache.ts
@@ -15,18 +15,40 @@ export type FeedCache = {
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
const store = new Map();
-function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
- return `${boardId}:${keyword}:${tag}:${sort}`;
+function cacheKey(
+ boardId: number,
+ keyword: string,
+ sort: FeedSort,
+ tag = '',
+ author = '',
+ titleOnly = false,
+) {
+ return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
-export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
- return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
+export function getFeedCache(
+ boardId: number,
+ keyword: string,
+ sort: FeedSort,
+ tag = '',
+ author = '',
+ titleOnly = false,
+): FeedCache | null {
+ return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
}
/** 保存帖子列表缓存 */
-export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
- store.set(cacheKey(boardId, keyword, sort, tag), data);
+export function setFeedCache(
+ boardId: number,
+ keyword: string,
+ sort: FeedSort,
+ data: FeedCache,
+ tag = '',
+ author = '',
+ titleOnly = false,
+) {
+ store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
}
/** 清除所有帖子列表缓存 */
diff --git a/handler/api.go b/handler/api.go
index 8b4c55c..420f790 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -364,6 +364,7 @@ func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
if comment, err := h.Comment.GetByID(uint(id)); err == nil {
comment.Status = model.ContentStatusPublished
h.Notify.AsyncNotifyCommentPublished(comment)
+ h.Notify.AsyncNotifyCommentMentions(comment)
}
}
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)
keyword := c.Query("keyword")
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{
BoardID: uint(boardID),
@@ -941,6 +944,8 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Size: size,
Keyword: keyword,
Tag: tag,
+ Author: author,
+ TitleOnly: titleOnly,
Sort: c.DefaultQuery("sort", "latest"),
ViewerID: h.currentUserID(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)})
}
-// APIHotPosts 热门 TOP
+// APIHotPosts 近期活跃讨论(近 7 日有回复)
func (h *Handlers) APIHotPosts(c *gin.Context) {
items, err := h.Post.HotPosts(10)
if err != nil {
diff --git a/handler/handlers.go b/handler/handlers.go
index a60bb1c..aa4b3d8 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -155,6 +155,77 @@ func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
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) {
var req struct {
Username string `json:"username" form:"username" binding:"required"`
@@ -468,6 +539,7 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
switch comment.Status {
case model.ContentStatusPublished:
h.Notify.AsyncNotifyCommentPublished(comment)
+ h.Notify.AsyncNotifyCommentMentions(comment)
case model.ContentStatusPending:
msg = "评论已提交,审核通过后公开显示"
h.Notify.AsyncNotifyPendingComment(comment)
diff --git a/model/models.go b/model/models.go
index 1936004..8970ad1 100644
--- a/model/models.go
+++ b/model/models.go
@@ -199,6 +199,7 @@ const (
MessageKindReject = "reject" // 帖子被拒/下架
MessageKindReportResult = "report_result" // 举报处理结果
MessageKindReply = "reply" // 帖子/评论被回复
+ MessageKindMention = "mention" // 被 @提及
MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
)
diff --git a/router/router.go b/router/router.go
index 1bc5b63..04ee2f6 100644
--- a/router/router.go
+++ b/router/router.go
@@ -117,10 +117,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig)
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/hot", h.APIHotPosts)
pubAPI.GET("/tags", h.APITags)
pubAPI.GET("/comments/recent", h.APIRecentComments)
+ // search 须在 :id 之前
+ pubAPI.GET("/users/search", h.APISearchUsers)
pubAPI.GET("/users/:id", h.APIUserPublic)
pubAPI.GET("/posts/:id", h.APIPostDetail)
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
diff --git a/service/email_code.go b/service/email_code.go
index 248cf62..b2f4a4e 100644
--- a/service/email_code.go
+++ b/service/email_code.go
@@ -2,6 +2,7 @@ package service
import (
"crypto/rand"
+ "errors"
"math/big"
"strings"
"sync"
@@ -14,9 +15,12 @@ const (
emailCodeLen = 6
emailCodeTTL = 10 * time.Minute
emailCodeCooldown = 60 * time.Second
+
+ EmailCodePurposeRegister = "register"
+ EmailCodePurposeReset = "reset"
)
-// EmailCodeLen 注册邮箱验证码位数(供 API 告知前端)
+// EmailCodeLen 邮箱验证码位数(供 API 告知前端)
const EmailCodeLen = emailCodeLen
type emailCodeEntry struct {
@@ -25,7 +29,7 @@ type emailCodeEntry struct {
sentAt time.Time
}
-// EmailCodeService 注册邮箱验证码
+// EmailCodeService 邮箱验证码(按 purpose 隔离)
type EmailCodeService struct {
mu sync.Mutex
entries map[string]emailCodeEntry
@@ -41,20 +45,45 @@ func NewEmailCodeService(mail *MailService) *EmailCodeService {
return s
}
-// SendRegisterCode 向邮箱发送注册验证码
+func emailCodeKey(purpose, email string) string {
+ return purpose + ":" + NormalizeEmail(email)
+}
+
+// SendRegisterCode 向邮箱发送注册验证码(邮箱须未注册)
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)
if err := ValidateEmail(email); err != nil {
return err
}
var exist model.User
- if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
- return ErrEmailExists
+ found := model.DB.Where("email = ?", email).First(&exist).Error == nil
+ switch purpose {
+ case EmailCodePurposeRegister:
+ if found {
+ return ErrEmailExists
+ }
+ case EmailCodePurposeReset:
+ if !found {
+ // 防邮箱枚举:假装已发送
+ return nil
+ }
+ default:
+ return errors.New("无效的验证码用途")
}
+ key := emailCodeKey(purpose, email)
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()
return ErrEmailCodeCooldown
}
@@ -69,13 +98,18 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
if s.mail != nil && s.mail.settings != nil {
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 {
return err
}
s.mu.Lock()
- s.entries[email] = emailCodeEntry{
+ s.entries[key] = emailCodeEntry{
code: code,
expiresAt: time.Now().Add(emailCodeTTL),
sentAt: time.Now(),
@@ -84,20 +118,26 @@ func (s *EmailCodeService) SendRegisterCode(email string) error {
return nil
}
-// Verify 校验邮箱验证码(一次性)
+// Verify 校验邮箱验证码(一次性);兼容旧调用 Verify(email, code) 视为注册用途
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)
code = strings.TrimSpace(code)
- if email == "" || code == "" {
+ if purpose == "" || email == "" || code == "" {
return false
}
+ key := emailCodeKey(purpose, email)
s.mu.Lock()
defer s.mu.Unlock()
- entry, ok := s.entries[email]
+ entry, ok := s.entries[key]
if !ok {
return false
}
- delete(s.entries, email)
+ delete(s.entries, key)
if time.Now().After(entry.expiresAt) {
return false
}
@@ -109,9 +149,9 @@ func (s *EmailCodeService) cleanup() {
for range ticker.C {
now := time.Now()
s.mu.Lock()
- for email, entry := range s.entries {
+ for key, entry := range s.entries {
if now.After(entry.expiresAt) {
- delete(s.entries, email)
+ delete(s.entries, key)
}
}
s.mu.Unlock()
diff --git a/service/mail_template.go b/service/mail_template.go
index d8ed1b0..f637bf6 100644
--- a/service/mail_template.go
+++ b/service/mail_template.go
@@ -93,6 +93,89 @@ func BuildRegisterCodeMail(siteName, code string, ttlMinutes int) (subject, text
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(`
+
+
+
+
+%s
+
+
+ %s
+
+
+
+
+
+ |
+ %s
+ 重置密码验证
+ |
+
+
+ |
+ 你好,
+ 你正在重置 %s 的登录密码。请在页面输入下方验证码:
+ 验 证 码
+
+ %s
+
+ 共 %d 位数字,请完整输入
+
+
+
+ 有效期:%d 分钟
+ 超时请返回页面重新获取验证码。
+ |
+
+
+ 如非本人操作,请忽略本邮件。请勿将验证码告知他人。
+ |
+
+
+ |
+ 此邮件由 %s 自动发送,请勿直接回复
+ |
+
+
+ |
+
+
+
+`,
+ html.EscapeString(subject),
+ preheader,
+ safeSite,
+ safeSite,
+ safeCode,
+ len(code),
+ ttlMinutes,
+ safeSite,
+ )
+ return subject, textBody, htmlBody
+}
+
// BuildReplyMail 生成「收到新回复」提醒邮件
// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。
func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) {
diff --git a/service/mention.go b/service/mention.go
new file mode 100644
index 0000000..cdfd165
--- /dev/null
+++ b/service/mention.go
@@ -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
+}
diff --git a/service/mention_test.go b/service/mention_test.go
new file mode 100644
index 0000000..4161806
--- /dev/null
+++ b/service/mention_test.go
@@ -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)
+ }
+}
diff --git a/service/notify.go b/service/notify.go
index e35e1d9..b2800b0 100644
--- a/service/notify.go
+++ b/service/notify.go
@@ -43,6 +43,15 @@ func (s *NotifyService) AsyncNotifyCommentPublished(comment *model.Comment) {
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 异步:待审帖通知管理员
func (s *NotifyService) AsyncNotifyPendingPost(post *model.Post) {
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)
}
+// 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 新帖进入待审时通知全部管理员
func (s *NotifyService) NotifyPendingPost(post *model.Post) {
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)
}
+// FormatMentionContent @提及站内通知正文
+func FormatMentionContent(authorName, postTitle string, displayFloor int) string {
+ return fmt.Sprintf("%s 在《%s》#%d 楼中提到了你。", authorName, postTitle, displayFloor)
+}
+
// FormatPendingPostContent 待审帖站内私信正文
func FormatPendingPostContent(authorName, postTitle string, postID uint) string {
return fmt.Sprintf(
diff --git a/service/post.go b/service/post.go
index da4d2eb..8bc30dc 100644
--- a/service/post.go
+++ b/service/post.go
@@ -35,6 +35,8 @@ type PostListQuery struct {
Size int
Keyword string
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
+ Author string // 作者用户名或昵称(解析为 UserID)
+ TitleOnly bool // 关键词仅匹配标题
Sort string // latest | reply | hot
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
@@ -127,14 +129,29 @@ func parseSQLiteTime(s string) (time.Time, bool) {
return time.Time{}, false
}
+// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
limit = 10
}
+ since := time.Now().Add(-7 * 24 * time.Hour)
var posts []model.Post
err := model.DB.Preload("User").Preload("Board").
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 {
return nil, err
}
@@ -143,9 +160,14 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
+ replyMap := s.lastReplyMap(ids)
items := make([]PostListItem, len(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
}
@@ -260,6 +282,15 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
}
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 = applyPostVisibility(db, q)
if q.BoardID > 0 {
@@ -270,7 +301,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
- db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)
+ 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)
+ }
}
if tag := strings.TrimSpace(q.Tag); tag != "" {
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
@@ -325,6 +360,22 @@ func escapeLikePattern(s string) string {
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) {
var post model.Post
err := model.DB.Preload("User").Preload("Board").First(&post, id).Error
diff --git a/service/user.go b/service/user.go
index a135354..7cfff55 100644
--- a/service/user.go
+++ b/service/user.go
@@ -72,6 +72,57 @@ func (s *UserService) GetByUsername(username string) (*model.User, error) {
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 修改昵称
func (s *UserService) UpdateNickname(userID uint, nickname string) error {
nickname = strings.TrimSpace(nickname)