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

@@ -21,7 +21,6 @@ export default function FeedHeader({
keyword,
tag = '',
author = '',
titleOnly = false,
boards,
stats,
postTotal,
@@ -30,20 +29,16 @@ export default function FeedHeader({
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
const filtered = !!(keyword || tag || author);
const isSearch = !!(keyword || author);
const isTag = !!tag;
const filtered = isSearch || isTag;
const inBoard = !filtered && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
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;
let title = '';
if (isTag) title = `标签:${tag}`;
else if (isSearch) title = '搜索结果';
return (
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
<div className="feed-head__title">
@@ -77,19 +72,19 @@ export default function FeedHeader({
</span>
</div>
)}
{filtered && (
<span className="feed-head__meta feed-head__meta--count"> {postTotal} </span>
)}
</div>
{filtered && (
{isTag && (
<button
type="button"
className="feed-head__clear"
onClick={() => navigateFeed(nav, '/')}
>
{tag ? '清除标签' : '清除搜索'}
</button>
)}
{filtered && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -33,6 +34,13 @@ interface Props {
onScrollRestored?: () => void;
/** 搜索关键词(用于空态文案) */
keyword?: string;
/** 是否为帖子搜索(区别于标签筛选) */
isSearchMode?: boolean;
searchKeyword?: string;
searchAuthor?: string;
searchTitleOnly?: boolean;
searchScopeBoardId?: number;
onClearSearch?: () => void;
/** 当前板块 id0 表示全部 */
boardId?: number;
/** 当前板块名 */
@@ -62,6 +70,12 @@ export default function VirtualPostList({
onScrollTopChange,
onScrollRestored,
keyword = '',
isSearchMode = false,
searchKeyword = '',
searchAuthor = '',
searchTitleOnly = false,
searchScopeBoardId = 0,
onClearSearch,
boardId = 0,
boardName = '',
noBoards = false,
@@ -132,7 +146,7 @@ export default function VirtualPostList({
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && !!keyword.trim();
const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim());
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
@@ -173,6 +187,12 @@ export default function VirtualPostList({
return () => el.removeEventListener('scroll', onScroll);
}, [getScrollElement, isMobile]);
const searchSummaryParts: string[] = [];
if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}`);
if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`);
if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题');
if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`);
const emptyActions = (
<div className="empty-feed-actions">
{noBoards ? (
@@ -187,12 +207,15 @@ export default function VirtualPostList({
)
) : isSearchEmpty ? (
<>
<Button type="button" size="sm" variant="outline" onClick={dispatchOpenPostSearch}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => (onClearSearch ? onClearSearch() : nav('/'))}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
{user ? '发帖' : '登录后发帖'}
</Button>
</>
) : (
<>
@@ -235,7 +258,9 @@ export default function VirtualPostList({
{noBoards
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
: isSearchEmpty
? '试试更短的关键词,或浏览标签云 / 板块'
? (searchSummaryParts.length > 0
? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
: '试试更短的关键词,或浏览标签云 / 板块')
: boardName
? `${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}

View File

@@ -0,0 +1,87 @@
import { X } from 'lucide-react';
import type { Board } from '../../api/types';
import type { PostSearchState, SearchFilterKey } from '../../hooks/usePostSearch';
import { dispatchOpenPostSearch } from '../../hooks/usePostSearch';
interface Props {
filters: PostSearchState;
boards: Board[];
onRemove: (key: SearchFilterKey) => void;
onClear: () => void;
}
export default function FeedSearchFilters({ filters, boards, onRemove, onClear }: Props) {
const { keyword, author, titleOnly, scopeBoardId } = filters;
if (!keyword && !author) return null;
const boardName = scopeBoardId > 0
? boards.find((b) => b.id === scopeBoardId)?.name || '当前板块'
: '';
return (
<div className="search-filter-bar">
<div className="search-filter-bar__chips" role="list">
{keyword && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text">{keyword}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除关键词 ${keyword}`}
onClick={() => onRemove('keyword')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{author && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text"> {author}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除作者 ${author}`}
onClick={() => onRemove('author')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{titleOnly && keyword && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text"></span>
<button
type="button"
className="search-filter-chip__remove"
aria-label="取消仅标题"
onClick={() => onRemove('titleOnly')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{scopeBoardId > 0 && boardName && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text">{boardName}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除板块 ${boardName}`}
onClick={() => onRemove('board')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
</div>
<div className="search-filter-bar__actions">
<button type="button" className="search-filter-bar__link" onClick={dispatchOpenPostSearch}>
</button>
<button type="button" className="search-filter-bar__link search-filter-bar__link--muted" onClick={onClear}>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, User } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { api } from '../../api/client';
import { useMediaQuery } from '../../hooks/useTheme';
import { useForumLimits } from '../../hooks/useForumLimits';
import {
getRecentSearches,
type PostSearchSubmitInput,
type RecentSearch,
} from '../../hooks/usePostSearch';
import { cn } from '@/lib/utils';
export interface PostSearchDraft {
keyword: string;
author: string;
titleOnly: boolean;
scopeBoardId: number;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
draft: PostSearchDraft;
contextBoardId: number;
contextBoardName?: string;
onSubmit: (input: PostSearchSubmitInput) => boolean;
onClear: () => void;
}
type UserSuggest = { id: number; username: string; nickname: string; avatar?: string };
export default function PostSearchPanel({
open,
onOpenChange,
draft,
contextBoardId,
contextBoardName = '',
onSubmit,
onClear,
}: Props) {
const isMobile = useMediaQuery('(max-width: 768px)');
const { limits } = useForumLimits();
const keywordRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState(draft.keyword);
const [author, setAuthor] = useState(draft.author);
const [titleOnly, setTitleOnly] = useState(draft.titleOnly);
const [scopeBoardId, setScopeBoardId] = useState(draft.scopeBoardId);
const [recent, setRecent] = useState<RecentSearch[]>([]);
const [suggestions, setSuggestions] = useState<UserSuggest[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false);
const authorWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
setKeyword(draft.keyword);
setAuthor(draft.author);
setTitleOnly(draft.titleOnly);
setScopeBoardId(draft.scopeBoardId);
setRecent(getRecentSearches());
setSuggestions([]);
setSuggestOpen(false);
const t = window.setTimeout(() => keywordRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, [open, draft]);
useEffect(() => {
if (!open) return;
const q = author.trim();
if (q.length < 1) {
setSuggestions([]);
setSuggestOpen(false);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
api.searchUsers(q, 8).then((r) => {
if (cancelled) return;
const users = Array.isArray(r.users) ? r.users : [];
setSuggestions(users);
setSuggestOpen(users.length > 0);
}).catch(() => {
if (!cancelled) {
setSuggestions([]);
setSuggestOpen(false);
}
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [author, open]);
useEffect(() => {
if (!suggestOpen) return;
const onDoc = (e: MouseEvent) => {
if (authorWrapRef.current?.contains(e.target as Node)) return;
setSuggestOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [suggestOpen]);
const handleSubmit = useCallback(() => {
const ok = onSubmit({
keyword,
author,
titleOnly: !!keyword.trim() && titleOnly,
scopeBoardId: scopeBoardId > 0 ? scopeBoardId : 0,
});
if (ok) onOpenChange(false);
}, [keyword, author, titleOnly, scopeBoardId, onSubmit, onOpenChange]);
const applyRecent = (item: RecentSearch) => {
setKeyword(item.keyword);
setAuthor(item.author);
setTitleOnly(item.titleOnly);
setScopeBoardId(item.scopeBoardId);
};
const pickAuthor = (u: UserSuggest) => {
setAuthor(u.nickname || u.username);
setSuggestOpen(false);
};
const showBoardScope = contextBoardId > 0;
const kwHint = limits.search_keyword_min > 1
? `关键词 ${limits.search_keyword_min}${limits.search_keyword_max}`
: `关键词最多 ${limits.search_keyword_max}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'post-search-panel',
isMobile && 'post-search-panel--mobile',
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className="post-search-panel__header">
<DialogTitle></DialogTitle>
<DialogDescription className="post-search-panel__desc">
</DialogDescription>
</DialogHeader>
<div className="post-search-panel__body">
<label className="post-search-field">
<span className="post-search-field__label"></span>
<div className="post-search-field__input-wrap">
<Search size={16} aria-hidden className="post-search-field__icon" />
<input
ref={keywordRef}
type="search"
className="post-search-field__input"
placeholder="输入标题或正文关键词…"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={limits.search_keyword_max > 0 ? limits.search_keyword_max : undefined}
enterKeyHint="search"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<span className="post-search-field__hint">{kwHint}</span>
</label>
<label className="post-search-field">
<span className="post-search-field__label"></span>
<div className="post-search-field__input-wrap" ref={authorWrapRef}>
<User size={16} aria-hidden className="post-search-field__icon" />
<input
type="text"
className="post-search-field__input"
placeholder="用户名或昵称"
value={author}
onChange={(e) => setAuthor(e.target.value)}
onFocus={() => suggestions.length > 0 && setSuggestOpen(true)}
autoComplete="off"
/>
{suggestOpen && suggestions.length > 0 && (
<ul className="search-author-suggest" role="listbox">
{suggestions.map((u) => (
<li key={u.id}>
<button
type="button"
className="search-author-suggest__item"
role="option"
onClick={() => pickAuthor(u)}
>
{u.avatar
? <img src={u.avatar} alt="" className="search-author-suggest__avatar" loading="lazy" />
: <span className="search-author-suggest__avatar search-author-suggest__avatar--ph">{(u.nickname || u.username).charAt(0)}</span>}
<span className="search-author-suggest__name">{u.nickname || u.username}</span>
{u.nickname && u.username !== u.nickname && (
<span className="search-author-suggest__user">@{u.username}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</label>
{showBoardScope && (
<div className="post-search-field">
<span className="post-search-field__label"></span>
<div className="search-scope-pills" role="group" aria-label="搜索范围">
<button
type="button"
className={cn('search-scope-pill', scopeBoardId === 0 && 'active')}
aria-pressed={scopeBoardId === 0}
onClick={() => setScopeBoardId(0)}
>
</button>
<button
type="button"
className={cn('search-scope-pill', scopeBoardId === contextBoardId && 'active')}
aria-pressed={scopeBoardId === contextBoardId}
onClick={() => setScopeBoardId(contextBoardId)}
>
{contextBoardName || '当前板块'}
</button>
</div>
</div>
)}
<div className="post-search-field post-search-field--row">
<div>
<span className="post-search-field__label"></span>
<span className="post-search-field__hint"></span>
</div>
<Switch
checked={titleOnly}
onCheckedChange={setTitleOnly}
disabled={!keyword.trim()}
aria-label="仅搜索标题"
/>
</div>
{recent.length > 0 && (
<div className="post-search-recent">
<span className="post-search-field__label"></span>
<ul className="post-search-recent__list">
{recent.map((item) => {
const label = [
item.keyword && `${item.keyword}`,
item.author && `@${item.author}`,
item.titleOnly && '仅标题',
item.scopeBoardId > 0 && '本板块',
].filter(Boolean).join(' · ') || '搜索';
return (
<li key={`${item.keyword}-${item.author}-${item.at}`}>
<button
type="button"
className="post-search-recent__item"
onClick={() => applyRecent(item)}
>
{label}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
<div className="post-search-panel__footer">
<Button
type="button"
variant="outline"
onClick={() => {
setKeyword('');
setAuthor('');
setTitleOnly(false);
setScopeBoardId(0);
onClear();
}}
>
</Button>
<Button type="button" onClick={handleSubmit}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}