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 (
@@ -77,19 +72,19 @@ export default function FeedHeader({
)} + {filtered && ( + 共 {postTotal} 条 + )}
- {filtered && ( + {isTag && ( )} - {filtered && ( - 共 {postTotal} 条 - )} ); } diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 9dc20ce..ddef3de 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -11,6 +11,7 @@ import { useAuth } from '../hooks/useAuth'; import { useForumLimits } from '../hooks/useForumLimits'; import { useMediaQuery } from '../hooks/useTheme'; import { loginPath } from '../utils/authRedirect'; +import { dispatchOpenPostSearch } from '../hooks/usePostSearch'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; @@ -33,6 +34,13 @@ interface Props { onScrollRestored?: () => void; /** 搜索关键词(用于空态文案) */ keyword?: string; + /** 是否为帖子搜索(区别于标签筛选) */ + isSearchMode?: boolean; + searchKeyword?: string; + searchAuthor?: string; + searchTitleOnly?: boolean; + searchScopeBoardId?: number; + onClearSearch?: () => void; /** 当前板块 id,0 表示全部 */ boardId?: number; /** 当前板块名 */ @@ -62,6 +70,12 @@ export default function VirtualPostList({ onScrollTopChange, onScrollRestored, keyword = '', + isSearchMode = false, + searchKeyword = '', + searchAuthor = '', + searchTitleOnly = false, + searchScopeBoardId = 0, + onClearSearch, boardId = 0, boardName = '', noBoards = false, @@ -132,7 +146,7 @@ export default function VirtualPostList({ const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading; const isInitialLoad = loading && posts.length === 0; const isEmpty = !loading && posts.length === 0; - const isSearchEmpty = isEmpty && !!keyword.trim(); + const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim()); const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose'; const isAdmin = user?.role === 'admin'; @@ -173,6 +187,12 @@ export default function VirtualPostList({ return () => el.removeEventListener('scroll', onScroll); }, [getScrollElement, isMobile]); + const searchSummaryParts: string[] = []; + if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}」`); + if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`); + if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题'); + if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`); + const emptyActions = (
{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 ( + + e.preventDefault()} + > + + 搜索帖子 + + 按关键词、作者或板块范围查找帖子 + + + +
+ + + + + {showBoardScope && ( +
+ 范围 +
+ + +
+
+ )} + +
+
+ 仅搜标题 + 需填写关键词时生效 +
+ +
+ + {recent.length > 0 && ( +
+ 最近搜索 +
    + {recent.map((item) => { + const label = [ + item.keyword && `「${item.keyword}」`, + item.author && `@${item.author}`, + item.titleOnly && '仅标题', + item.scopeBoardId > 0 && '本板块', + ].filter(Boolean).join(' · ') || '搜索'; + 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 && (
{ e.preventDefault(); - doSearch(); - if (isMobile) setSearchExpanded(false); + doQuickSearch(); }} >
@@ -421,79 +416,41 @@ export default function MainLayout() { ref={searchInputRef} className="header-search-input" type="search" - placeholder="搜索帖子..." + placeholder="搜索帖子…" aria-label="搜索帖子" - value={keyword} - onChange={e => setKeyword(e.target.value)} + value={keywordDraft} + onChange={e => setKeywordDraft(e.target.value)} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} enterKeyHint="search" /> - {(keyword || searchAuthor) && ( + {(keywordDraft || postSearch.filters.isFiltered) && ( )} - {isMobile && searchExpanded && ( - - )} + Ctrl K
- {searchAdvanced && ( -
- - {boardId > 0 && ( - - )} - setSearchAuthor(e.target.value)} - /> -
- )}
)} - {!(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;