feat: 重设计搜索帖子 UI/UX,新增筛选面板与结果页 chips
将高级筛选移入独立面板,统一 URL 状态与清除逻辑,并优化移动端搜索与空结果反馈。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/** 当前板块 id,0 表示全部 */
|
||||
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}」还没有内容,来发第一篇吧`
|
||||
: '换个板块看看,或发第一篇内容'}
|
||||
|
||||
87
frontend/src/components/search/FeedSearchFilters.tsx
Normal file
87
frontend/src/components/search/FeedSearchFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/search/PostSearchPanel.tsx
Normal file
306
frontend/src/components/search/PostSearchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user