diff --git a/frontend/src/components/FeedHeader.tsx b/frontend/src/components/FeedHeader.tsx
index b200335..429612e 100644
--- a/frontend/src/components/FeedHeader.tsx
+++ b/frontend/src/components/FeedHeader.tsx
@@ -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 (
{noBoards ? (
@@ -187,12 +207,15 @@ export default function VirtualPostList({
)
) : isSearchEmpty ? (
<>
+
+
-
>
) : (
<>
@@ -235,7 +258,9 @@ export default function VirtualPostList({
{noBoards
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
: isSearchEmpty
- ? '试试更短的关键词,或浏览标签云 / 板块'
+ ? (searchSummaryParts.length > 0
+ ? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
+ : '试试更短的关键词,或浏览标签云 / 板块')
: boardName
? `「${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}
diff --git a/frontend/src/components/search/FeedSearchFilters.tsx b/frontend/src/components/search/FeedSearchFilters.tsx
new file mode 100644
index 0000000..734f581
--- /dev/null
+++ b/frontend/src/components/search/FeedSearchFilters.tsx
@@ -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 (
+
+
+ {keyword && (
+
+ 关键词「{keyword}」
+
+
+ )}
+ {author && (
+
+ 作者 {author}
+
+
+ )}
+ {titleOnly && keyword && (
+
+ 仅标题
+
+
+ )}
+ {scopeBoardId > 0 && boardName && (
+
+ 板块:{boardName}
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/search/PostSearchPanel.tsx b/frontend/src/components/search/PostSearchPanel.tsx
new file mode 100644
index 0000000..c395fca
--- /dev/null
+++ b/frontend/src/components/search/PostSearchPanel.tsx
@@ -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
(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([]);
+ const [suggestions, setSuggestions] = useState([]);
+ const [suggestOpen, setSuggestOpen] = useState(false);
+ const authorWrapRef = useRef(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 (
+
+ );
+}
diff --git a/frontend/src/hooks/usePostSearch.ts b/frontend/src/hooks/usePostSearch.ts
new file mode 100644
index 0000000..7d62d64
--- /dev/null
+++ b/frontend/src/hooks/usePostSearch.ts
@@ -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 {
+ 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) {
+ 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>,
+): 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));
+}
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index 3d2765c..96eceee 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
-import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail } from 'lucide-react';
+import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,7 +24,11 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
-import { notify } from '@/lib/notify';
+import PostSearchPanel from '../components/search/PostSearchPanel';
+import {
+ POST_SEARCH_OPEN_EVENT,
+ usePostSearch,
+} from '../hooks/usePostSearch';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
@@ -63,7 +67,7 @@ export default function MainLayout() {
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
- const [searchExpanded, setSearchExpanded] = useState(false);
+ const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const searchInputRef = useRef(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
@@ -73,13 +77,10 @@ export default function MainLayout() {
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
- const [keyword, setKeyword] = useState(params.get('keyword') || '');
- const [searchAuthor, setSearchAuthor] = useState(params.get('author') || '');
- const [searchTitleOnly, setSearchTitleOnly] = useState(params.get('title_only') === '1');
- const [searchInBoard, setSearchInBoard] = useState(!!params.get('board') && !!params.get('keyword'));
- const [searchAdvanced, setSearchAdvanced] = useState(false);
+ const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
+ const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled);
@@ -118,15 +119,11 @@ export default function MainLayout() {
setBoardId(Number(params.get('board')) || 0);
}, [loc.pathname, params]);
useEffect(() => {
- setKeyword(params.get('keyword') || '');
- setSearchAuthor(params.get('author') || '');
- setSearchTitleOnly(params.get('title_only') === '1');
- setSearchInBoard(!!params.get('board') && (!!params.get('keyword') || !!params.get('author')));
+ setKeywordDraft(params.get('keyword') || '');
}, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
- setSearchExpanded(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
@@ -135,16 +132,35 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
- if (!isMobile) {
- setSidebarOpen(false);
- setSearchExpanded(false);
- }
+ if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
+
+ const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []);
+
useEffect(() => {
- if (!searchExpanded) return;
- const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
- return () => window.clearTimeout(t);
- }, [searchExpanded]);
+ const onOpen = () => setSearchPanelOpen(true);
+ window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
+ return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
+ }, []);
+
+ useEffect(() => {
+ if (isCompose) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
+ const tag = (e.target as HTMLElement | null)?.tagName;
+ const editable = (e.target as HTMLElement | null)?.isContentEditable;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
+ e.preventDefault();
+ if (isMobile) {
+ setSearchPanelOpen(true);
+ } else {
+ searchInputRef.current?.focus();
+ searchInputRef.current?.select();
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [isCompose, isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
@@ -268,46 +284,28 @@ export default function MainLayout() {
};
}, [needRecentUsers]);
- const doSearch = () => {
- const kw = keyword.trim();
- const author = searchAuthor.trim();
- const activeKw = (params.get('keyword') || '').trim();
- const activeAuthor = (params.get('author') || '').trim();
- const activeTitleOnly = params.get('title_only') === '1';
- const activeBoard = boardId;
- if (!kw && !author) {
- if (activeKw || activeAuthor) navigateFeed(nav, '/');
- return;
- }
- if (kw) {
- const len = [...kw].length;
- if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
- notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
- return;
- }
- if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
- notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
- return;
- }
- }
- const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
- const target = buildHomeUrl(scopeBoard, 'latest', {
- keyword: kw,
+ const doQuickSearch = () => {
+ const { author, titleOnly, scopeBoardId } = postSearch.filters;
+ postSearch.submitSearch({
+ keyword: keywordDraft,
author,
- titleOnly: !!kw && searchTitleOnly,
- permalink: forumLimits,
- });
- const same =
- (loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname))
- && activeKw === kw
- && activeAuthor === author
- && activeTitleOnly === (!!kw && searchTitleOnly)
- && activeBoard === scopeBoard;
- if (same) {
- navigateFeed(nav, target);
- return;
- }
- nav(target);
+ titleOnly,
+ scopeBoardId,
+ }, { refreshIfSame: true });
+ };
+
+ const handleHeaderClear = () => {
+ const hasUrlSearch = postSearch.filters.isFiltered;
+ setKeywordDraft('');
+ if (hasUrlSearch) postSearch.clearSearch();
+ };
+
+ const contextBoard = boards.find((b) => b.id === boardId);
+ const searchPanelDraft = {
+ keyword: keywordDraft,
+ author: postSearch.filters.author,
+ titleOnly: postSearch.filters.titleOnly,
+ scopeBoardId: postSearch.filters.scopeBoardId,
};
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
@@ -371,9 +369,9 @@ export default function MainLayout() {
return (
-
+
- {isMobile && !isCompose && !searchExpanded && (
+ {isMobile && !isCompose && (
)}
- {!(isMobile && searchExpanded) && (
-
- )}
+
- {!isCompose && isMobile && !searchExpanded && (
+ {!isCompose && isMobile && (
)}
- {!isCompose && (!isMobile || searchExpanded) && (
+ {!isCompose && !isMobile && (
)}
- {!(isMobile && searchExpanded) && (
{!isCompose && (
- )}
+ {!isCompose && (
+ {
+ const ok = postSearch.submitSearch(input);
+ if (ok) setKeywordDraft((input.keyword ?? '').trim());
+ return ok;
+ }}
+ onClear={postSearch.clearSearch}
+ />
+ )}
+
{!isCompose && (
();
const { branding } = useSiteBranding();
const { limits, loading: limitsLoading } = useForumLimits();
+ const postSearch = usePostSearch(limits);
const pageSize = Math.max(1, limits.page_size_default);
const boardId = boardIdFromLocation(boardRouteId, params);
@@ -60,7 +63,7 @@ export default function HomePage() {
const feedTitle = tag
? `标签:${tag}`
: keyword || author
- ? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
+ ? '搜索结果'
: (boardId && board ? board.name : '');
usePageSEO({
title: feedTitle || undefined,
@@ -273,6 +276,8 @@ export default function HomePage() {
};
const showSortBar = !keyword && !tag && !author;
+ const searchFilters = parseSearchFromUrl(location.pathname, params);
+ const isSearchActive = !!(keyword || author);
if (isInvalidBoardRoute || isMissingBoard) {
return (
@@ -298,7 +303,6 @@ export default function HomePage() {
keyword={keyword}
tag={tag}
author={author}
- titleOnly={titleOnly}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
@@ -308,6 +312,14 @@ export default function HomePage() {
)}
+ {isSearchActive && (
+
+ )}
{ scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
+ isSearchMode={!!(keyword || author)}
+ searchKeyword={keyword}
+ searchAuthor={author}
+ searchTitleOnly={titleOnly}
+ searchScopeBoardId={searchFilters.scopeBoardId}
+ onClearSearch={postSearch.clearSearch}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 75b557a..0c6d0a4 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -388,13 +388,7 @@ img.site-brand-logo-img {
border-radius: 999px;
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
- transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, border-radius 0.15s;
-}
-
-.header-search-wrap--advanced {
- border-radius: 14px;
- padding-bottom: 8px;
- max-width: 480px;
+ transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.header-search-row {
@@ -405,57 +399,56 @@ img.site-brand-logo-img {
min-height: 36px;
}
-.header-search-advanced {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px 12px;
- padding: 0 2px 2px 24px;
-}
-
-.header-search-opt {
+.header-search-filter-btn {
+ position: relative;
+ flex-shrink: 0;
display: inline-flex;
align-items: center;
- gap: 0.3rem;
- font-size: 0.75rem;
- color: var(--color-text-3, #64748b);
- cursor: pointer;
- white-space: nowrap;
-}
-
-.header-search-opt input {
- margin: 0;
-}
-
-.header-search-author {
- flex: 1;
- min-width: 7rem;
+ justify-content: center;
+ width: 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-radius: 8px;
background: transparent;
color: var(--color-text-3);
- font-size: 0.75rem;
- font-family: inherit;
- padding: 0 2px;
cursor: pointer;
+ transition: background 0.15s, color 0.15s;
}
-.header-search-adv-toggle.active,
-.header-search-adv-toggle:hover {
+.header-search-filter-btn:hover,
+.header-search-filter-btn--active {
+ background: var(--j13-green-bg);
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 {
background: var(--j13-bg-surface);
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);
}
+/* —— 搜索面板 —— */
+.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 {
display: flex;
align-items: center;
@@ -1453,20 +1765,6 @@ img.site-brand-logo-img {
.sidebar { display: none; }
.header-inner { padding: 0 12px; gap: 8px; }
.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; }
.feed-banner-row { flex-direction: row; gap: 10px; }
.sidebar-drawer-extras {
@@ -2013,6 +2311,11 @@ body:has(.admin-topbar) .ptr-indicator {
font-weight: 600;
}
+.feed-head__meta--count {
+ font-size: 12px;
+ color: var(--color-text-3);
+}
+
.feed-head__dot {
margin: 0 4px;
opacity: 0.55;