From e1ab9d5b8d0360b9bd7c4a2549739c520c5875fc Mon Sep 17 00:00:00 2001 From: shinya Date: Fri, 27 Feb 2026 21:52:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=99=9A=E6=8B=9F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 6 +- CHANGELOG | 6 ++ VERSION.txt | 2 +- docker-compose.dev.yml | 39 +++++++++++ package.json | 1 + pnpm-lock.yaml | 35 +++++----- scripts/dev-docker.sh | 40 +++++++++++ src/app/douban/page.tsx | 24 ++++--- src/app/search/page.tsx | 119 ++++++++++++++++++--------------- src/components/VirtualGrid.tsx | 114 +++++++++++++++++++++++++++++++ src/lib/changelog.ts | 13 ++++ 11 files changed, 318 insertions(+), 81 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100755 scripts/dev-docker.sh create mode 100644 src/components/VirtualGrid.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index a775463..c4b0d32 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" -} +} \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index 9e60908..ac6b914 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +## [100.1.1] - 2026-02-27 + +### Changed + +- 搜索页使用虚拟滚动,优化滚动性能 + ## [100.1.0] - 2026-02-27 ### Added diff --git a/VERSION.txt b/VERSION.txt index 5aa8d51..f890b16 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -100.1.0 \ No newline at end of file +100.1.1 \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d816a7a --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/package.json b/package.json index e5be487..ee4f82f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa9521..b679f62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/dev-docker.sh b/scripts/dev-docker.sh new file mode 100755 index 0000000..50bd82d --- /dev/null +++ b/scripts/dev-docker.sh @@ -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 diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 33b0207..b8c355c 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -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() { {/* 内容展示区域 */}
{/* 内容网格 */} -
- {loading || !selectorsReady - ? // 显示骨架屏 - skeletonData.map((index) => ) - : // 显示实际数据 - doubanData.map((item, index) => ( + {loading || !selectorsReady + ? // 显示骨架屏 +
+ {skeletonData.map((index) => )} +
+ : // 显示实际数据 + (
- ))} -
+ )} + /> + } {/* 加载更多指示器 */} {hasMore && !loading && ( diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c63742a..cfe751c 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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() {
) ) : ( -
- {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'; +
+ {viewMode === 'agg' ? ( + { + 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 ( -
+ return ( +
+ +
+ ); + }} + /> + ) : ( + ( +
1 ? 'tv' : 'movie'} />
- ); - }) - : filteredAllResults.map((item) => ( -
- 1 ? 'tv' : 'movie'} - /> -
- ))} + )} + /> + )}
)} diff --git a/src/components/VirtualGrid.tsx b/src/components/VirtualGrid.tsx new file mode 100644 index 0000000..82537f3 --- /dev/null +++ b/src/components/VirtualGrid.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useVirtualizer } from '@tanstack/react-virtual'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +interface VirtualGridProps { + 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({ + items, + renderItem, + estimateRowHeight = 320, + rowGapClass = 'pb-14 sm:pb-20', + overscan = 3, + className = '', +}: VirtualGridProps) { + const parentRef = useRef(null); + const [columns, setColumns] = useState(3); + + // Detect column count from a hidden probe row + const probeRef = useRef(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 */} +
+
+
+ +
+ {virtualRows.map((virtualRow) => { + const startIdx = virtualRow.index * columns; + const rowItems = items.slice(startIdx, startIdx + columns); + + return ( +
+
+ {rowItems.map((item, i) => ( + + {renderItem(item, startIdx + i)} + + ))} +
+
+ ); + })} +
+ + ); +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index ca44f3d..f22f9b9 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -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",