This commit is contained in:
mtvpls
2026-03-11 19:43:49 +08:00
5 changed files with 256 additions and 100 deletions

View File

@@ -66,7 +66,6 @@
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-window": "^2.2.3",
"redis": "^4.6.7",
"remark-gfm": "^3.0.1",
"server-only": "^0.0.1",
@@ -96,7 +95,6 @@
"@types/pg": "^8.16.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/react-window": "^2.0.0",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/xml2js": "^0.4.14",
"@typescript-eslint/eslint-plugin": "^5.62.0",

28
pnpm-lock.yaml generated
View File

@@ -128,9 +128,6 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@18.3.27)(react@18.3.1)
react-window:
specifier: ^2.2.3
version: 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
redis:
specifier: ^4.6.7
version: 4.7.1
@@ -213,9 +210,6 @@ importers:
'@types/react-dom':
specifier: ^19.1.6
version: 19.2.3(@types/react@18.3.27)
'@types/react-window':
specifier: ^2.0.0
version: 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/testing-library__jest-dom':
specifier: ^5.14.9
version: 5.14.9
@@ -3318,10 +3312,6 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-window@2.0.0':
resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==}
deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed.
'@types/react@18.3.27':
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
@@ -7323,12 +7313,6 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-window@2.2.4:
resolution: {integrity: sha512-FiZsQHvt2qbnTz6cN+/FXvX62v2xukQ+AajUivkm/Ivdp9rnU3bp0B1eDcCNpQXNaDBdqkEVGNYHlvIUGU9yBw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -13168,13 +13152,6 @@ snapshots:
dependencies:
'@types/react': 18.3.27
'@types/react-window@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
react-window: 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
transitivePeerDependencies:
- react
- react-dom
'@types/react@18.3.27':
dependencies:
'@types/prop-types': 15.7.15
@@ -18137,11 +18114,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-window@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0

View File

@@ -22,6 +22,7 @@ import PansouSearch from '@/components/PansouSearch';
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
function SearchPageClient() {
// 搜索历史
@@ -372,11 +373,11 @@ function SearchPageClient() {
const yearsSet = new Set<string>();
searchResults.forEach((item) => {
if (item.source && item.source_name) {
if (item.source && item.source_name && item.source.trim() !== '' && item.source_name.trim() !== '') {
sourcesSet.set(item.source, item.source_name);
}
if (item.title) titlesSet.add(item.title);
if (item.year) yearsSet.add(item.year);
if (item.title && item.title.trim() !== '') titlesSet.add(item.title);
if (item.year && item.year.trim() !== '') yearsSet.add(item.year);
});
const sourceOptions: { label: string; value: string }[] = [
@@ -518,6 +519,11 @@ function SearchPageClient() {
});
}, [aggregatedResults, filterAgg, searchQuery]);
const useVirtualGrid = useMemo(() => {
const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
return cardCount >= 100;
}, [viewMode, filteredAggResults.length, filteredAllResults.length]);
// 监听选项卡切换,自动执行搜索
useEffect(() => {
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
@@ -1194,77 +1200,98 @@ function SearchPageClient() {
</div>
)
) : (
<div
key={`search-results-${viewMode}`}
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
>
{viewMode === 'agg'
? filteredAggResults.map(([mapKey, group]) => {
const title = group[0]?.title || '';
const poster = group[0]?.poster || '';
const year = group[0]?.year || 'unknown';
const { episodes, source_names, douban_id } = computeGroupStats(group);
(() => {
const gridClassName =
'justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8';
// 从 mapKey 中提取类型mapKey 格式normalizedTitle-type-year
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
const lastDashIndex = mapKey.lastIndexOf('-');
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
const type = secondLastDashIndex > 0
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
const gridChildren =
viewMode === 'agg'
? filteredAggResults.map(([mapKey, group]) => {
const title = group[0]?.title || '';
const poster = group[0]?.poster || '';
const year = group[0]?.year || 'unknown';
const { episodes, source_names, douban_id } = computeGroupStats(group);
// 如果该聚合第一次出现,写入初始统计
if (!groupStatsRef.current.has(mapKey)) {
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
}
// 从 mapKey 中提取类型mapKey 格式normalizedTitle-type-year
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
const lastDashIndex = mapKey.lastIndexOf('-');
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
const type = secondLastDashIndex > 0
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
return (
<div key={`agg-${mapKey}`} className='w-full'>
// 如果该聚合第一次出现,写入初始统计
if (!groupStatsRef.current.has(mapKey)) {
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
}
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
ref={getGroupRef(mapKey)}
from='search'
isAggregate={true}
title={title}
poster={poster}
year={year}
episodes={episodes}
source_names={source_names}
douban_id={douban_id}
query={
searchQuery.trim() !== title
? searchQuery.trim()
: ''
}
type={type}
/>
</div>
);
})
: filteredAllResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
<VideoCard
ref={getGroupRef(mapKey)}
from='search'
isAggregate={true}
title={title}
poster={poster}
year={year}
episodes={episodes}
source_names={source_names}
douban_id={douban_id}
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id}
query={
searchQuery.trim() !== title
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
type={type}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
);
})
: filteredAllResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
));
if (useVirtualGrid) {
return (
<VirtualScrollableGrid
key={`search-results-virtual-${viewMode}`}
gridClassName={gridClassName}
>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
))}
</div>
{gridChildren}
</VirtualScrollableGrid>
);
}
return (
<div
key={`search-results-${viewMode}`}
className={gridClassName}
>
{gridChildren}
</div>
);
})()
)}
</>
) : activeTab === 'pansou' ? (

View File

@@ -0,0 +1,154 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
interface VirtualScrollableGridProps {
children: React.ReactNode[];
gridClassName: string;
/** extra rows rendered above/below viewport */
overscanRows?: number;
/** < 640px columns */
mobileColumns?: number;
/** >= 640px min card width (px) to derive columns */
minItemWidth?: number;
/** >= 640px max content width (px) to derive columns */
maxContentWidth?: number;
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
export default function VirtualScrollableGrid({
children,
gridClassName,
overscanRows = 3,
mobileColumns = 3,
minItemWidth = 176,
maxContentWidth = 1400,
}: VirtualScrollableGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const columnsRef = useRef<number>(mobileColumns);
const rowHeightRef = useRef<number>(320);
const totalRowsRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
const [range, setRange] = useState({ startRow: 0, endRow: 0 });
const computeColumns = () => {
if (typeof window === 'undefined') return mobileColumns;
const width = window.innerWidth;
if (width < 640) return mobileColumns;
const containerWidth = Math.min(width - 32, maxContentWidth);
return Math.max(mobileColumns, Math.floor(containerWidth / minItemWidth));
};
const updateLayout = () => {
const gapY = window.innerWidth >= 640 ? 80 : 56; // gap-y-14 / sm:gap-y-20
const columns = computeColumns();
columnsRef.current = columns;
totalRowsRef.current = Math.ceil(children.length / Math.max(1, columns));
// Measure a single item height (wrapper div around VideoCard) and add vertical gap.
const measureEl = measureRef.current;
const firstItem = measureEl?.querySelector<HTMLElement>('[data-virtual-measure-item]');
const itemH = firstItem?.getBoundingClientRect().height;
if (itemH && Number.isFinite(itemH) && itemH > 0) {
rowHeightRef.current = Math.max(120, Math.round(itemH + gapY));
}
};
const updateRange = () => {
const el = containerRef.current;
if (!el) return;
const totalRows = totalRowsRef.current;
if (totalRows <= 0) return;
const rowHeight = rowHeightRef.current;
if (!rowHeight || rowHeight <= 0) return;
// This app uses `document.body` as the actual scroll container (see search page back-to-top logic).
const scrollTop = document.body.scrollTop || 0;
const viewportBottom = scrollTop + window.innerHeight;
const containerTop = el.getBoundingClientRect().top + scrollTop;
const startRow = Math.floor((scrollTop - containerTop) / rowHeight) - overscanRows;
const endRow = Math.ceil((viewportBottom - containerTop) / rowHeight) + overscanRows;
const clampedStart = clamp(startRow, 0, Math.max(0, totalRows - 1));
const clampedEnd = clamp(endRow, clampedStart, Math.max(0, totalRows - 1));
setRange((prev) => {
if (prev.startRow === clampedStart && prev.endRow === clampedEnd) return prev;
return { startRow: clampedStart, endRow: clampedEnd };
});
};
const scheduleUpdate = () => {
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updateLayout();
updateRange();
});
};
useEffect(() => {
updateLayout();
updateRange();
let isRunning = true;
const rafLoop = () => {
if (!isRunning) return;
scheduleUpdate();
window.requestAnimationFrame(rafLoop);
};
rafLoop();
document.body.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
isRunning = false;
document.body.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children.length, overscanRows, mobileColumns, minItemWidth, maxContentWidth]);
const columns = columnsRef.current;
const totalRows = totalRowsRef.current;
const rowHeight = rowHeightRef.current;
const startIndex = range.startRow * columns;
const endIndexExclusive = Math.min(children.length, (range.endRow + 1) * columns);
const visibleChildren = children.slice(startIndex, endIndexExclusive);
const topSpacerHeight = range.startRow * rowHeight;
const bottomSpacerHeight = Math.max(0, (totalRows - range.endRow - 1) * rowHeight);
return (
<div ref={containerRef} className='w-full'>
{/* hidden measuring row (first visible row) */}
<div
ref={measureRef}
className='pointer-events-none absolute left-0 top-0 -z-10 opacity-0'
aria-hidden='true'
>
<div className={gridClassName}>
{children.slice(0, Math.max(1, columns)).map((child, idx) => (
<div key={`measure-${idx}`} data-virtual-measure-item>
{child}
</div>
))}
</div>
</div>
<div style={{ height: topSpacerHeight }} />
<div className={gridClassName}>{visibleChildren}</div>
<div style={{ height: bottomSpacerHeight }} />
</div>
);
}

