增加 @提及、搜索筛选与找回密码,并将右栏热门改为正在聊。
补齐评论提及补全与通知、按作者/板块/仅标题搜索,以及邮箱验证码重置密码;同时修复 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 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<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) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
<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 widget-item--active"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
title={item.title}
|
||||
>
|
||||
<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,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) && (
|
||||
<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,34 +379,80 @@ export default function MainLayout() {
|
||||
if (isMobile) setSearchExpanded(false);
|
||||
}}
|
||||
>
|
||||
<Search className="header-search-icon" size={16} aria-hidden />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="header-search-input"
|
||||
type="search"
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
{keyword && (
|
||||
<div className="header-search-row">
|
||||
<Search className="header-search-icon" size={16} aria-hidden />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="header-search-input"
|
||||
type="search"
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-clear"
|
||||
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }}
|
||||
aria-label="清除搜索"
|
||||
>×</button>
|
||||
)}
|
||||
{isMobile && searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-cancel"
|
||||
onClick={() => setSearchExpanded(false)}
|
||||
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('');
|
||||
setSearchAuthor('');
|
||||
setSearchTitleOnly(false);
|
||||
navigateFeed(nav, '/');
|
||||
}}
|
||||
aria-label="清除搜索"
|
||||
>×</button>
|
||||
)}
|
||||
{isMobile && searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-cancel"
|
||||
onClick={() => setSearchExpanded(false)}
|
||||
>
|
||||
取消
|
||||
</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);
|
||||
}
|
||||
|
||||
/** 清除所有帖子列表缓存 */
|
||||
|
||||
Reference in New Issue
Block a user