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,
|
keyword,
|
||||||
tag = '',
|
tag = '',
|
||||||
author = '',
|
author = '',
|
||||||
titleOnly = false,
|
|
||||||
boards,
|
boards,
|
||||||
stats,
|
stats,
|
||||||
postTotal,
|
postTotal,
|
||||||
@@ -30,20 +29,16 @@ export default function FeedHeader({
|
|||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const board = boards.find(b => b.id === boardId);
|
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;
|
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;
|
const TitleTag = titleAs;
|
||||||
|
|
||||||
|
let title = '';
|
||||||
|
if (isTag) title = `标签:${tag}`;
|
||||||
|
else if (isSearch) title = '搜索结果';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
||||||
<div className="feed-head__title">
|
<div className="feed-head__title">
|
||||||
@@ -77,19 +72,19 @@ export default function FeedHeader({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{filtered && (
|
||||||
|
<span className="feed-head__meta feed-head__meta--count">共 {postTotal} 条</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{filtered && (
|
{isTag && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="feed-head__clear"
|
className="feed-head__clear"
|
||||||
onClick={() => navigateFeed(nav, '/')}
|
onClick={() => navigateFeed(nav, '/')}
|
||||||
>
|
>
|
||||||
{tag ? '清除标签' : '清除搜索'}
|
清除标签
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{filtered && (
|
|
||||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { useMediaQuery } from '../hooks/useTheme';
|
import { useMediaQuery } from '../hooks/useTheme';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { FeedSort } from './FeedSortBar';
|
import type { FeedSort } from './FeedSortBar';
|
||||||
|
|
||||||
@@ -33,6 +34,13 @@ interface Props {
|
|||||||
onScrollRestored?: () => void;
|
onScrollRestored?: () => void;
|
||||||
/** 搜索关键词(用于空态文案) */
|
/** 搜索关键词(用于空态文案) */
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
/** 是否为帖子搜索(区别于标签筛选) */
|
||||||
|
isSearchMode?: boolean;
|
||||||
|
searchKeyword?: string;
|
||||||
|
searchAuthor?: string;
|
||||||
|
searchTitleOnly?: boolean;
|
||||||
|
searchScopeBoardId?: number;
|
||||||
|
onClearSearch?: () => void;
|
||||||
/** 当前板块 id,0 表示全部 */
|
/** 当前板块 id,0 表示全部 */
|
||||||
boardId?: number;
|
boardId?: number;
|
||||||
/** 当前板块名 */
|
/** 当前板块名 */
|
||||||
@@ -62,6 +70,12 @@ export default function VirtualPostList({
|
|||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
onScrollRestored,
|
onScrollRestored,
|
||||||
keyword = '',
|
keyword = '',
|
||||||
|
isSearchMode = false,
|
||||||
|
searchKeyword = '',
|
||||||
|
searchAuthor = '',
|
||||||
|
searchTitleOnly = false,
|
||||||
|
searchScopeBoardId = 0,
|
||||||
|
onClearSearch,
|
||||||
boardId = 0,
|
boardId = 0,
|
||||||
boardName = '',
|
boardName = '',
|
||||||
noBoards = false,
|
noBoards = false,
|
||||||
@@ -132,7 +146,7 @@ export default function VirtualPostList({
|
|||||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||||
const isInitialLoad = loading && posts.length === 0;
|
const isInitialLoad = loading && posts.length === 0;
|
||||||
const isEmpty = !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 composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
@@ -173,6 +187,12 @@ export default function VirtualPostList({
|
|||||||
return () => el.removeEventListener('scroll', onScroll);
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
}, [getScrollElement, isMobile]);
|
}, [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 = (
|
const emptyActions = (
|
||||||
<div className="empty-feed-actions">
|
<div className="empty-feed-actions">
|
||||||
{noBoards ? (
|
{noBoards ? (
|
||||||
@@ -187,12 +207,15 @@ export default function VirtualPostList({
|
|||||||
)
|
)
|
||||||
) : isSearchEmpty ? (
|
) : 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 type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
||||||
返回全部帖子
|
返回全部帖子
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
|
|
||||||
{user ? '发帖' : '登录后发帖'}
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -235,7 +258,9 @@ export default function VirtualPostList({
|
|||||||
{noBoards
|
{noBoards
|
||||||
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
|
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
|
||||||
: isSearchEmpty
|
: isSearchEmpty
|
||||||
? '试试更短的关键词,或浏览标签云 / 板块'
|
? (searchSummaryParts.length > 0
|
||||||
|
? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
|
||||||
|
: '试试更短的关键词,或浏览标签云 / 板块')
|
||||||
: boardName
|
: boardName
|
||||||
? `「${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
frontend/src/hooks/usePostSearch.ts
Normal file
270
frontend/src/hooks/usePostSearch.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { buildHomeUrl } from '../components/FeedSortBar';
|
||||||
|
import { navigateFeed } from '../utils/feedCache';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
|
||||||
|
|
||||||
|
/** 打开搜索面板(全局事件) */
|
||||||
|
export const POST_SEARCH_OPEN_EVENT = 'post-search-open';
|
||||||
|
|
||||||
|
export type SearchFilterKey = 'keyword' | 'author' | 'titleOnly' | 'board';
|
||||||
|
|
||||||
|
export interface PostSearchSubmitInput {
|
||||||
|
keyword?: string;
|
||||||
|
author?: string;
|
||||||
|
titleOnly?: boolean;
|
||||||
|
/** 0 = 全站,>0 = 限定板块 */
|
||||||
|
scopeBoardId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostSearchState {
|
||||||
|
keyword: string;
|
||||||
|
author: string;
|
||||||
|
titleOnly: boolean;
|
||||||
|
scopeBoardId: number;
|
||||||
|
contextBoardId: number;
|
||||||
|
isFiltered: boolean;
|
||||||
|
hasAdvancedFilters: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentSearch {
|
||||||
|
keyword: string;
|
||||||
|
author: string;
|
||||||
|
titleOnly: boolean;
|
||||||
|
scopeBoardId: number;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_KEY = 'jiang13-recent-searches';
|
||||||
|
const RECENT_MAX = 5;
|
||||||
|
|
||||||
|
export function parseBoardIdFromLocation(pathname: string, params: URLSearchParams): number {
|
||||||
|
const m = pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
|
||||||
|
if (m) return parsePermalinkID(m[1]) || 0;
|
||||||
|
const q = Number(params.get('board')) || 0;
|
||||||
|
return q > 0 ? q : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchFromUrl(
|
||||||
|
pathname: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): Pick<PostSearchState, 'keyword' | 'author' | 'titleOnly' | 'scopeBoardId'> {
|
||||||
|
const keyword = params.get('keyword') || '';
|
||||||
|
const author = params.get('author') || '';
|
||||||
|
const titleOnly = params.get('title_only') === '1';
|
||||||
|
const pathBoardId = parseBoardIdFromLocation(pathname, params);
|
||||||
|
const scopeBoardId = (keyword || author) && pathBoardId > 0 ? pathBoardId : 0;
|
||||||
|
return { keyword, author, titleOnly, scopeBoardId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecentSearches(): RecentSearch[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as RecentSearch[];
|
||||||
|
return Array.isArray(parsed) ? parsed.slice(0, RECENT_MAX) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRecentSearches(items: RecentSearch[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, RECENT_MAX)));
|
||||||
|
} catch {
|
||||||
|
/* 忽略存储失败 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRecentSearch(entry: Omit<RecentSearch, 'at'>) {
|
||||||
|
const kw = entry.keyword.trim();
|
||||||
|
const author = entry.author.trim();
|
||||||
|
if (!kw && !author) return;
|
||||||
|
const next: RecentSearch = { ...entry, keyword: kw, author, at: Date.now() };
|
||||||
|
const prev = readRecentSearches().filter(
|
||||||
|
(r) => !(r.keyword === next.keyword && r.author === next.author
|
||||||
|
&& r.titleOnly === next.titleOnly && r.scopeBoardId === next.scopeBoardId),
|
||||||
|
);
|
||||||
|
writeRecentSearches([next, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentSearches(): RecentSearch[] {
|
||||||
|
return readRecentSearches();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKeyword(kw: string, limits: PermalinkOpts & { search_keyword_min: number; search_keyword_max: number }): boolean {
|
||||||
|
if (!kw) return true;
|
||||||
|
const len = [...kw].length;
|
||||||
|
if (limits.search_keyword_min > 0 && len < limits.search_keyword_min) {
|
||||||
|
notify.warning(`搜索关键词至少 ${limits.search_keyword_min} 个字`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (limits.search_keyword_max > 0 && len > limits.search_keyword_max) {
|
||||||
|
notify.warning(`搜索关键词最多 ${limits.search_keyword_max} 个字`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameSearchTarget(
|
||||||
|
pathname: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
target: string,
|
||||||
|
input: Required<Pick<PostSearchSubmitInput, 'keyword' | 'author' | 'titleOnly' | 'scopeBoardId'>>,
|
||||||
|
): boolean {
|
||||||
|
const active = parseSearchFromUrl(pathname, params);
|
||||||
|
const activeBoard = parseBoardIdFromLocation(pathname, params);
|
||||||
|
const onFeed = pathname === '/' || /^\/board\/\d+/.test(pathname);
|
||||||
|
return onFeed
|
||||||
|
&& active.keyword === input.keyword
|
||||||
|
&& active.author === input.author
|
||||||
|
&& active.titleOnly === input.titleOnly
|
||||||
|
&& (input.scopeBoardId > 0 ? activeBoard === input.scopeBoardId : active.scopeBoardId === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePostSearch(
|
||||||
|
limits: PermalinkOpts & { search_keyword_min: number; search_keyword_max: number },
|
||||||
|
) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const loc = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
|
||||||
|
const contextBoardId = useMemo(
|
||||||
|
() => parseBoardIdFromLocation(loc.pathname, params),
|
||||||
|
[loc.pathname, params],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filters = useMemo((): PostSearchState => {
|
||||||
|
const parsed = parseSearchFromUrl(loc.pathname, params);
|
||||||
|
const isFiltered = !!(parsed.keyword || parsed.author);
|
||||||
|
const hasAdvancedFilters = !!(
|
||||||
|
parsed.author
|
||||||
|
|| (parsed.titleOnly && parsed.keyword)
|
||||||
|
|| parsed.scopeBoardId > 0
|
||||||
|
);
|
||||||
|
return { ...parsed, contextBoardId, isFiltered, hasAdvancedFilters };
|
||||||
|
}, [loc.pathname, params, contextBoardId]);
|
||||||
|
|
||||||
|
const buildUrl = useCallback((input: PostSearchSubmitInput) => {
|
||||||
|
const kw = (input.keyword ?? '').trim();
|
||||||
|
const author = (input.author ?? '').trim();
|
||||||
|
const titleOnly = !!kw && (input.titleOnly ?? false);
|
||||||
|
const scopeBoard = input.scopeBoardId ?? 0;
|
||||||
|
return buildHomeUrl(scopeBoard, 'latest', {
|
||||||
|
keyword: kw,
|
||||||
|
author,
|
||||||
|
titleOnly,
|
||||||
|
permalink: limits,
|
||||||
|
});
|
||||||
|
}, [limits]);
|
||||||
|
|
||||||
|
const submitSearch = useCallback((
|
||||||
|
input: PostSearchSubmitInput,
|
||||||
|
opts?: { refreshIfSame?: boolean },
|
||||||
|
) => {
|
||||||
|
const kw = (input.keyword ?? '').trim();
|
||||||
|
const author = (input.author ?? '').trim();
|
||||||
|
const activeKw = (params.get('keyword') || '').trim();
|
||||||
|
const activeAuthor = (params.get('author') || '').trim();
|
||||||
|
|
||||||
|
if (!kw && !author) {
|
||||||
|
if (activeKw || activeAuthor) navigateFeed(nav, '/');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!validateKeyword(kw, limits)) return false;
|
||||||
|
|
||||||
|
const titleOnly = !!kw && (input.titleOnly ?? false);
|
||||||
|
const scopeBoardId = input.scopeBoardId ?? 0;
|
||||||
|
const target = buildUrl({ keyword: kw, author, titleOnly, scopeBoardId });
|
||||||
|
const normalized = {
|
||||||
|
keyword: kw,
|
||||||
|
author,
|
||||||
|
titleOnly,
|
||||||
|
scopeBoardId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSameSearchTarget(loc.pathname, params, target, normalized)) {
|
||||||
|
if (opts?.refreshIfSame) navigateFeed(nav, target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
|
||||||
|
nav(target);
|
||||||
|
return true;
|
||||||
|
}, [nav, params, loc.pathname, limits, buildUrl]);
|
||||||
|
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
navigateFeed(nav, '/');
|
||||||
|
}, [nav]);
|
||||||
|
|
||||||
|
const removeFilter = useCallback((key: SearchFilterKey) => {
|
||||||
|
const current = parseSearchFromUrl(loc.pathname, params);
|
||||||
|
if (!current.keyword && !current.author) return;
|
||||||
|
|
||||||
|
let next: PostSearchSubmitInput;
|
||||||
|
switch (key) {
|
||||||
|
case 'keyword':
|
||||||
|
next = {
|
||||||
|
keyword: '',
|
||||||
|
author: current.author,
|
||||||
|
titleOnly: false,
|
||||||
|
scopeBoardId: current.scopeBoardId,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'author':
|
||||||
|
next = {
|
||||||
|
keyword: current.keyword,
|
||||||
|
author: '',
|
||||||
|
titleOnly: current.titleOnly,
|
||||||
|
scopeBoardId: current.scopeBoardId,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'titleOnly':
|
||||||
|
next = {
|
||||||
|
keyword: current.keyword,
|
||||||
|
author: current.author,
|
||||||
|
titleOnly: false,
|
||||||
|
scopeBoardId: current.scopeBoardId,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'board':
|
||||||
|
next = {
|
||||||
|
keyword: current.keyword,
|
||||||
|
author: current.author,
|
||||||
|
titleOnly: current.titleOnly,
|
||||||
|
scopeBoardId: 0,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kw = (next.keyword ?? '').trim();
|
||||||
|
const author = (next.author ?? '').trim();
|
||||||
|
if (!kw && !author) {
|
||||||
|
clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav(buildUrl({
|
||||||
|
keyword: kw,
|
||||||
|
author,
|
||||||
|
titleOnly: next.titleOnly,
|
||||||
|
scopeBoardId: next.scopeBoardId,
|
||||||
|
}));
|
||||||
|
}, [loc.pathname, params, nav, buildUrl, clearSearch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
buildUrl,
|
||||||
|
submitSearch,
|
||||||
|
clearSearch,
|
||||||
|
removeFilter,
|
||||||
|
getRecentSearches,
|
||||||
|
saveRecentSearch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchOpenPostSearch() {
|
||||||
|
window.dispatchEvent(new CustomEvent(POST_SEARCH_OPEN_EVENT));
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
|
|||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -24,7 +24,11 @@ import { useForumLimits } from '../hooks/useForumLimits';
|
|||||||
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
import { resolveAsideWidgets } from '../utils/asideWidgets';
|
||||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||||
import { navigateFeed } from '../utils/feedCache';
|
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 { cn } from '@/lib/utils';
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
@@ -63,7 +67,7 @@ export default function MainLayout() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [asideOpen, setAsideOpen] = useState(false);
|
const [asideOpen, setAsideOpen] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||||
@@ -73,13 +77,10 @@ export default function MainLayout() {
|
|||||||
if (m) return parsePermalinkID(m[1]) || 0;
|
if (m) return parsePermalinkID(m[1]) || 0;
|
||||||
return Number(params.get('board')) || 0;
|
return Number(params.get('board')) || 0;
|
||||||
});
|
});
|
||||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
const [keywordDraft, setKeywordDraft] = 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 feedSort = parseFeedSort(params.get('sort'));
|
||||||
const { limits: forumLimits } = useForumLimits();
|
const { limits: forumLimits } = useForumLimits();
|
||||||
|
const postSearch = usePostSearch(forumLimits);
|
||||||
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
|
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
|
||||||
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
|
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
|
||||||
const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && 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);
|
setBoardId(Number(params.get('board')) || 0);
|
||||||
}, [loc.pathname, params]);
|
}, [loc.pathname, params]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setKeyword(params.get('keyword') || '');
|
setKeywordDraft(params.get('keyword') || '');
|
||||||
setSearchAuthor(params.get('author') || '');
|
|
||||||
setSearchTitleOnly(params.get('title_only') === '1');
|
|
||||||
setSearchInBoard(!!params.get('board') && (!!params.get('keyword') || !!params.get('author')));
|
|
||||||
}, [params]);
|
}, [params]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAsideOpen(false);
|
setAsideOpen(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
setSearchExpanded(false);
|
|
||||||
}, [loc.pathname, loc.search]);
|
}, [loc.pathname, loc.search]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
||||||
@@ -135,16 +132,35 @@ export default function MainLayout() {
|
|||||||
if (!hideAside) setAsideOpen(false);
|
if (!hideAside) setAsideOpen(false);
|
||||||
}, [hideAside]);
|
}, [hideAside]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) setSidebarOpen(false);
|
||||||
setSidebarOpen(false);
|
|
||||||
setSearchExpanded(false);
|
|
||||||
}
|
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchExpanded) return;
|
const onOpen = () => setSearchPanelOpen(true);
|
||||||
const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
|
window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
|
||||||
}, [searchExpanded]);
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!asideOpen && !sidebarOpen) return;
|
if (!asideOpen && !sidebarOpen) return;
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
@@ -268,46 +284,28 @@ export default function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, [needRecentUsers]);
|
}, [needRecentUsers]);
|
||||||
|
|
||||||
const doSearch = () => {
|
const doQuickSearch = () => {
|
||||||
const kw = keyword.trim();
|
const { author, titleOnly, scopeBoardId } = postSearch.filters;
|
||||||
const author = searchAuthor.trim();
|
postSearch.submitSearch({
|
||||||
const activeKw = (params.get('keyword') || '').trim();
|
keyword: keywordDraft,
|
||||||
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,
|
|
||||||
author,
|
author,
|
||||||
titleOnly: !!kw && searchTitleOnly,
|
titleOnly,
|
||||||
permalink: forumLimits,
|
scopeBoardId,
|
||||||
});
|
}, { refreshIfSame: true });
|
||||||
const same =
|
};
|
||||||
(loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname))
|
|
||||||
&& activeKw === kw
|
const handleHeaderClear = () => {
|
||||||
&& activeAuthor === author
|
const hasUrlSearch = postSearch.filters.isFiltered;
|
||||||
&& activeTitleOnly === (!!kw && searchTitleOnly)
|
setKeywordDraft('');
|
||||||
&& activeBoard === scopeBoard;
|
if (hasUrlSearch) postSearch.clearSearch();
|
||||||
if (same) {
|
};
|
||||||
navigateFeed(nav, target);
|
|
||||||
return;
|
const contextBoard = boards.find((b) => b.id === boardId);
|
||||||
}
|
const searchPanelDraft = {
|
||||||
nav(target);
|
keyword: keywordDraft,
|
||||||
|
author: postSearch.filters.author,
|
||||||
|
titleOnly: postSearch.filters.titleOnly,
|
||||||
|
scopeBoardId: postSearch.filters.scopeBoardId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
|
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
|
||||||
@@ -371,9 +369,9 @@ export default function MainLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<div className="app-frame">
|
<div className="app-frame">
|
||||||
<header className={`app-header${searchExpanded && isMobile ? ' app-header--search-open' : ''}`}>
|
<header className="app-header">
|
||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
{isMobile && !isCompose && !searchExpanded && (
|
{isMobile && !isCompose && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-icon-btn"
|
className="header-icon-btn"
|
||||||
@@ -386,18 +384,16 @@ export default function MainLayout() {
|
|||||||
<Menu size={18} aria-hidden />
|
<Menu size={18} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!(isMobile && searchExpanded) && (
|
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCompose && isMobile && !searchExpanded && (
|
{!isCompose && isMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-icon-btn header-search-toggle"
|
className="header-icon-btn header-search-toggle"
|
||||||
onClick={() => setSearchExpanded(true)}
|
onClick={openSearchPanel}
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
title="搜索"
|
title="搜索"
|
||||||
>
|
>
|
||||||
@@ -405,14 +401,13 @@ export default function MainLayout() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCompose && (!isMobile || searchExpanded) && (
|
{!isCompose && !isMobile && (
|
||||||
<form
|
<form
|
||||||
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}${searchAdvanced ? ' header-search-wrap--advanced' : ''}`}
|
className="header-search-wrap"
|
||||||
role="search"
|
role="search"
|
||||||
onSubmit={e => {
|
onSubmit={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
doSearch();
|
doQuickSearch();
|
||||||
if (isMobile) setSearchExpanded(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="header-search-row">
|
<div className="header-search-row">
|
||||||
@@ -421,79 +416,41 @@ export default function MainLayout() {
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
className="header-search-input"
|
className="header-search-input"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索帖子..."
|
placeholder="搜索帖子…"
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
value={keyword}
|
value={keywordDraft}
|
||||||
onChange={e => setKeyword(e.target.value)}
|
onChange={e => setKeywordDraft(e.target.value)}
|
||||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||||
enterKeyHint="search"
|
enterKeyHint="search"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn('header-search-adv-toggle', searchAdvanced && 'active')}
|
className={cn(
|
||||||
onClick={() => setSearchAdvanced((v) => !v)}
|
'header-search-filter-btn',
|
||||||
aria-expanded={searchAdvanced}
|
postSearch.filters.hasAdvancedFilters && 'header-search-filter-btn--active',
|
||||||
title="高级搜索"
|
)}
|
||||||
|
onClick={openSearchPanel}
|
||||||
|
aria-label="搜索筛选"
|
||||||
|
title="筛选"
|
||||||
>
|
>
|
||||||
筛选
|
<SlidersHorizontal size={15} aria-hidden />
|
||||||
|
{postSearch.filters.hasAdvancedFilters && (
|
||||||
|
<span className="header-search-filter-btn__dot" aria-hidden />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{(keyword || searchAuthor) && (
|
{(keywordDraft || postSearch.filters.isFiltered) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-search-clear"
|
className="header-search-clear"
|
||||||
onClick={() => {
|
onClick={handleHeaderClear}
|
||||||
setKeyword('');
|
|
||||||
setSearchAuthor('');
|
|
||||||
setSearchTitleOnly(false);
|
|
||||||
navigateFeed(nav, '/');
|
|
||||||
}}
|
|
||||||
aria-label="清除搜索"
|
aria-label="清除搜索"
|
||||||
>×</button>
|
>×</button>
|
||||||
)}
|
)}
|
||||||
{isMobile && searchExpanded && (
|
<kbd className="header-search-kbd" aria-hidden>Ctrl K</kbd>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="header-search-cancel"
|
|
||||||
onClick={() => setSearchExpanded(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!(isMobile && searchExpanded) && (
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
<button
|
<button
|
||||||
@@ -597,10 +554,25 @@ export default function MainLayout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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' : ''}`}>
|
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type { PostItem } from '../api/types';
|
|||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
import VirtualPostList from '../components/VirtualPostList';
|
import VirtualPostList from '../components/VirtualPostList';
|
||||||
import FeedHeader from '../components/FeedHeader';
|
import FeedHeader from '../components/FeedHeader';
|
||||||
|
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||||
import {
|
import {
|
||||||
getFeedCache,
|
getFeedCache,
|
||||||
setFeedCache,
|
setFeedCache,
|
||||||
@@ -41,6 +43,7 @@ export default function HomePage() {
|
|||||||
const ctx = useOutletContext<LayoutCtx>();
|
const ctx = useOutletContext<LayoutCtx>();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const { limits, loading: limitsLoading } = useForumLimits();
|
const { limits, loading: limitsLoading } = useForumLimits();
|
||||||
|
const postSearch = usePostSearch(limits);
|
||||||
const pageSize = Math.max(1, limits.page_size_default);
|
const pageSize = Math.max(1, limits.page_size_default);
|
||||||
|
|
||||||
const boardId = boardIdFromLocation(boardRouteId, params);
|
const boardId = boardIdFromLocation(boardRouteId, params);
|
||||||
@@ -60,7 +63,7 @@ export default function HomePage() {
|
|||||||
const feedTitle = tag
|
const feedTitle = tag
|
||||||
? `标签:${tag}`
|
? `标签:${tag}`
|
||||||
: keyword || author
|
: keyword || author
|
||||||
? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
|
? '搜索结果'
|
||||||
: (boardId && board ? board.name : '');
|
: (boardId && board ? board.name : '');
|
||||||
usePageSEO({
|
usePageSEO({
|
||||||
title: feedTitle || undefined,
|
title: feedTitle || undefined,
|
||||||
@@ -273,6 +276,8 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showSortBar = !keyword && !tag && !author;
|
const showSortBar = !keyword && !tag && !author;
|
||||||
|
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
||||||
|
const isSearchActive = !!(keyword || author);
|
||||||
|
|
||||||
if (isInvalidBoardRoute || isMissingBoard) {
|
if (isInvalidBoardRoute || isMissingBoard) {
|
||||||
return (
|
return (
|
||||||
@@ -298,7 +303,6 @@ export default function HomePage() {
|
|||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
author={author}
|
author={author}
|
||||||
titleOnly={titleOnly}
|
|
||||||
boards={ctx?.boards ?? []}
|
boards={ctx?.boards ?? []}
|
||||||
stats={ctx?.stats ?? null}
|
stats={ctx?.stats ?? null}
|
||||||
postTotal={postTotal}
|
postTotal={postTotal}
|
||||||
@@ -308,6 +312,14 @@ export default function HomePage() {
|
|||||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSearchActive && (
|
||||||
|
<FeedSearchFilters
|
||||||
|
filters={postSearch.filters}
|
||||||
|
boards={ctx?.boards ?? []}
|
||||||
|
onRemove={postSearch.removeFilter}
|
||||||
|
onClear={postSearch.clearSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<VirtualPostList
|
<VirtualPostList
|
||||||
posts={posts}
|
posts={posts}
|
||||||
@@ -325,6 +337,12 @@ export default function HomePage() {
|
|||||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||||
keyword={keyword || tag || author}
|
keyword={keyword || tag || author}
|
||||||
|
isSearchMode={!!(keyword || author)}
|
||||||
|
searchKeyword={keyword}
|
||||||
|
searchAuthor={author}
|
||||||
|
searchTitleOnly={titleOnly}
|
||||||
|
searchScopeBoardId={searchFilters.scopeBoardId}
|
||||||
|
onClearSearch={postSearch.clearSearch}
|
||||||
boardId={boardId}
|
boardId={boardId}
|
||||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||||
|
|||||||
@@ -388,13 +388,7 @@ img.site-brand-logo-img {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--j13-bg-block-muted);
|
background: var(--j13-bg-block-muted);
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, border-radius 0.15s;
|
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||||
}
|
|
||||||
|
|
||||||
.header-search-wrap--advanced {
|
|
||||||
border-radius: 14px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
max-width: 480px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search-row {
|
.header-search-row {
|
||||||
@@ -405,57 +399,56 @@ img.site-brand-logo-img {
|
|||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search-advanced {
|
.header-search-filter-btn {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-wrap: wrap;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
|
||||||
gap: 8px 12px;
|
|
||||||
padding: 0 2px 2px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-search-opt {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
justify-content: center;
|
||||||
font-size: 0.75rem;
|
width: 28px;
|
||||||
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;
|
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;
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
font-size: 0.75rem;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 0 2px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search-adv-toggle.active,
|
.header-search-filter-btn:hover,
|
||||||
.header-search-adv-toggle:hover {
|
.header-search-filter-btn--active {
|
||||||
|
background: var(--j13-green-bg);
|
||||||
color: var(--j13-green);
|
color: var(--j13-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-search-filter-btn__dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-kbd {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.header-search-kbd { display: inline-flex; }
|
||||||
|
}
|
||||||
|
|
||||||
.header-search-wrap:focus-within {
|
.header-search-wrap:focus-within {
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
border-color: color-mix(in srgb, var(--j13-green) 30%, transparent);
|
border-color: color-mix(in srgb, var(--j13-green) 30%, transparent);
|
||||||
@@ -505,6 +498,325 @@ img.site-brand-logo-img {
|
|||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 搜索面板 —— */
|
||||||
|
.post-search-panel {
|
||||||
|
max-width: 480px;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel--mobile {
|
||||||
|
top: auto !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
translate: none !important;
|
||||||
|
max-width: none !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-height: min(92vh, 640px);
|
||||||
|
border-radius: 16px 16px 0 0 !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel .post-search-panel__desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel > [data-radix-dialog-close] {
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel__header {
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field--row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--j13-bg-block-muted);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__input-wrap:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 35%, transparent);
|
||||||
|
box-shadow: 0 0 0 3px var(--j13-green-bg);
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-field__input::placeholder {
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-scope-pills {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--j13-bg-block-muted);
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-scope-pill {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-scope-pill.active {
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
color: var(--j13-green);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__item:hover {
|
||||||
|
background: var(--j13-bg-block-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__avatar--ph {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--j13-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-author-suggest__user {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-recent__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-recent__item {
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: var(--j13-bg-block-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-recent__item:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 40%, transparent);
|
||||||
|
color: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 20px;
|
||||||
|
border-top: 1px solid var(--j13-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-search-panel--mobile .post-search-panel__footer {
|
||||||
|
padding-bottom: max(20px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 结果页搜索筛选条 —— */
|
||||||
|
.search-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 12px;
|
||||||
|
padding: 0 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-bar__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 3px 6px 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
background: var(--j13-bg-block-muted);
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip__text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip__remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-chip__remove:hover {
|
||||||
|
background: var(--color-fill-3);
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-bar__link {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--j13-green);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-bar__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter-bar__link--muted {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1453,20 +1765,6 @@ img.site-brand-logo-img {
|
|||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
.header-inner { padding: 0 12px; gap: 8px; }
|
.header-inner { padding: 0 12px; gap: 8px; }
|
||||||
.header-search-wrap { max-width: none; }
|
.header-search-wrap { max-width: none; }
|
||||||
.header-search-wrap--expanded {
|
|
||||||
flex: 1;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
.header-search-cancel {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--j13-green);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 0 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
|
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
|
||||||
.feed-banner-row { flex-direction: row; gap: 10px; }
|
.feed-banner-row { flex-direction: row; gap: 10px; }
|
||||||
.sidebar-drawer-extras {
|
.sidebar-drawer-extras {
|
||||||
@@ -2013,6 +2311,11 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-head__meta--count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-head__dot {
|
.feed-head__dot {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
|
|||||||
Reference in New Issue
Block a user