View File

@@ -860,13 +860,15 @@ export async function getSearchHistory(): Promise<string[]> {
// 返回缓存数据,同时后台异步更新
fetchFromApi<string[]>(`/api/searchhistory`)
.then((freshData) => {
// 去重处理
const uniqueData = Array.from(new Set(freshData));
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSearchHistory(freshData);
if (JSON.stringify(cachedData) !== JSON.stringify(uniqueData)) {
cacheManager.cacheSearchHistory(uniqueData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: freshData,
detail: uniqueData,
})
);
}
@@ -881,8 +883,10 @@ export async function getSearchHistory(): Promise<string[]> {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
return freshData;
// 去重处理
const uniqueData = Array.from(new Set(freshData));
cacheManager.cacheSearchHistory(uniqueData);
return uniqueData;
} catch (err) {
console.error('获取搜索历史失败:', err);
triggerGlobalError('获取搜索历史失败');
@@ -896,8 +900,9 @@ export async function getSearchHistory(): Promise<string[]> {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return [];
const arr = JSON.parse(raw) as string[];
// 仅返回字符串数组
return Array.isArray(arr) ? arr : [];
// 仅返回字符串数组,并去重
const validArray = Array.isArray(arr) ? arr : [];
return Array.from(new Set(validArray));
} catch (err) {
console.error('读取搜索历史失败:', err);
triggerGlobalError('读取搜索历史失败');