From 94f6a9666b1427d4f86fe26c57736678be72c5b9 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 7 Aug 2026 07:39:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20@=E6=8F=90=E5=8F=8A?= =?UTF-8?q?=E3=80=81=E6=90=9C=E7=B4=A2=E7=AD=9B=E9=80=89=E4=B8=8E=E6=89=BE?= =?UTF-8?q?=E5=9B=9E=E5=AF=86=E7=A0=81=EF=BC=8C=E5=B9=B6=E5=B0=86=E5=8F=B3?= =?UTF-8?q?=E6=A0=8F=E7=83=AD=E9=97=A8=E6=94=B9=E4=B8=BA=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E8=81=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 Go 提及正则并优化侧栏悬停样式。 Co-authored-by: Cursor --- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 20 ++ frontend/src/components/CommentBox.tsx | 175 ++++++++++++++-- frontend/src/components/CommentContent.tsx | 44 +++- frontend/src/components/FeedHeader.tsx | 26 ++- frontend/src/components/FeedSortBar.tsx | 11 +- frontend/src/components/RightPanel.tsx | 70 ++++--- frontend/src/layouts/MainLayout.tsx | 155 ++++++++++---- frontend/src/pages/ForgotPasswordPage.tsx | 182 ++++++++++++++++ frontend/src/pages/HomePage.tsx | 36 ++-- frontend/src/pages/LoginPage.tsx | 2 + frontend/src/pages/MessagesPage.tsx | 2 + frontend/src/styles/global.css | 229 ++++++++++++++++++++- frontend/src/utils/content.ts | 9 +- frontend/src/utils/feedCache.ts | 34 ++- handler/api.go | 7 +- handler/handlers.go | 72 +++++++ model/models.go | 1 + router/router.go | 4 + service/email_code.go | 68 ++++-- service/mail_template.go | 83 ++++++++ service/mention.go | 65 ++++++ service/mention_test.go | 14 ++ service/notify.go | 49 +++++ service/post.go | 57 ++++- service/user.go | 51 +++++ 26 files changed, 1315 insertions(+), 153 deletions(-) create mode 100644 frontend/src/pages/ForgotPasswordPage.tsx create mode 100644 service/mention.go create mode 100644 service/mention_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4236363..5877de9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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( }> }>} /> }>} /> + }>} /> } /> }> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 31eefa5..768f434 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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' }), diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index 5fc7dbc..7e436ad 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -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(null); + const [mentionStart, setMentionStart] = useState(-1); + const [mentionUsers, setMentionUsers] = useState([]); + const [mentionIndex, setMentionIndex] = useState(0); + const [mentionLoading, setMentionLoading] = useState(false); const textareaRef = useRef(null); const boxRef = useRef(null); const owoRef = useRef(null); + const mentionTimer = useRef(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 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); - setContent(next); - requestAnimationFrame(() => { - el.focus(); - const pos = start + emoji.length; - el.setSelectionRange(pos, pos); - }); - } else { - setContent((prev) => prev + emoji); + 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) { + 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 + insert.length; + el.setSelectionRange(pos, pos); + }); + }; + + 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) => { + 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 (
@@ -144,13 +244,52 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo )}
+ {showMentionPopup && ( +
+ {mentionLoading && mentionUsers.length === 0 ? ( +
搜索中…
+ ) : mentionUsers.length === 0 ? ( +
没有匹配用户
+ ) : ( + mentionUsers.map((u, i) => ( + + )) + )} +
+ )}