48 Commits

Author SHA1 Message Date
senshinya 9c8609a634 Merge pull request #191 from RiverOnVenus/lowercase-patch
fix: ensure GHCR image tags use lowercase repository owner
2025-07-18 00:47:26 +08:00
shinya e2aeb352a1 fix: add fallback savetime 2025-07-18 00:38:10 +08:00
shinya da23e04564 fix: add log 2025-07-18 00:28:08 +08:00
shinya 286beba206 fix: build error 2025-07-18 00:19:39 +08:00
shinya 1e2f05b3a1 fix: fix null search_title 2025-07-18 00:16:10 +08:00
RiverOnVenus e4d8a03071 fix: ensure GHCR image tags use lowercase repository owner 2025-07-17 23:54:40 +08:00
shinya 880b6282c0 feat: adjust douban page mobile style 2025-07-17 21:20:20 +08:00
senshinya a007b7a50f Merge pull request #185 from JohnsonRan/manifest
feat: generate manifest.json for docker too
2025-07-17 16:17:35 +08:00
JohnsonRan 41c1c3989b feat: generate manifest.json for docker too
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-17 15:49:58 +08:00
shinya 0ab4115f6d fix: safari show resolution fallback 2025-07-17 10:24:02 +08:00
shinya a412c5cb54 feat: adjust favorite grid style 2025-07-17 00:25:30 +08:00
shinya 1a6da8b7bc fix: change selector name 2025-07-17 00:16:47 +08:00
shinya 4fe8f37c7a fix: keep secondary selection unchange when changing primary selection 2025-07-17 00:02:16 +08:00
shinya e1521179d4 feat: new douban page and selector 2025-07-16 23:34:28 +08:00
senshinya 53a1b6603b Merge pull request #177 from JohnsonRan/adblockbtn
chore: more understandable AD block button
2025-07-16 18:52:00 +08:00
shinya af2d257344 fix: prefer select 2025-07-16 18:38:05 +08:00
JohnsonRan 79418b66dc chore: more understandable AD block button
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-16 17:40:06 +08:00
senshinya 1cd7ad3b7b Merge pull request #174 from JohnsonRan/dynsitename
feat: generate PWA site name dynamically
2025-07-16 15:31:01 +08:00
JohnsonRan 623edd7959 feat: generate PWA site name dynamically
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-16 15:13:41 +08:00
shinya df99bff693 fix: z-index 2025-07-16 12:26:28 +08:00
senshinya 1783944024 Update page.tsx 2025-07-16 02:06:09 +08:00
shinya b1073be89d fix: scrollable row index 2025-07-16 01:23:58 +08:00
shinya c7b7d90238 feat: adjust 2xl style 2025-07-16 01:02:54 +08:00
shinya 4772a4120d feat: add readme 2025-07-15 23:26:00 +08:00
senshinya 962e3b2656 Merge pull request #166 from JohnsonRan/medumb
fix: forgot there's downstream repos
2025-07-15 22:46:52 +08:00
旋律已经死了。 2f4a2e936c fix: forgot there's downstream repos 2025-07-15 22:43:13 +08:00
shinya af03f9f149 fix: image proxy logic 2025-07-15 22:28:01 +08:00
shinya 90129c0d69 feat: add global image proxy config 2025-07-15 22:20:42 +08:00
shinya cca4092519 feat: add optimizationEnabled switch 2025-07-15 21:37:15 +08:00
shinya 1c06174453 fix: setting panel z-index 2025-07-15 20:51:27 +08:00
shinya 318253632d fix: episode selector styles 2025-07-15 20:48:44 +08:00
shinya 644174eae0 feat: cf merge file config 2025-07-15 20:42:07 +08:00
senshinya 205b25c9a8 Merge pull request #162 from JohnsonRan/perm
fix: no need set permissions here
2025-07-15 16:46:08 +08:00
旋律已经死了。 a74136ee1e fix: no need set permissions here 2025-07-15 16:11:25 +08:00
senshinya 9709320ea9 Merge pull request #160 from 1411430556/patch-1
docs: Update README.md
2025-07-15 13:25:27 +08:00
shinya 628f0d7425 fix: add tooltip back #close 161 2025-07-15 13:24:59 +08:00
shinya 1626ccab2c feat: unify local cache 2025-07-15 13:13:17 +08:00
COYG⚡️ 997c983677 docs: Update README.md 2025-07-15 12:08:25 +08:00
senshinya 792467c3f2 Merge pull request #159 from JohnsonRan/rounded
feat: rounded icons
2025-07-15 11:55:40 +08:00
JohnsonRan 76daee4e41 feat: rounded icons
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-15 11:54:27 +08:00
senshinya 94c7b84a5d Merge pull request #158 from OuOumm/main
refactor(组件): 优化滚动行和视频卡的样式与动画效果
2025-07-15 11:42:05 +08:00
senshinya de99152543 Merge pull request #157 from JohnsonRan/workflows
ci: optimize workflow runs
2025-07-15 11:39:25 +08:00
SongPro f187e56e2e refactor(组件): 优化滚动行和视频卡的样式与动画效果
- 调整 ScrollableRow 的内边距样式
- 简化 ImagePlaceholder 的类名并修改圆角大小
- 重构 VideoCard 的悬停动画和交互效果,改进加载状态处理
- 统一过渡动画的缓动函数和持续时间
2025-07-15 11:27:58 +08:00
JohnsonRan 2f4b4a2815 ci: optimize workflow runs
- Add Docker build cache for faster builds
- Increase sync frequency from daily to every 6 hours
- keep latest 3 workflow runs only
Signed-off-by: JohnsonRan <me@ihtw.moe>
2025-07-15 11:15:59 +08:00
shinya 656c1c256f fix: cron condition 2025-07-15 01:54:45 +08:00
shinya 61cd291574 feat: add local settings 2025-07-15 00:35:28 +08:00
shinya 76eacd97f9 fix: auth 2025-07-14 22:49:56 +08:00
senshinya 3add216e97 Merge pull request #152 from senshinya/d1_cache
feat: d1 local cache
2025-07-14 22:16:37 +08:00
45 changed files with 2171 additions and 672 deletions
+16 -7
View File
@@ -5,11 +5,6 @@ on:
branches: branches:
- main - main
# 写入/读取 package 权限,用于推送到 GHCR (ghcr.io)
permissions:
contents: read
packages: write
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -31,6 +26,10 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase repository owner
id: lowercase
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -39,5 +38,15 @@ jobs:
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: |
ghcr.io/${{ github.repository_owner }}/moontv:latest ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:latest
ghcr.io/${{ github.repository_owner }}/moontv:${{ github.sha }} ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Delete workflow runs
uses: Mattraks/delete-workflow-runs@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
retain_days: 0
keep_minimum_runs: 2
+13 -4
View File
@@ -1,13 +1,14 @@
name: Upstream Sync name: Upstream Sync
permissions:
contents: write
on: on:
schedule: schedule:
- cron: "0 4 * * *" # At 12PM UTC+8 - cron: "0 */6 * * *" # run every 6 hours
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
actions: write
jobs: jobs:
sync_latest_from_upstream: sync_latest_from_upstream:
name: Sync latest commits from upstream repo name: Sync latest commits from upstream repo
@@ -34,3 +35,11 @@ jobs:
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
+2
View File
@@ -48,6 +48,8 @@ ENV DOCKER_ENV=true
# 从构建器中复制 standalone 输出 # 从构建器中复制 standalone 输出
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js # 从构建器中复制 start.js
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录 # 从构建器中复制 public 和 .next/static 目录
+14 -12
View File
@@ -78,6 +78,8 @@
### Cloudflare 部署 ### Cloudflare 部署
**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**
#### 普通部署(localstorage #### 普通部署(localstorage
1. **Fork** 本仓库到你的 GitHub 账户。 1. **Fork** 本仓库到你的 GitHub 账户。
@@ -112,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 最佳实践
@@ -180,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 | (空) |
## 配置说明 ## 配置说明
+5 -3
View File
@@ -3,8 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:runtime && next dev -H 0.0.0.0", "dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:runtime && next build", "build": "pnpm gen:runtime && pnpm gen:manifest && next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format", "lint:fix": "eslint src --fix && pnpm format",
@@ -15,9 +15,10 @@
"format": "prettier -w .", "format": "prettier -w .",
"format:check": "prettier -c .", "format:check": "prettier -c .",
"gen:runtime": "node scripts/convert-config.js", "gen:runtime": "node scripts/convert-config.js",
"gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'", "postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install", "prepare": "husky install",
"pages:build": "pnpm gen:runtime && next build && npx @cloudflare/next-on-pages --experimental-minify" "pages:build": "pnpm gen:runtime && pnpm gen:manifest && next build && npx @cloudflare/next-on-pages --experimental-minify"
}, },
"dependencies": { "dependencies": {
"@cloudflare/next-on-pages": "^1.13.12", "@cloudflare/next-on-pages": "^1.13.12",
@@ -57,6 +58,7 @@
"@testing-library/react": "^15.0.7", "@testing-library/react": "^15.0.7",
"@types/node": "24.0.3", "@types/node": "24.0.3",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
+12
View File
@@ -114,6 +114,9 @@ importers:
'@types/react': '@types/react':
specifier: ^18.3.18 specifier: ^18.3.18
version: 18.3.23 version: 18.3.23
'@types/react-dom':
specifier: ^19.1.6
version: 19.1.6(@types/react@18.3.23)
'@types/testing-library__jest-dom': '@types/testing-library__jest-dom':
specifier: ^5.14.9 specifier: ^5.14.9
version: 5.14.9 version: 5.14.9
@@ -1918,6 +1921,11 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^18.0.0 '@types/react': ^18.0.0
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
'@types/react': ^19.0.0
'@types/react@18.3.23': '@types/react@18.3.23':
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
@@ -8342,6 +8350,10 @@ snapshots:
dependencies: dependencies:
'@types/react': 18.3.23 '@types/react': 18.3.23
'@types/react-dom@19.1.6(@types/react@18.3.23)':
dependencies:
'@types/react': 18.3.23
'@types/react@18.3.23': '@types/react@18.3.23':
dependencies: dependencies:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
+240
View File
@@ -0,0 +1,240 @@
/* eslint-disable */
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
try {
const url = new URL(request.url);
// 如果访问根目录,返回HTML
if (url.pathname === '/') {
return new Response(getRootHtml(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
// 从请求路径中提取目标 URL
let actualUrlStr = decodeURIComponent(url.pathname.replace('/', ''));
// 判断用户输入的 URL 是否带有协议
actualUrlStr = ensureProtocol(actualUrlStr, url.protocol);
// 保留查询参数
actualUrlStr += url.search;
// 创建新 Headers 对象,排除以 'cf-' 开头的请求头
const newHeaders = filterHeaders(
request.headers,
(name) => !name.startsWith('cf-')
);
// 创建一个新的请求以访问目标 URL
const modifiedRequest = new Request(actualUrlStr, {
headers: newHeaders,
method: request.method,
body: request.body,
redirect: 'manual',
});
// 发起对目标 URL 的请求
const response = await fetch(modifiedRequest);
let body = response.body;
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.status)) {
body = response.body;
// 创建新的 Response 对象以修改 Location 头部
return handleRedirect(response, body);
} else if (response.headers.get('Content-Type')?.includes('text/html')) {
body = await handleHtmlContent(
response,
url.protocol,
url.host,
actualUrlStr
);
}
// 创建修改后的响应对象
const modifiedResponse = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// 添加禁用缓存的头部
setNoCacheHeaders(modifiedResponse.headers);
// 添加 CORS 头部,允许跨域访问
setCorsHeaders(modifiedResponse.headers);
return modifiedResponse;
} catch (error) {
// 如果请求目标地址时出现错误,返回带有错误消息的响应和状态码 500(服务器错误)
return jsonResponse(
{
error: error.message,
},
500
);
}
}
// 确保 URL 带有协议
function ensureProtocol(url, defaultProtocol) {
return url.startsWith('http://') || url.startsWith('https://')
? url
: defaultProtocol + '//' + url;
}
// 处理重定向
function handleRedirect(response, body) {
const location = new URL(response.headers.get('location'));
const modifiedLocation = `/${encodeURIComponent(location.toString())}`;
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: {
...response.headers,
Location: modifiedLocation,
},
});
}
// 处理 HTML 内容中的相对路径
async function handleHtmlContent(response, protocol, host, actualUrlStr) {
const originalText = await response.text();
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
let modifiedText = replaceRelativePaths(
originalText,
protocol,
host,
new URL(actualUrlStr).origin
);
return modifiedText;
}
// 替换 HTML 内容中的相对路径
function replaceRelativePaths(text, protocol, host, origin) {
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
return text.replace(regex, `$1${protocol}//${host}/${origin}/`);
}
// 返回 JSON 格式的响应
function jsonResponse(data, status) {
return new Response(JSON.stringify(data), {
status: status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
}
// 过滤请求头
function filterHeaders(headers, filterFunc) {
return new Headers([...headers].filter(([name]) => filterFunc(name)));
}
// 设置禁用缓存的头部
function setNoCacheHeaders(headers) {
headers.set('Cache-Control', 'no-store');
}
// 设置 CORS 头部
function setCorsHeaders(headers) {
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
headers.set('Access-Control-Allow-Headers', '*');
}
// 返回根目录的 HTML
function getRootHtml() {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
<title>Proxy Everything</title>
<link rel="icon" type="image/png" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="Description" content="Proxy Everything with CF Workers.">
<meta property="og:description" content="Proxy Everything with CF Workers.">
<meta property="og:image" content="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="robots" content="index, follow">
<meta http-equiv="Content-Language" content="zh-CN">
<meta name="copyright" content="Copyright © ymyuuu">
<meta name="author" content="ymyuuu">
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<style>
body, html {
height: 100%;
margin: 0;
}
.background {
background-image: url('https://imgapi.cn/bing.php');
background-size: cover;
background-position: center;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background-color: rgba(255, 255, 255, 0.8);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
background-color: rgba(255, 255, 255, 1);
box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.3);
}
.input-field input[type=text] {
color: #2c3e50;
}
.input-field input[type=text]:focus+label {
color: #2c3e50 !important;
}
.input-field input[type=text]:focus {
border-bottom: 1px solid #2c3e50 !important;
box-shadow: 0 1px 0 0 #2c3e50 !important;
}
</style>
</head>
<body>
<div class="background">
<div class="container">
<div class="row">
<div class="col s12 m8 offset-m2 l6 offset-l3">
<div class="card">
<div class="card-content">
<span class="card-title center-align"><i class="material-icons left">link</i>Proxy Everything</span>
<form id="urlForm" onsubmit="redirectToProxy(event)">
<div class="input-field">
<input type="text" id="targetUrl" placeholder="在此输入目标地址" required>
<label for="targetUrl">目标地址</label>
</div>
<button type="submit" class="btn waves-effect waves-light teal darken-2 full-width">跳转</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script>
function redirectToProxy(event) {
event.preventDefault();
const targetUrl = document.getElementById('targetUrl').value.trim();
const currentOrigin = window.location.origin;
window.open(currentOrigin + '/' + encodeURIComponent(targetUrl), '_blank');
}
</script>
</body>
</html>`;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 169 KiB

+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/* eslint-disable */
// 根据 SITE_NAME 动态生成 manifest.json
const fs = require('fs');
const path = require('path');
// 获取项目根目录
const projectRoot = path.resolve(__dirname, '..');
const publicDir = path.join(projectRoot, 'public');
const manifestPath = path.join(publicDir, 'manifest.json');
// 从环境变量获取站点名称
const siteName = process.env.SITE_NAME || 'MoonTV';
// manifest.json 模板
const manifestTemplate = {
"name": siteName,
"short_name": siteName,
"description": "影视聚合",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"apple-mobile-web-app-capable": "yes",
"apple-mobile-web-app-status-bar-style": "black",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
};
try {
// 确保 public 目录存在
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// 写入 manifest.json
fs.writeFileSync(manifestPath, JSON.stringify(manifestTemplate, null, 2));
console.log(`✅ Generated manifest.json with site name: ${siteName}`);
} catch (error) {
console.error('❌ Error generating manifest.json:', error);
process.exit(1);
}
+23 -24
View File
@@ -50,7 +50,7 @@ interface SiteConfig {
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
} }
// 视频源数据类型 // 视频源数据类型
@@ -948,7 +948,7 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
Announcement: '', Announcement: '',
SearchDownstreamMaxPage: 1, SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200, SiteInterfaceCacheTime: 7200,
SearchResultDefaultAggregate: false, ImageProxy: '',
}); });
// 保存状态 // 保存状态
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -960,7 +960,10 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
useEffect(() => { useEffect(() => {
if (config?.SiteConfig) { if (config?.SiteConfig) {
setSiteSettings(config.SiteConfig); setSiteSettings({
...config.SiteConfig,
ImageProxy: config.SiteConfig.ImageProxy || '',
});
} }
}, [config]); }, [config]);
@@ -1094,43 +1097,39 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
/> />
</div> </div>
{/* 默认按标题和年份聚合 */} {/* 图片代理 */}
<div className='flex items-center justify-between'> <div>
<label <label
className={`text-gray-700 dark:text-gray-300 ${ className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isD1Storage ? 'opacity-50' : '' isD1Storage ? 'opacity-50' : ''
}`} }`}
> >
{isD1Storage && ( {isD1Storage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'> <span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(D1 ) (D1 )
</span> </span>
)} )}
</label> </label>
<button <input
onClick={() => type='text'
placeholder='例如: https://imageproxy.example.com/?url='
value={siteSettings.ImageProxy}
onChange={(e) =>
!isD1Storage && !isD1Storage &&
setSiteSettings((prev) => ({ setSiteSettings((prev) => ({
...prev, ...prev,
SearchResultDefaultAggregate: !prev.SearchResultDefaultAggregate, ImageProxy: e.target.value,
})) }))
} }
disabled={isD1Storage} disabled={isD1Storage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${ className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
siteSettings.SearchResultDefaultAggregate isD1Storage ? 'opacity-50 cursor-not-allowed' : ''
? 'bg-green-600' }`}
: 'bg-gray-200 dark:bg-gray-700' />
} ${isD1Storage ? 'opacity-50 cursor-not-allowed' : ''}`} <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
> 访访使
<span </p>
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
siteSettings.SearchResultDefaultAggregate
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
+4 -4
View File
@@ -33,13 +33,13 @@ export async function POST(request: NextRequest) {
Announcement, Announcement,
SearchDownstreamMaxPage, SearchDownstreamMaxPage,
SiteInterfaceCacheTime, SiteInterfaceCacheTime,
SearchResultDefaultAggregate, ImageProxy,
} = body as { } = body as {
SiteName: string; SiteName: string;
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
}; };
// 参数校验 // 参数校验
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
typeof Announcement !== 'string' || typeof Announcement !== 'string' ||
typeof SearchDownstreamMaxPage !== 'number' || typeof SearchDownstreamMaxPage !== 'number' ||
typeof SiteInterfaceCacheTime !== 'number' || typeof SiteInterfaceCacheTime !== 'number' ||
typeof SearchResultDefaultAggregate !== 'boolean' typeof ImageProxy !== 'string'
) { ) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
Announcement, Announcement,
SearchDownstreamMaxPage, SearchDownstreamMaxPage,
SiteInterfaceCacheTime, SiteInterfaceCacheTime,
SearchResultDefaultAggregate, ImageProxy,
}; };
// 写入数据库 // 写入数据库
+2 -2
View File
@@ -37,9 +37,9 @@ export async function GET(request: NextRequest) {
async function refreshRecordAndFavorites() { async function refreshRecordAndFavorites() {
if ( if (
process.env.NEXT_PUBLIC_STORAGE_TYPE || (process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
'localstorage' === 'localstorage'
) { ) {
console.log('跳过刷新:当前使用 localstorage 存储模式');
return; return;
} }
+129
View File
@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
async function fetchDoubanData(
url: string
): Promise<DoubanCategoryApiResponse> {
// 添加超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 设置请求选项,包括信号和头部
const fetchOptions = {
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept: 'application/json, text/plain, */*',
Origin: 'https://movie.douban.com',
},
};
try {
// 尝试直接访问豆瓣API
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind') || 'movie';
const category = searchParams.get('category');
const type = searchParams.get('type');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
// 验证参数
if (!kind || !category || !type) {
return NextResponse.json(
{ error: '缺少必要参数: kind 或 category 或 type' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(kind)) {
return NextResponse.json(
{ error: 'kind 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageLimit < 1 || pageLimit > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}`,
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
+3 -3
View File
@@ -89,12 +89,12 @@ export async function POST(request: NextRequest) {
); );
} }
const favoriteWithoutUserId = { const finalFavorite = {
...favorite, ...favorite,
save_time: favorite.save_time ?? Date.now(), save_time: favorite.save_time ?? Date.now(),
} as Omit<Favorite, 'user_id'>; } as Favorite;
await db.saveFavorite(authInfo.username, source, id, favoriteWithoutUserId); await db.saveFavorite(authInfo.username, source, id, finalFavorite);
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });
} catch (err) { } catch (err) {
+1 -1
View File
@@ -43,7 +43,7 @@ export async function GET(request: Request) {
} }
// 设置缓存头(可选) // 设置缓存头(可选)
headers.set('Cache-Control', 'public, max-age=86400'); // 缓存24小时 headers.set('Cache-Control', 'public, max-age=15720000'); // 缓存半年
// 直接返回图片流 // 直接返回图片流
return new Response(imageResponse.body, { return new Response(imageResponse.body, {
+6 -1
View File
@@ -62,7 +62,12 @@ export async function POST(request: NextRequest) {
); );
} }
await db.savePlayRecord(authInfo.username, source, id, record); const finalRecord = {
...record,
save_time: record.save_time ?? Date.now(),
} as PlayRecord;
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
return NextResponse.json({ success: true }, { status: 200 }); return NextResponse.json({ success: true }, { status: 200 });
} catch (err) { } catch (err) {
+216 -132
View File
@@ -1,36 +1,136 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps */
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DoubanItem, DoubanResult } from '@/lib/types'; import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton'; import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import DoubanSelector from '@/components/DoubanSelector';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard'; import VideoCard from '@/components/VideoCard';
function DoubanPageClient() { function DoubanPageClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]); const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null); const loadingRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const type = searchParams.get('type'); const type = searchParams.get('type') || 'movie';
const tag = searchParams.get('tag');
// 选择器状态 - 完全独立,不依赖URL参数
const [primarySelection, setPrimarySelection] = useState<string>(() => {
return type === 'movie' ? '热门' : '';
});
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
if (type === 'movie') return '全部';
if (type === 'tv') return 'tv';
if (type === 'show') return 'show';
return '全部';
});
// 初始化时标记选择器为准备好状态
useEffect(() => {
// 短暂延迟确保初始状态设置完成
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// type变化时立即重置selectorsReady(最高优先级)
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
}, [type]);
// 当type变化时重置选择器状态
useEffect(() => {
// 批量更新选择器状态
if (type === 'movie') {
setPrimarySelection('热门');
setSecondarySelection('全部');
} else if (type === 'tv') {
setPrimarySelection('');
setSecondarySelection('tv');
} else if (type === 'show') {
setPrimarySelection('');
setSecondarySelection('show');
} else {
setPrimarySelection('');
setSecondarySelection('全部');
}
// 使用短暂延迟确保状态更新完成后标记选择器准备好
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, [type]);
// 生成骨架屏数据 // 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index); const skeletonData = Array.from({ length: 25 }, (_, index) => index);
// 生成API请求参数的辅助函数
const getRequestParams = useCallback(
(pageStart: number) => {
// 当type为tv或show时,kind统一为'tv'category使用type本身
if (type === 'tv' || type === 'show') {
return {
kind: 'tv' as const,
category: type,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
}
// 电影类型保持原逻辑
return {
kind: type as 'tv' | 'movie',
category: primarySelection,
type: secondarySelection,
pageLimit: 25,
pageStart,
};
},
[type, primarySelection, secondarySelection]
);
// 防抖的数据加载函数
const loadInitialData = useCallback(async () => {
try {
setLoading(true);
const data = await getDoubanCategories(getRequestParams(0));
if (data.code === 200) {
setDoubanData(data.list);
setHasMore(data.list.length === 25);
setLoading(false);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
}
}, [type, primarySelection, secondarySelection, getRequestParams]);
// 只在选择器准备好后才加载数据
useEffect(() => { useEffect(() => {
if (!type || !tag) { // 只有在选择器准备好时才开始加载
setError('缺少必要参数: type 或 tag'); if (!selectorsReady) {
setLoading(false);
return; return;
} }
@@ -38,58 +138,43 @@ function DoubanPageClient() {
setDoubanData([]); setDoubanData([]);
setCurrentPage(0); setCurrentPage(0);
setHasMore(true); setHasMore(true);
setError(null);
setIsLoadingMore(false); setIsLoadingMore(false);
// 立即加载第一页数据 // 清除之前的防抖定时器
const loadInitialData = async () => { if (debounceTimeoutRef.current) {
try { clearTimeout(debounceTimeoutRef.current);
setLoading(true); }
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=0`
);
if (!response.ok) { // 使用防抖机制加载数据,避免连续状态更新触发多次请求
throw new Error('获取豆瓣数据失败'); debounceTimeoutRef.current = setTimeout(() => {
} loadInitialData();
}, 100); // 100ms 防抖延迟
const data: DoubanResult = await response.json(); // 清理函数
return () => {
if (data.code === 200) { if (debounceTimeoutRef.current) {
setDoubanData(data.list); clearTimeout(debounceTimeoutRef.current);
setHasMore(data.list.length === 25);
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败');
} finally {
setLoading(false);
} }
}; };
}, [
loadInitialData(); selectorsReady,
}, [type, tag]); type,
primarySelection,
secondarySelection,
loadInitialData,
]);
// 单独处理 currentPage 变化(加载更多) // 单独处理 currentPage 变化(加载更多)
useEffect(() => { useEffect(() => {
if (currentPage > 0 && type && tag) { if (currentPage > 0) {
const fetchMoreData = async () => { const fetchMoreData = async () => {
try { try {
setIsLoadingMore(true); setIsLoadingMore(true);
const response = await fetch( const data = await getDoubanCategories(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=${ getRequestParams(currentPage * 25)
currentPage * 25
}`
); );
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResult = await response.json();
if (data.code === 200) { if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]); setDoubanData((prev) => [...prev, ...data.list]);
setHasMore(data.list.length === 25); setHasMore(data.list.length === 25);
@@ -97,7 +182,7 @@ function DoubanPageClient() {
throw new Error(data.message || '获取数据失败'); throw new Error(data.message || '获取数据失败');
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '获取豆瓣数据失败'); console.error(err);
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
} }
@@ -105,7 +190,7 @@ function DoubanPageClient() {
fetchMoreData(); fetchMoreData();
} }
}, [currentPage, type, tag]); }, [currentPage, type, primarySelection, secondarySelection]);
// 设置滚动监听 // 设置滚动监听
useEffect(() => { useEffect(() => {
@@ -138,28 +223,28 @@ function DoubanPageClient() {
}; };
}, [hasMore, isLoadingMore, loading]); }, [hasMore, isLoadingMore, loading]);
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
setLoading(true);
setPrimarySelection(value);
},
[type]
);
const handleSecondaryChange = useCallback((value: string) => {
setLoading(true);
setSecondarySelection(value);
}, []);
const getPageTitle = () => { const getPageTitle = () => {
// 优先使用 URL 中的 title 参数 // 根据 type 生成标题
const titleParam = searchParams.get('title'); return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
if (titleParam) {
return titleParam;
}
// 如果 title 参数不存在,根据 type 和 tag 拼接
if (!type || !tag) return '豆瓣内容';
const typeText = type === 'movie' ? '电影' : '电视剧';
const tagText = tag === 'top250' ? 'Top250' : tag;
return `${typeText} - ${tagText}`;
}; };
const getActivePath = () => { const getActivePath = () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (type) params.set('type', type); if (type) params.set('type', type);
if (tag) params.set('tag', tag);
const titleParam = searchParams.get('title');
if (titleParam) params.set('title', titleParam);
const queryString = params.toString(); const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`; const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
@@ -169,81 +254,80 @@ function DoubanPageClient() {
return ( return (
<PageLayout activePath={getActivePath()}> <PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'> <div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题 */} {/* 页面标题和选择器 */}
<div className='mb-8'> <div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
<h1 className='text-3xl font-bold text-gray-800 mb-2 dark:text-gray-200'> {/* 页面标题 */}
{getPageTitle()} <div>
</h1> <h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
<p className='text-gray-600 dark:text-gray-400'></p> {getPageTitle()}
</h1>
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
</p>
</div>
{/* 选择器组件 */}
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
</div> </div>
{/* 内容展示区域 */} {/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'> <div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{error ? ( {/* 内容网格 */}
<div className='flex justify-center items-center h-40'> <div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
<div className='text-red-500 text-center'> {loading || !selectorsReady
<div className='text-lg font-semibold mb-2'></div> ? // 显示骨架屏
<div className='text-sm'>{error}</div> skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
</div> : // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
{/* 加载更多指示器 */}
{hasMore && !loading && (
<div
ref={(el) => {
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div> </div>
) : ( )}
<>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading
? // 显示骨架屏
skeletonData.map((index) => (
<DoubanCardSkeleton key={index} />
))
: // 显示实际数据
doubanData.map((item, index) => (
<div key={`${item.title}-${index}`} className='w-full'>
<VideoCard
from='douban'
title={item.title}
poster={item.poster}
douban_id={item.id}
rate={item.rate}
/>
</div>
))}
</div>
{/* 加载更多指示器 */} {/* 没有更多数据提示 */}
{hasMore && !loading && ( {!hasMore && doubanData.length > 0 && (
<div <div className='text-center text-gray-500 py-8'></div>
ref={(el) => { )}
if (el && el.offsetParent !== null) {
(
loadingRef as React.MutableRefObject<HTMLDivElement | null>
).current = el;
}
}}
className='flex justify-center mt-12 py-8'
>
{isLoadingMore && (
<div className='flex items-center gap-2'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='text-gray-600'>...</span>
</div>
)}
</div>
)}
{/* 没有更多数据提示 */} {/* 空状态 */}
{!hasMore && doubanData.length > 0 && ( {!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'> <div className='text-center text-gray-500 py-8'></div>
</div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && !error && (
<div className='text-center text-gray-500 py-8'>
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
+3 -4
View File
@@ -40,21 +40,20 @@ export default async function RootLayout({
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 (
+13 -12
View File
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
'use client'; 'use client';
@@ -13,7 +13,8 @@ import {
getAllPlayRecords, getAllPlayRecords,
subscribeToDataUpdates, 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';
@@ -63,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);
} }
@@ -178,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
@@ -208,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'
> >
@@ -254,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'
> >
+63 -40
View File
@@ -9,16 +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,
subscribeToDataUpdates, subscribeToDataUpdates,
toggleFavorite,
} 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';
@@ -128,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 }>
@@ -523,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) {
@@ -560,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');
@@ -944,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,
@@ -955,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);
} }
@@ -1122,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 {
@@ -1418,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'>
@@ -1474,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'
@@ -1494,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'>
@@ -1611,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'
/> />
+13 -7
View File
@@ -28,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(() => {
+79 -15
View File
@@ -1,4 +1,6 @@
import React from 'react'; /* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useRef, useState } from 'react';
interface CapsuleSwitchProps { interface CapsuleSwitchProps {
options: { label: string; value: string }[]; options: { label: string; value: string }[];
@@ -13,25 +15,87 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
onChange, onChange,
className, className,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [indicatorStyle, setIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const activeIndex = options.findIndex((opt) => opt.value === active);
// 更新指示器位置
const updateIndicatorPosition = () => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, []);
// 监听选中项变化
useEffect(() => {
const timeoutId = setTimeout(updateIndicatorPosition, 0);
return () => clearTimeout(timeoutId);
}, [activeIndex]);
return ( return (
<div <div
className={`inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${ ref={containerRef}
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
className || '' className || ''
}`} }`}
> >
{options.map((opt) => ( {/* 滑动的白色背景指示器 */}
<button {indicatorStyle.width > 0 && (
key={opt.value} <div
onClick={() => onChange(opt.value)} className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
className={`w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 ${ style={{
active === opt.value left: `${indicatorStyle.left}px`,
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-500 dark:text-gray-100' width: `${indicatorStyle.width}px`,
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100' }}
}`} />
> )}
{opt.label}
</button> {options.map((opt, index) => {
))} const isActive = active === opt.value;
return (
<button
key={opt.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(opt.value)}
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{opt.label}
</button>
);
})}
</div> </div>
); );
}; };
+330
View File
@@ -0,0 +1,330 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface SelectorOption {
label: string;
value: string;
}
interface DoubanSelectorProps {
type: 'movie' | 'tv' | 'show';
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
}
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(null);
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
// 电影的一级选择器选项
const moviePrimaryOptions: SelectorOption[] = [
{ label: '热门电影', value: '热门' },
{ label: '最新电影', value: '最新' },
{ label: '豆瓣高分', value: '豆瓣高分' },
{ label: '冷门佳片', value: '冷门佳片' },
];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '华语', value: '华语' },
{ label: '欧美', value: '欧美' },
{ label: '韩国', value: '韩国' },
{ label: '日本', value: '日本' },
];
// 电视剧选择器选项
const tvOptions: SelectorOption[] = [
{ label: '全部', value: 'tv' },
{ label: '国产', value: 'tv_domestic' },
{ label: '欧美', value: 'tv_american' },
{ label: '日本', value: 'tv_japanese' },
{ label: '韩国', value: 'tv_korean' },
{ label: '动漫', value: 'tv_animation' },
{ label: '纪录片', value: 'tv_documentary' },
];
// 综艺选择器选项
const showOptions: SelectorOption[] = [
{ label: '全部', value: 'show' },
{ label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' },
];
// 更新指示器位置的通用函数
const updateIndicatorPosition = (
activeIndex: number,
containerRef: React.RefObject<HTMLDivElement>,
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
setIndicatorStyle: React.Dispatch<
React.SetStateAction<{ left: number; width: number }>
>
) => {
if (
activeIndex >= 0 &&
buttonRefs.current[activeIndex] &&
containerRef.current
) {
const timeoutId = setTimeout(() => {
const button = buttonRefs.current[activeIndex];
const container = containerRef.current;
if (button && container) {
const buttonRect = button.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (buttonRect.width > 0) {
setIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}
}, 0);
return () => clearTimeout(timeoutId);
}
};
// 组件挂载时立即计算初始位置
useEffect(() => {
// 主选择器初始位置
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || moviePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
let secondaryActiveIndex = -1;
if (type === 'movie') {
secondaryActiveIndex = movieSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
);
} else if (type === 'tv') {
secondaryActiveIndex = tvOptions.findIndex(
(opt) => opt.value === (secondarySelection || tvOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showOptions.findIndex(
(opt) => opt.value === (secondarySelection || showOptions[0].value)
);
}
if (secondaryActiveIndex >= 0) {
updateIndicatorPosition(
secondaryActiveIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [type]); // 只在type变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (type === 'movie') {
const activeIndex = moviePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection]);
// 监听副选择器变化
useEffect(() => {
let activeIndex = -1;
let options: SelectorOption[] = [];
if (type === 'movie') {
activeIndex = movieSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = movieSecondaryOptions;
} else if (type === 'tv') {
activeIndex = tvOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvOptions;
} else if (type === 'show') {
activeIndex = showOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showOptions;
}
if (options.length > 0) {
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: SelectorOption[],
activeValue: string | undefined,
onChange: (value: string) => void,
isPrimary = false
) => {
const containerRef = isPrimary
? primaryContainerRef
: secondaryContainerRef;
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
const indicatorStyle = isPrimary
? primaryIndicatorStyle
: secondaryIndicatorStyle;
return (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
return (
<div className='space-y-4 sm:space-y-6'>
{/* 电影类型 - 显示两级选择器 */}
{type === 'movie' && (
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
moviePrimaryOptions,
primarySelection || moviePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
movieSecondaryOptions,
secondarySelection || movieSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
</div>
)}
{/* 电视剧类型 - 只显示一级选择器 */}
{type === 'tv' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
tvOptions,
secondarySelection || tvOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
{/* 综艺类型 - 只显示一级选择器 */}
{type === 'show' && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
showOptions,
secondarySelection || showOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
);
};
export default DoubanSelector;
+31 -11
View File
@@ -10,7 +10,7 @@ import React, {
} from 'react'; } from 'react';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型 // 定义视频信息类型
interface VideoInfo { interface VideoInfo {
@@ -162,10 +162,30 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
} }
}, [precomputedVideoInfo]); }, [precomputedVideoInfo]);
// 读取本地“优选和测速”开关,默认开启
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发 // 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
useEffect(() => { useEffect(() => {
const fetchVideoInfosInBatches = async () => { const fetchVideoInfosInBatches = async () => {
if (activeTab !== 'sources' || availableSources.length === 0) return; if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0
)
return;
// 筛选出尚未测速的播放源 // 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => { const pendingSources = availableSources.filter((source) => {
@@ -185,7 +205,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
fetchVideoInfosInBatches(); fetchVideoInfosInBatches();
// 依赖项保持与之前一致 // 依赖项保持与之前一致
}, [activeTab, availableSources, getVideoInfo]); }, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
// 升序分页标签 // 升序分页标签
const categoriesAsc = useMemo(() => { const categoriesAsc = useMemo(() => {
@@ -437,18 +457,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
onClick={() => onClick={() =>
!isCurrentSource && handleSourceClick(source) !isCurrentSource && handleSourceClick(source)
} }
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 relative className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${ ${
isCurrentSource isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02]' : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
}`.trim()} }`.trim()}
> >
{/* 封面 */} {/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'> <div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.episodes && source.episodes.length > 0 && ( {source.episodes && source.episodes.length > 0 && (
<img <img
src={source.poster} src={processImageUrl(source.poster)}
alt={source.title} alt={source.title}
className='w-full h-full object-cover' className='w-full h-full object-cover'
onError={(e) => { onError={(e) => {
@@ -462,14 +482,14 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{/* 信息区域 */} {/* 信息区域 */}
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'> <div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
{/* 标题和分辨率 - 顶部 */} {/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-2 h-6'> <div className='flex items-start justify-between gap-3 h-6'>
<div className='flex-1 relative group/title'> <div className='flex-1 min-w-0 relative group/title'>
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'> <h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
{source.title} {source.title}
</h3> </h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */} {/* 标题级别的 tooltip - 第一个元素不显示 */}
{index !== 0 && ( {index !== 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[9999] pointer-events-none'> <div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
{source.title} {source.title}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div> <div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div> </div>
@@ -482,7 +502,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (videoInfo && videoInfo.quality !== '未知') { if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) { if (videoInfo.hasError) {
return ( return (
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0'> <div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
</div> </div>
); );
@@ -502,7 +522,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
return ( return (
<div <div
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0`} className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
> >
{videoInfo.quality} {videoInfo.quality}
</div> </div>
+1 -1
View File
@@ -1,7 +1,7 @@
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式) // 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => ( const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
<div <div
className={`w-full ${aspectRatio} rounded-md overflow-hidden transition-opacity duration-500`} className={`w-full ${aspectRatio} rounded-lg`}
style={{ style={{
background: background:
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)', 'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
+6 -32
View File
@@ -1,17 +1,6 @@
'use client'; 'use client';
import { import { Clover, Film, Home, Search, Tv } from 'lucide-react';
Clover,
Film,
Home,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@@ -34,36 +23,22 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
{ {
icon: Film, icon: Film,
label: '电影', label: '电影',
href: '/douban?type=movie&tag=热门&title=热门电影', href: '/douban?type=movie',
}, },
{ {
icon: Tv, icon: Tv,
label: '剧集', label: '剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集', href: '/douban?type=tv',
},
{
icon: Star,
label: '高分',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
}, },
{ {
icon: Clover, icon: Clover,
label: '综艺', label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺', href: '/douban?type=show',
}, },
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
]; ];
const isActive = (href: string) => { const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1]; const typeMatch = href.match(/type=([^&]+)/)?.[1];
const tagMatch = href.match(/tag=([^&]+)/)?.[1];
// 解码URL以进行正确的比较 // 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive); const decodedActive = decodeURIComponent(currentActive);
@@ -72,14 +47,13 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
return ( return (
decodedActive === decodedItemHref || decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') && (decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`) && decodedActive.includes(`type=${typeMatch}`))
decodedActive.includes(`tag=${tagMatch}`))
); );
}; };
return ( return (
<nav <nav
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide dark:bg-gray-900/80 dark:border-gray-700/50' className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-gray-700/50'
style={{ style={{
/* 紧贴视口底部,同时在内部留出安全区高度 */ /* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0, bottom: 0,
+15 -13
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton'; import { LogoutButton } from './LogoutButton';
import { SettingsButton } from './SettingsButton';
import { useSite } from './SiteProvider'; import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
@@ -15,15 +16,22 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite(); const { siteName } = useSite();
return ( return (
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'> <header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
{/* 返回按钮 */} <div className='h-12 flex items-center justify-between px-4'>
{showBackButton && ( {/* 左侧:返回按钮和设置按钮 */}
<div className='absolute top-1/2 left-4 -translate-y-1/2'> <div className='flex items-center gap-2'>
<BackButton /> {showBackButton && <BackButton />}
<SettingsButton />
</div> </div>
)}
{/* 站点名称 */} {/* 右侧按钮 */}
<div className='h-12 flex items-center justify-center'> <div className='flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</div>
{/* 中间:Logo(绝对居中) */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link <Link
href='/' href='/'
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity' className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
@@ -31,12 +39,6 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{siteName} {siteName}
</Link> </Link>
</div> </div>
{/* 右侧按钮 */}
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</header> </header>
); );
}; };
+2
View File
@@ -2,6 +2,7 @@ import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton'; import { LogoutButton } from './LogoutButton';
import MobileBottomNav from './MobileBottomNav'; import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader'; import MobileHeader from './MobileHeader';
import { SettingsButton } from './SettingsButton';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
@@ -34,6 +35,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 桌面端顶部按钮 */} {/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'> <div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<SettingsButton />
<LogoutButton /> <LogoutButton />
<ThemeToggle /> <ThemeToggle />
</div> </div>
+17 -13
View File
@@ -102,27 +102,29 @@ export default function ScrollableRow({
> >
<div <div
ref={containerRef} ref={containerRef}
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14' className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6'
onScroll={checkScroll} onScroll={checkScroll}
> >
{children} {children}
</div> </div>
{showLeftScroll && ( {showLeftScroll && (
<div <div
className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-50 transition-opacity duration-200 ${ className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0' isHovered ? 'opacity-100' : 'opacity-0'
}`} }`}
style={{ style={{
background: 'transparent', background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}} }}
> >
<div
className='absolute inset-0'
onClick={(e) => e.stopPropagation()}
/>
<div <div
className='absolute inset-0 flex items-center justify-center' className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%', left: '-4.5rem' }} style={{
top: '40%',
bottom: '60%',
left: '-4.5rem',
pointerEvents: 'auto',
}}
> >
<button <button
onClick={handleScrollLeftClick} onClick={handleScrollLeftClick}
@@ -136,20 +138,22 @@ export default function ScrollableRow({
{showRightScroll && ( {showRightScroll && (
<div <div
className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-50 transition-opacity duration-200 ${ className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0' isHovered ? 'opacity-100' : 'opacity-0'
}`} }`}
style={{ style={{
background: 'transparent', background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}} }}
> >
<div
className='absolute inset-0'
onClick={(e) => e.stopPropagation()}
/>
<div <div
className='absolute inset-0 flex items-center justify-center' className='absolute inset-0 flex items-center justify-center'
style={{ top: '40%', bottom: '60%', right: '-4.5rem' }} style={{
top: '40%',
bottom: '60%',
right: '-4.5rem',
pointerEvents: 'auto',
}}
> >
<button <button
onClick={handleScrollRightClick} onClick={handleScrollRightClick}
+307
View File
@@ -0,0 +1,307 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Settings, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export const SettingsButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [imageProxyUrl, setImageProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [enableImageProxy, setEnableImageProxy] = useState(false);
const [mounted, setMounted] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
}
const savedEnableImageProxy = localStorage.getItem('enableImageProxy');
const defaultImageProxy =
(window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
if (savedEnableImageProxy !== null) {
setEnableImageProxy(JSON.parse(savedEnableImageProxy));
} else if (defaultImageProxy) {
// 如果有默认图片代理配置,则默认开启
setEnableImageProxy(true);
}
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
if (savedImageProxyUrl !== null) {
setImageProxyUrl(savedImageProxyUrl);
} else if (defaultImageProxy) {
setImageProxyUrl(defaultImageProxy);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
}
}, []);
// 保存设置到 localStorage
const handleAggregateToggle = (value: boolean) => {
setDefaultAggregateSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
}
};
const handleDoubanProxyUrlChange = (value: string) => {
setDoubanProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrl', value);
}
};
const handleImageProxyUrlChange = (value: string) => {
setImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('imageProxyUrl', value);
}
};
const handleOptimizationToggle = (value: boolean) => {
setEnableOptimization(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableOptimization', JSON.stringify(value));
}
};
const handleImageProxyToggle = (value: boolean) => {
setEnableImageProxy(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableImageProxy', JSON.stringify(value));
}
};
const handleSettingsClick = () => {
setIsOpen(!isOpen);
};
const handleClosePanel = () => {
setIsOpen(false);
};
// 重置所有设置为默认值
const handleResetSettings = () => {
const defaultImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
// 重置所有状态
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setDoubanProxyUrl('');
setEnableImageProxy(!!defaultImageProxy);
setImageProxyUrl(defaultImageProxy);
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('doubanProxyUrl', '');
localStorage.setItem(
'enableImageProxy',
JSON.stringify(!!defaultImageProxy)
);
localStorage.setItem('imageProxyUrl', defaultImageProxy);
}
};
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleClosePanel}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
</button>
</div>
<button
onClick={handleClosePanel}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 设置项 */}
<div className='space-y-6'>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 优选和测速 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableOptimization}
onChange={(e) => handleOptimizationToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 豆瓣代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
URL以绕过豆瓣访问限制使API
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
{/* 图片代理开关 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableImageProxy}
onChange={(e) => handleImageProxyToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 图片代理地址设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
enableImageProxy
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
}`}
placeholder='例如: https://imageproxy.example.com/?url='
value={imageProxyUrl}
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
disabled={!enableImageProxy}
/>
</div>
</div>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
</div>
</>
);
return (
<>
<button
onClick={handleSettingsClick}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
aria-label='Settings'
>
<Settings className='w-full h-full' />
</button>
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isOpen && mounted && createPortal(settingsPanel, document.body)}
</>
);
};
+6 -31
View File
@@ -1,18 +1,6 @@
'use client'; 'use client';
import { import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
Clover,
Film,
Home,
Menu,
MessageCircleHeart,
MountainSnow,
Search,
Star,
Swords,
Tv,
VenetianMask,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { import {
@@ -137,32 +125,19 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const menuItems = [ const menuItems = [
{ {
icon: Film, icon: Film,
label: '热门电影', label: '电影',
href: '/douban?type=movie&tag=热门&title=热门电影', href: '/douban?type=movie',
}, },
{ {
icon: Tv, icon: Tv,
label: '热门剧集', label: '剧集',
href: '/douban?type=tv&tag=热门&title=热门剧集', href: '/douban?type=tv',
},
{
icon: Star,
label: '豆瓣 Top250',
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
}, },
{ {
icon: Clover, icon: Clover,
label: '综艺', label: '综艺',
href: '/douban?type=tv&tag=综艺&title=综艺', href: '/douban?type=show',
}, },
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
{
icon: MessageCircleHeart,
label: '韩剧',
href: '/douban?type=tv&tag=韩剧',
},
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
]; ];
return ( return (
+71 -69
View File
@@ -6,13 +6,15 @@ import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
deleteFavorite,
deletePlayRecord, deletePlayRecord,
generateStorageKey, generateStorageKey,
isFavorited, isFavorited,
saveFavorite,
subscribeToDataUpdates, subscribeToDataUpdates,
toggleFavorite,
} from '@/lib/db.client'; } 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';
@@ -53,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;
@@ -143,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('切换收藏状态失败');
} }
@@ -165,6 +174,7 @@ export default function VideoCard({
actualYear, actualYear,
actualPoster, actualPoster,
actualEpisodes, actualEpisodes,
favorited,
] ]
); );
@@ -253,130 +263,122 @@ export default function VideoCard({
return ( return (
<div <div
className='group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-out hover:-translate-y-1 hover:scale-[1.02] cursor-pointer' className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
onClick={handleClick} onClick={handleClick}
> >
{/* 海报容器 */} {/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg shadow-md transition-shadow duration-300 ease-out group-hover:shadow-xl'> <div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 - 添加渐入动画 */} {/* 骨架屏 */}
{!isLoaded && ( {!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
<ImagePlaceholder aspectRatio='aspect-[2/3] transition-opacity duration-500 ease-out' /> {/* 图片 */}
)}
{/* 图片加载动画 - 改进淡入和锐化效果 */}
<Image <Image
src={actualPoster} src={processImageUrl(actualPoster)}
alt={actualTitle} alt={actualTitle}
fill fill
className={`object-cover transition-all duration-700 ease-out ${ className='object-cover'
isLoaded ? 'opacity-100 blur-0' : 'opacity-0 blur-md'
}`}
onLoadingComplete={() => setIsLoaded(true)}
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
priority={false} onLoadingComplete={() => setIsLoading(true)}
/> />
{/* 悬浮层 - 改进渐变和元素过渡 */} {/* 悬浮遮罩 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-out pointer-events-auto'></div> <div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
{/* 播放按钮 */} {/* 播放按钮 */}
{config.showPlayButton && ( {config.showPlayButton && (
<div className='absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 pointer-events-auto'> <div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
<PlayCircleIcon <PlayCircleIcon
size={50} size={50}
strokeWidth={0.8} strokeWidth={0.8}
className='rounded-full text-white fill-transparent transition-[fill,transform] duration-200 ease-out [-webkit-transform:translateZ(0)] [-webkit-backface-visibility:hidden] [backface-visibility:hidden] [will-change:transform,fill,opacity] hover:fill-green-500 hover:scale-110' className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
/> />
</div> </div>
)} )}
{/* 已看 / 收藏按钮 - 改进延迟和缓动效果 */} {/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && ( {(config.showHeart || config.showCheckCircle) && (
<div className='absolute bottom-3 right-3 flex items-center gap-3 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 ease-out delay-75 pointer-events-auto'> <div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
{config.showCheckCircle && ( {config.showCheckCircle && (
<CheckCircle <CheckCircle
onClick={handleDeleteRecord} onClick={handleDeleteRecord}
size={20} size={20}
className='rounded-full text-white hover:stroke-green-500 transition-transform duration-200 ease-out hover:scale-110' className='text-white transition-all duration-300 ease-out hover:stroke-green-500 hover:scale-[1.1]'
/> />
)} )}
{config.showHeart && ( {config.showHeart && (
<Heart <Heart
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
size={20} size={20}
className={`rounded-full transition-all duration-300 ease-out hover:scale-110 ${ className={`transition-all duration-300 ease-out ${
favorited favorited
? 'fill-red-600 stroke-red-600 animate-pulse-subtle' ? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400' : 'fill-transparent stroke-white hover:stroke-red-400'
}`} } hover:scale-[1.1]`}
/> />
)} )}
</div> </div>
)} )}
{/* 评分徽章 - 添加弹出效果 */} {/* 徽章 */}
{config.showRating && rate && ( {config.showRating && rate && (
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold p-1 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-110 group-hover:shadow-lg'> <div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{rate} {rate}
</div> </div>
)} )}
{/* 集数徽章 - 改进缩放和阴影 */} {actualEpisodes && actualEpisodes > 1 && (
{actualEpisodes && actualEpisodes > 1 && currentEpisode && ( <div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold rounded-md px-2 py-1 shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-105 group-hover:shadow-lg'> {currentEpisode
{currentEpisode}/{actualEpisodes} ? `${currentEpisode}/${actualEpisodes}`
</div> : actualEpisodes}
)}
{actualEpisodes && actualEpisodes > 1 && !currentEpisode && (
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold rounded-md px-2 py-1 shadow-md transition-all duration-300 ease-out transform scale-90 group-hover:scale-105 group-hover:shadow-lg'>
{actualEpisodes}
</div> </div>
)} )}
{/* 豆瓣链接按钮 - 改进进入方向和延迟 */} {/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && ( {config.showDoubanLink && actualDoubanId && (
<a <a
href={`https://movie.douban.com/subject/${actualDoubanId}`} href={`https://movie.douban.com/subject/${actualDoubanId}`}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-4 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300 ease-out delay-100' className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
> >
<div className='bg-green-500 text-white text-xs font-bold p-1 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-110 transition-all duration-200 ease-out'> <div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Link size={16} /> <Link size={16} />
</div> </div>
</a> </a>
)} )}
</div> </div>
{/* 进度条 - 添加动画效果 */} {/* 进度条 */}
{config.showProgress && progress !== undefined && ( {config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'> <div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div <div
className='h-full bg-green-500 rounded-full transition-all duration-1000 ease-out' className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
)} )}
{/* 标题与来源信息 - 改进颜色过渡和延迟 */} {/* 标题与来源 */}
<div className='relative'> <div className='mt-2 text-center'>
<span className='mt-2 block text-center text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-all duration-300 ease-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'> <div className='relative'>
{actualTitle} <span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
</span> {actualTitle}
{/* 自定义 tooltip */}
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-50 pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-center text-xs text-gray-500 dark:text-gray-400 mt-1 transition-all duration-300 ease-out delay-75 group-hover:text-green-500 dark:group-hover:text-green-500 group-hover:scale-105'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-out group-hover:border-green-500/60'>
{source_name}
</span> </span>
</span> {/* 自定义 tooltip */}
)} <div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
{source_name}
</span>
</span>
)}
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -4,7 +4,7 @@ export interface AdminConfig {
Announcement: string; Announcement: string;
SearchDownstreamMaxPage: number; SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number; SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean; ImageProxy: string;
}; };
UserConfig: { UserConfig: {
AllowRegister: boolean; AllowRegister: boolean;
+30 -8
View File
@@ -160,8 +160,7 @@ async function initConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -199,8 +198,7 @@ async function initConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -238,8 +236,33 @@ export async function getConfig(): Promise<AdminConfig> {
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
adminConfig.UserConfig.AllowRegister = adminConfig.UserConfig.AllowRegister =
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true'; process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
adminConfig.SiteConfig.SearchResultDefaultAggregate = adminConfig.SiteConfig.ImageProxy =
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false'; process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
// 合并文件中的源信息
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
const apiSiteEntries = Object.entries(fileConfig.api_site);
const existed = new Set((adminConfig.SourceConfig || []).map((s) => s.key));
apiSiteEntries.forEach(([key, site]) => {
if (!existed.has(key)) {
adminConfig!.SourceConfig.push({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
});
}
});
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
cachedConfig = adminConfig; cachedConfig = adminConfig;
} else { } else {
// DB 无配置,执行一次初始化 // DB 无配置,执行一次初始化
@@ -283,8 +306,7 @@ export async function resetConfig() {
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200, SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate: ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
}, },
UserConfig: { UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true', AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
+2
View File
@@ -187,6 +187,7 @@ export class D1Storage implements IStorage {
year: result.year, year: result.year,
total_episodes: result.total_episodes, total_episodes: result.total_episodes,
save_time: result.save_time, save_time: result.save_time,
search_title: result.search_title,
}; };
} catch (err) { } catch (err) {
console.error('Failed to get favorite:', err); console.error('Failed to get favorite:', err);
@@ -246,6 +247,7 @@ export class D1Storage implements IStorage {
year: row.year, year: row.year,
total_episodes: row.total_episodes, total_episodes: row.total_episodes,
save_time: row.save_time, save_time: row.save_time,
search_title: row.search_title,
}; };
}); });
+125 -197
View File
@@ -380,7 +380,7 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
} }
// D1 存储模式:使用混合缓存策略 // D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据 // 优先从缓存获取数据
const cachedData = cacheManager.getCachedPlayRecords(); const cachedData = cacheManager.getCachedPlayRecords();
@@ -419,11 +419,6 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
} }
} }
// 其他数据库存储模式:直接从 API 获取
if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`);
}
// localstorage 模式 // localstorage 模式
try { try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY); const raw = localStorage.getItem(PLAY_RECORDS_KEY);
@@ -447,7 +442,7 @@ export async function savePlayRecord(
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {}; const cachedRecords = cacheManager.getCachedPlayRecords() || {};
cachedRecords[key] = record; cachedRecords[key] = record;
@@ -480,24 +475,6 @@ export async function savePlayRecord(
return; return;
} }
// 其他数据库存储模式:直接通过 API 保存
if (STORAGE_TYPE !== 'localstorage') {
try {
const res = await fetch('/api/playrecords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, record }),
});
if (!res.ok) throw new Error(`保存播放记录失败: ${res.status}`);
} catch (err) {
console.error('保存播放记录到数据库失败:', err);
throw err;
}
return;
}
// localstorage 模式 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端保存播放记录到 localStorage'); console.warn('无法在服务端保存播放记录到 localStorage');
@@ -508,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;
@@ -525,7 +507,7 @@ export async function deletePlayRecord(
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {}; const cachedRecords = cacheManager.getCachedPlayRecords() || {};
delete cachedRecords[key]; delete cachedRecords[key];
@@ -554,23 +536,6 @@ export async function deletePlayRecord(
return; return;
} }
// 其他数据库存储模式:直接通过 API 删除
if (STORAGE_TYPE !== 'localstorage') {
try {
const res = await fetch(
`/api/playrecords?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
} catch (err) {
console.error('删除播放记录到数据库失败:', err);
throw err;
}
return;
}
// localstorage 模式 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage'); console.warn('无法在服务端删除播放记录到 localStorage');
@@ -581,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;
@@ -601,7 +570,7 @@ export async function getSearchHistory(): Promise<string[]> {
} }
// D1 存储模式:使用混合缓存策略 // D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据 // 优先从缓存获取数据
const cachedData = cacheManager.getCachedSearchHistory(); const cachedData = cacheManager.getCachedSearchHistory();
@@ -638,16 +607,6 @@ export async function getSearchHistory(): Promise<string[]> {
} }
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
return fetchFromApi<string[]>(`/api/searchhistory`);
} catch (err) {
console.error('获取搜索历史失败:', err);
return [];
}
}
// localStorage 模式 // localStorage 模式
try { try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY); const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
@@ -670,7 +629,7 @@ export async function addSearchHistory(keyword: string): Promise<void> {
if (!trimmed) return; if (!trimmed) return;
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || []; const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)]; const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
@@ -703,22 +662,6 @@ export async function addSearchHistory(keyword: string): Promise<void> {
return; return;
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
await fetch('/api/searchhistory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keyword: trimmed }),
});
} catch (err) {
console.error('保存搜索历史失败:', err);
}
return;
}
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -730,6 +673,11 @@ 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);
} }
@@ -741,7 +689,7 @@ export async function addSearchHistory(keyword: string): Promise<void> {
*/ */
export async function clearSearchHistory(): Promise<void> { export async function clearSearchHistory(): Promise<void> {
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
cacheManager.cacheSearchHistory([]); cacheManager.cacheSearchHistory([]);
@@ -764,21 +712,14 @@ export async function clearSearchHistory(): Promise<void> {
return; return;
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
await fetch(`/api/searchhistory`, {
method: 'DELETE',
});
} catch (err) {
console.error('清空搜索历史失败:', err);
}
return;
}
// 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: [],
})
);
} }
/** /**
@@ -790,7 +731,7 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
if (!trimmed) return; if (!trimmed) return;
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || []; const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = cachedHistory.filter((k) => k !== trimmed); const newHistory = cachedHistory.filter((k) => k !== trimmed);
@@ -818,18 +759,6 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
return; return;
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, {
method: 'DELETE',
});
} catch (err) {
console.error('删除搜索历史失败:', err);
}
return;
}
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -837,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);
} }
@@ -855,7 +789,7 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
} }
// D1 存储模式:使用混合缓存策略 // D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据 // 优先从缓存获取数据
const cachedData = cacheManager.getCachedFavorites(); const cachedData = cacheManager.getCachedFavorites();
@@ -894,11 +828,6 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
} }
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
}
// localStorage 模式 // localStorage 模式
try { try {
const raw = localStorage.getItem(FAVORITES_KEY); const raw = localStorage.getItem(FAVORITES_KEY);
@@ -922,7 +851,7 @@ export async function saveFavorite(
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {}; const cachedFavorites = cacheManager.getCachedFavorites() || {};
cachedFavorites[key] = favorite; cachedFavorites[key] = favorite;
@@ -952,24 +881,6 @@ export async function saveFavorite(
return; return;
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, favorite }),
});
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
} catch (err) {
console.error('保存收藏到数据库失败:', err);
throw err;
}
return;
}
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端保存收藏到 localStorage'); console.warn('无法在服务端保存收藏到 localStorage');
@@ -980,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;
@@ -997,7 +913,7 @@ export async function deleteFavorite(
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// D1 存储模式:乐观更新策略 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') { if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存 // 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {}; const cachedFavorites = cacheManager.getCachedFavorites() || {};
delete cachedFavorites[key]; delete cachedFavorites[key];
@@ -1023,20 +939,6 @@ export async function deleteFavorite(
return; return;
} }
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') {
try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
} catch (err) {
console.error('删除收藏到数据库失败:', err);
throw err;
}
return;
}
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端删除收藏到 localStorage'); console.warn('无法在服务端删除收藏到 localStorage');
@@ -1047,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;
@@ -1055,7 +962,7 @@ export async function deleteFavorite(
/** /**
* 判断是否已收藏。 * 判断是否已收藏。
* D1 存储模式下优先使用缓存数据。 * D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function isFavorited( export async function isFavorited(
source: string, source: string,
@@ -1063,35 +970,42 @@ export async function isFavorited(
): Promise<boolean> { ): Promise<boolean> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// D1 存储模式:优先使用缓存 // D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') {
const cachedFavorites = cacheManager.getCachedFavorites();
if (cachedFavorites) {
return !!cachedFavorites[key];
}
// 缓存为空时从 API 获取
try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
if (!res.ok) return false;
const data = await res.json();
return !!data;
} catch (err) {
console.error('检查收藏状态失败:', err);
return false;
}
}
// 其他数据库存储模式
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;
}
} }
} }
@@ -1100,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;
} }
@@ -1144,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;
} }
@@ -1166,6 +1089,11 @@ 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: {},
})
);
} }
// ---------------- 混合缓存辅助函数 ---------------- // ---------------- 混合缓存辅助函数 ----------------
-21
View File
@@ -129,27 +129,6 @@ export class DbManager {
return favorite !== null; return favorite !== null;
} }
async toggleFavorite(
userName: string,
source: string,
id: string,
favoriteData?: Favorite
): Promise<boolean> {
const isFav = await this.isFavorited(userName, source, id);
if (isFav) {
await this.deleteFavorite(userName, source, id);
return false;
}
if (favoriteData) {
await this.saveFavorite(userName, source, id, favoriteData);
return true;
}
throw new Error('Favorite data is required when adding to favorites');
}
// ---------- 用户相关 ---------- // ---------- 用户相关 ----------
async registerUser(userName: string, password: string): Promise<void> { async registerUser(userName: string, password: string): Promise<void> {
await this.storage.registerUser(userName, password); await this.storage.registerUser(userName, password);
+245
View File
@@ -0,0 +1,245 @@
import { DoubanItem, DoubanResult } from './types';
interface DoubanApiResponse {
subjects: Array<{
id: string;
title: string;
cover: string;
rate: string;
}>;
}
interface DoubanRecommendsParams {
type: 'tv' | 'movie';
tag: string;
pageSize?: number;
pageStart?: number;
}
interface DoubanCategoriesParams {
kind: 'tv' | 'movie';
category: string;
type: string;
pageLimit?: number;
pageStart?: number;
}
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
/**
* 浏览器端豆瓣数据获取函数
*/
export async function fetchDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
const { type, tag, pageSize = 16, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(type)) {
throw new Error('type 参数必须是 tv 或 movie');
}
if (pageSize < 1 || pageSize > 100) {
throw new Error('pageSize 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣数据失败: ${(error as Error).message}`);
}
}
/**
* 带超时的 fetch 请求
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {}
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 检查是否使用代理
const proxyUrl = getDoubanProxyUrl();
const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url;
const fetchOptions: RequestInit = {
...options,
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept: 'application/json, text/plain, */*',
...options.headers,
},
};
try {
const response = await fetch(finalUrl, fetchOptions);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* 获取豆瓣代理 URL 设置
*/
export function getDoubanProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
const doubanProxyUrl = localStorage.getItem('doubanProxyUrl');
return doubanProxyUrl && doubanProxyUrl.trim() ? doubanProxyUrl.trim() : null;
}
/**
* 检查是否应该使用客户端获取豆瓣数据
*/
export function shouldUseDoubanClient(): boolean {
return getDoubanProxyUrl() !== null;
}
/**
* 统一的豆瓣数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanRecommends(params);
} else {
// 使用服务端 API(当没有设置代理 URL 时)
const { type, tag, pageSize = 16, pageStart = 0 } = params;
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=${pageSize}&pageStart=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
return response.json();
}
}
/**
* 浏览器端豆瓣分类数据获取函数
*/
export async function fetchDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(kind)) {
throw new Error('kind 参数必须是 tv 或 movie');
}
if (!category || !type) {
throw new Error('category 和 type 参数不能为空');
}
if (pageLimit < 1 || pageLimit > 100) {
throw new Error('pageLimit 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanCategoryApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.items.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
/**
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanCategories(params);
} else {
// 使用服务端 API(当没有设置代理 URL 时)
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣分类数据失败');
}
return response.json();
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ export interface PlayRecord {
play_time: number; // 播放进度(秒) play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒) total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳) save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题 search_title: string; // 搜索时使用的标题
} }
// 收藏数据结构 // 收藏数据结构
@@ -22,7 +22,7 @@ export interface Favorite {
year: string; year: string;
cover: string; cover: string;
save_time: number; // 记录保存时间(时间戳) save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题 search_title: string; // 搜索时使用的标题
} }
// 存储接口 // 存储接口
+38
View File
@@ -2,6 +2,44 @@
import Hls from 'hls.js'; import Hls from 'hls.js';
/**
* 获取图片代理 URL 设置
*/
export function getImageProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
// 本地未开启图片代理,则不使用代理
const enableImageProxy = localStorage.getItem('enableImageProxy');
if (enableImageProxy !== null) {
if (!JSON.parse(enableImageProxy) as boolean) {
return null;
}
}
const localImageProxy = localStorage.getItem('imageProxyUrl');
if (localImageProxy != null) {
return localImageProxy.trim() ? localImageProxy.trim() : null;
}
// 如果未设置,则使用全局对象
const serverImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY;
return serverImageProxy && serverImageProxy.trim()
? serverImageProxy.trim()
: null;
}
/**
* 处理图片 URL,如果设置了图片代理则使用代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
const proxyUrl = getImageProxyUrl();
if (!proxyUrl) return originalUrl;
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
}
export function cleanHtmlTags(text: string): string { export function cleanHtmlTags(text: string): string {
if (!text) return ''; if (!text) return '';
return text return text
+20
View File
@@ -2,6 +2,26 @@
/* eslint-disable no-console,@typescript-eslint/no-var-requires */ /* eslint-disable no-console,@typescript-eslint/no-var-requires */
const http = require('http'); const http = require('http');
const path = require('path');
// 调用 generate-manifest.js 生成 manifest.json
function generateManifest() {
console.log('Generating manifest.json for Docker deployment...');
try {
const generateManifestScript = path.join(
__dirname,
'scripts',
'generate-manifest.js'
);
require(generateManifestScript);
} catch (error) {
console.error('❌ Error calling generate-manifest.js:', error);
throw error;
}
}
generateManifest();
// 直接在当前进程中启动 standalone Server`server.js` // 直接在当前进程中启动 standalone Server`server.js`
require('./server.js'); require('./server.js');