mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-02 17:27:31 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31653c1dd7 | ||
|
|
e1ab9d5b8d |
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -5,7 +5,9 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
// Tailwind CSS Autocomplete, add more if used in projects
|
// Tailwind CSS Autocomplete, add more if used in projects
|
||||||
"tailwindCSS.classAttributes": [
|
"tailwindCSS.classAttributes": [
|
||||||
"class",
|
"class",
|
||||||
@@ -14,4 +16,4 @@
|
|||||||
"containerClassName"
|
"containerClassName"
|
||||||
],
|
],
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
## [100.1.1] - 2026-02-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 搜索页使用虚拟滚动,优化滚动性能
|
||||||
|
|
||||||
## [100.1.0] - 2026-02-27
|
## [100.1.0] - 2026-02-27
|
||||||
|
|
||||||
### Added
|
### 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",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@headlessui/react": "^2.2.4",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@tanstack/react-virtual": "^3.13.19",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@upstash/redis": "^1.25.0",
|
"@upstash/redis": "^1.25.0",
|
||||||
"@vidstack/react": "^1.12.13",
|
"@vidstack/react": "^1.12.13",
|
||||||
|
|||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@heroicons/react':
|
'@heroicons/react':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0(react@18.3.1)
|
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':
|
'@types/crypto-js':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
@@ -1410,14 +1413,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
|
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':
|
'@tanstack/react-virtual@3.13.19':
|
||||||
resolution: {integrity: sha512-nvrzk4E9mWB4124YdJ7/yzwou7IfHxlSef6ugCFcBfRmsnsma3heciiiV97sBNxyc3VuwtZvmwXd0aB5BpucVw==}
|
resolution: {integrity: sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.10':
|
'@tanstack/virtual-core@3.13.19':
|
||||||
resolution: {integrity: sha512-sPEDhXREou5HyZYqSWIqdU580rsF6FGeN7vpzijmP3KTiOGjOMZASz4Y6+QKjiFQwhWrR58OP8izYaNGVxvViA==}
|
resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.0':
|
'@testing-library/dom@10.4.0':
|
||||||
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
|
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)
|
'@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/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)
|
'@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: 18.3.1
|
||||||
react-dom: 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)
|
use-sync-external-store: 1.5.0(react@18.3.1)
|
||||||
@@ -6865,13 +6868,13 @@ snapshots:
|
|||||||
mini-svg-data-uri: 1.4.4
|
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))
|
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:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.10
|
'@tanstack/virtual-core': 3.13.19
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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':
|
'@testing-library/dom@10.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8220,8 +8223,8 @@ snapshots:
|
|||||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
'@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-node: 0.3.9
|
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)
|
||||||
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)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(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: 7.37.5(eslint@8.57.1)
|
||||||
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
|
||||||
@@ -8244,7 +8247,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.1(supports-color@9.4.0)
|
debug: 4.4.1(supports-color@9.4.0)
|
||||||
@@ -8255,22 +8258,22 @@ snapshots:
|
|||||||
tinyglobby: 0.2.14
|
tinyglobby: 0.2.14
|
||||||
unrs-resolver: 1.9.0
|
unrs-resolver: 1.9.0
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
'@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-node: 0.3.9
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
@@ -8281,7 +8284,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
eslint-import-resolver-node: 0.3.9
|
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
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|||||||
@@ -195,18 +195,20 @@ function main() {
|
|||||||
|
|
||||||
fs.writeFileSync(outputPath, tsContent, 'utf-8');
|
fs.writeFileSync(outputPath, tsContent, 'utf-8');
|
||||||
|
|
||||||
|
// 读取 VERSION.txt 并同步到 version.ts
|
||||||
|
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
|
||||||
|
const versionFromFile = fs.readFileSync(versionTxtPath, 'utf8').trim();
|
||||||
|
console.log(`📄 VERSION.txt 版本: ${versionFromFile}`);
|
||||||
|
updateVersionTs(versionFromFile);
|
||||||
|
|
||||||
// 检查是否在 GitHub Actions 环境中运行
|
// 检查是否在 GitHub Actions 环境中运行
|
||||||
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
|
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
|
||||||
|
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
// 在 GitHub Actions 中,更新版本文件
|
// 在 GitHub Actions 中,更新 VERSION.txt 为 CHANGELOG 最新版本
|
||||||
console.log('正在更新版本文件...');
|
console.log('正在更新 VERSION.txt...');
|
||||||
updateVersionFile(latestVersion);
|
updateVersionFile(latestVersion);
|
||||||
updateVersionTs(latestVersion);
|
updateVersionTs(latestVersion);
|
||||||
} else {
|
|
||||||
// 在本地运行时,只提示但不更新版本文件
|
|
||||||
console.log('🔧 本地运行模式:跳过版本文件更新');
|
|
||||||
console.log('💡 版本文件更新将在 git tag 触发的 release 工作流中完成');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ 成功生成 ${outputPath}`);
|
console.log(`✅ 成功生成 ${outputPath}`);
|
||||||
|
|||||||
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 DoubanSelector from '@/components/DoubanSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
import VirtualGrid from '@/components/VirtualGrid';
|
||||||
|
|
||||||
function DoubanPageClient() {
|
function DoubanPageClient() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -754,12 +755,18 @@ function DoubanPageClient() {
|
|||||||
{/* 内容展示区域 */}
|
{/* 内容展示区域 */}
|
||||||
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
<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
|
||||||
{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} />)
|
{skeletonData.map((index) => <DoubanCardSkeleton key={index} />)}
|
||||||
: // 显示实际数据
|
</div>
|
||||||
doubanData.map((item, index) => (
|
: // 显示实际数据
|
||||||
|
<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'>
|
<div key={`${item.title}-${index}`} className='w-full'>
|
||||||
<VideoCard
|
<VideoCard
|
||||||
from='douban'
|
from='douban'
|
||||||
@@ -774,8 +781,9 @@ function DoubanPageClient() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 加载更多指示器 */}
|
||||||
{hasMore && !loading && (
|
{hasMore && !loading && (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import PageLayout from '@/components/PageLayout';
|
|||||||
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 VirtualGrid from '@/components/VirtualGrid';
|
||||||
|
|
||||||
function SearchPageClient() {
|
function SearchPageClient() {
|
||||||
// 搜索历史
|
// 搜索历史
|
||||||
@@ -759,69 +760,79 @@ function SearchPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div key={`search-results-${viewMode}`}>
|
||||||
key={`search-results-${viewMode}`}
|
{viewMode === 'agg' ? (
|
||||||
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'
|
<VirtualGrid
|
||||||
>
|
items={filteredAggResults}
|
||||||
{viewMode === 'agg'
|
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'
|
||||||
? filteredAggResults.map(([mapKey, group]) => {
|
rowGapClass='pb-14 sm:pb-20'
|
||||||
const title = group[0]?.title || '';
|
estimateRowHeight={320}
|
||||||
const poster = group[0]?.poster || '';
|
renderItem={([mapKey, group]) => {
|
||||||
const year = group[0]?.year || 'unknown';
|
const title = group[0]?.title || '';
|
||||||
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
const poster = group[0]?.poster || '';
|
||||||
const type = episodes === 1 ? 'movie' : 'tv';
|
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)) {
|
||||||
if (!groupStatsRef.current.has(mapKey)) {
|
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
||||||
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`agg-${mapKey}`} className='w-full'>
|
<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
|
<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) => (
|
)}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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[] = [
|
export const changelog: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "100.1.1",
|
||||||
|
date: "2026-02-27",
|
||||||
|
added: [
|
||||||
|
// 无新增内容
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"搜索页使用虚拟滚动,优化滚动性能"
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
// 无修复内容
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "100.1.0",
|
version: "100.1.0",
|
||||||
date: "2026-02-27",
|
date: "2026-02-27",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const CURRENT_VERSION = '100.1.0';
|
const CURRENT_VERSION = '100.1.1';
|
||||||
|
|
||||||
// 导出当前版本号供其他地方使用
|
// 导出当前版本号供其他地方使用
|
||||||
export { CURRENT_VERSION };
|
export { CURRENT_VERSION };
|
||||||
|
|||||||
Reference in New Issue
Block a user