1 Commits

Author SHA1 Message Date
shinya
e1ab9d5b8d 使用虚拟滚动 2026-02-27 21:52:42 +08:00
11 changed files with 318 additions and 81 deletions

View File

@@ -5,7 +5,9 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
// Tailwind CSS Autocomplete, add more if used in projects
"tailwindCSS.classAttributes": [
"class",
@@ -14,4 +16,4 @@
"containerClassName"
],
"typescript.preferences.importModuleSpecifier": "non-relative"
}
}

View File

@@ -1,3 +1,9 @@
## [100.1.1] - 2026-02-27
### Changed
- 搜索页使用虚拟滚动,优化滚动性能
## [100.1.0] - 2026-02-27
### Added

View File

@@ -1 +1 @@
100.1.0
100.1.1

39
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: lunatv-redis
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: lunatv-app
ports:
- '3000:3000'
depends_on:
redis:
condition: service_healthy
environment:
# 存储类型:使用 redis
- NEXT_PUBLIC_STORAGE_TYPE=redis
# Redis 连接地址(容器内通过 service name 访问)
- REDIS_URL=redis://redis:6379
# 站长账号
- USERNAME=admin
# 站长密码
- PASSWORD=admin123
# 站点名称(可选)
- NEXT_PUBLIC_SITE_NAME=MoonTV
volumes:
redis-data:

View File

@@ -25,6 +25,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-virtual": "^3.13.19",
"@types/crypto-js": "^4.2.2",
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",

35
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.13.19
version: 3.13.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
@@ -1410,14 +1413,14 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
'@tanstack/react-virtual@3.13.10':
resolution: {integrity: sha512-nvrzk4E9mWB4124YdJ7/yzwou7IfHxlSef6ugCFcBfRmsnsma3heciiiV97sBNxyc3VuwtZvmwXd0aB5BpucVw==}
'@tanstack/react-virtual@3.13.19':
resolution: {integrity: sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.10':
resolution: {integrity: sha512-sPEDhXREou5HyZYqSWIqdU580rsF6FGeN7vpzijmP3KTiOGjOMZASz4Y6+QKjiFQwhWrR58OP8izYaNGVxvViA==}
'@tanstack/virtual-core@3.13.19':
resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==}
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
@@ -6318,7 +6321,7 @@ snapshots:
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/focus': 3.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-aria/interactions': 3.25.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual': 3.13.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual': 3.13.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.5.0(react@18.3.1)
@@ -6865,13 +6868,13 @@ snapshots:
mini-svg-data-uri: 1.4.4
tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@24.0.3)(typescript@4.9.5))
'@tanstack/react-virtual@3.13.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
'@tanstack/react-virtual@3.13.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.10
'@tanstack/virtual-core': 3.13.19
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.13.10': {}
'@tanstack/virtual-core@3.13.19': {}
'@testing-library/dom@10.4.0':
dependencies:
@@ -8220,8 +8223,8 @@ snapshots:
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -8244,7 +8247,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1(supports-color@9.4.0)
@@ -8255,22 +8258,22 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -8281,7 +8284,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3

40
scripts/dev-docker.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# 本地构建并启动 Docker 镜像 + Redis
# 用法: ./scripts/dev-docker.sh [up|down|rebuild|logs]
set -e
COMPOSE_FILE="docker-compose.dev.yml"
case "${1:-up}" in
up)
echo "🚀 构建并启动服务..."
docker compose -f "$COMPOSE_FILE" up -d --build
echo ""
echo "✅ 服务已启动"
echo " 应用: http://localhost:3000"
echo " Redis: localhost:6379"
echo ""
echo " 默认账号: admin / admin123"
echo " 查看日志: ./scripts/dev-docker.sh logs"
echo " 停止服务: ./scripts/dev-docker.sh down"
;;
down)
echo "🛑 停止并移除服务..."
docker compose -f "$COMPOSE_FILE" down
echo "✅ 已停止"
;;
rebuild)
echo "🔄 重新构建并启动..."
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate
echo "✅ 已重新构建并启动"
;;
logs)
docker compose -f "$COMPOSE_FILE" logs -f
;;
*)
echo "用法: $0 [up|down|rebuild|logs]"
exit 1
;;
esac

View File

