增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
|
||||
const PostDetailPage = lazyWithRetry(() => import('./pages/PostDetailPage'));
|
||||
const LoginPage = lazyWithRetry(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazyWithRetry(() => import('./pages/RegisterPage'));
|
||||
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
|
||||
const ComposePage = lazyWithRetry(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazyWithRetry(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazyWithRetry(() => import('./pages/ProfilePage'));
|
||||
@@ -45,6 +46,7 @@ const router = createBrowserRouter(
|
||||
<Route errorElement={<AppRouteError />}>
|
||||
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/forgot-password" element={<Suspense fallback={<AuthPageFallback />}><ForgotPasswordPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
|
||||
@@ -400,6 +400,26 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
sendResetEmailCode: (email: string) =>
|
||||
request<{ message: string }>('/api/password-reset/email-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
resetPassword: (data: { email: string; emailCode: string; newPassword: string }) =>
|
||||
request<{ message: string }>('/api/password-reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: data.email,
|
||||
email_code: data.emailCode,
|
||||
new_password: data.newPassword,
|
||||
}),
|
||||
}),
|
||||
searchUsers: (q: string, limit = 8) => {
|
||||
const sp = new URLSearchParams({ q, limit: String(limit) });
|
||||
return request<{ users: Array<{ id: number; username: string; nickname: string; avatar?: string }> }>(
|
||||
`/api/users/search?${sp}`,
|
||||
);
|
||||
},
|
||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||
logout: () => request('/api/logout', { method: 'POST' }),
|
||||
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { User, Comment } from '../api/types';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import { commentNick } from '../utils/comment';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CommentSubmitData {
|
||||
content: string;
|
||||
@@ -24,14 +26,22 @@ interface Props {
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
/** 评论输入框:需登录后发表 */
|
||||
type MentionUser = { id: number; username: string; nickname: string; avatar?: string };
|
||||
|
||||
/** 评论输入框:需登录后发表;支持 @ 用户补全 */
|
||||
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
|
||||
const [content, setContent] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionStart, setMentionStart] = useState(-1);
|
||||
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([]);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [mentionLoading, setMentionLoading] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const owoRef = useRef<HTMLButtonElement>(null);
|
||||
const mentionTimer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inline && replyTo) {
|
||||
@@ -43,6 +53,8 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
setContent('');
|
||||
setShowEmoji(false);
|
||||
setIsPrivate(false);
|
||||
setMentionQuery(null);
|
||||
setMentionUsers([]);
|
||||
}, [submitCount]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,21 +79,86 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
};
|
||||
}, [showEmoji]);
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const closeMention = useCallback(() => {
|
||||
setMentionQuery(null);
|
||||
setMentionUsers([]);
|
||||
setMentionIndex(0);
|
||||
setMentionStart(-1);
|
||||
}, []);
|
||||
|
||||
const scanMention = useCallback((text: string, caret: number) => {
|
||||
const before = text.slice(0, caret);
|
||||
const m = before.match(/@([\w\u4e00-\u9fa5_-]*)$/);
|
||||
if (!m) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
const start = caret - m[0].length;
|
||||
// @ 前须为行首或空白,避免邮箱等误触
|
||||
if (start > 0 && !/\s/.test(text[start - 1])) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
setMentionStart(start);
|
||||
setMentionQuery(m[1] ?? '');
|
||||
}, [closeMention]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionQuery === null) return;
|
||||
if (mentionQuery.length === 0) {
|
||||
setMentionUsers([]);
|
||||
setMentionLoading(false);
|
||||
return;
|
||||
}
|
||||
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
|
||||
mentionTimer.current = window.setTimeout(() => {
|
||||
setMentionLoading(true);
|
||||
api.searchUsers(mentionQuery, 8)
|
||||
.then((r) => {
|
||||
setMentionUsers(r.users || []);
|
||||
setMentionIndex(0);
|
||||
})
|
||||
.catch(() => setMentionUsers([]))
|
||||
.finally(() => setMentionLoading(false));
|
||||
}, 200);
|
||||
return () => {
|
||||
if (mentionTimer.current) window.clearTimeout(mentionTimer.current);
|
||||
};
|
||||
}, [mentionQuery]);
|
||||
|
||||
const insertAtCaret = (insert: string, replaceFrom?: number, replaceTo?: number) => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const start = el.selectionStart ?? content.length;
|
||||
const end = el.selectionEnd ?? content.length;
|
||||
const next = content.slice(0, start) + emoji + content.slice(end);
|
||||
if (!el) {
|
||||
setContent((prev) => prev + insert);
|
||||
return;
|
||||
}
|
||||
const start = replaceFrom ?? el.selectionStart ?? content.length;
|
||||
const end = replaceTo ?? el.selectionEnd ?? content.length;
|
||||
const next = content.slice(0, start) + insert + content.slice(end);
|
||||
setContent(next);
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = start + emoji.length;
|
||||
const pos = start + insert.length;
|
||||
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 = () => {
|
||||
@@ -96,6 +173,28 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (mentionQuery !== null && mentionUsers.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((i) => (i + 1) % mentionUsers.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((i) => (i - 1 + mentionUsers.length) % mentionUsers.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
pickMention(mentionUsers[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
@@ -120,6 +219,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
|
||||
const avatarInitial = user.nickname?.[0] || '?';
|
||||
const canSend = !!content.trim() && !submitting;
|
||||
const showMentionPopup = mentionQuery !== null && mentionQuery.length > 0;
|
||||
|
||||
return (
|
||||
<div className="comment-box" ref={boxRef}>
|
||||
@@ -144,13 +244,52 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
)}
|
||||
|
||||
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
|
||||
{showMentionPopup && (
|
||||
<div className="comment-mention-popup" role="listbox" aria-label="提及用户">
|
||||
{mentionLoading && mentionUsers.length === 0 ? (
|
||||
<div className="comment-mention-empty">搜索中…</div>
|
||||
) : mentionUsers.length === 0 ? (
|
||||
<div className="comment-mention-empty">没有匹配用户</div>
|
||||
) : (
|
||||
mentionUsers.map((u, i) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === mentionIndex}
|
||||
className={cn('comment-mention-item', i === mentionIndex && 'active')}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
pickMention(u);
|
||||
}}
|
||||
>
|
||||
<span className="comment-mention-avatar" aria-hidden>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" />
|
||||
: (u.nickname?.[0] || u.username[0] || '?')}
|
||||
</span>
|
||||
<span className="comment-mention-meta">
|
||||
<span className="comment-mention-nick">{u.nickname || u.username}</span>
|
||||
<span className="comment-mention-user">@{u.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="comment-box-textarea"
|
||||
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
|
||||
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧,可用 @ 提及用户'}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => scanMention(content, e.currentTarget.selectionStart ?? content.length)}
|
||||
onKeyUp={(e) => {
|
||||
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
|
||||
scanMention(content, e.currentTarget.selectionStart ?? content.length);
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { highlightMentions } from '../utils/content';
|
||||
import { userPath } from '../utils/userPath';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onMentionClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||
/** 渲染评论正文(支持正文内 @ 高亮与点击跳转) */
|
||||
export default function CommentContent({ content }: Props) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const openMention = async (name: string) => {
|
||||
try {
|
||||
const r = await api.searchUsers(name, 5);
|
||||
const exact = (r.users || []).find(
|
||||
(u) => u.username === name || u.nickname === name,
|
||||
);
|
||||
const user = exact || r.users?.[0];
|
||||
if (user) {
|
||||
nav(userPath(user.id));
|
||||
}
|
||||
} catch {
|
||||
// 未登录或失败时忽略
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="floor-body"
|
||||
onClick={(e) => {
|
||||
const el = (e.target as HTMLElement).closest('.mention') as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const name = el.getAttribute('data-name');
|
||||
if (!name) return;
|
||||
e.preventDefault();
|
||||
void openMention(name);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const el = e.target as HTMLElement;
|
||||
if (!el.classList.contains('mention')) return;
|
||||
const name = el.getAttribute('data-name');
|
||||
if (!name) return;
|
||||
e.preventDefault();
|
||||
void openMention(name);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
__html: highlightMentions(content),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}` : '/';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
|
||||
import { ListTree, MessageCircle, MessagesSquare, Tags, Sparkles } from 'lucide-react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatShortDateTime } from '../utils/content';
|
||||
import { formatShortDateTime, formatTime } from '../utils/content';
|
||||
import TagCloud from './TagCloud';
|
||||
import UserLink from './UserLink';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
@@ -31,20 +31,13 @@ interface Props {
|
||||
postDetail?: PostDetailAside | null;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
if (index === 0) return 'widget-rank widget-rank--1';
|
||||
if (index === 1) return 'widget-rank widget-rank--2';
|
||||
if (index === 2) return 'widget-rank widget-rank--3';
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
function HotSkeleton() {
|
||||
function ActiveSkeleton() {
|
||||
return (
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="正在聊加载中">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="widget-item widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-rank" />
|
||||
<div key={i} className="widget-item widget-item--active widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
|
||||
<Skeleton className="skeleton--widget-time" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -84,14 +77,15 @@ export default function RightPanel({
|
||||
const isSiteHome = loc.pathname === '/'
|
||||
&& !params.get('board')
|
||||
&& !params.get('keyword')
|
||||
&& !params.get('tag');
|
||||
&& !params.get('tag')
|
||||
&& !params.get('author');
|
||||
const description = branding.description?.trim() || '';
|
||||
const slogan = branding.slogan?.trim() || '';
|
||||
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
||||
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
|
||||
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
|
||||
const showHot = loading || hotList.length >= 4;
|
||||
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
|
||||
// 有近期讨论则展示「正在聊」;否则显示欢迎引导
|
||||
const showActive = loading || hotList.length > 0;
|
||||
const showWelcome = !loading && hotList.length === 0;
|
||||
const isPostDetail = !!postDetail;
|
||||
|
||||
return (
|
||||
@@ -137,28 +131,38 @@ export default function RightPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && showHot && (
|
||||
{!isPostDetail && showActive && (
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
<MessagesSquare className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
正在聊
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<HotSkeleton />
|
||||
<ActiveSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<div className="widget-empty">近 7 日暂无新回复</div>
|
||||
) : hotList.map((item) => {
|
||||
const replyLabel = item.last_reply_at
|
||||
? `${formatTime(item.last_reply_at)}有人回`
|
||||
: '近期有讨论';
|
||||
const count = item.comment_count ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
className="widget-item widget-item--active"
|
||||
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-meta">
|
||||
<span className="widget-item-time">{replyLabel}</span>
|
||||
{count > 0 && <span className="widget-item-count">{count} 评</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,12 +235,16 @@ 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;
|
||||
}
|
||||
if (kw) {
|
||||
const len = [...kw].length;
|
||||
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
|
||||
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
|
||||
@@ -241,9 +254,20 @@ export default function MainLayout() {
|
||||
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
|
||||
return;
|
||||
}
|
||||
const target = `/?keyword=${encodeURIComponent(kw)}`;
|
||||
// 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航
|
||||
if (active === kw && loc.pathname === '/') {
|
||||
}
|
||||
const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
|
||||
const target = buildHomeUrl(scopeBoard, 'latest', {
|
||||
keyword: kw,
|
||||
author,
|
||||
titleOnly: !!kw && searchTitleOnly,
|
||||
});
|
||||
const same =
|
||||
loc.pathname === '/'
|
||||
&& activeKw === kw
|
||||
&& activeAuthor === author
|
||||
&& activeTitleOnly === (!!kw && searchTitleOnly)
|
||||
&& activeBoard === scopeBoard;
|
||||
if (same) {
|
||||
navigateFeed(nav, target);
|
||||
return;
|
||||
}
|
||||
@@ -259,9 +283,10 @@ export default function MainLayout() {
|
||||
const isFeedHome = loc.pathname === '/';
|
||||
const outletKeyword = params.get('keyword') || '';
|
||||
const outletTag = params.get('tag') || '';
|
||||
const outletAuthor = params.get('author') || '';
|
||||
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
||||
const mobileActiveBoard =
|
||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag
|
||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor
|
||||
? -1
|
||||
: boardId;
|
||||
|
||||
@@ -346,7 +371,7 @@ export default function MainLayout() {
|
||||
|
||||
{!isCompose && (!isMobile || searchExpanded) && (
|
||||
<form
|
||||
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}`}
|
||||
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}${searchAdvanced ? ' header-search-wrap--advanced' : ''}`}
|
||||
role="search"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
@@ -354,6 +379,7 @@ export default function MainLayout() {
|
||||
if (isMobile) setSearchExpanded(false);
|
||||
}}
|
||||
>
|
||||
<div className="header-search-row">
|
||||
<Search className="header-search-icon" size={16} aria-hidden />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -366,11 +392,25 @@ export default function MainLayout() {
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
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
|
||||
type="button"
|
||||
className="header-search-clear"
|
||||
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }}
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setSearchAuthor('');
|
||||
setSearchTitleOnly(false);
|
||||
navigateFeed(nav, '/');
|
||||
}}
|
||||
aria-label="清除搜索"
|
||||
>×</button>
|
||||
)}
|
||||
@@ -383,6 +423,37 @@ export default function MainLayout() {
|
||||
取消
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
182
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
182
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import AuthPasswordInput from '@/components/AuthPasswordInput';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = (minLen: number, codeLen: number) => z.object({
|
||||
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
|
||||
email_code: z.string().regex(new RegExp(`^\\d{${codeLen}}$`), `请输入 ${codeLen} 位数字验证码`),
|
||||
new_password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('找回密码');
|
||||
const nav = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [mailReady, setMailReady] = useState<boolean | null>(null);
|
||||
const codeLen = 6;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema(limits.password_min_len, codeLen)),
|
||||
defaultValues: { email: '', email_code: '', new_password: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.registerConfig()
|
||||
.then((c) => setMailReady(!!c.mail_ready))
|
||||
.catch(() => setMailReady(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = window.setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
const sendCode = async () => {
|
||||
const email = form.getValues('email');
|
||||
const parsed = z.string().email().safeParse(email);
|
||||
if (!parsed.success) {
|
||||
form.setError('email', { message: '请先填写有效邮箱' });
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const r = await api.sendResetEmailCode(email);
|
||||
notify.success(r.message);
|
||||
setCountdown(60);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.resetPassword({
|
||||
email: values.email,
|
||||
emailCode: values.email_code,
|
||||
newPassword: values.new_password,
|
||||
});
|
||||
notify.success(r.message);
|
||||
nav(loginPath());
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '重置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
</Link>
|
||||
<h1>找回密码</h1>
|
||||
<p className="subtitle">
|
||||
{mailReady === false
|
||||
? '邮件服务未配置,请联系站长重置密码'
|
||||
: '通过注册邮箱验证码设置新密码'}
|
||||
</p>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>注册邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="you@example.com" autoComplete="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱验证码</FormLabel>
|
||||
<div className="auth-captcha-row">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${codeLen} 位数字`}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={codeLen}
|
||||
className="auth-email-code-input"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, '').slice(0, codeLen);
|
||||
field.onChange(digits);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="auth-code-btn"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0 || mailReady === false}
|
||||
onClick={() => void sendCode()}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="new_password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>新密码</FormLabel>
|
||||
<FormControl>
|
||||
<AuthPasswordInput placeholder="新密码" autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={loading} disabled={mailReady === false}>
|
||||
重置密码
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
想起密码了?<Link to={loginPath()}>返回登录</Link>
|
||||
</p>
|
||||
<Link to="/" className="auth-back">
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,14 +33,16 @@ export default function HomePage() {
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const tag = params.get('tag') || '';
|
||||
const author = params.get('author') || '';
|
||||
const titleOnly = params.get('title_only') === '1';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||
const isSiteHome = !boardId && !keyword && !tag;
|
||||
const isSiteHome = !boardId && !keyword && !tag && !author;
|
||||
const siteIntro = siteMetaDescription(branding);
|
||||
const feedTitle = tag
|
||||
? `标签:${tag}`
|
||||
: keyword
|
||||
? `搜索:${keyword}`
|
||||
: keyword || author
|
||||
? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
|
||||
: (boardId && board ? board.name : '');
|
||||
usePageSEO({
|
||||
title: feedTitle || undefined,
|
||||
@@ -67,7 +69,7 @@ export default function HomePage() {
|
||||
const pageRef = useRef(1);
|
||||
pageRef.current = page;
|
||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
||||
const feedSnapRef = useRef({ boardId, keyword, tag, sort, posts, postTotal, page });
|
||||
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
@@ -96,6 +98,8 @@ export default function HomePage() {
|
||||
board_id: boardId || '',
|
||||
keyword: tag ? '' : keyword,
|
||||
tag: tag || '',
|
||||
author: tag ? '' : author,
|
||||
title_only: !tag && titleOnly ? '1' : '',
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
@@ -114,7 +118,7 @@ export default function HomePage() {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, tag, sort, pageSize]);
|
||||
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
|
||||
|
||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||
|
||||
@@ -142,7 +146,7 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getFeedCache(boardId, keyword, sort, tag);
|
||||
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
|
||||
if (cached && cached.posts.length > 0) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
@@ -163,6 +167,8 @@ export default function HomePage() {
|
||||
boardId,
|
||||
keyword,
|
||||
tag,
|
||||
author,
|
||||
titleOnly,
|
||||
sort,
|
||||
location.key,
|
||||
location.state,
|
||||
@@ -175,15 +181,17 @@ export default function HomePage() {
|
||||
feedSnapRef.current.boardId === boardId
|
||||
&& feedSnapRef.current.keyword === keyword
|
||||
&& feedSnapRef.current.tag === tag
|
||||
&& feedSnapRef.current.author === author
|
||||
&& feedSnapRef.current.titleOnly === titleOnly
|
||||
&& feedSnapRef.current.sort === sort
|
||||
) {
|
||||
feedSnapRef.current = { boardId, keyword, tag, sort, posts, postTotal, page };
|
||||
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
|
||||
}
|
||||
|
||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||
useEffect(() => {
|
||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||
feedSnapRef.current = { boardId, keyword, tag, sort, posts: [], postTotal: 0, page: 1 };
|
||||
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current) return;
|
||||
const snap = feedSnapRef.current;
|
||||
@@ -193,9 +201,9 @@ export default function HomePage() {
|
||||
postTotal: snap.postTotal,
|
||||
page: snap.page,
|
||||
scrollTop: scrollTopRef.current,
|
||||
}, snap.tag);
|
||||
}, snap.tag, snap.author, snap.titleOnly);
|
||||
};
|
||||
}, [boardId, keyword, tag, sort]);
|
||||
}, [boardId, keyword, tag, author, titleOnly, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
@@ -222,10 +230,10 @@ export default function HomePage() {
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag }));
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly }));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword && !tag;
|
||||
const showSortBar = !keyword && !tag && !author;
|
||||
|
||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||
if ((loading || limitsLoading) && posts.length === 0) {
|
||||
@@ -241,6 +249,8 @@ export default function HomePage() {
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
tag={tag}
|
||||
author={author}
|
||||
titleOnly={titleOnly}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
@@ -266,7 +276,7 @@ export default function HomePage() {
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={keyword || tag}
|
||||
keyword={keyword || tag || author}
|
||||
boardId={boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||
|
||||
@@ -92,6 +92,8 @@ export default function LoginPage() {
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
<Link to="/forgot-password">忘记密码</Link>
|
||||
<span className="auth-footer-sep" aria-hidden>·</span>
|
||||
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||
</p>
|
||||
<Link to="/" className="auth-back">
|
||||
|
||||
@@ -20,6 +20,7 @@ type MsgTab = 'dm' | 'notify';
|
||||
const NOTIFY_KINDS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'reply', label: '回复' },
|
||||
{ key: 'mention', label: '@提及' },
|
||||
{ key: 'moderation', label: '待审' },
|
||||
{ key: 'reject', label: '拒帖' },
|
||||
{ key: 'report_result', label: '举报' },
|
||||
@@ -31,6 +32,7 @@ function kindLabel(kind: string) {
|
||||
case 'reject': return '拒帖通知';
|
||||
case 'report_result': return '举报结果';
|
||||
case 'reply': return '回复提醒';
|
||||
case 'mention': return '@提及';
|
||||
case 'moderation': return '待审提醒';
|
||||
case 'system': return '系统通知';
|
||||
default: return '通知';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,10 +9,13 @@ function escapeWithBreaks(text: string): string {
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
/** @用户名 高亮(data-name 供点击跳转用户主页) */
|
||||
export function highlightMentions(text: string): string {
|
||||
return escapeWithBreaks(text)
|
||||
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
|
||||
.replace(
|
||||
/@([\w\u4e00-\u9fa5_-]+)/g,
|
||||
'<span class="mention" data-name="$1" role="link" tabindex="0">@$1</span>',
|
||||
);
|
||||
}
|
||||
|
||||
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前;更早用具体日期 */
|
||||
|
||||
@@ -15,18 +15,40 @@ export type FeedCache = {
|
||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort, tag = '') {
|
||||
return `${boardId}:${keyword}:${tag}:${sort}`;
|
||||
function cacheKey(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
sort: FeedSort,
|
||||
tag = '',
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
) {
|
||||
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
|
||||
}
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort, tag = ''): FeedCache | null {
|
||||
return store.get(cacheKey(boardId, keyword, sort, tag)) ?? null;
|
||||
export function getFeedCache(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
sort: FeedSort,
|
||||
tag = '',
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
): FeedCache | null {
|
||||
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
|
||||
}
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache, tag = '') {
|
||||
store.set(cacheKey(boardId, keyword, sort, tag), data);
|
||||
export function setFeedCache(
|
||||
boardId: number,
|
||||
keyword: string,
|
||||
sort: FeedSort,
|
||||
data: FeedCache,
|
||||
tag = '',
|
||||
author = '',
|
||||
titleOnly = false,
|
||||
) {
|
||||
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
|
||||
}
|
||||
|
||||
/** 清除所有帖子列表缓存 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -199,6 +199,7 @@ const (
|
||||
MessageKindReject = "reject" // 帖子被拒/下架
|
||||
MessageKindReportResult = "report_result" // 举报处理结果
|
||||
MessageKindReply = "reply" // 帖子/评论被回复
|
||||
MessageKindMention = "mention" // 被 @提及
|
||||
MessageKindModeration = "moderation" // 新内容待审核(通知管理员)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
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()
|
||||
|
||||
@@ -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(`<!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 生成「收到新回复」提醒邮件
|
||||
// displayFloor 为页面可见顶层楼号;底部展示帖子主题,不展示路径 URL。
|
||||
func BuildReplyMail(siteName, authorName, postTitle string, displayFloor int, isNested bool, excerpt, link string) (subject, textBody, htmlBody string) {
|
||||
|
||||
65
service/mention.go
Normal file
65
service/mention.go
Normal 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
14
service/mention_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,8 +301,12 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
||||
}
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
if tag := strings.TrimSpace(q.Tag); tag != "" {
|
||||
// 整枚标签匹配:逗号/中文逗号分隔,忽略标签两侧空格,大小写不敏感
|
||||
escaped := escapeLikePattern(strings.ToLower(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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user