Merge branch 'dev' of https://github.com/mtvpls/moontvplus-dev into dev
This commit is contained in:
@@ -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
28
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
154
src/components/VirtualScrollableGrid.tsx
Normal file
154
src/components/VirtualScrollableGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('读取搜索历史失败');
|
||||
|
||||
Reference in New Issue
Block a user