55 Commits

Author SHA1 Message Date
senshinya 9c8609a634 Merge pull request #191 from RiverOnVenus/lowercase-patch
fix: ensure GHCR image tags use lowercase repository owner
2025-07-18 00:47:26 +08:00
shinya e2aeb352a1 fix: add fallback savetime 2025-07-18 00:38:10 +08:00
shinya da23e04564 fix: add log 2025-07-18 00:28:08 +08:00
shinya 286beba206 fix: build error 2025-07-18 00:19:39 +08:00
shinya 1e2f05b3a1 fix: fix null search_title 2025-07-18 00:16:10 +08:00
RiverOnVenus e4d8a03071 fix: ensure GHCR image tags use lowercase repository owner 2025-07-17 23:54:40 +08:00
shinya 880b6282c0 feat: adjust douban page mobile style 2025-07-17 21:20:20 +08:00
senshinya a007b7a50f Merge pull request #185 from JohnsonRan/manifest
feat: generate manifest.json for docker too
2025-07-17 16:17:35 +08:00
JohnsonRan 41c1c3989b feat: generate manifest.json for docker too
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-17 15:49:58 +08:00
shinya 0ab4115f6d fix: safari show resolution fallback 2025-07-17 10:24:02 +08:00
shinya a412c5cb54 feat: adjust favorite grid style 2025-07-17 00:25:30 +08:00
shinya 1a6da8b7bc fix: change selector name 2025-07-17 00:16:47 +08:00
shinya 4fe8f37c7a fix: keep secondary selection unchange when changing primary selection 2025-07-17 00:02:16 +08:00
shinya e1521179d4 feat: new douban page and selector 2025-07-16 23:34:28 +08:00
senshinya 53a1b6603b Merge pull request #177 from JohnsonRan/adblockbtn
chore: more understandable AD block button
2025-07-16 18:52:00 +08:00
shinya af2d257344 fix: prefer select 2025-07-16 18:38:05 +08:00
JohnsonRan 79418b66dc chore: more understandable AD block button
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-16 17:40:06 +08:00
senshinya 1cd7ad3b7b Merge pull request #174 from JohnsonRan/dynsitename
feat: generate PWA site name dynamically
2025-07-16 15:31:01 +08:00
JohnsonRan 623edd7959 feat: generate PWA site name dynamically
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-16 15:13:41 +08:00
shinya df99bff693 fix: z-index 2025-07-16 12:26:28 +08:00
senshinya 1783944024 Update page.tsx 2025-07-16 02:06:09 +08:00
shinya b1073be89d fix: scrollable row index 2025-07-16 01:23:58 +08:00
shinya c7b7d90238 feat: adjust 2xl style 2025-07-16 01:02:54 +08:00
shinya 4772a4120d feat: add readme 2025-07-15 23:26:00 +08:00
senshinya 962e3b2656 Merge pull request #166 from JohnsonRan/medumb
fix: forgot there's downstream repos
2025-07-15 22:46:52 +08:00
旋律已经死了。 2f4a2e936c fix: forgot there's downstream repos 2025-07-15 22:43:13 +08:00
shinya af03f9f149 fix: image proxy logic 2025-07-15 22:28:01 +08:00
shinya 90129c0d69 feat: add global image proxy config 2025-07-15 22:20:42 +08:00
shinya cca4092519 feat: add optimizationEnabled switch 2025-07-15 21:37:15 +08:00
shinya 1c06174453 fix: setting panel z-index 2025-07-15 20:51:27 +08:00
shinya 318253632d fix: episode selector styles 2025-07-15 20:48:44 +08:00
shinya 644174eae0 feat: cf merge file config 2025-07-15 20:42:07 +08:00
senshinya 205b25c9a8 Merge pull request #162 from JohnsonRan/perm
fix: no need set permissions here
2025-07-15 16:46:08 +08:00
旋律已经死了。 a74136ee1e fix: no need set permissions here 2025-07-15 16:11:25 +08:00
senshinya 9709320ea9 Merge pull request #160 from 1411430556/patch-1
docs: Update README.md
2025-07-15 13:25:27 +08:00
shinya 628f0d7425 fix: add tooltip back #close 161 2025-07-15 13:24:59 +08:00
shinya 1626ccab2c feat: unify local cache 2025-07-15 13:13:17 +08:00
COYG⚡️ 997c983677 docs: Update README.md 2025-07-15 12:08:25 +08:00
senshinya 792467c3f2 Merge pull request #159 from JohnsonRan/rounded
feat: rounded icons
2025-07-15 11:55:40 +08:00
JohnsonRan 76daee4e41 feat: rounded icons
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-15 11:54:27 +08:00
senshinya 94c7b84a5d Merge pull request #158 from OuOumm/main
refactor(组件): 优化滚动行和视频卡的样式与动画效果
2025-07-15 11:42:05 +08:00
senshinya de99152543 Merge pull request #157 from JohnsonRan/workflows
ci: optimize workflow runs
2025-07-15 11:39:25 +08:00
SongPro f187e56e2e refactor(组件): 优化滚动行和视频卡的样式与动画效果
- 调整 ScrollableRow 的内边距样式
- 简化 ImagePlaceholder 的类名并修改圆角大小
- 重构 VideoCard 的悬停动画和交互效果,改进加载状态处理
- 统一过渡动画的缓动函数和持续时间
2025-07-15 11:27:58 +08:00
JohnsonRan 2f4b4a2815 ci: optimize workflow runs
- Add Docker build cache for faster builds
- Increase sync frequency from daily to every 6 hours
- keep latest 3 workflow runs only
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-15 11:15:59 +08:00
shinya 656c1c256f fix: cron condition 2025-07-15 01:54:45 +08:00
shinya 61cd291574 feat: add local settings 2025-07-15 00:35:28 +08:00
shinya 76eacd97f9 fix: auth 2025-07-14 22:49:56 +08:00
senshinya 3add216e97 Merge pull request #152 from senshinya/d1_cache
feat: d1 local cache
2025-07-14 22:16:37 +08:00
shinya b61430856a feat: d1 local cache 2025-07-14 22:09:53 +08:00
shinya 7e6f4bcadc feat: update readme 2025-07-14 21:02:59 +08:00
shinya c2ebf5758e fix: add await 2025-07-14 20:53:40 +08:00
senshinya 446bb0e9f0 Update layout.tsx 2025-07-14 19:59:19 +08:00
shinya ea3d1065e8 feat: lower d1 saveCurrentPlayProgress frequency 2025-07-14 13:23:46 +08:00
shinya eb3bffab4e fix: sitename 2025-07-14 13:21:37 +08:00
senshinya 45dfdc62f0 Merge pull request #147 from senshinya/d1
support D1 storage
2025-07-14 13:15:39 +08:00
53 changed files with 3128 additions and 741 deletions
+16 -7
View File
@@ -5,11 +5,6 @@ on:
branches: branches:
- main - main
# 写入/读取 package 权限,用于推送到 GHCR (ghcr.io)
permissions:
contents: read
packages: write
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -31,6 +26,10 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase repository owner
id: lowercase
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -39,5 +38,15 @@ jobs:
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: |
ghcr.io/${{ github.repository_owner }}/moontv:latest ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:latest
ghcr.io/${{ github.repository_owner }}/moontv:${{ github.sha }} ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Delete workflow runs
uses: Mattraks/delete-workflow-runs@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
retain_days: 0
keep_minimum_runs: 2
+14 -5
View File
@@ -1,13 +1,14 @@
name: Upstream Sync name: Upstream Sync
permissions:
contents: write
on: on:
schedule: schedule:
- cron: "0 4 * * *" # At 12PM UTC+8 - cron: "0 */6 * * *" # run every 6 hours
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
actions: write
jobs: jobs:
sync_latest_from_upstream: sync_latest_from_upstream:
name: Sync latest commits from upstream repo name: Sync latest commits from upstream repo
@@ -33,4 +34,12 @@ jobs:
if: failure() if: failure()
run: | run: |
echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork." echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork."
exit 1 exit 1
- name: Delete workflow runs
uses: Mattraks/delete-workflow-runs@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
retain_days: 0
keep_minimum_runs: 2
+63 -63
View File
@@ -1,75 +1,75 @@
```sql ```sql
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY, username TEXT PRIMARY KEY,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
CREATE TABLE IF NOT EXISTS play_records ( CREATE TABLE IF NOT EXISTS play_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
source_name TEXT NOT NULL, source_name TEXT NOT NULL,
cover TEXT NOT NULL, cover TEXT NOT NULL,
year TEXT NOT NULL, year TEXT NOT NULL,
index_episode INTEGER NOT NULL, index_episode INTEGER NOT NULL,
total_episodes INTEGER NOT NULL, total_episodes INTEGER NOT NULL,
play_time INTEGER NOT NULL, play_time INTEGER NOT NULL,
total_time INTEGER NOT NULL, total_time INTEGER NOT NULL,
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
search_title TEXT, search_title TEXT,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
source_name TEXT NOT NULL, source_name TEXT NOT NULL,
cover TEXT NOT NULL, cover TEXT NOT NULL,
year TEXT NOT NULL, year TEXT NOT NULL,
total_episodes INTEGER NOT NULL, total_episodes INTEGER NOT NULL,
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS search_history ( CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
keyword TEXT NOT NULL, keyword TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(username, keyword) UNIQUE(username, keyword)
); );
CREATE TABLE IF NOT EXISTS admin_config ( CREATE TABLE IF NOT EXISTS admin_config (
id INTEGER PRIMARY KEY DEFAULT 1, id INTEGER PRIMARY KEY DEFAULT 1,
config TEXT NOT NULL, config TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
-- 基本索引 -- 基本索引
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username); CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username); CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username); CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
-- 复合索引优化查询性能 -- 复合索引优化查询性能
-- 播放记录:用户名+键值的复合索引,用于快速查找特定记录 -- 播放记录:用户名+键值的复合索引,用于快速查找特定记录
CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key); CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key);
-- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询 -- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC);
-- 收藏:用户名+键值的复合索引,用于快速查找特定收藏 -- 收藏:用户名+键值的复合索引,用于快速查找特定收藏
CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key); CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key);
-- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询 -- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC);
-- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录 -- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录
CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword); CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword);
-- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询 -- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC);
-- 搜索历史清理查询的优化索引 -- 搜索历史清理查询的优化索引
CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC);
``` ```
+2
View File
@@ -48,6 +48,8 @@ ENV DOCKER_ENV=true
# 从构建器中复制 standalone 输出 # 从构建器中复制 standalone 输出
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js # 从构建器中复制 start.js
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录 # 从构建器中复制 public 和 .next/static 目录
+27 -15
View File
@@ -23,10 +23,10 @@
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。 - 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。 - 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。 - ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
- ❤️ **收藏 + 继续观看**Docker 部署支持 Redis 存储,多端同步进度。 - ❤️ **收藏 + 继续观看**:支持 Redis/D1 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel。 - 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性) - 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
<details> <details>
@@ -78,6 +78,10 @@
### Cloudflare 部署 ### Cloudflare 部署
**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**
#### 普通部署(localstorage
1. **Fork** 本仓库到你的 GitHub 账户。 1. **Fork** 本仓库到你的 GitHub 账户。
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers-> Workers 和 Pages**,点击创建 2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers-> Workers 和 Pages**,点击创建
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库 3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
@@ -87,6 +91,14 @@
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。 7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
8. 每次 Push 到 `main` 分支将自动触发重新构建。 8. 每次 Push 到 `main` 分支将自动触发重新构建。
#### D1 支持
1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意
2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 Run All,等待运行完成
3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 d1;设置 USERNAME 和 PASSWORD 作为站长账号
5. 重试部署
### Docker 部署 ### Docker 部署
> 适用于自建服务器 / NAS / 群晖等场景。 > 适用于自建服务器 / NAS / 群晖等场景。
@@ -102,7 +114,7 @@ docker pull ghcr.io/senshinya/moontv:latest
docker run -d --name moontv -p 3000:3000 ghcr.io/senshinya/moontv:latest docker run -d --name moontv -p 3000:3000 ghcr.io/senshinya/moontv:latest
``` ```
访问 `http://服务器 IP:3000` 即可。 访问 `http://服务器 IP:3000` 即可。(需自行到服务器控制台放通 `3000` 端口)
## Docker Compose 最佳实践 ## Docker Compose 最佳实践
@@ -170,17 +182,17 @@ networks:
## 环境变量 ## 环境变量
| 变量 | 说明 | 可选值 | 默认值 | | 变量 | 说明 | 可选值 | 默认值 |
| ----------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) | | USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) | | PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV | | SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | | ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage | | NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage(本地浏览器存储)、redis(仅 docker 支持) | localstorage |
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 | | REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false | | NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在 redis 部署时生效 | true / false | false |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | | NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT | 搜索结果默认是否按标题和年份聚合 | true / false | true | | NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
## 配置说明 ## 配置说明
@@ -213,7 +225,7 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。
## 管理员配置 ## 管理员配置
**该特性目前仅支持通过 Docker Redis 的部署方式使用** **该特性目前仅支持通过 Docker+Redis 或 Cloudflare+D1 的部署方式使用**
支持在运行时动态变更服务配置 支持在运行时动态变更服务配置
+6 -4
View File
@@ -3,8 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:runtime && next dev -H 0.0.0.0", "dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:runtime && next build", "build": "pnpm gen:runtime && pnpm gen:manifest && next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format", "lint:fix": "eslint src --fix && pnpm format",
@@ -15,9 +15,10 @@
"format": "prettier -w .", "format": "prettier -w .",
"format:check": "prettier -c .", "format:check": "prettier -c .",
"gen:runtime": "node scripts/convert-config.js", "gen:runtime": "node scripts/convert-config.js",
"gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'", "postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install", "prepare": "husky install",
"pages:build": "pnpm gen:runtime && next build && npx @cloudflare/next-on-pages --experimental-minify" "pages:build": "pnpm gen:runtime && pnpm gen:manifest && next build && npx @cloudflare/next-on-pages --experimental-minify"
}, },
"dependencies": { "dependencies": {
"@cloudflare/next-on-pages": "^1.13.12", "@cloudflare/next-on-pages": "^1.13.12",
@@ -57,6 +58,7 @@
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/node": "24.0.3", "@types/node": "24.0.3",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
@@ -86,4 +88,4 @@
] ]
}, },
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
} }
+12
View File
@@ -114,6 +114,9 @@ importers:
'@types/react': '@types/react':
specifier: ^18.3.18 specifier: ^18.3.18
version: 18.3.23 version: 18.3.23
'@types/react-dom':
specifier: ^19.1.6
version: 19.1.6(@types/react@18.3.23)
'@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
@@ -1918,6 +1921,11 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^18.0.0 '@types/react': ^18.0.0
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
'@types/react': ^19.0.0
'@types/react@18.3.23': '@types/react@18.3.23':
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
@@ -8342,6 +8350,10 @@ snapshots:
dependencies: dependencies:
'@types/react': 18.3.23 '@types/react': 18.3.23
'@types/react-dom@19.1.6(@types/react@18.3.23)':
dependencies:
'@types/react': 18.3.23
'@types/react@18.3.23': '@types/react@18.3.23':
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
+240
View File
@@ -0,0 +1,240 @@
/* eslint-disable */
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
try {
const url = new URL(request.url);
// 如果访问根目录,返回HTML
if (url.pathname === '/') {
return new Response(getRootHtml(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
// 从请求路径中提取目标 URL
let actualUrlStr = decodeURIComponent(url.pathname.replace('/', ''));
// 判断用户输入的 URL 是否带有协议
actualUrlStr = ensureProtocol(actualUrlStr, url.protocol);
// 保留查询参数
actualUrlStr += url.search;
// 创建新 Headers 对象,排除以 'cf-' 开头的请求头
const newHeaders = filterHeaders(
request.headers,
(name) => !name.startsWith('cf-')
);
// 创建一个新的请求以访问目标 URL
const modifiedRequest = new Request(actualUrlStr, {
headers: newHeaders,
method: request.method,
body: request.body,
redirect: 'manual',
});
// 发起对目标 URL 的请求
const response = await fetch(modifiedRequest);
let body = response.body;
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.status)) {
body = response.body;
// 创建新的 Response 对象以修改 Location 头部
return handleRedirect(response, body);
} else if (response.headers.get('Content-Type')?.includes('text/html')) {
body = await handleHtmlContent(
response,
url.protocol,
url.host,
actualUrlStr
);
}
// 创建修改后的响应对象
const modifiedResponse = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// 添加禁用缓存的头部
setNoCacheHeaders(modifiedResponse.headers);
// 添加 CORS 头部,允许跨域访问
setCorsHeaders(modifiedResponse.headers);
return modifiedResponse;
} catch (error) {
// 如果请求目标地址时出现错误,返回带有错误消息的响应和状态码 500(服务器错误)
return jsonResponse(
{
error: error.message,
},
500
);
}
}
// 确保 URL 带有协议
function ensureProtocol(url, defaultProtocol) {
return url.startsWith('http://') || url.startsWith('https://')
? url
: defaultProtocol + '//' + url;
}
// 处理重定向
function handleRedirect(response, body) {
const location = new URL(response.headers.get('location'));
const modifiedLocation = `/${encodeURIComponent(location.toString())}`;
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
...response.headers,
Location: modifiedLocation,
},
});
}
// 处理 HTML 内容中的相对路径
async function handleHtmlContent(response, protocol, host, actualUrlStr) {
const originalText = await response.text();
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
let modifiedText = replaceRelativePaths(
originalText,
protocol,
host,
new URL(actualUrlStr).origin
);
return modifiedText;
}
// 替换 HTML 内容中的相对路径
function replaceRelativePaths(text, protocol, host, origin) {
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
return text.replace(regex, `$1${protocol}//${host}/${origin}/`);
}
// 返回 JSON 格式的响应
function jsonResponse(data, status) {
return new Response(JSON.stringify(data), {
status: status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
}
// 过滤请求头
function filterHeaders(headers, filterFunc) {
return new Headers([...headers].filter(([name]) => filterFunc(name)));
}
// 设置禁用缓存的头部
function setNoCacheHeaders(headers) {
headers.set('Cache-Control', 'no-store');
}
// 设置 CORS 头部
function setCorsHeaders(headers) {
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
headers.set('Access-Control-Allow-Headers', '*');
}
// 返回根目录的 HTML
function getRootHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
<title>Proxy Everything</title>
<link rel="icon" type="image/png" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="Description" content="Proxy Everything with CF Workers.">
<meta property="og:description" content="Proxy Everything with CF Workers.">
<meta property="og:image" content="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="robots" content="index, follow">
<meta http-equiv="Content-Language" content="zh-CN">
<meta name="copyright" content="Copyright © ymyuuu">
<meta name="author" content="ymyuuu">
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<style>
body, html {
height: 100%;
margin: 0;
}
.background {
background-image: url('https://imgapi.cn/bing.php');
background-size: cover;
background-position: center;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background-color: rgba(255, 255, 255, 0.8);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
background-color: rgba(255, 255, 255, 1);
box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.3);
}
.input-field input[type=text] {
color: #2c3e50;
}
.input-field input[type=text]:focus+label {
color: #2c3e50 !important;
}
.input-field input[type=text]:focus {
border-bottom: 1px solid #2c3e50 !important;
box-shadow: 0 1px 0 0 #2c3e50 !important;
}
</style>
</head>
<body>
<div class="background">
<div class="container">
<div class="row">
<div class="col s12 m8 offset-m2 l6 offset-l3">
<div class="card">
<div class="card-content">
<span class="card-title center-align"><i class="material-icons left">link</i>Proxy Everything</span>
<form id="urlForm" onsubmit="redirectToProxy(event)">
<div class="input-field">
<input type="text" id="targetUrl" placeholder="在此输入目标地址" required>
<label for="targetUrl">目标地址</label>
</div>
<button type="submit" class="btn waves-effect waves-light teal darken-2 full-width">跳转</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script>
function redirectToProxy(event) {
event.preventDefault();
const targetUrl = document.getElementById('targetUrl').value.trim();
const currentOrigin = window.location.origin;
window.open(currentOrigin + '/' + encodeURIComponent(targetUrl), '_blank');
}
</script>
</body>
</html>`;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 169 KiB

+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/* eslint-disable */
// 根据 SITE_NAME 动态生成 manifest.json
const fs = require('fs');
const path = require('path');
// 获取项目根目录
const projectRoot = path.resolve(__dirname, '..');
const publicDir = path.join(projectRoot, 'public');
const manifestPath = path.join(publicDir, 'manifest.json');
// 从环境变量获取站点名称
const siteName = process.env.SITE_NAME || 'MoonTV';
// manifest.json 模板
const manifestTemplate = {
"name": siteName,
"short_name": siteName,
"description": "影视聚合",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"apple-mobile-web-app-capable": "yes",
"apple-mobile-web-app-status-bar-style": "black",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
};
try {
// 确保 public 目录存在
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// 写入 manifest.json
fs.writeFileSync(manifestPath, JSON.stringify(manifestTemplate, null, 2));
console.log(`✅ Generated manifest.json with site name: ${siteName}`);
} catch (error) {
console.error('❌ Error generating manifest.json:', error);
process.exit(1);
}
+23 -24
View File
@@ -50,7 +50,7 @@ interface SiteConfig {
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
} }
// 视频源数据类型 // 视频源数据类型
@@ -948,7 +948,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
Announcement: '', Announcement: '',
SearchDownstreamMaxPage: 1, SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200, SiteInterfaceCacheTime: 7200,
SearchResultDefaultAggregate: false, ImageProxy: '',
}); });
// 保存状态 // 保存状态
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -960,7 +960,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
useEffect(() => { useEffect(() => {
if (config?.SiteConfig) { if (config?.SiteConfig) {
setSiteSettings(config.SiteConfig); setSiteSettings({
...config.SiteConfig,
ImageProxy: config.SiteConfig.ImageProxy || '',
});
} }
}, [config]); }, [config]);
@@ -1094,43 +1097,39 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
/> />
</div> </div>
{/* 默认按标题和年份聚合 */} {/* 图片代理 */}
<div className='flex items-center justify-between'> <div>
<label <label
className={`text-gray-700 dark:text-gray-300 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isD1Storage ? 'opacity-50' : '' isD1Storage ? 'opacity-50' : ''
}`} }`}
> >
{isD1Storage && ( {isD1Storage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'> <span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(D1 ) (D1 )
</span> </span>
)} )}
</label> </label>
<button <input
onClick={() => type='text'
placeholder='例如: https://imageproxy.example.com/?url='
value={siteSettings.ImageProxy}
onChange={(e) =>
!isD1Storage && !isD1Storage &&
setSiteSettings((prev) => ({ setSiteSettings((prev) => ({
...prev, ...prev,
SearchResultDefaultAggregate: !prev.SearchResultDefaultAggregate, ImageProxy: e.target.value,
})) }))
} }
disabled={isD1Storage} disabled={isD1Storage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
siteSettings.SearchResultDefaultAggregate isD1Storage ? 'opacity-50 cursor-not-allowed' : ''
? 'bg-green-600' }`}
: 'bg-gray-200 dark:bg-gray-700' />
} ${isD1Storage ? 'opacity-50 cursor-not-allowed' : ''}`} <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
> 访访使
<span </p>
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
siteSettings.SearchResultDefaultAggregate
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
+4 -4
View File
@@ -33,13 +33,13 @@ export async function POST(request: NextRequest) {
Announcement, Announcement,
SearchDownstreamMaxPage, SearchDownstreamMaxPage,
SiteInterfaceCacheTime, SiteInterfaceCacheTime,
SearchResultDefaultAggregate, ImageProxy,
} = body as { } = body as {
SiteName: string; SiteName: string;
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
}; };
// 参数校验 // 参数校验
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
typeof Announcement !== 'string' || typeof Announcement !== 'string' ||
typeof SearchDownstreamMaxPage !== 'number' || typeof SearchDownstreamMaxPage !== 'number' ||
typeof SiteInterfaceCacheTime !== 'number' || typeof SiteInterfaceCacheTime !== 'number' ||
typeof SearchResultDefaultAggregate !== 'boolean' typeof ImageProxy !== 'string'
) { ) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
Announcement, Announcement,
SearchDownstreamMaxPage, SearchDownstreamMaxPage,
SiteInterfaceCacheTime, SiteInterfaceCacheTime,
SearchResultDefaultAggregate, ImageProxy,
}; };
// 写入数据库 // 写入数据库
+2 -2
View File
@@ -37,9 +37,9 @@ export async function GET(request: NextRequest) {
async function refreshRecordAndFavorites() { async function refreshRecordAndFavorites() {
if ( if (
process.env.NEXT_PUBLIC_STORAGE_TYPE || (process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
'localstorage' === 'localstorage'
) { ) {
console.log('跳过刷新:当前使用 localstorage 存储模式');
return; return;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
} }
const result = await getDetailFromApi(apiSite, id); const result = await getDetailFromApi(apiSite, id);
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(result, { return NextResponse.json(result, {
headers: { headers: {
+129
View File
@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
async function fetchDoubanData(
url: string
): Promise<DoubanCategoryApiResponse> {
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 设置请求选项,包括信号和头部
const fetchOptions = {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept: 'application/json, text/plain, */*',
Origin: 'https://movie.douban.com',
},
};
try {
// 尝试直接访问豆瓣API
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind') || 'movie';
const category = searchParams.get('category');
const type = searchParams.get('type');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
// 验证参数
if (!kind || !category || !type) {
return NextResponse.json(
{ error: '缺少必要参数: kind 或 category 或 type' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(kind)) {
return NextResponse.json(
{ error: 'kind 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageLimit < 1 || pageLimit > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}`,
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
+2 -2
View File
@@ -108,7 +108,7 @@ export async function GET(request: Request) {
list: list, list: list,
}; };
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(response, { return NextResponse.json(response, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}`,
@@ -180,7 +180,7 @@ function handleTop250(pageStart: number) {
list: movies, list: movies,
}; };
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(apiResponse, { return NextResponse.json(apiResponse, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}`,
+3 -3
View File
@@ -89,12 +89,12 @@ export async function POST(request: NextRequest) {
); );
} }
const favoriteWithoutUserId = { const finalFavorite = {
...favorite, ...favorite,
save_time: favorite.save_time ?? Date.now(), save_time: favorite.save_time ?? Date.now(),
} as Omit<Favorite, 'user_id'>; } as Favorite;
await db.saveFavorite(authInfo.username, source, id, favoriteWithoutUserId); await db.saveFavorite(authInfo.username, source, id, finalFavorite);
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });
} catch (err) { } catch (err) {
+1 -1
View File
@@ -43,7 +43,7 @@ export async function GET(request: Request) {
} }
// 设置缓存头(可选) // 设置缓存头(可选)
headers.set('Cache-Control', 'public, max-age=86400'); // 缓存24小时 headers.set('Cache-Control', 'public, max-age=15720000'); // 缓存半年
// 直接返回图片流 // 直接返回图片流
return new Response(imageResponse.body, { return new Response(imageResponse.body, {
+6 -1
View File
@@ -62,7 +62,12 @@ export async function POST(request: NextRequest) {
); );
} }
await db.savePlayRecord(authInfo.username, source, id, record); const finalRecord = {
...record,
save_time: record.save_time ?? Date.now(),
} as PlayRecord;
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });
} catch (err) { } catch (err) {
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
const resourceId = searchParams.get('resourceId'); const resourceId = searchParams.get('resourceId');
if (!query || !resourceId) { if (!query || !resourceId) {
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json( return NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' }, { result: null, error: '缺少必要参数: q 或 resourceId' },
{ {
@@ -40,7 +40,7 @@ export async function GET(request: Request) {
const results = await searchFromApi(targetSite, query); const results = await searchFromApi(targetSite, query);
const result = results.filter((r) => r.title === query); const result = results.filter((r) => r.title === query);
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
if (result.length === 0) { if (result.length === 0) {
return NextResponse.json( return NextResponse.json(
+2 -2
View File
@@ -7,8 +7,8 @@ export const runtime = 'edge';
// OrionTV 兼容接口 // OrionTV 兼容接口
export async function GET() { export async function GET() {
try { try {
const apiSites = getAvailableApiSites(); const apiSites = await getAvailableApiSites();
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(apiSites, { return NextResponse.json(apiSites, {
headers: { headers: {
+1 -1
View File
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
try { try {
const results = await Promise.all(searchPromises); const results = await Promise.all(searchPromises);
const flattenedResults = results.flat(); const flattenedResults = results.flat();
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json( return NextResponse.json(
{ results: flattenedResults }, { results: flattenedResults },
+216 -132
View File
@@ -1,36 +1,136 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps */
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DoubanItem, DoubanResult } from '@/lib/types'; import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton'; import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
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';
function DoubanPageClient() { function DoubanPageClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]); const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null); const loadingRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const type = searchParams.get('type'); const type = searchParams.get('type') || 'movie';
const tag = searchParams.get('tag');
// 选择器状态 - 完全独立,不依赖URL参数
const [primarySelection, setPrimarySelection] = useState<string>(() => {
return type === 'movie' ? '热门' : '';
});
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
if (type === 'movie') return '全部';
if (type === 'tv') return 'tv';
if (type === 'show') return 'show';
return '全部';
});
// 初始化时标记选择器为准备好状态
useEffect(() => {
// 短暂延迟确保初始状态设置完成
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// type变化时立即重置selectorsReady(最高优先级)
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
}, [type]);
// 当type变化时重置选择器状态
useEffect(() => {
// 批量更新选择器状态
if (type === 'movie') {
setPrimarySelection('热门');
setSecondarySelection('全部');
} else if (type === 'tv') {
setPrimarySelection('');
setSecondarySelection('tv');
} else if (type === 'show') {
setPrimarySelection('');
setSecondarySelection('show');
} else {
setPrimarySelection('');
setSecondarySelection('全部');
}
// 使用短暂延迟确保状态更新完成后标记选择器准备好
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, [type]);
// 生成骨架屏数据 // 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index); const skeletonData = Array.from({ length: 25 }, (_, index) => index);
// 生成API请求参数的辅助函数
const getRequestParams = useCallback(
(pageStart: number) => {
// 当type为tv或show时,kind统一为'tv'category使用type本身
if (type === 'tv' || type === 'show') {
return {
kind: 'tv' as const,
category: type,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
}
// 电影类型保持原逻辑
return {
kind: type as 'tv' | 'movie',
category: primarySelection,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
},
[type, primarySelection, secondarySelection]
);
// 防抖的数据加载函数
const loadInitialData = useCallback(async () => {
try {
setLoading(true);
const data = await getDoubanCategories(getRequestParams(0));
if (data.code === 200) {
setDoubanData(data.list);
setHasMore(data.list.length === 25);
setLoading(false);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
}
}, [type, primarySelection, secondarySelection, getRequestParams]);
// 只在选择器准备好后才加载数据
useEffect(() => { useEffect(() => {
if (!type || !tag) { // 只有在选择器准备好时才开始加载
setError('缺少必要参数: type 或 tag'); if (!selectorsReady) {
setLoading(false);
return; return;
} }
@@ -38,58 +138,43 @@ function DoubanPageClient() {
setDoubanData([]); setDoubanData([]);
setCurrentPage(0); setCurrentPage(0);
setHasMore(true); setHasMore(true);
setError(null);
setIsLoadingMore(false); setIsLoadingMore(false);
// 立即加载第一页数据 // 清除之前的防抖定时器
const loadInitialData = async () => { if (debounceTimeoutRef.current) {
try { clearTimeout(debounceTimeoutRef.current);
setLoading(true); }
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=0`
);
if (!response.ok) { // 使用防抖机制加载数据,避免连续状态更新触发多次请求
throw new Error('获取豆瓣数据失败'); debounceTimeoutRef.current = setTimeout(() => {
} loadInitialData();
}, 100); // 100ms 防抖延迟
const data: DoubanResult = await response.json(); // 清理函数
return () => {
if (data.code === 200) { if (debounceTimeoutRef.current) {
setDoubanData(data.list); clearTimeout(debounceTimeoutRef.current);
setHasMore(data.list.length === 25);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
} finally {
setLoading(false);
} }
}; };
}, [
loadInitialData(); selectorsReady,
}, [type, tag]); type,
primarySelection,
secondarySelection,
loadInitialData,
]);
// 单独处理 currentPage 变化(加载更多) // 单独处理 currentPage 变化(加载更多)
useEffect(() => { useEffect(() => {
if (currentPage > 0 && type && tag) { if (currentPage > 0) {
const fetchMoreData = async () => { const fetchMoreData = async () => {
try { try {
setIsLoadingMore(true); setIsLoadingMore(true);
const response = await fetch( const data = await getDoubanCategories(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=${ getRequestParams(currentPage * 25)
currentPage * 25
}`
); );
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResult = await response.json();
if (data.code === 200) { if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]); setDoubanData((prev) => [...prev, ...data.list]);
setHasMore(data.list.length === 25); setHasMore(data.list.length === 25);
@@ -97,7 +182,7 @@ function DoubanPageClient() {
throw new Error(data.message || '获取数据失败'); throw new Error(data.message || '获取数据失败');
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败'); console.error(err);
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
} }
@@ -105,7 +190,7 @@ function DoubanPageClient() {
fetchMoreData(); fetchMoreData();
} }
}, [currentPage, type, tag]); }, [currentPage, type, primarySelection, secondarySelection]);
// 设置滚动监听 // 设置滚动监听
useEffect(() => { useEffect(() => {
@@ -138,28 +223,28 @@ function DoubanPageClient() {
}; };
}, [hasMore, isLoadingMore, loading]); }, [hasMore, isLoadingMore, loading]);
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
setLoading(true);
setPrimarySelection(value);
},
[type]
);
const handleSecondaryChange = useCallback((value: string) => {
setLoading(true);
setSecondarySelection(value);
}, []);
const getPageTitle = () => { const getPageTitle = () => {
// 优先使用 URL 中的 title 参数 // 根据 type 生成标题
const titleParam = searchParams.get('title'); return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
if (titleParam) {
return titleParam;
}
// 如果 title 参数不存在,根据 type 和 tag 拼接
if (!type || !tag) return '豆瓣内容';
const typeText = type === 'movie' ? '电影' : '电视剧';
const tagText = tag === 'top250' ? 'Top250' : tag;
return `${typeText} - ${tagText}`;
}; };
const getActivePath = () => { const getActivePath = () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (type) params.set('type', type); if (type) params.set('type', type);
if (tag) params.set('tag', tag);
const titleParam = searchParams.get('title');
if (titleParam) params.set('title', titleParam);
const queryString = params.toString(); const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`; const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
@@ -169,81 +254,80 @@ function DoubanPageClient() {
return ( return (
<PageLayout activePath={getActivePath()}> <PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'> <div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题 */} {/* 页面标题和选择器 */}
<div className='mb-8'> <div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
<h1 className='text-3xl font-bold text-gray-800 mb-2 dark:text-gray-200'> {/* 页面标题 */}
{getPageTitle()} <div>
</h1> <h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
<p className='text-gray-600 dark:text-gray-400'></p> {getPageTitle()}
</h1>
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
</p>
</div>
{/* 选择器组件 */}
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
</div> </div>
{/* 内容展示区域 */} {/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'> <div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{error ? ( {/* 内容网格 */}
<div className='flex justify-center items-center h-40'> <div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
<div className='text-red-500 text-center'> {loading || !selectorsReady
<div className='text-lg font-semibold mb-2'></div> ? // 显示骨架屏
<div className='text-sm'>{error}</div> skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
</div> : // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={(el) => {
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div> </div>
) : ( )}
<>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading
? // 显示骨架屏
skeletonData.map((index) => (
<DoubanCardSkeleton key={index} />
))
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
{/* 加载更多指示器 */} {/* 没有更多数据提示 */}
{hasMore && !loading && ( {!hasMore && doubanData.length > 0 && (
<div <div className='text-center text-gray-500 py-8'></div>
ref={(el) => { )}
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
)}
{/* 没有更多数据提示 */} {/* 空状态 */}
{!hasMore && doubanData.length > 0 && ( {!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'> <div className='text-center text-gray-500 py-8'></div>
</div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && !error && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
+5 -6
View File
@@ -13,7 +13,7 @@ const inter = Inter({ subsets: ['latin'] });
// 动态生成 metadata,支持配置更新后的标题变化 // 动态生成 metadata,支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME; let siteName = process.env.SITE_NAME || 'MoonTV';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') { if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
const config = await getConfig(); const config = await getConfig();
siteName = config.SiteConfig.SiteName; siteName = config.SiteConfig.SiteName;
@@ -35,26 +35,25 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; let siteName = process.env.SITE_NAME || 'MoonTV';
let announcement = let announcement =
process.env.ANNOUNCEMENT || process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true'; let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
let aggregateSearchResult = let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') { if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
const config = await getConfig(); const config = await getConfig();
siteName = config.SiteConfig.SiteName; siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement; announcement = config.SiteConfig.Announcement;
enableRegister = config.UserConfig.AllowRegister; enableRegister = config.UserConfig.AllowRegister;
aggregateSearchResult = config.SiteConfig.SearchResultDefaultAggregate; imageProxy = config.SiteConfig.ImageProxy;
} }
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取 // 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = { const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage', STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
ENABLE_REGISTER: enableRegister, ENABLE_REGISTER: enableRegister,
AGGREGATE_SEARCH_RESULT: aggregateSearchResult, IMAGE_PROXY: imageProxy,
}; };
return ( return (
+59 -40
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
'use client'; 'use client';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@@ -9,8 +11,10 @@ import {
clearAllFavorites, clearAllFavorites,
getAllFavorites, getAllFavorites,
getAllPlayRecords, getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { DoubanItem, DoubanResult } from '@/lib/types'; import { getDoubanRecommends } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch'; import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching'; import ContinueWatching from '@/components/ContinueWatching';
@@ -60,20 +64,20 @@ function HomeClient() {
setLoading(true); setLoading(true);
// 并行获取热门电影和热门剧集 // 并行获取热门电影和热门剧集
const [moviesResponse, tvShowsResponse] = await Promise.all([ const [moviesData, tvShowsData] = await Promise.all([
fetch('/api/douban?type=movie&tag=热门'), getDoubanRecommends({ type: 'movie', tag: '热门' }),
fetch('/api/douban?type=tv&tag=热门'), getDoubanRecommends({ type: 'tv', tag: '热门' }),
]); ]);
if (moviesResponse.ok) { if (moviesData.code === 200) {
const moviesData: DoubanResult = await moviesResponse.json();
setHotMovies(moviesData.list); setHotMovies(moviesData.list);
} }
if (tvShowsResponse.ok) { if (tvShowsData.code === 200) {
const tvShowsData: DoubanResult = await tvShowsResponse.json();
setHotTvShows(tvShowsData.list); setHotTvShows(tvShowsData.list);
} }
} catch (error) {
console.error('获取豆瓣数据失败:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -82,42 +86,57 @@ function HomeClient() {
fetchDoubanData(); fetchDoubanData();
}, []); }, []);
// 处理收藏数据更新的函数
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
};
// 当切换到收藏夹时加载收藏数据 // 当切换到收藏夹时加载收藏数据
useEffect(() => { useEffect(() => {
if (activeTab !== 'favorites') return; if (activeTab !== 'favorites') return;
(async () => { const loadFavorites = async () => {
const [allFavorites, allPlayRecords] = await Promise.all([ const allFavorites = await getAllFavorites();
getAllFavorites(), await updateFavoriteItems(allFavorites);
getAllPlayRecords(), };
]);
// 根据保存时间排序(从近到远) loadFavorites();
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数 // 监听收藏更新事件
const playRecord = allPlayRecords[key]; const unsubscribe = subscribeToDataUpdates(
const currentEpisode = playRecord?.index; 'favoritesUpdated',
(newFavorites: Record<string, any>) => {
updateFavoriteItems(newFavorites);
}
);
return { return unsubscribe;
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
})();
}, [activeTab]); }, [activeTab]);
const handleCloseAnnouncement = (announcement: string) => { const handleCloseAnnouncement = (announcement: string) => {
@@ -160,7 +179,7 @@ function HomeClient() {
</button> </button>
)} )}
</div> </div>
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:px-4'> <div 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'>
{favoriteItems.map((item) => ( {favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'> <div key={item.id + item.source} className='w-full'>
<VideoCard <VideoCard
@@ -190,7 +209,7 @@ function HomeClient() {
</h2> </h2>
<Link <Link
href='/douban?type=movie&tag=热门&title=热门电影' href='/douban?type=movie'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
> >
@@ -236,7 +255,7 @@ function HomeClient() {
</h2> </h2>
<Link <Link
href='/douban?type=tv&tag=热门&title=热门剧集' href='/douban?type=tv'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
> >
+84 -41
View File
@@ -9,15 +9,17 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { import {
deleteFavorite,
deletePlayRecord, deletePlayRecord,
generateStorageKey, generateStorageKey,
getAllPlayRecords, getAllPlayRecords,
isFavorited, isFavorited,
saveFavorite,
savePlayRecord, savePlayRecord,
toggleFavorite, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector'; import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
@@ -127,6 +129,21 @@ function PlayPageClient() {
null null
); );
// 优选和测速开关
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 保存优选时的测速结果,避免EpisodeSelector重复测速 // 保存优选时的测速结果,避免EpisodeSelector重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number }> Map<string, { quality: string; loadSpeed: string; pingTime: number }>
@@ -522,35 +539,39 @@ function PlayPageClient() {
return; return;
} }
let needLoadSource = currentSource; let detailData: SearchResult = sourcesInfo[0];
let needLoadId = currentId; // 指定源和id且无需优选
if ((!currentSource && !currentId) || needPreferRef.current) { if (currentSource && currentId && !needPreferRef.current) {
const target = sourcesInfo.find(
(source) => source.source === currentSource && source.id === currentId
);
if (target) {
detailData = target;
} else {
setError('未找到匹配结果');
setLoading(false);
return;
}
}
// 未指定源和 id 或需要优选,且开启优选开关
if (
(!currentSource || !currentId || needPreferRef.current) &&
optimizationEnabled
) {
setLoadingStage('preferring'); setLoadingStage('preferring');
setLoadingMessage('⚡ 正在优选最佳播放源...'); setLoadingMessage('⚡ 正在优选最佳播放源...');
const preferredSource = await preferBestSource(sourcesInfo); detailData = await preferBestSource(sourcesInfo);
setNeedPrefer(false);
setCurrentSource(preferredSource.source);
setCurrentId(preferredSource.id);
setVideoYear(preferredSource.year);
needLoadSource = preferredSource.source;
needLoadId = preferredSource.id;
} }
console.log(sourcesInfo); console.log(detailData.source, detailData.id);
console.log(needLoadSource, needLoadId);
const detailData = sourcesInfo.find( setNeedPrefer(false);
(source) => setCurrentSource(detailData.source);
source.source === needLoadSource && setCurrentId(detailData.id);
source.id.toString() === needLoadId.toString()
);
if (!detailData) {
setError('未找到匹配结果');
setLoading(false);
return;
}
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoYear(detailData.year); setVideoYear(detailData.year);
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoCover(detailData.poster); setVideoCover(detailData.poster);
setDetail(detailData); setDetail(detailData);
if (currentEpisodeIndex >= detailData.episodes.length) { if (currentEpisodeIndex >= detailData.episodes.length) {
@@ -559,8 +580,8 @@ function PlayPageClient() {
// 规范URL参数 // 规范URL参数
const newUrl = new URL(window.location.href); const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', needLoadSource); newUrl.searchParams.set('source', detailData.source);
newUrl.searchParams.set('id', needLoadId); newUrl.searchParams.set('id', detailData.id);
newUrl.searchParams.set('year', detailData.year); newUrl.searchParams.set('year', detailData.year);
newUrl.searchParams.set('title', detailData.title); newUrl.searchParams.set('title', detailData.title);
newUrl.searchParams.delete('prefer'); newUrl.searchParams.delete('prefer');
@@ -916,6 +937,22 @@ function PlayPageClient() {
})(); })();
}, [currentSource, currentId]); }, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏 // 切换收藏
const handleToggleFavorite = async () => { const handleToggleFavorite = async () => {
if ( if (
@@ -927,10 +964,13 @@ function PlayPageClient() {
return; return;
try { try {
const newState = await toggleFavorite( if (favorited) {
currentSourceRef.current, // 如果已收藏,删除收藏
currentIdRef.current, await deleteFavorite(currentSourceRef.current, currentIdRef.current);
{ setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current, title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '', source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year, year: detailRef.current?.year,
@@ -938,9 +978,9 @@ function PlayPageClient() {
total_episodes: detailRef.current?.episodes.length || 1, total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(), save_time: Date.now(),
search_title: searchTitle, search_title: searchTitle,
} });
); setFavorited(true);
setFavorited(newState); }
} catch (err) { } catch (err) {
console.error('切换收藏失败:', err); console.error('切换收藏失败:', err);
} }
@@ -1105,9 +1145,9 @@ function PlayPageClient() {
}, },
settings: [ settings: [
{ {
html: blockAdEnabled ? '关闭去广告' : '开启去广告', html: '去广告',
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>', icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
tooltip: blockAdEnabled ? '当前开启' : '当前关闭', tooltip: blockAdEnabled ? '开启' : '关闭',
onClick() { onClick() {
const newVal = !blockAdEnabled; const newVal = !blockAdEnabled;
try { try {
@@ -1205,7 +1245,10 @@ function PlayPageClient() {
artPlayerRef.current.on('video:timeupdate', () => { artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now(); const now = Date.now();
if (now - lastSaveTimeRef.current > 5000) { if (
now - lastSaveTimeRef.current >
(process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' ? 10000 : 5000)
) {
saveCurrentPlayProgress(); saveCurrentPlayProgress();
lastSaveTimeRef.current = now; lastSaveTimeRef.current = now;
} }
@@ -1398,7 +1441,7 @@ function PlayPageClient() {
return ( return (
<PageLayout activePath='/play'> <PageLayout activePath='/play'>
<div className='flex flex-col gap-3 py-4 px-5 lg:px-10'> <div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
{/* 第一行:影片标题 */} {/* 第一行:影片标题 */}
<div className='py-1'> <div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'> <h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
@@ -1454,7 +1497,7 @@ function PlayPageClient() {
</div> </div>
<div <div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] transition-all duration-300 ease-in-out ${ className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed isEpisodeSelectorCollapsed
? 'grid-cols-1' ? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-4'
@@ -1474,7 +1517,7 @@ function PlayPageClient() {
{/* 换源加载蒙层 */} {/* 换源加载蒙层 */}
{isVideoLoading && ( {isVideoLoading && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[9999] transition-all duration-300'> <div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'> <div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */} {/* 动画影院图标 */}
<div className='relative mb-8'> <div className='relative mb-8'>
@@ -1591,7 +1634,7 @@ function PlayPageClient() {
<div className='bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'> <div className='bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? ( {videoCover ? (
<img <img
src={videoCover} src={processImageUrl(videoCover)}
alt={videoTitle} alt={videoTitle}
className='w-full h-full object-cover' className='w-full h-full object-cover'
/> />
+34 -24
View File
@@ -10,6 +10,7 @@ import {
clearSearchHistory, clearSearchHistory,
deleteSearchHistory, deleteSearchHistory,
getSearchHistory, getSearchHistory,
subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
@@ -27,14 +28,20 @@ function SearchPageClient() {
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
// 视图模式:聚合(agg) 或 全部(all),默认值由环境变量 NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT 决定 // 获取默认聚合设置:只读取用户本地设置,默认为 true
const defaultAggregate = const getDefaultAggregate = () => {
typeof window !== 'undefined' && if (typeof window !== 'undefined') {
Boolean((window as any).RUNTIME_CONFIG?.AGGREGATE_SEARCH_RESULT); const userSetting = localStorage.getItem('defaultAggregateSearch');
if (userSetting !== null) {
return JSON.parse(userSetting);
}
}
return true; // 默认启用聚合
};
const [viewMode, setViewMode] = useState<'agg' | 'all'>( const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
defaultAggregate ? 'agg' : 'all' return getDefaultAggregate() ? 'agg' : 'all';
); });
// 聚合后的结果(按标题和年份分组) // 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => { const aggregatedResults = useMemo(() => {
@@ -85,7 +92,19 @@ function SearchPageClient() {
useEffect(() => { useEffect(() => {
// 无搜索参数时聚焦搜索框 // 无搜索参数时聚焦搜索框
!searchParams.get('q') && document.getElementById('searchInput')?.focus(); !searchParams.get('q') && document.getElementById('searchInput')?.focus();
// 初始加载搜索历史
getSearchHistory().then(setSearchHistory); getSearchHistory().then(setSearchHistory);
// 监听搜索历史更新事件
const unsubscribe = subscribeToDataUpdates(
'searchHistoryUpdated',
(newHistory: string[]) => {
setSearchHistory(newHistory);
}
);
return unsubscribe;
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -95,11 +114,8 @@ function SearchPageClient() {
setSearchQuery(query); setSearchQuery(query);
fetchSearchResults(query); fetchSearchResults(query);
// 保存到搜索历史 // 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(query).then(async () => { addSearchHistory(query);
const history = await getSearchHistory();
setSearchHistory(history);
});
} else { } else {
setShowResults(false); setShowResults(false);
} }
@@ -161,11 +177,8 @@ function SearchPageClient() {
// 直接发请求 // 直接发请求
fetchSearchResults(trimmed); fetchSearchResults(trimmed);
// 保存到搜索历史 // 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(trimmed).then(async () => { addSearchHistory(trimmed);
const history = await getSearchHistory();
setSearchHistory(history);
});
}; };
return ( return (
@@ -276,9 +289,8 @@ function SearchPageClient() {
{searchHistory.length > 0 && ( {searchHistory.length > 0 && (
<button <button
onClick={async () => { onClick={() => {
await clearSearchHistory(); clearSearchHistory(); // 事件监听会自动更新界面
setSearchHistory([]);
}} }}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500' className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
> >
@@ -303,12 +315,10 @@ function SearchPageClient() {
{/* 删除按钮 */} {/* 删除按钮 */}
<button <button
aria-label='删除搜索历史' aria-label='删除搜索历史'
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
await deleteSearchHistory(item); deleteSearchHistory(item); // 事件监听会自动更新界面
const history = await getSearchHistory();
setSearchHistory(history);
}} }}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors' className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
> >
+79 -15
View File
@@ -1,4 +1,6 @@
import React from 'react'; /* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useRef, useState } from 'react';
interface CapsuleSwitchProps { interface CapsuleSwitchProps {
options: { label: string; value: string }[]; options: { label: string; value: string }[];
@@ -13,25 +15,87 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
onChange, onChange,
className, className,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const activeIndex = options.findIndex((opt) => opt.value === active);
// 更新指示器位置
const updateIndicatorPosition = () => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, []);
// 监听选中项变化
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
return ( return (
<div <div
className={`inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${ ref={containerRef}
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
className || '' className || ''
}`} }`}
> >
{options.map((opt) => ( {/* 滑动的白色背景指示器 */}
<button {indicatorStyle.width > 0 && (
key={opt.value} <div
onClick={() => onChange(opt.value)} className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
className={`w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 ${ style={{
active === opt.value left: `${indicatorStyle.left}px`,
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-500 dark:text-gray-100' width: `${indicatorStyle.width}px`,
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100' }}
}`} />
> )}
{opt.label}
</button> {options.map((opt, index) => {
))} const isActive = active === opt.value;
return (
<button
key={opt.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(opt.value)}
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
);
})}
</div> </div>
); );
}; };
+33 -17
View File
@@ -4,7 +4,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { PlayRecord } from '@/lib/db.client'; import type { PlayRecord } from '@/lib/db.client';
import { clearAllPlayRecords, getAllPlayRecords } from '@/lib/db.client'; import {
clearAllPlayRecords,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import ScrollableRow from '@/components/ScrollableRow'; import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard'; import VideoCard from '@/components/VideoCard';
@@ -19,28 +23,30 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
>([]); >([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 处理播放记录数据更新的函数
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
// 将记录转换为数组并根据 save_time 由近到远排序
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
...record,
key,
}));
// 按 save_time 降序排序(最新的在前面)
const sortedRecords = recordsArray.sort(
(a, b) => b.save_time - a.save_time
);
setPlayRecords(sortedRecords);
};
useEffect(() => { useEffect(() => {
const fetchPlayRecords = async () => { const fetchPlayRecords = async () => {
try { try {
setLoading(true); setLoading(true);
// 从 localStorage 获取所有播放记录 // 从缓存或API获取所有播放记录
const allRecords = await getAllPlayRecords(); const allRecords = await getAllPlayRecords();
updatePlayRecords(allRecords);
// 将记录转换为数组并根据 save_time 由近到远排序
const recordsArray = Object.entries(allRecords).map(
([key, record]) => ({
...record,
key,
})
);
// 按 save_time 降序排序(最新的在前面)
const sortedRecords = recordsArray.sort(
(a, b) => b.save_time - a.save_time
);
setPlayRecords(sortedRecords);
} catch (error) { } catch (error) {
console.error('获取播放记录失败:', error); console.error('获取播放记录失败:', error);
setPlayRecords([]); setPlayRecords([]);
@@ -50,6 +56,16 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
}; };
fetchPlayRecords(); fetchPlayRecords();
// 监听播放记录更新事件
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
updatePlayRecords(newRecords);
}
);
return unsubscribe;
}, []); }, []);
// 如果没有播放记录,则不渲染组件 // 如果没有播放记录,则不渲染组件
+330
View File
@@ -0,0 +1,330 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface SelectorOption {
label: string;
value: string;
}
interface DoubanSelectorProps {
type: 'movie' | 'tv' | 'show';
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
}
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 电影的一级选择器选项
const moviePrimaryOptions: SelectorOption[] = [
{ label: '热门电影', value: '热门' },
{ label: '最新电影', value: '最新' },
{ label: '豆瓣高分', value: '豆瓣高分' },
{ label: '冷门佳片', value: '冷门佳片' },
];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '华语', value: '华语' },
{ label: '欧美', value: '欧美' },
{ label: '韩国', value: '韩国' },
{ label: '日本', value: '日本' },
];
// 电视剧选择器选项
const tvOptions: SelectorOption[] = [
{ label: '全部', value: 'tv' },
{ label: '国产', value: 'tv_domestic' },
{ label: '欧美', value: 'tv_american' },
{ label: '日本', value: 'tv_japanese' },
{ label: '韩国', value: 'tv_korean' },
{ label: '动漫', value: 'tv_animation' },
{ label: '纪录片', value: 'tv_documentary' },
];
// 综艺选择器选项
const showOptions: SelectorOption[] = [
{ label: '全部', value: 'show' },
{ label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' },
];
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || moviePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
let secondaryActiveIndex = -1;
if (type === 'movie') {
secondaryActiveIndex = movieSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
);
} else if (type === 'tv') {
secondaryActiveIndex = tvOptions.findIndex(
(opt) => opt.value === (secondarySelection || tvOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showOptions.findIndex(
(opt) => opt.value === (secondarySelection || showOptions[0].value)
);
}
if (secondaryActiveIndex >= 0) {
updateIndicatorPosition(
secondaryActiveIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [type]); // 只在type变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection]);
// 监听副选择器变化
useEffect(() => {
let activeIndex = -1;
let options: SelectorOption[] = [];
if (type === 'movie') {
activeIndex = movieSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = movieSecondaryOptions;
} else if (type === 'tv') {
activeIndex = tvOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvOptions;
} else if (type === 'show') {
activeIndex = showOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showOptions;
}
if (options.length > 0) {
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
return (
<div className='space-y-4 sm:space-y-6'>
{/* 电影类型 - 显示两级选择器 */}
{type === 'movie' && (
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
</div>
)}
{/* 电视剧类型 - 只显示一级选择器 */}
{type === 'tv' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvOptions,
secondarySelection || tvOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
{/* 综艺类型 - 只显示一级选择器 */}
{type === 'show' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showOptions,
secondarySelection || showOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
);
};
export default DoubanSelector;
+31 -11
View File
@@ -10,7 +10,7 @@ import React, {
} from 'react'; } from 'react';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型 // 定义视频信息类型
interface VideoInfo { interface VideoInfo {
@@ -162,10 +162,30 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
} }
}, [precomputedVideoInfo]); }, [precomputedVideoInfo]);
// 读取本地“优选和测速”开关,默认开启
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发 // 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
useEffect(() => { useEffect(() => {
const fetchVideoInfosInBatches = async () => { const fetchVideoInfosInBatches = async () => {
if (activeTab !== 'sources' || availableSources.length === 0) return; if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0
)
return;
// 筛选出尚未测速的播放源 // 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => { const pendingSources = availableSources.filter((source) => {
@@ -185,7 +205,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
fetchVideoInfosInBatches(); fetchVideoInfosInBatches();
// 依赖项保持与之前一致 // 依赖项保持与之前一致
}, [activeTab, availableSources, getVideoInfo]); }, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
// 升序分页标签 // 升序分页标签
const categoriesAsc = useMemo(() => { const categoriesAsc = useMemo(() => {
@@ -437,18 +457,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
onClick={() => onClick={() =>
!isCurrentSource && handleSourceClick(source) !isCurrentSource && handleSourceClick(source)
} }
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 relative className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${ ${
isCurrentSource isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02]' : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()} }`.trim()}
> >
{/* 封面 */} {/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'> <div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.episodes && source.episodes.length > 0 && ( {source.episodes && source.episodes.length > 0 && (
<img <img
src={source.poster} src={processImageUrl(source.poster)}
alt={source.title} alt={source.title}
className='w-full h-full object-cover' className='w-full h-full object-cover'
onError={(e) => { onError={(e) => {
@@ -462,14 +482,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 信息区域 */} {/* 信息区域 */}
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'> <div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
{/* 标题和分辨率 - 顶部 */} {/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-2 h-6'> <div className='flex items-start justify-between gap-3 h-6'>
<div className='flex-1 relative group/title'> <div className='flex-1 min-w-0 relative group/title'>
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'> <h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
{source.title} {source.title}
</h3> </h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */} {/* 标题级别的 tooltip - 第一个元素不显示 */}
{index !== 0 && ( {index !== 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[9999] pointer-events-none'> <div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
{source.title} {source.title}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div> <div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div> </div>
@@ -482,7 +502,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (videoInfo && videoInfo.quality !== '未知') { if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) { if (videoInfo.hasError) {
return ( return (
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0'> <div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
</div> </div>
); );
@@ -502,7 +522,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
return ( return (
<div <div
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0`} className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
> >
{videoInfo.quality} {videoInfo.quality}
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式) // 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => ( const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
<div <div
className={`w-full ${aspectRatio} rounded-md overflow-hidden transition-opacity duration-500`} className={`w-full ${aspectRatio} rounded-lg`}
style={{ style={{
background: background:
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)', 'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
+6 -32
View File
@@ -1,17 +1,6 @@
'use client'; 'use client';
import { import { Clover, Film, Home, Search, Tv } from 'lucide-react';
Clover,
Film,
Home,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@@ -34,36 +23,22 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
{ {
icon: Film, icon: Film,
label: '电影', label: '电影',
href: '/douban?type=movie&tag=热门&title=热门电影', href: '/douban?type=movie',
}, },
{ {
icon: Tv, icon: Tv,
label: '剧集', label: '剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集', href: '/douban?type=tv',
},
{
icon: Star,
label: '高分',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
}, },
{ {
icon: Clover, icon: Clover,
label: '综艺', label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺', href: '/douban?type=show',
}, },
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
]; ];
const isActive = (href: string) => { const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1]; const typeMatch = href.match(/type=([^&]+)/)?.[1];
const tagMatch = href.match(/tag=([^&]+)/)?.[1];
// 解码URL以进行正确的比较 // 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive); const decodedActive = decodeURIComponent(currentActive);
@@ -72,14 +47,13 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
return ( return (
decodedActive === decodedItemHref || decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') && (decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`) && decodedActive.includes(`type=${typeMatch}`))
decodedActive.includes(`tag=${tagMatch}`))
); );
}; };
return ( return (
<nav <nav
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide dark:bg-gray-900/80 dark:border-gray-700/50' className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50'
style={{ style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */ /* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0, bottom: 0,
+15 -13
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton'; import { LogoutButton } from './LogoutButton';
import { SettingsButton } from './SettingsButton';
import { useSite } from './SiteProvider'; import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
@@ -15,15 +16,22 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite(); const { siteName } = useSite();
return ( return (
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'> <header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
{/* 返回按钮 */} <div className='h-12 flex items-center justify-between px-4'>
{showBackButton && ( {/* 左侧:返回按钮和设置按钮 */}
<div className='absolute top-1/2 left-4 -translate-y-1/2'> <div className='flex items-center gap-2'>
<BackButton /> {showBackButton && <BackButton />}
<SettingsButton />
</div> </div>
)}
{/* 站点名称 */} {/* 右侧按钮 */}
<div className='h-12 flex items-center justify-center'> <div className='flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</div>
{/* 中间:Logo(绝对居中) */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link <Link
href='/' href='/'
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity' className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
@@ -31,12 +39,6 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{siteName} {siteName}
</Link> </Link>
</div> </div>
{/* 右侧按钮 */}
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</header> </header>
); );
}; };
+2
View File
@@ -2,6 +2,7 @@ import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton'; import { LogoutButton } from './LogoutButton';
import MobileBottomNav from './MobileBottomNav'; import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader'; import MobileHeader from './MobileHeader';
import { SettingsButton } from './SettingsButton';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
@@ -34,6 +35,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 桌面端顶部按钮 */} {/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'> <div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<SettingsButton />
<LogoutButton /> <LogoutButton />
<ThemeToggle /> <ThemeToggle />
</div> </div>
+17 -13
View File
@@ -102,27 +102,29 @@ export default function ScrollableRow({
> >
<div <div
ref={containerRef} ref={containerRef}
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14' className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6'
onScroll={checkScroll} onScroll={checkScroll}
> >
{children} {children}
</div> </div>
{showLeftScroll && ( {showLeftScroll && (
<div <div
className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-50 transition-opacity duration-200 ${ className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0' isHovered ? 'opacity-100' : 'opacity-0'
}`} }`}
style={{ style={{
background: 'transparent', background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}} }}
> >
<div
className='absolute inset-0'
onClick={(e) => e.stopPropagation()}
/>
<div <div
className='absolute inset-0 flex items-center justify-center' className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%', left: '-4.5rem' }} style={{
top: '40%',
bottom: '60%',
left: '-4.5rem',
pointerEvents: 'auto',
}}
> >
<button <button
onClick={handleScrollLeftClick} onClick={handleScrollLeftClick}
@@ -136,20 +138,22 @@ export default function ScrollableRow({
{showRightScroll && ( {showRightScroll && (
<div <div
className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-50 transition-opacity duration-200 ${ className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0' isHovered ? 'opacity-100' : 'opacity-0'
}`} }`}
style={{ style={{
background: 'transparent', background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}} }}
> >
<div
className='absolute inset-0'
onClick={(e) => e.stopPropagation()}
/>
<div <div
className='absolute inset-0 flex items-center justify-center' className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%', right: '-4.5rem' }} style={{
top: '40%',
bottom: '60%',
right: '-4.5rem',
pointerEvents: 'auto',
}}
> >
<button <button
onClick={handleScrollRightClick} onClick={handleScrollRightClick}
+307
View File
@@ -0,0 +1,307 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Settings, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export const SettingsButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [imageProxyUrl, setImageProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [enableImageProxy, setEnableImageProxy] = useState(false);
const [mounted, setMounted] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
}
const savedEnableImageProxy = localStorage.getItem('enableImageProxy');
const defaultImageProxy =
(window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
if (savedEnableImageProxy !== null) {
setEnableImageProxy(JSON.parse(savedEnableImageProxy));
} else if (defaultImageProxy) {
// 如果有默认图片代理配置,则默认开启
setEnableImageProxy(true);
}
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
if (savedImageProxyUrl !== null) {
setImageProxyUrl(savedImageProxyUrl);
} else if (defaultImageProxy) {
setImageProxyUrl(defaultImageProxy);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
}
}, []);
// 保存设置到 localStorage
const handleAggregateToggle = (value: boolean) => {
setDefaultAggregateSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
}
};
const handleDoubanProxyUrlChange = (value: string) => {
setDoubanProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrl', value);
}
};
const handleImageProxyUrlChange = (value: string) => {
setImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('imageProxyUrl', value);
}
};
const handleOptimizationToggle = (value: boolean) => {
setEnableOptimization(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableOptimization', JSON.stringify(value));
}
};
const handleImageProxyToggle = (value: boolean) => {
setEnableImageProxy(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableImageProxy', JSON.stringify(value));
}
};
const handleSettingsClick = () => {
setIsOpen(!isOpen);
};
const handleClosePanel = () => {
setIsOpen(false);
};
// 重置所有设置为默认值
const handleResetSettings = () => {
const defaultImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
// 重置所有状态
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setDoubanProxyUrl('');
setEnableImageProxy(!!defaultImageProxy);
setImageProxyUrl(defaultImageProxy);
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('doubanProxyUrl', '');
localStorage.setItem(
'enableImageProxy',
JSON.stringify(!!defaultImageProxy)
);
localStorage.setItem('imageProxyUrl', defaultImageProxy);
}
};
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleClosePanel}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
</button>
</div>
<button
onClick={handleClosePanel}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 设置项 */}
<div className='space-y-6'>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 优选和测速 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableOptimization}
onChange={(e) => handleOptimizationToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 豆瓣代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
URL以绕过豆瓣访问限制使API
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
{/* 图片代理开关 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableImageProxy}
onChange={(e) => handleImageProxyToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 图片代理地址设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
enableImageProxy
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
}`}
placeholder='例如: https://imageproxy.example.com/?url='
value={imageProxyUrl}
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
disabled={!enableImageProxy}
/>
</div>
</div>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
</div>
</>
);
return (
<>
<button
onClick={handleSettingsClick}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
aria-label='Settings'
>
<Settings className='w-full h-full' />
</button>
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isOpen && mounted && createPortal(settingsPanel, document.body)}
</>
);
};
+6 -31
View File
@@ -1,18 +1,6 @@
'use client'; 'use client';
import { import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
Clover,
Film,
Home,
Menu,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { import {
@@ -137,32 +125,19 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const menuItems = [ const menuItems = [
{ {
icon: Film, icon: Film,
label: '热门电影', label: '电影',
href: '/douban?type=movie&tag=热门&title=热门电影', href: '/douban?type=movie',
}, },
{ {
icon: Tv, icon: Tv,
label: '热门剧集', label: '剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集', href: '/douban?type=tv',
},
{
icon: Star,
label: '豆瓣 Top250',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
}, },
{ {
icon: Clover, icon: Clover,
label: '综艺', label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺', href: '/douban?type=show',
}, },
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
]; ];
return ( return (
+94 -69
View File
@@ -1,10 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react'; import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client'; import {
deleteFavorite,
deletePlayRecord,
generateStorageKey,
isFavorited,
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { processImageUrl } from '@/lib/utils';
import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import { ImagePlaceholder } from '@/components/ImagePlaceholder';
@@ -45,7 +55,7 @@ export default function VideoCard({
}: VideoCardProps) { }: VideoCardProps) {
const router = useRouter(); const router = useRouter();
const [favorited, setFavorited] = useState(false); const [favorited, setFavorited] = useState(false);
const [isLoaded, setIsLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false);
const isAggregate = from === 'search' && !!items?.length; const isAggregate = from === 'search' && !!items?.length;
@@ -103,6 +113,7 @@ export default function VideoCard({
// 获取收藏状态 // 获取收藏状态
useEffect(() => { useEffect(() => {
if (from === 'douban' || !actualSource || !actualId) return; if (from === 'douban' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => { const fetchFavoriteStatus = async () => {
try { try {
const fav = await isFavorited(actualSource, actualId); const fav = await isFavorited(actualSource, actualId);
@@ -111,7 +122,21 @@ export default function VideoCard({
throw new Error('检查收藏状态失败'); throw new Error('检查收藏状态失败');
} }
}; };
fetchFavoriteStatus(); fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
);
return unsubscribe;
}, [from, actualSource, actualId]); }, [from, actualSource, actualId]);
const handleToggleFavorite = useCallback( const handleToggleFavorite = useCallback(
@@ -120,15 +145,22 @@ export default function VideoCard({
e.stopPropagation(); e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return; if (from === 'douban' || !actualSource || !actualId) return;
try { try {
const newState = await toggleFavorite(actualSource, actualId, { if (favorited) {
title: actualTitle, // 如果已收藏,删除收藏
source_name: source_name || '', await deleteFavorite(actualSource, actualId);
year: actualYear || '', setFavorited(false);
cover: actualPoster, } else {
total_episodes: actualEpisodes ?? 1, // 如果未收藏,添加收藏
save_time: Date.now(), await saveFavorite(actualSource, actualId, {
}); title: actualTitle,
setFavorited(newState); source_name: source_name || '',
year: actualYear || '',
cover: actualPoster,
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
setFavorited(true);
}
} catch (err) { } catch (err) {
throw new Error('切换收藏状态失败'); throw new Error('切换收藏状态失败');
} }
@@ -142,6 +174,7 @@ export default function VideoCard({
actualYear, actualYear,
actualPoster, actualPoster,
actualEpisodes, actualEpisodes,
favorited,
] ]
); );
@@ -230,130 +263,122 @@ export default function VideoCard({
return ( return (
<div <div
className='group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] cursor-pointer' className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
onClick={handleClick} onClick={handleClick}
> >
{/* 海报容器 */} {/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg shadow-md transition-shadow duration-300 ease-out group-hover:shadow-xl'> <div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 - 添加渐入动画 */} {/* 骨架屏 */}
{!isLoaded && ( {!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
<ImagePlaceholder aspectRatio='aspect-[2/3] transition-opacity duration-500 ease-out' /> {/* 图片 */}
)}
{/* 图片加载动画 - 改进淡入和锐化效果 */}
<Image <Image
src={actualPoster} src={processImageUrl(actualPoster)}
alt={actualTitle} alt={actualTitle}
fill fill
className={`object-cover transition-all duration-700 ease-out ${ className='object-cover'
isLoaded ? 'opacity-100 blur-0' : 'opacity-0 blur-md'
}`}
onLoadingComplete={() => setIsLoaded(true)}
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
priority={false} onLoadingComplete={() => setIsLoading(true)}
/> />
{/* 悬浮层 - 改进渐变和元素过渡 */} {/* 悬浮遮罩 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-out pointer-events-auto'></div> <div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
{/* 播放按钮 */} {/* 播放按钮 */}
{config.showPlayButton && ( {config.showPlayButton && (
<div className='absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-auto'> <div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
<PlayCircleIcon <PlayCircleIcon
size={50} size={50}
strokeWidth={0.8} strokeWidth={0.8}
className='rounded-full text-white fill-transparent transition-[fill,transform] duration-200 ease-out [-webkit-transform:translateZ(0)] [-webkit-backface-visibility:hidden] [backface-visibility:hidden] [will-change:transform,fill,opacity] hover:fill-green-500 hover:scale-110' className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
/> />
</div> </div>
)} )}
{/* 已看 / 收藏按钮 - 改进延迟和缓动效果 */} {/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && ( {(config.showHeart || config.showCheckCircle) && (
<div className='absolute bottom-3 right-3 flex items-center gap-3 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 ease-out delay-75 pointer-events-auto'> <div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
{config.showCheckCircle && ( {config.showCheckCircle && (
<CheckCircle <CheckCircle
onClick={handleDeleteRecord} onClick={handleDeleteRecord}
size={20} size={20}
className='rounded-full text-white hover:stroke-green-500 transition-transform duration-200 ease-out hover:scale-110' className='text-white transition-all duration-300 ease-out hover:stroke-green-500 hover:scale-[1.1]'
/> />
)} )}
{config.showHeart && ( {config.showHeart && (
<Heart <Heart
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
size={20} size={20}
className={`rounded-full transition-all duration-300 ease-out hover:scale-110 ${ className={`transition-all duration-300 ease-out ${
favorited favorited
? 'fill-red-600 stroke-red-600 animate-pulse-subtle' ? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400' : 'fill-transparent stroke-white hover:stroke-red-400'
}`} } hover:scale-[1.1]`}
/> />
)} )}
</div> </div>
)} )}
{/* 评分徽章 - 添加弹出效果 */} {/* 徽章 */}
{config.showRating && rate && ( {config.showRating && rate && (
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold p-1 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-110 group-hover:shadow-lg'> <div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{rate} {rate}
</div> </div>
)} )}
{/* 集数徽章 - 改进缩放和阴影 */} {actualEpisodes && actualEpisodes > 1 && (
{actualEpisodes && actualEpisodes > 1 && currentEpisode && ( <div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold rounded-md px-2 py-1 shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-105 group-hover:shadow-lg'> {currentEpisode
{currentEpisode}/{actualEpisodes} ? `${currentEpisode}/${actualEpisodes}`
</div> : actualEpisodes}
)}
{actualEpisodes && actualEpisodes > 1 && !currentEpisode && (
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold rounded-md px-2 py-1 shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-105 group-hover:shadow-lg'>
{actualEpisodes}
</div> </div>
)} )}
{/* 豆瓣链接按钮 - 改进进入方向和延迟 */} {/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && ( {config.showDoubanLink && actualDoubanId && (
<a <a
href={`https://movie.douban.com/subject/${actualDoubanId}`} href={`https://movie.douban.com/subject/${actualDoubanId}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-4 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300 ease-out delay-100' className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
> >
<div className='bg-green-500 text-white text-xs font-bold p-1 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-110 transition-all duration-200 ease-out'> <div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Link size={16} /> <Link size={16} />
</div> </div>
</a> </a>
)} )}
</div> </div>
{/* 进度条 - 添加动画效果 */} {/* 进度条 */}
{config.showProgress && progress !== undefined && ( {config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'> <div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div <div
className='h-full bg-green-500 rounded-full transition-all duration-1000 ease-out' className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
)} )}
{/* 标题与来源信息 - 改进颜色过渡和延迟 */} {/* 标题与来源 */}
<div className='relative'> <div className='mt-2 text-center'>
<span className='mt-2 block text-center text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-all duration-300 ease-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'> <div className='relative'>
{actualTitle} <span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
</span> {actualTitle}
{/* 自定义 tooltip */}
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-50 pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-center text-xs text-gray-500 dark:text-gray-400 mt-1 transition-all duration-300 ease-out delay-75 group-hover:text-green-500 dark:group-hover:text-green-500 group-hover:scale-105'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-out group-hover:border-green-500/60'>
{source_name}
</span> </span>
</span> {/* 自定义 tooltip */}
)} <div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
{source_name}
</span>
</span>
)}
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -4,7 +4,7 @@ export interface AdminConfig {
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
}; };
UserConfig: { UserConfig: {
AllowRegister: boolean; AllowRegister: boolean;
+30 -8
View File
@@ -160,8 +160,7 @@ async function initConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -199,8 +198,7 @@ async function initConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -238,8 +236,33 @@ export async function getConfig(): Promise<AdminConfig> {
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
adminConfig.UserConfig.AllowRegister = adminConfig.UserConfig.AllowRegister =
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true'; process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
adminConfig.SiteConfig.SearchResultDefaultAggregate = adminConfig.SiteConfig.ImageProxy =
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false'; process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
// 合并文件中的源信息
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
const apiSiteEntries = Object.entries(fileConfig.api_site);
const existed = new Set((adminConfig.SourceConfig || []).map((s) => s.key));
apiSiteEntries.forEach(([key, site]) => {
if (!existed.has(key)) {
adminConfig!.SourceConfig.push({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
});
}
});
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
cachedConfig = adminConfig; cachedConfig = adminConfig;
} else { } else {
// DB 无配置,执行一次初始化 // DB 无配置,执行一次初始化
@@ -283,8 +306,7 @@ export async function resetConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
+2
View File
@@ -187,6 +187,7 @@ export class D1Storage implements IStorage {
year: result.year, year: result.year,
total_episodes: result.total_episodes, total_episodes: result.total_episodes,
save_time: result.save_time, save_time: result.save_time,
search_title: result.search_title,
}; };
} catch (err) { } catch (err) {
console.error('Failed to get favorite:', err); console.error('Failed to get favorite:', err);
@@ -246,6 +247,7 @@ export class D1Storage implements IStorage {
year: row.year, year: row.year,
total_episodes: row.total_episodes, total_episodes: row.total_episodes,
save_time: row.save_time, save_time: row.save_time,
search_title: row.search_title,
}; };
}); });
+836 -115
View File
@@ -1,4 +1,4 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
'use client'; 'use client';
/** /**
@@ -9,10 +9,13 @@
* 功能: * 功能:
* 1. 获取全部播放记录(getAllPlayRecords)。 * 1. 获取全部播放记录(getAllPlayRecords)。
* 2. 保存播放记录(savePlayRecord)。 * 2. 保存播放记录(savePlayRecord)。
* 3. D1 存储模式下的混合缓存策略,提升用户体验。
* *
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。 * 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
*/ */
import { getAuthInfoFromBrowserCookie } from './auth';
// ---- 类型 ---- // ---- 类型 ----
export interface PlayRecord { export interface PlayRecord {
title: string; title: string;
@@ -27,8 +30,39 @@ export interface PlayRecord {
search_title?: string; // 搜索时使用的标题 search_title?: string; // 搜索时使用的标题
} }
// ---- 收藏类型 ----
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string;
}
// ---- 缓存数据结构 ----
interface CacheData<T> {
data: T;
timestamp: number;
version: string;
}
interface UserCacheStore {
playRecords?: CacheData<Record<string, PlayRecord>>;
favorites?: CacheData<Record<string, Favorite>>;
searchHistory?: CacheData<string[]>;
}
// ---- 常量 ---- // ---- 常量 ----
const PLAY_RECORDS_KEY = 'moontv_play_records'; const PLAY_RECORDS_KEY = 'moontv_play_records';
const FAVORITES_KEY = 'moontv_favorites';
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 缓存相关常量
const CACHE_PREFIX = 'moontv_cache_';
const CACHE_VERSION = '1.0.0';
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
// ---- 环境变量 ---- // ---- 环境变量 ----
const STORAGE_TYPE = (() => { const STORAGE_TYPE = (() => {
@@ -37,16 +71,288 @@ const STORAGE_TYPE = (() => {
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) || (window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) || (process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
'localstorage'; 'localstorage';
// 兼容 redis => database
return raw; return raw;
})(); })();
// ---------------- 搜索历史相关常量 ---------------- // ---------------- 搜索历史相关常量 ----------------
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 搜索历史最大保存条数 // 搜索历史最大保存条数
const SEARCH_HISTORY_LIMIT = 20; const SEARCH_HISTORY_LIMIT = 20;
// ---- 缓存管理器 ----
class HybridCacheManager {
private static instance: HybridCacheManager;
static getInstance(): HybridCacheManager {
if (!HybridCacheManager.instance) {
HybridCacheManager.instance = new HybridCacheManager();
}
return HybridCacheManager.instance;
}
/**
* 获取当前用户名
*/
private getCurrentUsername(): string | null {
const authInfo = getAuthInfoFromBrowserCookie();
return authInfo?.username || null;
}
/**
* 生成用户专属的缓存key
*/
private getUserCacheKey(username: string): string {
return `${CACHE_PREFIX}${username}`;
}
/**
* 获取用户缓存数据
*/
private getUserCache(username: string): UserCacheStore {
if (typeof window === 'undefined') return {};
try {
const cacheKey = this.getUserCacheKey(username);
const cached = localStorage.getItem(cacheKey);
return cached ? JSON.parse(cached) : {};
} catch (error) {
console.warn('获取用户缓存失败:', error);
return {};
}
}
/**
* 保存用户缓存数据
*/
private saveUserCache(username: string, cache: UserCacheStore): void {
if (typeof window === 'undefined') return;
try {
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (error) {
console.warn('保存用户缓存失败:', error);
}
}
/**
* 检查缓存是否有效
*/
private isCacheValid<T>(cache: CacheData<T>): boolean {
const now = Date.now();
return (
cache.version === CACHE_VERSION &&
now - cache.timestamp < CACHE_EXPIRE_TIME
);
}
/**
* 创建缓存数据
*/
private createCacheData<T>(data: T): CacheData<T> {
return {
data,
timestamp: Date.now(),
version: CACHE_VERSION,
};
}
/**
* 获取缓存的播放记录
*/
getCachedPlayRecords(): Record<string, PlayRecord> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.playRecords;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存播放记录
*/
cachePlayRecords(data: Record<string, PlayRecord>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.playRecords = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的收藏
*/
getCachedFavorites(): Record<string, Favorite> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.favorites;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存收藏
*/
cacheFavorites(data: Record<string, Favorite>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.favorites = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的搜索历史
*/
getCachedSearchHistory(): string[] | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.searchHistory;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存搜索历史
*/
cacheSearchHistory(data: string[]): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.searchHistory = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 清除指定用户的所有缓存
*/
clearUserCache(username?: string): void {
const targetUsername = username || this.getCurrentUsername();
if (!targetUsername) return;
try {
const cacheKey = this.getUserCacheKey(targetUsername);
localStorage.removeItem(cacheKey);
} catch (error) {
console.warn('清除用户缓存失败:', error);
}
}
/**
* 清除所有过期缓存
*/
clearExpiredCaches(): void {
if (typeof window === 'undefined') return;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
try {
const cache = JSON.parse(localStorage.getItem(key) || '{}');
// 检查是否有任何缓存数据过期
let hasValidData = false;
for (const [, cacheData] of Object.entries(cache)) {
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
hasValidData = true;
break;
}
}
if (!hasValidData) {
keysToRemove.push(key);
}
} catch {
// 解析失败的缓存也删除
keysToRemove.push(key);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.warn('清除过期缓存失败:', error);
}
}
}
// 获取缓存管理器实例
const cacheManager = HybridCacheManager.getInstance();
// ---- 错误处理辅助函数 ----
/**
* 数据库操作失败时的通用错误处理
* 立即从数据库刷新对应类型的缓存以保持数据一致性
*/
async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory',
error: any
): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error);
try {
let freshData: any;
let eventName: string;
switch (dataType) {
case 'playRecords':
freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
eventName = 'playRecordsUpdated';
break;
case 'favorites':
freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
eventName = 'favoritesUpdated';
break;
case 'searchHistory':
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
eventName = 'searchHistoryUpdated';
break;
}
// 触发更新事件通知组件
window.dispatchEvent(
new CustomEvent(eventName, {
detail: freshData,
})
);
} catch (refreshErr) {
console.error(`刷新${dataType}缓存失败:`, refreshErr);
}
}
// 页面加载时清理过期缓存
if (typeof window !== 'undefined') {
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
}
// ---- 工具函数 ---- // ---- 工具函数 ----
async function fetchFromApi<T>(path: string): Promise<T> { async function fetchFromApi<T>(path: string): Promise<T> {
const res = await fetch(path); const res = await fetch(path);
@@ -63,21 +369,57 @@ export function generateStorageKey(source: string, id: string): string {
// ---- API ---- // ---- API ----
/** /**
* 读取 localStorage 中的全部播放记录。 * 读取全部播放记录。
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。 * 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
*/ */
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> { export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
// 若配置标明使用数据库,则从后端 API 拉取 // 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`);
}
// 默认 / localstorage 流程
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
return {}; return {};
} }
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedPlayRecords();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cachePlayRecords(freshData);
// 触发数据更新事件,供组件监听
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步播放记录失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
return freshData;
} catch (err) {
console.error('获取播放记录失败:', err);
return {};
}
}
}
// localstorage 模式
try { try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY); const raw = localStorage.getItem(PLAY_RECORDS_KEY);
if (!raw) return {}; if (!raw) return {};
@@ -89,7 +431,8 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
} }
/** /**
* 保存播放记录到 localStorage 或通过 API 保存到数据库 * 保存播放记录
* D1 存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
*/ */
export async function savePlayRecord( export async function savePlayRecord(
source: string, source: string,
@@ -98,8 +441,21 @@ export async function savePlayRecord(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 若配置标明使用数据库,则通过 API 保存 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
cachedRecords[key] = record;
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try { try {
const res = await fetch('/api/playrecords', { const res = await fetch('/api/playrecords', {
method: 'POST', method: 'POST',
@@ -108,15 +464,18 @@ export async function savePlayRecord(
}, },
body: JSON.stringify({ key, record }), body: JSON.stringify({ key, record }),
}); });
if (!res.ok) throw new Error(`保存播放记录失败: ${res.status}`);
if (!res.ok) {
throw new Error(`保存播放记录失败: ${res.status}`);
}
} catch (err) { } catch (err) {
console.error('保存播放记录到数据库失败:', err); await handleDatabaseOperationFailure('playRecords', err);
throw err; throw err;
} }
return; return;
} }
// 默认 / localstorage 流程 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端保存播放记录到 localStorage'); console.warn('无法在服务端保存播放记录到 localStorage');
return; return;
@@ -126,6 +485,11 @@ export async function savePlayRecord(
const allRecords = await getAllPlayRecords(); const allRecords = await getAllPlayRecords();
allRecords[key] = record; allRecords[key] = record;
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords)); localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) { } catch (err) {
console.error('保存播放记录失败:', err); console.error('保存播放记录失败:', err);
throw err; throw err;
@@ -133,7 +497,8 @@ export async function savePlayRecord(
} }
/** /**
* 删除播放记录 * 删除播放记录
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deletePlayRecord( export async function deletePlayRecord(
source: string, source: string,
@@ -141,8 +506,21 @@ export async function deletePlayRecord(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 若配置标明使用数据库,则通过 API 删除 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
delete cachedRecords[key];
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try { try {
const res = await fetch( const res = await fetch(
`/api/playrecords?key=${encodeURIComponent(key)}`, `/api/playrecords?key=${encodeURIComponent(key)}`,
@@ -152,13 +530,13 @@ export async function deletePlayRecord(
); );
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`); if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('删除播放记录到数据库失败:', err); await handleDatabaseOperationFailure('playRecords', err);
throw err; throw err;
} }
return; return;
} }
// 默认 / localstorage 流程 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage'); console.warn('无法在服务端删除播放记录到 localStorage');
return; return;
@@ -168,7 +546,11 @@ export async function deletePlayRecord(
const allRecords = await getAllPlayRecords(); const allRecords = await getAllPlayRecords();
delete allRecords[key]; delete allRecords[key];
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords)); localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
console.log('播放记录已删除:', key); window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) { } catch (err) {
console.error('删除播放记录失败:', err); console.error('删除播放记录失败:', err);
throw err; throw err;
@@ -178,24 +560,54 @@ export async function deletePlayRecord(
/* ---------------- 搜索历史相关 API ---------------- */ /* ---------------- 搜索历史相关 API ---------------- */
/** /**
* 获取搜索历史 * 获取搜索历史
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function getSearchHistory(): Promise<string[]> { export async function getSearchHistory(): Promise<string[]> {
// 如果配置为使用数据库,则从后端 API 获取 // 服务器端渲染阶段直接返回空
if (STORAGE_TYPE !== 'localstorage') {
try {
return fetchFromApi<string[]>(`/api/searchhistory`);
} catch (err) {
console.error('获取搜索历史失败:', err);
return [];
}
}
// 默认从 localStorage 读取
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return []; return [];
} }
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSearchHistory();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<string[]>(`/api/searchhistory`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSearchHistory(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步搜索历史失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
return freshData;
} catch (err) {
console.error('获取搜索历史失败:', err);
return [];
}
}
}
// localStorage 模式
try { try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY); const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return []; if (!raw) return [];
@@ -209,24 +621,43 @@ export async function getSearchHistory(): Promise<string[]> {
} }
/** /**
* 将关键字添加到搜索历史 * 将关键字添加到搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function addSearchHistory(keyword: string): Promise<void> { export async function addSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try { try {
await fetch('/api/searchhistory', { const res = await fetch('/api/searchhistory', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ keyword: trimmed }), body: JSON.stringify({ keyword: trimmed }),
}); });
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('保存搜索历史失败:', err); await handleDatabaseOperationFailure('searchHistory', err);
} }
return; return;
} }
@@ -242,23 +673,41 @@ export async function addSearchHistory(keyword: string): Promise<void> {
newHistory.length = SEARCH_HISTORY_LIMIT; newHistory.length = SEARCH_HISTORY_LIMIT;
} }
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) { } catch (err) {
console.error('保存搜索历史失败:', err); console.error('保存搜索历史失败:', err);
} }
} }
/** /**
* 清空搜索历史 * 清空搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function clearSearchHistory(): Promise<void> { export async function clearSearchHistory(): Promise<void> {
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheSearchHistory([]);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
// 异步同步到数据库
try { try {
await fetch(`/api/searchhistory`, { const res = await fetch(`/api/searchhistory`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('清空搜索历史失败:', err); await handleDatabaseOperationFailure('searchHistory', err);
} }
return; return;
} }
@@ -266,23 +715,46 @@ export async function clearSearchHistory(): Promise<void> {
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem(SEARCH_HISTORY_KEY); localStorage.removeItem(SEARCH_HISTORY_KEY);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
} }
/** /**
* 删除单条搜索历史 * 删除单条搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deleteSearchHistory(keyword: string): Promise<void> { export async function deleteSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = cachedHistory.filter((k) => k !== trimmed);
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try { try {
await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, { const res = await fetch(
method: 'DELETE', `/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
}); {
method: 'DELETE',
}
);
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('删除搜索历史失败:', err); await handleDatabaseOperationFailure('searchHistory', err);
} }
return; return;
} }
@@ -294,6 +766,11 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
const history = await getSearchHistory(); const history = await getSearchHistory();
const newHistory = history.filter((k) => k !== trimmed); const newHistory = history.filter((k) => k !== trimmed);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory)); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) { } catch (err) {
console.error('删除搜索历史失败:', err); console.error('删除搜索历史失败:', err);
} }
@@ -301,34 +778,57 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
// ---------------- 收藏相关 API ---------------- // ---------------- 收藏相关 API ----------------
// 收藏数据结构
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string; // 搜索时使用的标题
}
// 收藏在 localStorage 中使用的 key
const FAVORITES_KEY = 'moontv_favorites';
/** /**
* 获取全部收藏 * 获取全部收藏
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function getAllFavorites(): Promise<Record<string, Favorite>> { export async function getAllFavorites(): Promise<Record<string, Favorite>> {
// 数据库模式 // 服务器端渲染阶段直接返回空
if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
}
// localStorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return {}; return {};
} }
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedFavorites();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步收藏失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
return freshData;
} catch (err) {
console.error('获取收藏失败:', err);
return {};
}
}
}
// localStorage 模式
try { try {
const raw = localStorage.getItem(FAVORITES_KEY); const raw = localStorage.getItem(FAVORITES_KEY);
if (!raw) return {}; if (!raw) return {};
@@ -340,7 +840,8 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
} }
/** /**
* 保存收藏 * 保存收藏
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function saveFavorite( export async function saveFavorite(
source: string, source: string,
@@ -349,8 +850,21 @@ export async function saveFavorite(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
cachedFavorites[key] = favorite;
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try { try {
const res = await fetch('/api/favorites', { const res = await fetch('/api/favorites', {
method: 'POST', method: 'POST',
@@ -361,7 +875,7 @@ export async function saveFavorite(
}); });
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`); if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('保存收藏到数据库失败:', err); await handleDatabaseOperationFailure('favorites', err);
throw err; throw err;
} }
return; return;
@@ -377,6 +891,11 @@ export async function saveFavorite(
const allFavorites = await getAllFavorites(); const allFavorites = await getAllFavorites();
allFavorites[key] = favorite; allFavorites[key] = favorite;
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites)); localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) { } catch (err) {
console.error('保存收藏失败:', err); console.error('保存收藏失败:', err);
throw err; throw err;
@@ -384,7 +903,8 @@ export async function saveFavorite(
} }
/** /**
* 删除收藏 * 删除收藏
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deleteFavorite( export async function deleteFavorite(
source: string, source: string,
@@ -392,15 +912,28 @@ export async function deleteFavorite(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
delete cachedFavorites[key];
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try { try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, { const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`); if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('删除收藏到数据库失败:', err); await handleDatabaseOperationFailure('favorites', err);
throw err; throw err;
} }
return; return;
@@ -416,6 +949,11 @@ export async function deleteFavorite(
const allFavorites = await getAllFavorites(); const allFavorites = await getAllFavorites();
delete allFavorites[key]; delete allFavorites[key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites)); localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) { } catch (err) {
console.error('删除收藏失败:', err); console.error('删除收藏失败:', err);
throw err; throw err;
@@ -423,7 +961,8 @@ export async function deleteFavorite(
} }
/** /**
* 判断是否已收藏 * 判断是否已收藏
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function isFavorited( export async function isFavorited(
source: string, source: string,
@@ -431,16 +970,42 @@ export async function isFavorited(
): Promise<boolean> { ): Promise<boolean> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { const cachedFavorites = cacheManager.getCachedFavorites();
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
if (!res.ok) return false; if (cachedFavorites) {
const data = await res.json(); // 返回缓存数据,同时后台异步更新
return !!data; fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
} catch (err) { .then((freshData) => {
console.error('检查收藏状态失败:', err); // 只有数据真正不同时才更新缓存
return false; if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步收藏失败:', err);
});
return !!cachedFavorites[key];
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
return !!freshData[key];
} catch (err) {
console.error('检查收藏状态失败:', err);
return false;
}
} }
} }
@@ -449,43 +1014,33 @@ export async function isFavorited(
return !!allFavorites[key]; return !!allFavorites[key];
} }
/**
* 切换收藏状态
* 返回切换后的状态(true = 已收藏)
*/
export async function toggleFavorite(
source: string,
id: string,
favoriteData?: Favorite
): Promise<boolean> {
const already = await isFavorited(source, id);
if (already) {
await deleteFavorite(source, id);
return false;
}
if (!favoriteData) {
throw new Error('收藏数据缺失');
}
await saveFavorite(source, id, favoriteData);
return true;
}
/** /**
* 清空全部播放记录 * 清空全部播放记录
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function clearAllPlayRecords(): Promise<void> { export async function clearAllPlayRecords(): Promise<void> {
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cachePlayRecords({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
// 异步同步到数据库
try { try {
await fetch(`/api/playrecords`, { const res = await fetch(`/api/playrecords`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (!res.ok) throw new Error(`清空播放记录失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('清空播放记录失败:', err); await handleDatabaseOperationFailure('playRecords', err);
throw err;
} }
return; return;
} }
@@ -493,21 +1048,40 @@ export async function clearAllPlayRecords(): Promise<void> {
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem(PLAY_RECORDS_KEY); localStorage.removeItem(PLAY_RECORDS_KEY);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
} }
/** /**
* 清空全部收藏 * 清空全部收藏
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function clearAllFavorites(): Promise<void> { export async function clearAllFavorites(): Promise<void> {
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheFavorites({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
// 异步同步到数据库
try { try {
await fetch(`/api/favorites`, { const res = await fetch(`/api/favorites`, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
if (!res.ok) throw new Error(`清空收藏失败: ${res.status}`);
} catch (err) { } catch (err) {
console.error('清空收藏失败:', err); await handleDatabaseOperationFailure('favorites', err);
throw err;
} }
return; return;
} }
@@ -515,4 +1089,151 @@ export async function clearAllFavorites(): Promise<void> {
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem(FAVORITES_KEY); localStorage.removeItem(FAVORITES_KEY);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
}
// ---------------- 混合缓存辅助函数 ----------------
/**
* 清除当前用户的所有缓存数据
* 用于用户登出时清理缓存
*/
export function clearUserCache(): void {
if (STORAGE_TYPE === 'd1') {
cacheManager.clearUserCache();
}
}
/**
* 手动刷新所有缓存数据
* 强制从服务器重新获取数据并更新缓存
*/
export async function refreshAllCache(): Promise<void> {
if (STORAGE_TYPE !== 'd1') return;
try {
// 并行刷新所有数据
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<string[]>(`/api/searchhistory`),
]);
if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: playRecords.value,
})
);
}
if (favorites.status === 'fulfilled') {
cacheManager.cacheFavorites(favorites.value);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: favorites.value,
})
);
}
if (searchHistory.status === 'fulfilled') {
cacheManager.cacheSearchHistory(searchHistory.value);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: searchHistory.value,
})
);
}
} catch (err) {
console.error('刷新缓存失败:', err);
}
}
/**
* 获取缓存状态信息
* 用于调试和监控缓存健康状态
*/
export function getCacheStatus(): {
hasPlayRecords: boolean;
hasFavorites: boolean;
hasSearchHistory: boolean;
username: string | null;
} {
if (STORAGE_TYPE !== 'd1') {
return {
hasPlayRecords: false,
hasFavorites: false,
hasSearchHistory: false,
username: null,
};
}
const authInfo = getAuthInfoFromBrowserCookie();
return {
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
hasFavorites: !!cacheManager.getCachedFavorites(),
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
username: authInfo?.username || null,
};
}
// ---------------- React Hook 辅助类型 ----------------
export type CacheUpdateEvent =
| 'playRecordsUpdated'
| 'favoritesUpdated'
| 'searchHistoryUpdated';
/**
* 用于 React 组件监听数据更新的事件监听器
* 使用方法:
*
* useEffect(() => {
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
* setPlayRecords(data);
* });
* return unsubscribe;
* }, []);
*/
export function subscribeToDataUpdates<T>(
eventType: CacheUpdateEvent,
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const handleUpdate = (event: CustomEvent) => {
callback(event.detail);
};
window.addEventListener(eventType, handleUpdate as EventListener);
return () => {
window.removeEventListener(eventType, handleUpdate as EventListener);
};
}
/**
* 预加载所有用户数据到缓存
* 适合在应用启动时调用,提升后续访问速度
*/
export async function preloadUserData(): Promise<void> {
if (STORAGE_TYPE !== 'd1') return;
// 检查是否已有有效缓存,避免重复请求
const status = getCacheStatus();
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
return;
}
// 后台静默预加载,不阻塞界面
refreshAllCache().catch((err) => {
console.warn('预加载用户数据失败:', err);
});
} }
-21
View File
@@ -129,27 +129,6 @@ export class DbManager {
return favorite !== null; return favorite !== null;
} }
async toggleFavorite(
userName: string,
source: string,
id: string,
favoriteData?: Favorite
): Promise<boolean> {
const isFav = await this.isFavorited(userName, source, id);
if (isFav) {
await this.deleteFavorite(userName, source, id);
return false;
}
if (favoriteData) {
await this.saveFavorite(userName, source, id, favoriteData);
return true;
}
throw new Error('Favorite data is required when adding to favorites');
}
// ---------- 用户相关 ---------- // ---------- 用户相关 ----------
async registerUser(userName: string, password: string): Promise<void> { async registerUser(userName: string, password: string): Promise<void> {
await this.storage.registerUser(userName, password); await this.storage.registerUser(userName, password);
+245
View File
@@ -0,0 +1,245 @@
import { DoubanItem, DoubanResult } from './types';
interface DoubanApiResponse {
subjects: Array<{
id: string;
title: string;
cover: string;
rate: string;
}>;
}
interface DoubanRecommendsParams {
type: 'tv' | 'movie';
tag: string;
pageSize?: number;
pageStart?: number;
}
interface DoubanCategoriesParams {
kind: 'tv' | 'movie';
category: string;
type: string;
pageLimit?: number;
pageStart?: number;
}
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
/**
* 浏览器端豆瓣数据获取函数
*/
export async function fetchDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
const { type, tag, pageSize = 16, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(type)) {
throw new Error('type 参数必须是 tv 或 movie');
}
if (pageSize < 1 || pageSize > 100) {
throw new Error('pageSize 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣数据失败: ${(error as Error).message}`);
}
}
/**
* 带超时的 fetch 请求
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {}
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 检查是否使用代理
const proxyUrl = getDoubanProxyUrl();
const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url;
const fetchOptions: RequestInit = {
...options,
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept: 'application/json, text/plain, */*',
...options.headers,
},
};
try {
const response = await fetch(finalUrl, fetchOptions);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* 获取豆瓣代理 URL 设置
*/
export function getDoubanProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
const doubanProxyUrl = localStorage.getItem('doubanProxyUrl');
return doubanProxyUrl && doubanProxyUrl.trim() ? doubanProxyUrl.trim() : null;
}
/**
* 检查是否应该使用客户端获取豆瓣数据
*/
export function shouldUseDoubanClient(): boolean {
return getDoubanProxyUrl() !== null;
}
/**
* 统一的豆瓣数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanRecommends(params);
} else {
// 使用服务端 API(当没有设置代理 URL 时)
const { type, tag, pageSize = 16, pageStart = 0 } = params;
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=${pageSize}&pageStart=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
return response.json();
}
}
/**
* 浏览器端豆瓣分类数据获取函数
*/
export async function fetchDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(kind)) {
throw new Error('kind 参数必须是 tv 或 movie');
}
if (!category || !type) {
throw new Error('category 和 type 参数不能为空');
}
if (pageLimit < 1 || pageLimit > 100) {
throw new Error('pageLimit 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanCategoryApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
/**
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanCategories(params);
} else {
// 使用服务端 API(当没有设置代理 URL 时)
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣分类数据失败');
}
return response.json();
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ export interface PlayRecord {
play_time: number; // 播放进度(秒) play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒) total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳) save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题 search_title: string; // 搜索时使用的标题
} }
// 收藏数据结构 // 收藏数据结构
@@ -22,7 +22,7 @@ export interface Favorite {
year: string; year: string;
cover: string; cover: string;
save_time: number; // 记录保存时间(时间戳) save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题 search_title: string; // 搜索时使用的标题
} }
// 存储接口 // 存储接口
+38
View File
@@ -2,6 +2,44 @@
import Hls from 'hls.js'; import Hls from 'hls.js';
/**
* 获取图片代理 URL 设置
*/
export function getImageProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
// 本地未开启图片代理,则不使用代理
const enableImageProxy = localStorage.getItem('enableImageProxy');
if (enableImageProxy !== null) {
if (!JSON.parse(enableImageProxy) as boolean) {
return null;
}
}
const localImageProxy = localStorage.getItem('imageProxyUrl');
if (localImageProxy != null) {
return localImageProxy.trim() ? localImageProxy.trim() : null;
}
// 如果未设置,则使用全局对象
const serverImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY;
return serverImageProxy && serverImageProxy.trim()
? serverImageProxy.trim()
: null;
}
/**
* 处理图片 URL,如果设置了图片代理则使用代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
const proxyUrl = getImageProxyUrl();
if (!proxyUrl) return originalUrl;
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
}
export function cleanHtmlTags(text: string): string { export function cleanHtmlTags(text: string): string {
if (!text) return ''; if (!text) return '';
return text return text
+16 -12
View File
@@ -23,13 +23,13 @@ export async function middleware(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) { if (!authInfo) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// localstorage模式:在middleware中完成验证 // localstorage模式:在middleware中完成验证
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) { if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
return NextResponse.next(); return NextResponse.next();
} }
@@ -37,7 +37,7 @@ export async function middleware(request: NextRequest) {
// 其他模式:只验证签名 // 其他模式:只验证签名
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中) // 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
if (!authInfo.username || !authInfo.signature) { if (!authInfo.username || !authInfo.signature) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// 验证签名(如果存在) // 验证签名(如果存在)
@@ -55,7 +55,7 @@ export async function middleware(request: NextRequest) {
} }
// 签名验证失败或不存在签名 // 签名验证失败或不存在签名
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// 验证签名 // 验证签名
@@ -96,8 +96,17 @@ async function verifySignature(
} }
} }
// 重定向到登录页面 // 处理认证失败的情况
function redirectToLogin(request: NextRequest, pathname: string): NextResponse { function handleAuthFailure(
request: NextRequest,
pathname: string
): NextResponse {
// 如果是 API 路由,返回 401 状态码
if (pathname.startsWith('/api')) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 否则重定向到登录页面
const loginUrl = new URL('/login', request.url); const loginUrl = new URL('/login', request.url);
// 保留完整的URL,包括查询参数 // 保留完整的URL,包括查询参数
const fullUrl = `${pathname}${request.nextUrl.search}`; const fullUrl = `${pathname}${request.nextUrl.search}`;
@@ -108,11 +117,6 @@ function redirectToLogin(request: NextRequest, pathname: string): NextResponse {
// 判断是否需要跳过认证的路径 // 判断是否需要跳过认证的路径
function shouldSkipAuth(pathname: string): boolean { function shouldSkipAuth(pathname: string): boolean {
const skipPaths = [ const skipPaths = [
'/login',
'/api/login',
'/api/register',
'/api/logout',
'/api/server-config',
'/_next', '/_next',
'/favicon.ico', '/favicon.ico',
'/robots.txt', '/robots.txt',
@@ -128,6 +132,6 @@ function shouldSkipAuth(pathname: string): boolean {
// 配置middleware匹配规则 // 配置middleware匹配规则
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/detail|api/search|api/image-proxy|api/douban|api/cron|api/server-config).*)', '/((?!_next/static|_next/image|favicon.ico|login|api/login|api/register|api/logout|api/cron|api/server-config).*)',
], ],
}; };
+20
View File
@@ -2,6 +2,26 @@
/* eslint-disable no-console,@typescript-eslint/no-var-requires */ /* eslint-disable no-console,@typescript-eslint/no-var-requires */
const http = require('http'); const http = require('http');
const path = require('path');
// 调用 generate-manifest.js 生成 manifest.json
function generateManifest() {
console.log('Generating manifest.json for Docker deployment...');
try {
const generateManifestScript = path.join(
__dirname,
'scripts',
'generate-manifest.js'
);
require(generateManifestScript);
} catch (error) {
console.error('❌ Error calling generate-manifest.js:', error);
throw error;
}
}
generateManifest();
// 直接在当前进程中启动 standalone Server`server.js` // 直接在当前进程中启动 standalone Server`server.js`
require('./server.js'); require('./server.js');