- 前端:vite ^8.2.2、react/react-dom ^19.2.8、react-router ^8.3.1(移除 react-router-dom)、typescript ^7.0.2、zustand ^5.0.15;适配升级带来的类型变化(react-hook-form Resolver、ProseMirror node.parent、tiptap focus 参数、timer undefined 等) - 后端:升级 gin/jwt/minio-go/gorm/modernc-sqlite/x-crypto 等直接依赖至最新;gowebp v0.1.1 已是最新版本 - tsconfig 移除 baseUrl,paths 改为相对路径以兼容 TS7
272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
import { useCallback, useMemo } from 'react';
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
|
import { buildHomeUrl } from '../components/FeedSortBar';
|
|
import { navigateFeed } from '../utils/feedCache';
|
|
import { transitionTo } from '../utils/spaTransition';
|
|
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 });
|
|
void transitionTo(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));
|
|
}
|