mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-28 09:23:14 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ab9d5b8d |
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
## [100.1.1] - 2026-02-27
|
||||
|
||||
### Changed
|
||||
|
||||
- 搜索页使用虚拟滚动,优化滚动性能
|
||||
|
||||
## [100.1.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1 +1 @@
|
||||
100.1.0
|
||||
100.1.1
|
||||
39
docker-compose.dev.yml
Normal file
39
docker-compose.dev.yml
Normal 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:
|
||||
@@ -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
35
pnpm-lock.yaml
generated
@@ -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
40
scripts/dev-docker.sh
Executable 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
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
114
src/components/VirtualGrid.tsx
Normal file
114
src/components/VirtualGrid.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user