Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c8609a634 | |||
| e2aeb352a1 | |||
| da23e04564 | |||
| 286beba206 | |||
| 1e2f05b3a1 | |||
| e4d8a03071 | |||
| 880b6282c0 | |||
| a007b7a50f | |||
| 41c1c3989b | |||
| 0ab4115f6d | |||
| a412c5cb54 | |||
| 1a6da8b7bc | |||
| 4fe8f37c7a | |||
| e1521179d4 | |||
| 53a1b6603b | |||
| af2d257344 | |||
| 79418b66dc | |||
| 1cd7ad3b7b | |||
| 623edd7959 | |||
| df99bff693 | |||
| 1783944024 | |||
| b1073be89d | |||
| c7b7d90238 | |||
| 4772a4120d | |||
| 962e3b2656 | |||
| 2f4a2e936c | |||
| af03f9f149 | |||
| 90129c0d69 | |||
| cca4092519 | |||
| 1c06174453 | |||
| 318253632d | |||
| 644174eae0 | |||
| 205b25c9a8 | |||
| a74136ee1e | |||
| 9709320ea9 | |||
| 628f0d7425 | |||
| 1626ccab2c | |||
| 997c983677 | |||
| 792467c3f2 | |||
| 76daee4e41 | |||
| 94c7b84a5d | |||
| de99152543 | |||
| f187e56e2e | |||
| 2f4b4a2815 | |||
| 656c1c256f | |||
| 61cd291574 | |||
| 76eacd97f9 | |||
| 3add216e97 | |||
| b61430856a | |||
| 7e6f4bcadc | |||
| c2ebf5758e | |||
| 446bb0e9f0 | |||
| ea3d1065e8 | |||
| eb3bffab4e | |||
| 45dfdc62f0 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 目录
|
||||||
|
|||||||
@@ -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 的部署方式使用**
|
||||||
|
|
||||||
支持在运行时动态变更服务配置
|
支持在运行时动态变更服务配置
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 169 KiB |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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'
|
||||||
>
|
>
|
||||||
查看更多
|
查看更多
|
||||||
|
|||||||
@@ -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'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 如果没有播放记录,则不渲染组件
|
// 如果没有播放记录,则不渲染组件
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,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%)',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; // 搜索时使用的标题
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储接口
|
// 存储接口
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||