feat: 重设计搜索帖子 UI/UX,新增筛选面板与结果页 chips

将高级筛选移入独立面板,统一 URL 状态与清除逻辑,并优化移动端搜索与空结果反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 19:21:55 +08:00
parent c6df703b96
commit 55b1831da2
8 changed files with 1190 additions and 214 deletions

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail } from 'lucide-react';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,7 +24,11 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
usePostSearch,
} from '../hooks/usePostSearch';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
@@ -63,7 +67,7 @@ export default function MainLayout() {
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
@@ -73,13 +77,10 @@ export default function MainLayout() {
if (m) return parsePermalinkID(m[1]) || 0;
return 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 [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled);
@@ -118,15 +119,11 @@ export default function MainLayout() {
setBoardId(Number(params.get('board')) || 0);
}, [loc.pathname, 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')));
setKeywordDraft(params.get('keyword') || '');
}, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
setSearchExpanded(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
@@ -135,16 +132,35 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!isMobile) {
setSidebarOpen(false);
setSearchExpanded(false);
}
if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []);
useEffect(() => {
if (!searchExpanded) return;
const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [searchExpanded]);
const onOpen = () => setSearchPanelOpen(true);
window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
}, []);
useEffect(() => {
if (isCompose) return;
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
const tag = (e.target as HTMLElement | null)?.tagName;
const editable = (e.target as HTMLElement | null)?.isContentEditable;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
e.preventDefault();
if (isMobile) {
setSearchPanelOpen(true);
} else {
searchInputRef.current?.focus();
searchInputRef.current?.select();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isCompose, isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
@@ -268,46 +284,28 @@ export default function MainLayout() {
};
}, [needRecentUsers]);
const doSearch = () => {
const kw = keyword.trim();
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 = boardId;
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} 个字`);
return;
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
}
const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
const target = buildHomeUrl(scopeBoard, 'latest', {
keyword: kw,
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
postSearch.submitSearch({
keyword: keywordDraft,
author,
titleOnly: !!kw && searchTitleOnly,
permalink: forumLimits,
});
const same =
(loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname))
&& activeKw === kw
&& activeAuthor === author
&& activeTitleOnly === (!!kw && searchTitleOnly)
&& activeBoard === scopeBoard;
if (same) {
navigateFeed(nav, target);
return;
}
nav(target);
titleOnly,
scopeBoardId,
}, { refreshIfSame: true });
};
const handleHeaderClear = () => {
const hasUrlSearch = postSearch.filters.isFiltered;
setKeywordDraft('');
if (hasUrlSearch) postSearch.clearSearch();
};
const contextBoard = boards.find((b) => b.id === boardId);
const searchPanelDraft = {
keyword: keywordDraft,
author: postSearch.filters.author,
titleOnly: postSearch.filters.titleOnly,
scopeBoardId: postSearch.filters.scopeBoardId,
};
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
@@ -371,9 +369,9 @@ export default function MainLayout() {
return (
<div className="app-shell">
<div className="app-frame">
<header className={`app-header${searchExpanded && isMobile ? ' app-header--search-open' : ''}`}>
<header className="app-header">
<div className="header-inner">
{isMobile && !isCompose && !searchExpanded && (
{isMobile && !isCompose && (
<button
type="button"
className="header-icon-btn"
@@ -386,18 +384,16 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
{!(isMobile && searchExpanded) && (
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
)}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
{!isCompose && isMobile && !searchExpanded && (
{!isCompose && isMobile && (
<button
type="button"
className="header-icon-btn header-search-toggle"
onClick={() => setSearchExpanded(true)}
onClick={openSearchPanel}
aria-label="搜索帖子"
title="搜索"
>
@@ -405,14 +401,13 @@ export default function MainLayout() {
</button>
)}
{!isCompose && (!isMobile || searchExpanded) && (
{!isCompose && !isMobile && (
<form
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}${searchAdvanced ? ' header-search-wrap--advanced' : ''}`}
className="header-search-wrap"
role="search"
onSubmit={e => {
e.preventDefault();
doSearch();
if (isMobile) setSearchExpanded(false);
doQuickSearch();
}}
>
<div className="header-search-row">
@@ -421,79 +416,41 @@ export default function MainLayout() {
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子..."
placeholder="搜索帖子"
aria-label="搜索帖子"
value={keyword}
onChange={e => setKeyword(e.target.value)}
value={keywordDraft}
onChange={e => setKeywordDraft(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search"
/>
<button
type="button"
className={cn('header-search-adv-toggle', searchAdvanced && 'active')}
onClick={() => setSearchAdvanced((v) => !v)}
aria-expanded={searchAdvanced}
title="高级搜索"
className={cn(
'header-search-filter-btn',
postSearch.filters.hasAdvancedFilters && 'header-search-filter-btn--active',
)}
onClick={openSearchPanel}
aria-label="搜索筛选"
title="筛选"
>
<SlidersHorizontal size={15} aria-hidden />
{postSearch.filters.hasAdvancedFilters && (
<span className="header-search-filter-btn__dot" aria-hidden />
)}
</button>
{(keyword || searchAuthor) && (
{(keywordDraft || postSearch.filters.isFiltered) && (
<button
type="button"
className="header-search-clear"
onClick={() => {
setKeyword('');
setSearchAuthor('');
setSearchTitleOnly(false);
navigateFeed(nav, '/');
}}
onClick={handleHeaderClear}
aria-label="清除搜索"
>×</button>
)}
{isMobile && searchExpanded && (
<button
type="button"
className="header-search-cancel"
onClick={() => setSearchExpanded(false)}
>
</button>
)}
<kbd className="header-search-kbd" aria-hidden>Ctrl K</kbd>
</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>
)}
{!(isMobile && searchExpanded) && (
<div className="header-actions">
{!isCompose && (
<button
@@ -597,10 +554,25 @@ export default function MainLayout() {
)}
</div>
</div>
)}
</div>
</header>
{!isCompose && (
<PostSearchPanel
open={searchPanelOpen}
onOpenChange={setSearchPanelOpen}
draft={searchPanelDraft}
contextBoardId={boardId}
contextBoardName={contextBoard?.name}
onSubmit={(input) => {
const ok = postSearch.submitSearch(input);
if (ok) setKeywordDraft((input.keyword ?? '').trim());
return ok;
}}
onClear={postSearch.clearSearch}
/>
)}
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
<Sidebar