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-dom": "^18.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-window": "^2.2.3",
|
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@@ -96,7 +95,6 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-window": "^2.0.0",
|
|
||||||
"@types/testing-library__jest-dom": "^5.14.9",
|
"@types/testing-library__jest-dom": "^5.14.9",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
|
|||||||
Generated
-28
@@ -128,9 +128,6 @@ importers:
|
|||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0(@types/[email protected])([email protected])
|
version: 10.1.0(@types/[email protected])([email protected])
|
||||||
react-window:
|
|
||||||
specifier: ^2.2.3
|
|
||||||
version: 2.2.4([email protected]([email protected]))([email protected])
|
|
||||||
redis:
|
redis:
|
||||||
specifier: ^4.6.7
|
specifier: ^4.6.7
|
||||||
version: 4.7.1
|
version: 4.7.1
|
||||||
@@ -213,9 +210,6 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.1.6
|
specifier: ^19.1.6
|
||||||
version: 19.2.3(@types/[email protected])
|
version: 19.2.3(@types/[email protected])
|
||||||
'@types/react-window':
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.0.0([email protected]([email protected]))([email protected])
|
|
||||||
'@types/testing-library__jest-dom':
|
'@types/testing-library__jest-dom':
|
||||||
specifier: ^5.14.9
|
specifier: ^5.14.9
|
||||||
version: 5.14.9
|
version: 5.14.9
|
||||||
@@ -3318,10 +3312,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
'@types/[email protected]':
|
|
||||||
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/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
|
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==}
|
||||||
|
|
||||||
@@ -7323,12 +7313,6 @@ packages:
|
|||||||
'@types/react': '>=18'
|
'@types/react': '>=18'
|
||||||
react: '>=18'
|
react: '>=18'
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-FiZsQHvt2qbnTz6cN+/FXvX62v2xukQ+AajUivkm/Ivdp9rnU3bp0B1eDcCNpQXNaDBdqkEVGNYHlvIUGU9yBw==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^18.0.0 || ^19.0.0
|
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -13168,13 +13152,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.27
|
'@types/react': 18.3.27
|
||||||
|
|
||||||
'@types/[email protected]([email protected]([email protected]))([email protected])':
|
|
||||||
dependencies:
|
|
||||||
react-window: 2.2.4([email protected]([email protected]))([email protected])
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- react
|
|
||||||
- react-dom
|
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
@@ -18137,11 +18114,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
[email protected]([email protected]([email protected]))([email protected]):
|
|
||||||
dependencies:
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1([email protected])
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
+90
-63
@@ -22,6 +22,7 @@ import PansouSearch from '@/components/PansouSearch';
|
|||||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||||
|
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
||||||
|
|
||||||
function SearchPageClient() {
|
function SearchPageClient() {
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
@@ -372,11 +373,11 @@ function SearchPageClient() {
|
|||||||
const yearsSet = new Set<string>();
|
const yearsSet = new Set<string>();
|
||||||
|
|
||||||
searchResults.forEach((item) => {
|
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);
|
sourcesSet.set(item.source, item.source_name);
|
||||||
}
|
}
|
||||||
if (item.title) titlesSet.add(item.title);
|
if (item.title && item.title.trim() !== '') titlesSet.add(item.title);
|
||||||
if (item.year) yearsSet.add(item.year);
|
if (item.year && item.year.trim() !== '') yearsSet.add(item.year);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sourceOptions: { label: string; value: string }[] = [
|
const sourceOptions: { label: string; value: string }[] = [
|
||||||
@@ -518,6 +519,11 @@ function SearchPageClient() {
|
|||||||
});
|
});
|
||||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||||
|
|
||||||
|
const useVirtualGrid = useMemo(() => {
|
||||||
|
const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
|
||||||
|
return cardCount >= 100;
|
||||||
|
}, [viewMode, filteredAggResults.length, filteredAllResults.length]);
|
||||||
|
|
||||||
// 监听选项卡切换,自动执行搜索
|
// 监听选项卡切换,自动执行搜索
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||||
@@ -1194,77 +1200,98 @@ function SearchPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
(() => {
|
||||||
key={`search-results-${viewMode}`}
|
const gridClassName =
|
||||||
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'
|
'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);
|
|
||||||
|
|
||||||
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
|
const gridChildren =
|
||||||
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
|
viewMode === 'agg'
|
||||||
const lastDashIndex = mapKey.lastIndexOf('-');
|
? filteredAggResults.map(([mapKey, group]) => {
|
||||||
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
|
const title = group[0]?.title || '';
|
||||||
const type = secondLastDashIndex > 0
|
const poster = group[0]?.poster || '';
|
||||||
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
|
const year = group[0]?.year || 'unknown';
|
||||||
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
|
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
||||||
|
|
||||||
// 如果该聚合第一次出现,写入初始统计
|
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
|
||||||
if (!groupStatsRef.current.has(mapKey)) {
|
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
|
||||||
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
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
|
<VideoCard
|
||||||
ref={getGroupRef(mapKey)}
|
id={item.id}
|
||||||
from='search'
|
title={item.title}
|
||||||
isAggregate={true}
|
poster={item.poster}
|
||||||
title={title}
|
episodes={item.episodes.length}
|
||||||
poster={poster}
|
source={item.source}
|
||||||
year={year}
|
source_name={item.source_name}
|
||||||
episodes={episodes}
|
douban_id={item.douban_id}
|
||||||
source_names={source_names}
|
|
||||||
douban_id={douban_id}
|
|
||||||
query={
|
query={
|
||||||
searchQuery.trim() !== title
|
searchQuery.trim() !== item.title
|
||||||
? searchQuery.trim()
|
? searchQuery.trim()
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
type={type}
|
year={item.year}
|
||||||
|
from='search'
|
||||||
|
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
})
|
|
||||||
: filteredAllResults.map((item) => (
|
if (useVirtualGrid) {
|
||||||
<div
|
return (
|
||||||
key={`all-${item.source}-${item.id}`}
|
<VirtualScrollableGrid
|
||||||
className='w-full'
|
key={`search-results-virtual-${viewMode}`}
|
||||||
|
gridClassName={gridClassName}
|
||||||
>
|
>
|
||||||
<VideoCard
|
{gridChildren}
|
||||||
id={item.id}
|
</VirtualScrollableGrid>
|
||||||
title={item.title}
|
);
|
||||||
poster={item.poster}
|
}
|
||||||
episodes={item.episodes.length}
|
|
||||||
source={item.source}
|
return (
|
||||||
source_name={item.source_name}
|
<div
|
||||||
douban_id={item.douban_id}
|
key={`search-results-${viewMode}`}
|
||||||
query={
|
className={gridClassName}
|
||||||
searchQuery.trim() !== item.title
|
>
|
||||||
? searchQuery.trim()
|
{gridChildren}
|
||||||
: ''
|
</div>
|
||||||
}
|
);
|
||||||
year={item.year}
|
})()
|
||||||
from='search'
|
|
||||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : activeTab === 'pansou' ? (
|
) : activeTab === 'pansou' ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-7
@@ -860,13 +860,15 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
// 返回缓存数据,同时后台异步更新
|
// 返回缓存数据,同时后台异步更新
|
||||||
fetchFromApi<string[]>(`/api/searchhistory`)
|
fetchFromApi<string[]>(`/api/searchhistory`)
|
||||||
.then((freshData) => {
|
.then((freshData) => {
|
||||||
|
// 去重处理
|
||||||
|
const uniqueData = Array.from(new Set(freshData));
|
||||||
// 只有数据真正不同时才更新缓存
|
// 只有数据真正不同时才更新缓存
|
||||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
if (JSON.stringify(cachedData) !== JSON.stringify(uniqueData)) {
|
||||||
cacheManager.cacheSearchHistory(freshData);
|
cacheManager.cacheSearchHistory(uniqueData);
|
||||||
// 触发数据更新事件
|
// 触发数据更新事件
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('searchHistoryUpdated', {
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
detail: freshData,
|
detail: uniqueData,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -881,8 +883,10 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
// 缓存为空,直接从 API 获取并缓存
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
try {
|
try {
|
||||||
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
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) {
|
} catch (err) {
|
||||||
console.error('获取搜索历史失败:', err);
|
console.error('获取搜索历史失败:', err);
|
||||||
triggerGlobalError('获取搜索历史失败');
|
triggerGlobalError('获取搜索历史失败');
|
||||||
@@ -896,8 +900,9 @@ export async function getSearchHistory(): Promise<string[]> {
|
|||||||
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
const arr = JSON.parse(raw) as string[];
|
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) {
|
} catch (err) {
|
||||||
console.error('读取搜索历史失败:', err);
|
console.error('读取搜索历史失败:', err);
|
||||||
triggerGlobalError('读取搜索历史失败');
|
triggerGlobalError('读取搜索历史失败');
|
||||||
|
|||||||
Reference in New Issue
Block a user