@@ -19,6 +19,7 @@ import DoubanCustomSelector from '@/components/DoubanCustomSelector';
import DoubanSelector from '@/components/DoubanSelector';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
import VirtualGrid from '@/components/VirtualGrid';
function DoubanPageClient() {
const searchParams = useSearchParams();
@@ -754,12 +755,18 @@ function DoubanPageClient() {
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{/* 内容网格 */}
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据
doubanData.map((item, index) => (
{loading || !selectorsReady
? // 显示骨架屏
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{skeletonData.map((index) => <DoubanCardSkeleton key={index} />)}
</div>
: // 显示实际数据
<VirtualGrid
items={doubanData}
className='grid-cols-3 gap-x-2 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8'
rowGapClass='pb-12 sm:pb-20'
estimateRowHeight={320}
renderItem={(item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
@@ -774,8 +781,9 @@ function DoubanPageClient() {
}
/>
</div>
))}
</div>
)}
/>
}
{/* 加载更多指示器 */}
{hasMore && !loading && (

View File

@@ -18,6 +18,7 @@ import PageLayout from '@/components/PageLayout';
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
import VirtualGrid from '@/components/VirtualGrid';
function SearchPageClient() {
// 搜索历史
@@ -759,69 +760,79 @@ 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 type = episodes === 1 ? 'movie' : 'tv';
<div key={`search-results-${viewMode}`}>
{viewMode === 'agg' ? (
<VirtualGrid
items={filteredAggResults}
className='grid-cols-3 gap-x-2 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
rowGapClass='pb-14 sm:pb-20'
estimateRowHeight={320}
renderItem={([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 type = episodes === 1 ? 'movie' : 'tv';
// 如果该聚合第一次出现,写入初始统计
if (!groupStatsRef.current.has(mapKey)) {
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
}
if (!groupStatsRef.current.has(mapKey)) {
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
}
return (
<div key={`agg-${mapKey}`} className='w-full'>
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>
);
}}
/>
) : (
<VirtualGrid
items={filteredAllResults}
className='grid-cols-3 gap-x-2 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
rowGapClass='pb-14 sm:pb-20'
estimateRowHeight={320}
renderItem={(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'
>
<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>
)}
</section>

View File

@@ -0,0 +1,114 @@
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface VirtualGridProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
/** Estimated row height in px (including gap). Will be refined by measurement. */
estimateRowHeight?: number;
/** CSS class for row gap, applied as padding-bottom on each row so measureElement captures it */
rowGapClass?: string;
/** Overscan rows */
overscan?: number;
className?: string;
}
/**
* A virtualised grid that piggy-backs on CSS grid for column layout
* and virtualises *rows* via @tanstack/react-virtual.
*
* It measures the actual container width + first-row height so it
* works with responsive `grid-template-columns`.
*/
export default function VirtualGrid<T>({
items,
renderItem,
estimateRowHeight = 320,
rowGapClass = 'pb-14 sm:pb-20',
overscan = 3,
className = '',
}: VirtualGridProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
// Detect column count from a hidden probe row
const probeRef = useRef<HTMLDivElement>(null);
const detectColumns = useCallback(() => {
if (!probeRef.current) return;
const style = window.getComputedStyle(probeRef.current);
const cols = style.gridTemplateColumns.split(' ').length;
if (cols > 0 && cols !== columns) setColumns(cols);
}, [columns]);
useEffect(() => {
detectColumns();
const ro = new ResizeObserver(detectColumns);
if (probeRef.current) ro.observe(probeRef.current);
return () => ro.disconnect();
}, [detectColumns]);
const rowCount = Math.ceil(items.length / columns);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => document.body,
estimateSize: () => estimateRowHeight,
overscan,
});
const virtualRows = virtualizer.getVirtualItems();
return (
<>
{/* Hidden probe element that shares the same grid CSS to measure column count */}
<div
ref={probeRef}
aria-hidden
className={`grid invisible h-0 overflow-hidden ${className}`}
>
<div />
</div>
<div
ref={parentRef}
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualRows.map((virtualRow) => {
const startIdx = virtualRow.index * columns;
const rowItems = items.slice(startIdx, startIdx + columns);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={`${rowGapClass}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className={`grid ${className}`}>
{rowItems.map((item, i) => (
<React.Fragment key={startIdx + i}>
{renderItem(item, startIdx + i)}
</React.Fragment>
))}
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -10,6 +10,19 @@ export interface ChangelogEntry {
}
export const changelog: ChangelogEntry[] = [
{
version: "100.1.1",
date: "2026-02-27",
added: [
// 无新增内容
],
changed: [
"搜索页使用虚拟滚动,优化滚动性能"
],
fixed: [
// 无修复内容
]
},
{
version: "100.1.0",
date: "2026-02-27",