first commit

This commit is contained in:
JohnsonRan
2025-08-12 21:50:58 +08:00
commit b4ed660eca
121 changed files with 36497 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
.env*.local
+84
View File
@@ -0,0 +1,84 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
extends: [
'eslint:recommended',
'next',
'next/core-web-vitals',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'no-unused-vars': 'off',
'no-console': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'react/no-unescaped-entities': 'off',
'react/display-name': 'off',
'react/jsx-curly-brace-presence': [
'warn',
{ props: 'never', children: 'never' },
],
//#region //*=========== Unused Import ===========
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
//#endregion //*======== Unused Import ===========
//#region //*=========== Import Sort ===========
'simple-import-sort/exports': 'warn',
'simple-import-sort/imports': [
'warn',
{
groups: [
// ext library & side effect imports
['^@?\\w', '^\\u0000'],
// {s}css files
['^.+\\.s?css$'],
// Lib and hooks
['^@/lib', '^@/hooks'],
// static data
['^@/data'],
// components
['^@/components', '^@/container'],
// zustand store
['^@/store'],
// Other imports
['^@/'],
// relative paths up until 3 level
[
'^\\./?$',
'^\\.(?!/?$)',
'^\\.\\./?$',
'^\\.\\.(?!/?$)',
'^\\.\\./\\.\\./?$',
'^\\.\\./\\.\\.(?!/?$)',
'^\\.\\./\\.\\./\\.\\./?$',
'^\\.\\./\\.\\./\\.\\.(?!/?$)',
],
['^@/types'],
// other that didnt fit in
['^'],
],
},
],
//#endregion //*======== Import Sort ===========
},
globals: {
React: true,
JSX: true,
},
};
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# next-sitemap
sitemap.xml
sitemap-*.xml
# generated files
src/lib/runtime.ts
public/manifest.json
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm install
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
View File
+1
View File
@@ -0,0 +1 @@
v20.10.0
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
.next
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# changelog
CHANGELOG.md
pnpm-lock.yaml
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
arrowParens: 'always',
singleQuote: true,
jsxSingleQuote: true,
tabWidth: 2,
semi: true,
};
+10
View File
@@ -0,0 +1,10 @@
{
"Region CSS": {
"prefix": "regc",
"body": [
"/* #region /**=========== ${1} =========== */",
"$0",
"/* #endregion /**======== ${1} =========== */"
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"css.validate": false,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
// Tailwind CSS Autocomplete, add more if used in projects
"tailwindCSS.classAttributes": [
"class",
"className",
"classNames",
"containerClassName"
],
"typescript.preferences.importModuleSpecifier": "non-relative"
}
+193
View File
@@ -0,0 +1,193 @@
{
//#region //*=========== React ===========
"import React": {
"prefix": "ir",
"body": ["import * as React from 'react';"]
},
"React.useState": {
"prefix": "us",
"body": [
"const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
]
},
"React.useEffect": {
"prefix": "uf",
"body": ["React.useEffect(() => {", " $0", "}, []);"]
},
"React.useReducer": {
"prefix": "ur",
"body": [
"const [state, dispatch] = React.useReducer(${0:someReducer}, {",
" ",
"})"
]
},
"React.useRef": {
"prefix": "urf",
"body": ["const ${1:someRef} = React.useRef($0)"]
},
"React Functional Component": {
"prefix": "rc",
"body": [
"import * as React from 'react';\n",
"export default function ${1:${TM_FILENAME_BASE}}() {",
" return (",
" <div>",
" $0",
" </div>",
" )",
"}"
]
},
"React Functional Component with Props": {
"prefix": "rcp",
"body": [
"import * as React from 'react';\n",
"import clsxm from '@/lib/clsxm';\n",
"type ${1:${TM_FILENAME_BASE}}Props= {\n",
"} & React.ComponentPropsWithoutRef<'div'>\n",
"export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
" return (",
" <div className={clsxm(['', className])} {...rest}>",
" $0",
" </div>",
" )",
"}"
]
},
//#endregion //*======== React ===========
//#region //*=========== Commons ===========
"Region": {
"prefix": "reg",
"scope": "javascript, typescript, javascriptreact, typescriptreact",
"body": [
"//#region //*=========== ${1} ===========",
"${TM_SELECTED_TEXT}$0",
"//#endregion //*======== ${1} ==========="
]
},
"Region CSS": {
"prefix": "regc",
"scope": "css, scss",
"body": [
"/* #region /**=========== ${1} =========== */",
"${TM_SELECTED_TEXT}$0",
"/* #endregion /**======== ${1} =========== */"
]
},
//#endregion //*======== Commons ===========
//#region //*=========== Next.js ===========
"Next Pages": {
"prefix": "np",
"body": [
"import * as React from 'react';\n",
"import Layout from '@/components/layout/Layout';",
"import Seo from '@/components/Seo';\n",
"export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
" return (",
" <Layout>",
" <Seo templateTitle='${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}' />\n",
" <main>\n",
" <section className=''>",
" <div className='layout py-20 min-h-screen'>",
" $0",
" </div>",
" </section>",
" </main>",
" </Layout>",
" )",
"}"
]
},
"Next API": {
"prefix": "napi",
"body": [
"import { NextApiRequest, NextApiResponse } from 'next';\n",
"export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
" if (req.method === 'GET') {",
" res.status(200).json({ name: 'Bambang' });",
" } else {",
" res.status(405).json({ message: 'Method Not Allowed' });",
" }",
"}"
]
},
"Get Static Props": {
"prefix": "gsp",
"body": [
"export const getStaticProps = async (context: GetStaticPropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Get Static Paths": {
"prefix": "gspa",
"body": [
"export const getStaticPaths: GetStaticPaths = async () => {",
" return {",
" paths: [",
" { params: { $1 }}",
" ],",
" fallback: ",
" };",
"}"
]
},
"Get Server Side Props": {
"prefix": "gssp",
"body": [
"export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Infer Get Static Props": {
"prefix": "igsp",
"body": "InferGetStaticPropsType<typeof getStaticProps>"
},
"Infer Get Server Side Props": {
"prefix": "igssp",
"body": "InferGetServerSidePropsType<typeof getServerSideProps>"
},
"Import useRouter": {
"prefix": "imust",
"body": ["import { useRouter } from 'next/router';"]
},
"Import Next Image": {
"prefix": "imimg",
"body": ["import Image from 'next/image';"]
},
"Import Next Link": {
"prefix": "iml",
"body": ["import Link from 'next/link';"]
},
//#endregion //*======== Next.js ===========
//#region //*=========== Snippet Wrap ===========
"Wrap with Fragment": {
"prefix": "ff",
"body": ["<>", "\t${TM_SELECTED_TEXT}", "</>"]
},
"Wrap with clsx": {
"prefix": "cx",
"body": ["{clsx([${TM_SELECTED_TEXT}$0])}"]
},
"Wrap with clsxm": {
"prefix": "cxm",
"body": ["{clsxm([${TM_SELECTED_TEXT}$0, className])}"]
},
//#endregion //*======== Snippet Wrap ===========
"Logger": {
"prefix": "lg",
"body": [
"logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
## [1.1.1] - 2025-08-12
### Changed
- 修正 zwei 提供的 cors proxy 地址
- 移除废弃代码
### Fixed
- [运维] docker workflow release 日期使用东八区日期
## [1.1.0] - 2025-08-12
### Added
- 每日新番放送功能,展示每日新番放送的番剧
### Fixed
- 修复远程 CHANGELOG 无法提取变更内容的问题
## [1.0.5] - 2025-08-12
### Changed
- 实现基于 Git 标签的自动 Release 工作流
## [1.0.4] - 2025-08-11
### Added
- 优化版本管理工作流,实现单点修改
### Changed
- 版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt
## [1.0.3] - 2025-08-11
### Changed
- 升级播放器 Artplayer 至版本 5.2.5
## [1.0.2] - 2025-08-11
### Changed
- 版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新
- [运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号
## [1.0.1] - 2025-08-11
### Fixed
- 修复版本检查功能,只要与最新版本号不一致即认为有更新
## [1.0.0] - 2025-08-10
### Added
- 基于 Semantic Versioning 的版本号机制
- 版本信息面板,展示本地变更日志和远程更新日志
+66
View File
@@ -0,0 +1,66 @@
# ---- 第 1 阶段:安装依赖 ----
FROM node:20-alpine AS deps
# 启用 corepack 并激活 pnpmNode20 默认提供 corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 仅复制依赖清单,提高构建缓存利用率
COPY package.json pnpm-lock.yaml ./
# 安装所有依赖(含 devDependencies,后续会裁剪)
RUN pnpm install --frozen-lockfile
# ---- 第 2 阶段:构建项目 ----
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
# 复制全部源代码
COPY . .
# 在构建阶段也显式设置 DOCKER_ENV,
# 确保 Next.js 在编译时即选择 Node Runtime 而不是 Edge Runtime
RUN find ./src -type f -name "route.ts" -print0 \
| xargs -0 sed -i "s/export const runtime = 'edge';/export const runtime = 'nodejs';/g"
ENV DOCKER_ENV=true
# For Docker builds, force dynamic rendering to read runtime environment variables.
RUN sed -i "/const inter = Inter({ subsets: \['latin'] });/a export const dynamic = 'force-dynamic';" src/app/layout.tsx
# 生成生产构建
RUN pnpm run build
# ---- 第 3 阶段:生成运行时镜像 ----
FROM node:20-alpine AS runner
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV DOCKER_ENV=true
# 从构建器中复制 standalone 输出
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/config.json ./config.json
# 切换到非特权用户
USER nextjs
EXPOSE 3000
# 使用自定义启动脚本,先预加载配置再启动服务器
CMD ["node", "start.js"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 LunaTechLab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+388
View File
@@ -0,0 +1,388 @@
# MoonTV
<div align="center">
<img src="public/logo.png" alt="LibreTV Logo" width="120">
</div>
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind&nbsp;CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。
<div align="center">
![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss)
![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript)
![License](https://img.shields.io/badge/License-MIT-green)
![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker)
</div>
---
## ✨ 功能特性
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
- ❤️ **收藏 + 继续观看**:支持 Redis/D1/Upstash 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel、Netlify 和 ~~Cloudflare~~
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
<details>
<summary>点击查看项目截图</summary>
<img src="public/screenshot1.png" alt="项目截图" style="max-width:600px">
<img src="public/screenshot2.png" alt="项目截图" style="max-width:600px">
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
</details>
## 🗺 目录
- [技术栈](#技术栈)
- [部署](#部署)
- [Docker Compose 最佳实践](#Docker-Compose-最佳实践)
- [环境变量](#环境变量)
- [配置说明](#配置说明)
- [管理员配置](#管理员配置)
- [AndroidTV 使用](#AndroidTV-使用)
- [Roadmap](#roadmap)
- [安全与隐私提醒](#安全与隐私提醒)
- [License](#license)
- [致谢](#致谢)
## 技术栈
| 分类 | 主要依赖 |
| --------- | ----------------------------------------------------------------------------------------------------- |
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
| UI & 样式 | [Tailwind&nbsp;CSS 3](https://tailwindcss.com/) |
| 语言 | TypeScript 4 |
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
| 代码质量 | ESLint · Prettier · Jest |
| 部署 | Docker · Vercel · CloudFlare pages |
## 部署
本项目**支持 Vercel、Docker、Netlify 和 ~~Cloudflare~~** 部署。
存储支持矩阵
| | Docker | Vercel | Netlify | ~~Cloudflare~~ |
| :---------------: | :----: | :----: | :-----: | :------------: |
| localstorage | ✅ | ✅ | ✅ | ✅ |
| 原生 redis | ✅ | | | |
| ~~Cloudflare D1~~ | | | | ✅ |
| Upstash Redis | ☑️ | ✅ | ✅ | ☑️ |
✅:经测试支持
☑️:理论上支持,未测试
除 localstorage 方式外,其他方式都支持多账户、记录同步和管理页面
### Vercel 部署
#### 普通部署(localstorage
1. **Fork** 本仓库到你的 GitHub 账户。
2. 登陆 [Vercel](https://vercel.com/),点击 **Add New → Project**,选择 Fork 后的仓库。
3. 设置 PASSWORD 环境变量。
4. 保持默认设置完成首次部署。
5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
6. 每次 Push 到 `main` 分支将自动触发重新构建。
部署完成后即可通过分配的域名访问,也可以绑定自定义域名。
#### Upstash Redis 支持
0. 完成普通部署并成功访问。
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
3. 返回你的 Vercel 项目,新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号
5. 重试部署
### Netlify 部署
#### 普通部署(localstorage
1. **Fork** 本仓库到你的 GitHub 账户。
2. 登陆 [Netlify](https://www.netlify.com/),点击 **Add New project → Importing an existing project**,授权 Github,选择 Fork 后的仓库。
3. 设置 PASSWORD 环境变量。
4. 保持默认设置完成首次部署。
5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
6. 每次 Push 到 `main` 分支将自动触发重新构建。
部署完成后即可通过分配的域名访问,也可以绑定自定义域名。
#### Upstash Redis 支持
0. 完成普通部署并成功访问。
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
3. 返回你的 Netlify 项目,**Project Configuration → Environment variables** 新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号
5. 重试部署
### Cloudflare 部署(**不支持,详情请看置顶 issue**)
~~**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**~~
#### ~~普通部署(localstorage~~
~~1. **Fork** 本仓库到你的 GitHub 账户。~~
~~2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers-> Workers 和 Pages**,点击创建~~
~~3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库~~
~~4. 构建命令填写 **pnpm install --frozen-lockfile && pnpm run pages:build**,预设框架为无,**构建输出目录**为 `.vercel/output/static`~~
~~5. 保持默认设置完成首次部署。进入设置,将兼容性标志设置为 `nodejs_compat`,无需选择,直接粘贴~~
~~6. 首次部署完成后进入设置,新增 PASSWORD 密钥(变量和机密下),而后重试部署。~~
~~7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。~~
~~8. 每次 Push 到 `main` 分支将自动触发重新构建。~~
#### ~~D1 支持~~
~~0. 完成普通部署并成功访问~~
~~1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意~~
~~2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 **Run All**,等待运行完成~~
~~3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**~~
~~4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **d1**;设置 USERNAME 和 PASSWORD 作为站长账号~~
~~5. 重试部署~~
### Docker 部署
#### 1. 直接运行(最简单,localstorage
```bash
# 拉取预构建镜像
# 推荐使用具体版本号标签,确保稳定性
docker pull ghcr.io/lunatechlab/moontv:1.0.4
# 或拉取最新版本
docker pull ghcr.io/lunatechlab/moontv:latest
# 运行容器
# -d: 后台运行 -p: 映射端口 3000 -> 3000
docker run -d --name moontv -p 3000:3000 --env PASSWORD=your_password ghcr.io/lunatechlab/moontv:latest
```
#### 可用标签
- `ghcr.io/lunatechlab/moontv:1.0.4` - 具体版本号,推荐用于生产环境
- `ghcr.io/lunatechlab/moontv:latest` - 最新版本,可能包含最新功能但也可能有未测试的变化
- `ghcr.io/lunatechlab/moontv:pr-{number}` - PR 构建版本,用于测试新功能
访问 `http://服务器 IP:3000` 即可。(需自行到服务器控制台放通 `3000` 端口)
## Docker Compose 最佳实践
若你使用 docker compose 部署,以下是一些 compose 示例
### local storage 版本
```yaml
services:
moontv-core:
image: ghcr.io/lunatechlab/moontv:latest
container_name: moontv-core
restart: unless-stopped
ports:
- '3000:3000'
environment:
- PASSWORD=your_password
# 如需自定义配置,可挂载文件
# volumes:
# - ./config.json:/app/config.json:ro
```
### Redis 版本(推荐,多账户数据隔离,跨设备同步)
```yaml
services:
moontv-core:
image: ghcr.io/lunatechlab/moontv:latest
container_name: moontv-core
restart: unless-stopped
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=admin_password
- NEXT_PUBLIC_STORAGE_TYPE=redis
- REDIS_URL=redis://moontv-redis:6379
- NEXT_PUBLIC_ENABLE_REGISTER=true
networks:
- moontv-network
depends_on:
- moontv-redis
# 如需自定义配置,可挂载文件
# volumes:
# - ./config.json:/app/config.json:ro
moontv-redis:
image: redis:alpine
container_name: moontv-redis
restart: unless-stopped
networks:
- moontv-network
# 如需持久化
# volumes:
# - ./data:/data
networks:
moontv-network:
driver: bridge
```
## 自动同步最近更改
建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。
如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。
## 环境变量
| 变量 | 说明 | 可选值 | 默认值 |
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| USERNAME | 非 localstorage 部署时的管理员账号 | 任意字符串 | (空) |
| PASSWORD | 非 localstorage 部署时为管理员密码 | 任意字符串 | (空) |
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| direct |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
- direct: 由服务器直接请求豆瓣源站
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- cors-anywhere: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 为公共服务 [cors-anywhere](https://cors-anywhere.com),限制每分钟 20 次请求
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
- direct:由浏览器直接请求豆瓣分配的默认图片域名
- server:由服务器代理请求豆瓣分配的默认图片域名
- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云)
- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
## 配置说明
所有可自定义项集中在根目录的 `config.json` 中:
```json
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源",
"detail": "http://caiji.dyttzyapi.com"
}
// ...更多站点
},
"custom_category": [
{
"name": "华语",
"type": "movie",
"query": "华语"
}
]
}
```
- `cache_time`:接口缓存时间(秒)。
- `api_site`:你可以增删或替换任何资源站,字段说明:
- `key`:唯一标识,保持小写字母/数字。
- `api`:资源站提供的 `vod` JSON API 根地址。
- `name`:在人机界面中展示的名称。
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。
- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类。以 type + query 作为唯一标识。支持以下字段:
- `name`:分类显示名称(可选,如不提供则使用 query 作为显示名)
- `type`:分类类型,支持 `movie`(电影)或 `tv`(电视剧)
- `query`:搜索关键词,用于在豆瓣 API 中搜索相关内容
custom_category 支持的自定义分类已知如下:
- movie:热门、最新、经典、豆瓣高分、冷门佳片、华语、欧美、韩国、日本、动作、喜剧、爱情、科幻、悬疑、恐怖、治愈
- tv:热门、美剧、英剧、韩剧、日剧、国产剧、港剧、日本动画、综艺、纪录片
也可输入如 "哈利波特" 效果等同于豆瓣搜索
MoonTV 支持标准的苹果 CMS V10 API 格式。
修改后 **无需重新构建**,服务会在启动时读取一次。
## 管理员配置
**该特性目前仅支持通过非 localstorage 存储的部署方式使用**
支持在运行时动态变更服务配置
设置环境变量 USERNAME 和 PASSWORD 即为站长用户,站长可设置用户为管理员
站长或管理员访问 `/admin` 即可进行管理员配置
## AndroidTV 使用
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
暂时收藏夹与播放记录和网页端隔离,后续会支持同步用户数据
## Roadmap
- [x] 深色模式
- [x] 持久化存储
- [x] 多账户
## 安全与隐私提醒
### 请设置密码保护并关闭公网注册
为了您的安全和避免潜在的法律风险,我们要求在部署时设置密码保护并**强烈建议关闭公网注册**:
- **避免公开访问**:不设置密码的实例任何人都可以访问,可能被恶意利用
- **防范版权风险**:公开的视频搜索服务可能面临版权方的投诉举报
- **保护个人隐私**:设置密码可以限制访问范围,保护您的使用记录
### 部署要求
1. **设置环境变量 `PASSWORD`**:为您的实例设置一个强密码
2. **仅供个人使用**:请勿将您的实例链接公开分享或传播
3. **遵守当地法律**:请确保您的使用行为符合当地法律法规
### 重要声明
- 本项目仅供学习和个人使用
- 请勿将部署的实例用于商业用途或公开服务
- 如因公开分享导致的任何法律问题,用户需自行承担责任
- 项目开发者不对用户的使用行为承担任何法律责任
## License
[MIT](LICENSE) © 2025 MoonTV & Contributors
## 致谢
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。
- [Zwei](https://github.com/bestzwei) — 提供获取豆瓣数据的 cors proxy
- [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务
- 感谢所有提供免费影视接口的站点。
---
## Star 趋势
[![Stargazers over time](https://starchart.cc/LunaTechLab/MoonTV.svg?variant=adaptive)](https://starchart.cc/LunaTechLab/MoonTV)
+1
View File
@@ -0,0 +1 @@
1.1.1
+24
View File
@@ -0,0 +1,24 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// TODO Add Scope Enum Here
// 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'chore',
'style',
'refactor',
'ci',
'test',
'perf',
'revert',
'vercel',
],
],
},
};
+30
View File
@@ -0,0 +1,30 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
/**
* Absolute imports and Module Path Aliases
*/
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1',
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/extend-expect';
// Allow router mocks.
// eslint-disable-next-line no-undef
jest.mock('next/router', () => require('next-router-mock'));
+74
View File
@@ -0,0 +1,74 @@
/** @type {import('next').NextConfig} */
/* eslint-disable @typescript-eslint/no-var-requires */
const nextConfig = {
output: 'standalone',
eslint: {
dirs: ['src'],
},
reactStrictMode: false,
swcMinify: true,
// Uncoment to add domain whitelist
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg')
);
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: { not: /\.(css|scss|sass)$/ },
resourceQuery: { not: /url/ }, // exclude if *.svg?url
loader: '@svgr/webpack',
options: {
dimensions: false,
titleProp: true,
},
}
);
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
config.resolve.fallback = {
...config.resolve.fallback,
net: false,
tls: false,
crypto: false,
};
return config;
},
};
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
});
module.exports = withPWA(nextConfig);
+92
View File
@@ -0,0 +1,92 @@
{
"name": "moontv",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:runtime && pnpm gen:manifest && next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
"typecheck": "tsc --noEmit --incremental false",
"test:watch": "jest --watch",
"test": "jest",
"format": "prettier -w .",
"format:check": "prettier -c .",
"gen:runtime": "node scripts/convert-config.js",
"gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install"
},
"dependencies": {
"@cloudflare/next-on-pages": "^1.13.12",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.2.5",
"clsx": "^2.0.0",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.6",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.4.0",
"redis": "^4.6.7",
"sweetalert2": "^11.11.0",
"swiper": "^11.2.8",
"tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15",
"zod": "^3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^16.3.0",
"@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7",
"@types/he": "^1.2.3",
"@types/node": "24.0.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.23",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
"lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0",
"postcss": "^8.5.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^4.9.5"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"eslint --max-warnings=0",
"prettier -w"
],
"**/*.{json,css,scss,md,webmanifest}": [
"prettier -w"
]
},
"packageManager": "[email protected]"
}
+13533
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+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.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+3
View File
@@ -0,0 +1,3 @@
# 禁止所有搜索引擎爬取
User-agent: *
Disallow: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin / env node
/* eslint-disable */
const fs = require('fs');
const path = require('path');
function parseChangelog(content) {
const lines = content.split('\n');
const versions = [];
let currentVersion = null;
let currentSection = null;
let inVersionContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = trimmedLine.match(
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
);
if (versionMatch) {
if (currentVersion) {
versions.push(currentVersion);
}
currentVersion = {
version: versionMatch[1],
date: versionMatch[2],
added: [],
changed: [],
fixed: [],
content: [], // 用于存储原始内容,当没有分类时使用
};
currentSection = null;
inVersionContent = true;
continue;
}
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
if (inVersionContent && currentVersion) {
// 匹配章节标题
if (trimmedLine === '### Added') {
currentSection = 'added';
continue;
} else if (trimmedLine === '### Changed') {
currentSection = 'changed';
continue;
} else if (trimmedLine === '### Fixed') {
currentSection = 'fixed';
continue;
}
// 匹配条目: - 内容
if (trimmedLine.startsWith('- ') && currentSection) {
const entry = trimmedLine.substring(2);
currentVersion[currentSection].push(entry);
} else if (
trimmedLine &&
!trimmedLine.startsWith('#') &&
!trimmedLine.startsWith('###')
) {
currentVersion.content.push(trimmedLine);
}
}
}
// 添加最后一个版本
if (currentVersion) {
versions.push(currentVersion);
}
// 后处理:如果某个版本没有分类内容,但有 content,则将 content 放到 changed 中
versions.forEach((version) => {
const hasCategories =
version.added.length > 0 ||
version.changed.length > 0 ||
version.fixed.length > 0;
if (!hasCategories && version.content.length > 0) {
version.changed = version.content;
}
// 清理 content 字段
delete version.content;
});
return { versions };
}
function generateTypeScript(changelogData) {
const entries = changelogData.versions
.map((version) => {
const addedEntries = version.added
.map((entry) => ` "${entry}"`)
.join(',\n');
const changedEntries = version.changed
.map((entry) => ` "${entry}"`)
.join(',\n');
const fixedEntries = version.fixed
.map((entry) => ` "${entry}"`)
.join(',\n');
return ` {
version: "${version.version}",
date: "${version.date}",
added: [
${addedEntries || ' // 无新增内容'}
],
changed: [
${changedEntries || ' // 无变更内容'}
],
fixed: [
${fixedEntries || ' // 无修复内容'}
]
}`;
})
.join(',\n');
return `// 此文件由 scripts/convert-changelog.js 自动生成
// 请勿手动编辑
export interface ChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const changelog: ChangelogEntry[] = [
${entries}
];
export default changelog;
`;
}
function updateVersionFile(version) {
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
try {
fs.writeFileSync(versionTxtPath, version, 'utf8');
console.log(`✅ 已更新 VERSION.txt: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 VERSION.txt:`, error.message);
process.exit(1);
}
}
function updateVersionTs(version) {
const versionTsPath = path.join(process.cwd(), 'src/lib/version.ts');
try {
let content = fs.readFileSync(versionTsPath, 'utf8');
// 替换 CURRENT_VERSION 常量
const updatedContent = content.replace(
/const CURRENT_VERSION = ['"`][^'"`]+['"`];/,
`const CURRENT_VERSION = '${version}';`
);
fs.writeFileSync(versionTsPath, updatedContent, 'utf8');
console.log(`✅ 已更新 version.ts: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 version.ts:`, error.message);
process.exit(1);
}
}
function main() {
try {
const changelogPath = path.join(process.cwd(), 'CHANGELOG');
const outputPath = path.join(process.cwd(), 'src/lib/changelog.ts');
console.log('正在读取 CHANGELOG 文件...');
const changelogContent = fs.readFileSync(changelogPath, 'utf-8');
console.log('正在解析 CHANGELOG 内容...');
const changelogData = parseChangelog(changelogContent);
if (changelogData.versions.length === 0) {
console.error('❌ 未在 CHANGELOG 中找到任何版本');
process.exit(1);
}
// 获取最新版本号(CHANGELOG中的第一个版本)
const latestVersion = changelogData.versions[0].version;
console.log(`🔢 最新版本: ${latestVersion}`);
console.log('正在生成 TypeScript 文件...');
const tsContent = generateTypeScript(changelogData);
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, tsContent, 'utf-8');
// 检查是否在 GitHub Actions 环境中运行
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
if (isGitHubActions) {
// 在 GitHub Actions 中,更新版本文件
console.log('正在更新版本文件...');
updateVersionFile(latestVersion);
updateVersionTs(latestVersion);
} else {
// 在本地运行时,只提示但不更新版本文件
console.log('🔧 本地运行模式:跳过版本文件更新');
console.log('💡 版本文件更新将在 git tag 触发的 release 工作流中完成');
}
console.log(`✅ 成功生成 ${outputPath}`);
console.log(`📊 版本统计:`);
changelogData.versions.forEach((version) => {
console.log(
` ${version.version} (${version.date}): +${version.added.length} ~${version.changed.length} !${version.fixed.length}`
);
});
console.log('\n🎉 转换完成!');
} catch (error) {
console.error('❌ 转换失败:', error);
process.exit(1);
}
}
if (require.main === module) {
main();
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/* eslint-disable */
// AUTO-GENERATED SCRIPT: Converts config.json to TypeScript definition.
// Usage: node scripts/convert-config.js
const fs = require('fs');
const path = require('path');
// Resolve project root (one level up from scripts folder)
const projectRoot = path.resolve(__dirname, '..');
// Paths
const configPath = path.join(projectRoot, 'config.json');
const libDir = path.join(projectRoot, 'src', 'lib');
const oldRuntimePath = path.join(libDir, 'runtime.ts');
const newRuntimePath = path.join(libDir, 'runtime.ts');
// Delete the old runtime.ts file if it exists
if (fs.existsSync(oldRuntimePath)) {
fs.unlinkSync(oldRuntimePath);
console.log('旧的 runtime.ts 已删除');
}
// Read and parse config.json
let rawConfig;
try {
rawConfig = fs.readFileSync(configPath, 'utf8');
} catch (err) {
console.error(`无法读取 ${configPath}:`, err);
process.exit(1);
}
let config;
try {
config = JSON.parse(rawConfig);
} catch (err) {
console.error('config.json 不是有效的 JSON:', err);
process.exit(1);
}
// Prepare TypeScript file content
const tsContent =
`// 该文件由 scripts/convert-config.js 自动生成,请勿手动修改\n` +
`/* eslint-disable */\n\n` +
`export const config = ${JSON.stringify(config, null, 2)} as const;\n\n` +
`export type RuntimeConfig = typeof config;\n\n` +
`export default config;\n`;
// Ensure lib directory exists
if (!fs.existsSync(libDir)) {
fs.mkdirSync(libDir, { recursive: true });
}
// Write to runtime.ts
try {
fs.writeFileSync(newRuntimePath, tsContent, 'utf8');
console.log('已生成 src/lib/runtime.ts');
} catch (err) {
console.error('写入 runtime.ts 失败:', err);
process.exit(1);
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/* eslint-disable */
// 根据 NEXT_PUBLIC_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.NEXT_PUBLIC_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);
}
+2110
View File
@@ -0,0 +1,2110 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
'use client';
import {
closestCenter,
DndContext,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Check,
ChevronDown,
ChevronUp,
ExternalLink,
FolderOpen,
Settings,
Users,
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import Swal from 'sweetalert2';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import PageLayout from '@/components/PageLayout';
// 统一弹窗方法(必须在首次使用前定义)
const showError = (message: string) =>
Swal.fire({ icon: 'error', title: '错误', text: message });
const showSuccess = (message: string) =>
Swal.fire({
icon: 'success',
title: '成功',
text: message,
timer: 2000,
showConfirmButton: false,
});
// 新增站点配置类型
interface SiteConfig {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
}
// 视频源数据类型
interface DataSource {
name: string;
key: string;
api: string;
detail?: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
type: 'movie' | 'tv';
query: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 可折叠标签组件
interface CollapsibleTabProps {
title: string;
icon?: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
const CollapsibleTab = ({
title,
icon,
isExpanded,
onToggle,
children,
}: CollapsibleTabProps) => {
return (
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'>
<button
onClick={onToggle}
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors'
>
<div className='flex items-center gap-3'>
{icon}
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
{title}
</h3>
</div>
<div className='text-gray-500 dark:text-gray-400'>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{isExpanded && <div className='px-6 py-4'>{children}</div>}
</div>
);
};
// 用户配置组件
interface UserConfigProps {
config: AdminConfig | null;
role: 'owner' | 'admin' | null;
refreshConfig: () => Promise<void>;
}
const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
const [userSettings, setUserSettings] = useState({
enableRegistration: false,
});
const [showAddUserForm, setShowAddUserForm] = useState(false);
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
password: '',
});
const [changePasswordUser, setChangePasswordUser] = useState({
username: '',
password: '',
});
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.UserConfig) {
setUserSettings({
enableRegistration: config.UserConfig.AllowRegister,
});
}
}, [config]);
// 切换允许注册设置
const toggleAllowRegister = async (value: boolean) => {
try {
// 先更新本地 UI
setUserSettings((prev) => ({ ...prev, enableRegistration: value }));
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'setAllowRegister',
allowRegister: value,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
// revert toggle UI
setUserSettings((prev) => ({ ...prev, enableRegistration: !value }));
}
};
const handleBanUser = async (uname: string) => {
await handleUserAction('ban', uname);
};
const handleUnbanUser = async (uname: string) => {
await handleUserAction('unban', uname);
};
const handleSetAdmin = async (uname: string) => {
await handleUserAction('setAdmin', uname);
};
const handleRemoveAdmin = async (uname: string) => {
await handleUserAction('cancelAdmin', uname);
};
const handleAddUser = async () => {
if (!newUser.username || !newUser.password) return;
await handleUserAction('add', newUser.username, newUser.password);
setNewUser({ username: '', password: '' });
setShowAddUserForm(false);
};
const handleChangePassword = async () => {
if (!changePasswordUser.username || !changePasswordUser.password) return;
await handleUserAction(
'changePassword',
changePasswordUser.username,
changePasswordUser.password
);
setChangePasswordUser({ username: '', password: '' });
setShowChangePasswordForm(false);
};
const handleShowChangePasswordForm = (username: string) => {
setChangePasswordUser({ username, password: '' });
setShowChangePasswordForm(true);
setShowAddUserForm(false); // 关闭添加用户表单
};
const handleDeleteUser = async (username: string) => {
const { isConfirmed } = await Swal.fire({
title: '确认删除用户',
text: `删除用户 ${username} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
confirmButtonColor: '#dc2626',
});
if (!isConfirmed) return;
await handleUserAction('deleteUser', username);
};
// 通用请求函数
const handleUserAction = async (
action:
| 'add'
| 'ban'
| 'unban'
| 'setAdmin'
| 'cancelAdmin'
| 'changePassword'
| 'deleteUser',
targetUsername: string,
targetPassword?: string
) => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername,
...(targetPassword ? { targetPassword } : {}),
action,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置(无需整页刷新)
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
}
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 用户统计 */}
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</h4>
<div className='p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800'>
<div className='text-2xl font-bold text-green-800 dark:text-green-300'>
{config.UserConfig.Users.length}
</div>
<div className='text-sm text-green-600 dark:text-green-400'>
</div>
</div>
</div>
{/* 注册设置 */}
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
</h4>
<div className='flex items-center justify-between'>
<label
className={`text-gray-700 dark:text-gray-300 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
onClick={() =>
!isUpstashStorage &&
toggleAllowRegister(!userSettings.enableRegistration)
}
disabled={isUpstashStorage}
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 ${
userSettings.enableRegistration
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
userSettings.enableRegistration
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* 用户列表 */}
<div>
<div className='flex items-center justify-between mb-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
{/* 添加用户表单 */}
{showAddUserForm && (
<div className='mb-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<input
type='text'
placeholder='用户名'
value={newUser.username}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, username: e.target.value }))
}
className='flex-1 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'
/>
<input
type='password'
placeholder='密码'
value={newUser.password}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, password: e.target.value }))
}
className='flex-1 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'
/>
<button
onClick={handleAddUser}
disabled={!newUser.username || !newUser.password}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 修改密码表单 */}
{showChangePasswordForm && (
<div className='mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700'>
<h5 className='text-sm font-medium text-blue-800 dark:text-blue-300 mb-3'>
</h5>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
<input
type='text'
placeholder='用户名'
value={changePasswordUser.username}
disabled
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-not-allowed'
/>
<input
type='password'
placeholder='新密码'
value={changePasswordUser.password}
onChange={(e) =>
setChangePasswordUser((prev) => ({
...prev,
password: e.target.value,
}))
}
className='flex-1 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-blue-500 focus:border-transparent'
/>
<button
onClick={handleChangePassword}
disabled={!changePasswordUser.password}
className='w-full sm:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
<button
onClick={() => {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}}
className='w-full sm:w-auto px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 用户列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
</tr>
</thead>
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
{(() => {
const sortedUsers = [...config.UserConfig.Users].sort((a, b) => {
type UserInfo = (typeof config.UserConfig.Users)[number];
const priority = (u: UserInfo) => {
if (u.username === currentUsername) return 0;
if (u.role === 'owner') return 1;
if (u.role === 'admin') return 2;
return 3;
};
return priority(a) - priority(b);
});
return (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sortedUsers.map((user) => {
// 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码
const canChangePassword =
user.role !== 'owner' && // 不能修改站长密码
(role === 'owner' || // 站长可以修改管理员和普通用户密码
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码
// 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户
const canDeleteUser =
user.username !== currentUsername &&
(role === 'owner' || // 站长可以删除除自己外的所有用户
(role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户
// 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户
const canOperate =
user.username !== currentUsername &&
(role === 'owner' ||
(role === 'admin' && user.role === 'user'));
return (
<tr
key={user.username}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
>
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
{user.username}
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
user.role === 'owner'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: user.role === 'admin'
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{user.role === 'owner'
? '站长'
: user.role === 'admin'
? '管理员'
: '普通用户'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!user.banned
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!user.banned ? '正常' : '已封禁'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
<button
onClick={() =>
handleShowChangePasswordForm(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors'
>
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors'
>
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-300 transition-colors'
>
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-300 transition-colors'
>
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 transition-colors'
>
</button>
)}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div>
</div>
</div>
);
};
// 视频源配置组件
const VideoSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState<DataSource>({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'config',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.SourceConfig) {
setSources(config.SourceConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = sources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callSourceApi({ action, key }).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
callSourceApi({ action: 'delete', key }).catch(() => {
console.error('操作失败', 'delete', key);
});
};
const handleAddSource = () => {
if (!newSource.name || !newSource.key || !newSource.api) return;
callSourceApi({
action: 'add',
key: newSource.key,
name: newSource.name,
api: newSource.api,
detail: newSource.detail,
})
.then(() => {
setNewSource({
name: '',
key: '',
api: '',
detail: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newSource);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sources.findIndex((s) => s.key === active.id);
const newIndex = sources.findIndex((s) => s.key === over.id);
setSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = sources.map((s) => s.key);
callSourceApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ source }: { source: DataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: source.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{source.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={source.api}
>
{source.api}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={source.detail || '-'}
>
{source.detail || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!source.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!source.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(source.key)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
!source.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!source.disabled ? '禁用' : '启用'}
</button>
{source.from !== 'config' && (
<button
onClick={() => handleDelete(source.key)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加视频源表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => setShowAddForm(!showAddForm)}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
>
{showAddForm ? '取消' : '添加视频源'}
</button>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newSource.name}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, name: e.target.value }))
}
className='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'
/>
<input
type='text'
placeholder='Key'
value={newSource.key}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, key: e.target.value }))
}
className='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'
/>
<input
type='text'
placeholder='API 地址'
value={newSource.api}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, api: e.target.value }))
}
className='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'
/>
<input
type='text'
placeholder='Detail 地址(选填)'
value={newSource.detail}
onChange={(e) =>
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
}
className='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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddSource}
disabled={!newSource.name || !newSource.key || !newSource.api}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 视频源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
API
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Detail
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={sources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sources.map((source) => (
<DraggableRow key={source.key} source={source} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
>
</button>
</div>
)}
</div>
);
};
// 分类配置组件
const CategoryConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState<CustomCategory>({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'config',
});
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.CustomCategories) {
setCategories(config.CustomCategories);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callCategoryApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/category', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (query: string, type: 'movie' | 'tv') => {
const target = categories.find((c) => c.query === query && c.type === type);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callCategoryApi({ action, query, type }).catch(() => {
console.error('操作失败', action, query, type);
});
};
const handleDelete = (query: string, type: 'movie' | 'tv') => {
callCategoryApi({ action: 'delete', query, type }).catch(() => {
console.error('操作失败', 'delete', query, type);
});
};
const handleAddCategory = () => {
if (!newCategory.name || !newCategory.query) return;
callCategoryApi({
action: 'add',
name: newCategory.name,
type: newCategory.type,
query: newCategory.query,
})
.then(() => {
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newCategory);
});
};
const handleDragEnd = (event: any) => {
if (isUpstashStorage) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === active.id
);
const newIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === over.id
);
setCategories((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`);
callCategoryApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ category }: { category: CustomCategory }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: `${category.query}:${category.type}` });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className={`px-2 py-4 ${
isUpstashStorage ? 'text-gray-200' : 'cursor-grab text-gray-400'
}`}
style={{ touchAction: 'none' }}
{...(isUpstashStorage ? {} : { ...attributes, ...listeners })}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{category.name || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
<span
className={`px-2 py-1 text-xs rounded-full ${
category.type === 'movie'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
}`}
>
{category.type === 'movie' ? '电影' : '电视剧'}
</span>
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={category.query}
>
{category.query}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${
!category.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!category.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() =>
!isUpstashStorage &&
handleToggleEnable(category.query, category.type)
}
disabled={isUpstashStorage}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: !category.disabled
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
} transition-colors`}
>
{!category.disabled ? '禁用' : '启用'}
</button>
{category.from !== 'config' && !isUpstashStorage && (
<button
onClick={() => handleDelete(category.query, category.type)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加分类表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</h4>
<button
onClick={() => !isUpstashStorage && setShowAddForm(!showAddForm)}
disabled={isUpstashStorage}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
isUpstashStorage
? 'bg-gray-400 cursor-not-allowed text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{showAddForm ? '取消' : '添加分类'}
</button>
</div>
{showAddForm && !isUpstashStorage && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='分类名称'
value={newCategory.name}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, name: e.target.value }))
}
className='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'
/>
<select
value={newCategory.type}
onChange={(e) =>
setNewCategory((prev) => ({
...prev,
type: e.target.value as 'movie' | 'tv',
}))
}
className='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'
>
<option value='movie'></option>
<option value='tv'></option>
</select>
<input
type='text'
placeholder='搜索关键词'
value={newCategory.query}
onChange={(e) =>
setNewCategory((prev) => ({ ...prev, query: e.target.value }))
}
className='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'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddCategory}
disabled={!newCategory.name || !newCategory.query}
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
</button>
</div>
</div>
)}
{/* 分类表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={isUpstashStorage ? [] : sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={categories.map((c) => `${c.query}:${c.type}`)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{categories.map((category) => (
<DraggableRow
key={`${category.query}:${category.type}`}
category={category}
/>
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && !isUpstashStorage && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
>
</button>
</div>
)}
</div>
);
};
// 新增站点配置组件
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
SiteName: '',
Announcement: '',
SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200,
DoubanProxyType: 'direct',
DoubanProxy: '',
DoubanImageProxyType: 'direct',
DoubanImageProxy: '',
DisableYellowFilter: false,
});
// 保存状态
const [saving, setSaving] = useState(false);
// 豆瓣数据源相关状态
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
// 豆瓣数据源选项
const doubanDataSourceOptions = [
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'cors-anywhere', label: 'Cors Anywhere20 qpm' },
{ value: 'custom', label: '自定义代理' },
];
// 豆瓣图片代理选项
const doubanImageProxyTypeOptions = [
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'custom', label: '自定义代理' },
];
// 获取感谢信息
const getThanksInfo = (dataSource: string) => {
switch (dataSource) {
case 'cors-proxy-zwei':
return {
text: 'Thanks to @Zwei',
url: 'https://github.com/bestzwei',
};
case 'cmliussss-cdn-tencent':
case 'cmliussss-cdn-ali':
return {
text: 'Thanks to @CMLiussss',
url: 'https://github.com/cmliu',
};
default:
return null;
}
};
// 检测存储类型是否为 upstash
const isUpstashStorage =
typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
useEffect(() => {
if (config?.SiteConfig) {
setSiteSettings({
...config.SiteConfig,
DoubanProxyType: config.SiteConfig.DoubanProxyType || 'direct',
DoubanProxy: config.SiteConfig.DoubanProxy || '',
DoubanImageProxyType:
config.SiteConfig.DoubanImageProxyType || 'direct',
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
});
}
}, [config]);
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource"]')) {
setIsDoubanDropdownOpen(false);
}
}
};
if (isDoubanDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
setIsDoubanImageProxyDropdownOpen(false);
}
}
};
if (isDoubanImageProxyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyDropdownOpen]);
// 处理豆瓣数据源变化
const handleDoubanDataSourceChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanProxyType: value,
}));
}
};
// 处理豆瓣图片代理变化
const handleDoubanImageProxyChange = (value: string) => {
if (!isUpstashStorage) {
setSiteSettings((prev) => ({
...prev,
DoubanImageProxyType: value,
}));
}
};
// 保存站点配置
const handleSave = async () => {
try {
setSaving(true);
const resp = await fetch('/api/admin/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...siteSettings }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `保存失败: ${resp.status}`);
}
showSuccess('保存成功, 请刷新页面');
} catch (err) {
showError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 站点名称 */}
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<input
type='text'
value={siteSettings.SiteName}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
}
disabled={isUpstashStorage}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
</div>
{/* 站点公告 */}
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<textarea
value={siteSettings.Announcement}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
Announcement: e.target.value,
}))
}
disabled={isUpstashStorage}
rows={3}
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 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
</div>
{/* 豆瓣数据源设置 */}
<div className='space-y-3'>
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{
doubanDataSourceOptions.find(
(option) => option.value === siteSettings.DoubanProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && !isUpstashStorage && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
siteSettings.DoubanProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{siteSettings.DoubanProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.DoubanProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(siteSettings.DoubanProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanProxyType === 'custom' && (
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanProxy: e.target.value,
}))
}
disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 豆瓣图片代理设置 */}
<div className='space-y-3'>
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === siteSettings.DoubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanImageProxyDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && !isUpstashStorage && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
siteSettings.DoubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{siteSettings.DoubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
{/* 感谢信息 */}
{getThanksInfo(siteSettings.DoubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(siteSettings.DoubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(siteSettings.DoubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{siteSettings.DoubanImageProxyType === 'custom' && (
<div>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
</label>
<input
type='text'
placeholder='例如: https://proxy.example.com/fetch?url='
value={siteSettings.DoubanImageProxy}
onChange={(e) =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DoubanImageProxy: e.target.value,
}))
}
disabled={isUpstashStorage}
className={`w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${
isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''
}`}
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
</div>
{/* 搜索接口可拉取最大页数 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SearchDownstreamMaxPage}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SearchDownstreamMaxPage: Number(e.target.value),
}))
}
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'
/>
</div>
{/* 站点接口缓存时间 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min={1}
value={siteSettings.SiteInterfaceCacheTime}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
SiteInterfaceCacheTime: Number(e.target.value),
}))
}
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'
/>
</div>
{/* 禁用黄色过滤器 */}
<div>
<div className='flex items-center justify-between'>
<label
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
isUpstashStorage ? 'opacity-50' : ''
}`}
>
{isUpstashStorage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(Upstash )
</span>
)}
</label>
<button
type='button'
onClick={() =>
!isUpstashStorage &&
setSiteSettings((prev) => ({
...prev,
DisableYellowFilter: !prev.DisableYellowFilter,
}))
}
disabled={isUpstashStorage}
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 ${
siteSettings.DisableYellowFilter
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${isUpstashStorage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
siteSettings.DisableYellowFilter
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 ${
saving
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700'
} text-white rounded-lg transition-colors`}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
);
};
function AdminPageClient() {
const [config, setConfig] = useState<AdminConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<'owner' | 'admin' | null>(null);
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
siteConfig: false,
categoryConfig: false,
});
// 获取管理员配置
// showLoading 用于控制是否在请求期间显示整体加载骨架。
const fetchConfig = useCallback(async (showLoading = false) => {
try {
if (showLoading) {
setLoading(true);
}
const response = await fetch(`/api/admin/config`);
if (!response.ok) {
const data = (await response.json()) as any;
throw new Error(`获取配置失败: ${data.error}`);
}
const data = (await response.json()) as AdminConfigResult;
setConfig(data.Config);
setRole(data.Role);
} catch (err) {
const msg = err instanceof Error ? err.message : '获取配置失败';
showError(msg);
setError(msg);
} finally {
if (showLoading) {
setLoading(false);
}
}
}, []);
useEffect(() => {
// 首次加载时显示骨架
fetchConfig(true);
}, [fetchConfig]);
// 切换标签展开状态
const toggleTab = (tabKey: string) => {
setExpandedTabs((prev) => ({
...prev,
[tabKey]: !prev[tabKey],
}));
};
// 新增: 重置配置处理函数
const handleResetConfig = async () => {
const { isConfirmed } = await Swal.fire({
title: '确认重置配置',
text: '此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: '确认',
cancelButtonText: '取消',
});
if (!isConfirmed) return;
try {
const response = await fetch(`/api/admin/reset`);
if (!response.ok) {
throw new Error(`重置失败: ${response.status}`);
}
showSuccess('重置成功,请刷新页面!');
} catch (err) {
showError(err instanceof Error ? err.message : '重置失败');
}
};
if (loading) {
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-8'>
</h1>
<div className='space-y-4'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='h-20 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
/>
))}
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
// 错误已通过 SweetAlert2 展示,此处直接返回空
return null;
}
return (
<PageLayout activePath='/admin'>
<div className='px-2 sm:px-10 py-4 sm:py-8'>
<div className='max-w-[95%] mx-auto'>
{/* 标题 + 重置配置按钮 */}
<div className='flex items-center gap-2 mb-8'>
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
</h1>
{config && role === 'owner' && (
<button
onClick={handleResetConfig}
className='px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-md transition-colors'
>
</button>
)}
</div>
{/* 站点配置标签 */}
<CollapsibleTab
title='站点配置'
icon={
<Settings
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.siteConfig}
onToggle={() => toggleTab('siteConfig')}
>
<SiteConfigComponent config={config} />
</CollapsibleTab>
<div className='space-y-4'>
{/* 用户配置标签 */}
<CollapsibleTab
title='用户配置'
icon={
<Users size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.userConfig}
onToggle={() => toggleTab('userConfig')}
>
<UserConfig
config={config}
role={role}
refreshConfig={fetchConfig}
/>
</CollapsibleTab>
{/* 视频源配置标签 */}
<CollapsibleTab
title='视频源配置'
icon={
<Video size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.videoSource}
onToggle={() => toggleTab('videoSource')}
>
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'
icon={
<FolderOpen
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.categoryConfig}
onToggle={() => toggleTab('categoryConfig')}
>
<CategoryConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
</div>
</div>
</div>
</PageLayout>
);
}
export default function AdminPage() {
return (
<Suspense>
<AdminPageClient />
</Suspense>
);
}
+209
View File
@@ -0,0 +1,209 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getStorage } from '@/lib/db';
import { IStorage } from '@/lib/types';
export const runtime = 'edge';
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
interface BaseBody {
action?: Action;
}
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
if (storageType === 'upstash') {
return NextResponse.json(
{
error: 'Upstash 实例请通过配置文件调整',
},
{ status: 400 }
);
}
try {
const body = (await request.json()) as BaseBody & Record<string, any>;
const { action } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 获取配置与存储
const adminConfig = await getConfig();
const storage: IStorage | null = getStorage();
// 权限与身份校验
if (username !== process.env.USERNAME) {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
switch (action) {
case 'add': {
const { name, type, query } = body as {
name?: string;
type?: 'movie' | 'tv';
query?: string;
};
if (!name || !type || !query) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 检查是否已存在相同的查询和类型组合
if (
adminConfig.CustomCategories.some(
(c) => c.query === query && c.type === type
)
) {
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
}
adminConfig.CustomCategories.push({
name,
type,
query,
from: 'custom',
disabled: false,
});
break;
}
case 'disable': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const entry = adminConfig.CustomCategories.find(
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = true;
break;
}
case 'enable': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const entry = adminConfig.CustomCategories.find(
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = false;
break;
}
case 'delete': {
const { query, type } = body as {
query?: string;
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
const idx = adminConfig.CustomCategories.findIndex(
(c) => c.query === query && c.type === type
);
if (idx === -1)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
const entry = adminConfig.CustomCategories[idx];
if (entry.from === 'config') {
return NextResponse.json(
{ error: '该分类不可删除' },
{ status: 400 }
);
}
adminConfig.CustomCategories.splice(idx, 1);
break;
}
case 'sort': {
const { order } = body as { order?: string[] };
if (!Array.isArray(order)) {
return NextResponse.json(
{ error: '排序列表格式错误' },
{ status: 400 }
);
}
const map = new Map(
adminConfig.CustomCategories.map((c) => [`${c.query}:${c.type}`, c])
);
const newList: typeof adminConfig.CustomCategories = [];
order.forEach((key) => {
const item = map.get(key);
if (item) {
newList.push(item);
map.delete(key);
}
});
// 未在 order 中的保持原顺序
adminConfig.CustomCategories.forEach((item) => {
if (map.has(`${item.query}:${item.type}`)) newList.push(item);
});
adminConfig.CustomCategories = newList;
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 持久化到存储
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store',
},
}
);
} catch (error) {
console.error('分类管理操作失败:', error);
return NextResponse.json(
{
error: '分类管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
try {
const config = await getConfig();
const result: AdminConfigResult = {
Role: 'owner',
Config: config,
};
if (username === process.env.USERNAME) {
result.Role = 'owner';
} else {
const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.role === 'admin' && !user.banned) {
result.Role = 'admin';
} else {
return NextResponse.json(
{ error: '你是管理员吗你就访问?' },
{ status: 401 }
);
}
}
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
});
} catch (error) {
console.error('获取管理员配置失败:', error);
return NextResponse.json(
{
error: '获取管理员配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { resetConfig } from '@/lib/config';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
if (username !== process.env.USERNAME) {
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
}
try {
await resetConfig();
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
}
);
} catch (error) {
return NextResponse.json(
{
error: '重置管理员配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+118
View File
@@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getStorage } from '@/lib/db';
export const runtime = 'edge';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const {
SiteName,
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
DoubanProxyType,
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
DisableYellowFilter,
} = body as {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
};
// 参数校验
if (
typeof SiteName !== 'string' ||
typeof Announcement !== 'string' ||
typeof SearchDownstreamMaxPage !== 'number' ||
typeof SiteInterfaceCacheTime !== 'number' ||
typeof DoubanProxyType !== 'string' ||
typeof DoubanProxy !== 'string' ||
typeof DoubanImageProxyType !== 'string' ||
typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean'
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
const adminConfig = await getConfig();
const storage = getStorage();
// 权限校验
if (username !== process.env.USERNAME) {
// 管理员
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
// 更新缓存中的站点设置
adminConfig.SiteConfig = {
SiteName,
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
DoubanProxyType,
DoubanProxy,
DoubanImageProxyType,
DoubanImageProxy,
DisableYellowFilter,
};
// 写入数据库
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 不缓存结果
},
}
);
} catch (error) {
console.error('更新站点配置失败:', error);
return NextResponse.json(
{
error: '更新站点配置失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+169
View File
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getStorage } from '@/lib/db';
import { IStorage } from '@/lib/types';
export const runtime = 'edge';
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
interface BaseBody {
action?: Action;
}
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = (await request.json()) as BaseBody & Record<string, any>;
const { action } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 获取配置与存储
const adminConfig = await getConfig();
const storage: IStorage | null = getStorage();
// 权限与身份校验
if (username !== process.env.USERNAME) {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
switch (action) {
case 'add': {
const { key, name, api, detail } = body as {
key?: string;
name?: string;
api?: string;
detail?: string;
};
if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
}
adminConfig.SourceConfig.push({
key,
name,
api,
detail,
from: 'custom',
disabled: false,
});
break;
}
case 'disable': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = true;
break;
}
case 'enable': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = false;
break;
}
case 'delete': {
const { key } = body as { key?: string };
if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx === -1)
return NextResponse.json({ error: '源不存在' }, { status: 404 });
const entry = adminConfig.SourceConfig[idx];
if (entry.from === 'config') {
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
}
adminConfig.SourceConfig.splice(idx, 1);
break;
}
case 'sort': {
const { order } = body as { order?: string[] };
if (!Array.isArray(order)) {
return NextResponse.json(
{ error: '排序列表格式错误' },
{ status: 400 }
);
}
const map = new Map(adminConfig.SourceConfig.map((s) => [s.key, s]));
const newList: typeof adminConfig.SourceConfig = [];
order.forEach((k) => {
const item = map.get(k);
if (item) {
newList.push(item);
map.delete(k);
}
});
// 未在 order 中的保持原顺序
adminConfig.SourceConfig.forEach((item) => {
if (map.has(item.key)) newList.push(item);
});
adminConfig.SourceConfig = newList;
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 持久化到存储
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store',
},
}
);
} catch (error) {
console.error('视频源管理操作失败:', error);
return NextResponse.json(
{
error: '视频源管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+337
View File
@@ -0,0 +1,337 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getStorage } from '@/lib/db';
import { IStorage } from '@/lib/types';
export const runtime = 'edge';
// 支持的操作类型
const ACTIONS = [
'add',
'ban',
'unban',
'setAdmin',
'cancelAdmin',
'setAllowRegister',
'changePassword',
'deleteUser',
] as const;
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
const {
targetUsername, // 目标用户名
targetPassword, // 目标用户密码(仅在添加用户时需要)
allowRegister,
action,
} = body as {
targetUsername?: string;
targetPassword?: string;
allowRegister?: boolean;
action?: (typeof ACTIONS)[number];
};
if (!action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
if (action !== 'setAllowRegister' && !targetUsername) {
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
}
if (
action !== 'setAllowRegister' &&
action !== 'changePassword' &&
action !== 'deleteUser' &&
username === targetUsername
) {
return NextResponse.json(
{ error: '无法对自己进行此操作' },
{ status: 400 }
);
}
// 获取配置与存储
const adminConfig = await getConfig();
const storage: IStorage | null = getStorage();
// 判定操作者角色
let operatorRole: 'owner' | 'admin';
if (username === process.env.USERNAME) {
operatorRole = 'owner';
} else {
const userEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
operatorRole = 'admin';
}
// 查找目标用户条目
let targetEntry = adminConfig.UserConfig.Users.find(
(u) => u.username === targetUsername
);
if (
targetEntry &&
targetEntry.role === 'owner' &&
action !== 'changePassword'
) {
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
}
// 权限校验逻辑
const isTargetAdmin = targetEntry?.role === 'admin';
if (action === 'setAllowRegister') {
if (typeof allowRegister !== 'boolean') {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
adminConfig.UserConfig.AllowRegister = allowRegister;
// 保存后直接返回成功(走后面的统一保存逻辑)
} else {
switch (action) {
case 'add': {
if (targetEntry) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
}
if (!targetPassword) {
return NextResponse.json(
{ error: '缺少目标用户密码' },
{ status: 400 }
);
}
if (!storage || typeof storage.registerUser !== 'function') {
return NextResponse.json(
{ error: '存储未配置用户注册' },
{ status: 500 }
);
}
await storage.registerUser(targetUsername!, targetPassword);
// 更新配置
adminConfig.UserConfig.Users.push({
username: targetUsername!,
role: 'user',
});
targetEntry =
adminConfig.UserConfig.Users[
adminConfig.UserConfig.Users.length - 1
];
break;
}
case 'ban': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (isTargetAdmin) {
// 目标是管理员
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可封禁管理员' },
{ status: 401 }
);
}
}
targetEntry.banned = true;
break;
}
case 'unban': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (isTargetAdmin) {
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可操作管理员' },
{ status: 401 }
);
}
}
targetEntry.banned = false;
break;
}
case 'setAdmin': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role === 'admin') {
return NextResponse.json(
{ error: '该用户已是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可设置管理员' },
{ status: 401 }
);
}
targetEntry.role = 'admin';
break;
}
case 'cancelAdmin': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role !== 'admin') {
return NextResponse.json(
{ error: '目标用户不是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可取消管理员' },
{ status: 401 }
);
}
targetEntry.role = 'user';
break;
}
case 'changePassword': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (!targetPassword) {
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
}
// 权限检查:不允许修改站长密码
if (targetEntry.role === 'owner') {
return NextResponse.json(
{ error: '无法修改站长密码' },
{ status: 401 }
);
}
if (
isTargetAdmin &&
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
{ error: '仅站长可修改其他管理员密码' },
{ status: 401 }
);
}
if (!storage || typeof storage.changePassword !== 'function') {
return NextResponse.json(
{ error: '存储未配置密码修改功能' },
{ status: 500 }
);
}
await storage.changePassword(targetUsername!, targetPassword);
break;
}
case 'deleteUser': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
if (username === targetUsername) {
return NextResponse.json(
{ error: '不能删除自己' },
{ status: 400 }
);
}
if (isTargetAdmin && operatorRole !== 'owner') {
return NextResponse.json(
{ error: '仅站长可删除管理员' },
{ status: 401 }
);
}
if (!storage || typeof storage.deleteUser !== 'function') {
return NextResponse.json(
{ error: '存储未配置用户删除功能' },
{ status: 500 }
);
}
await storage.deleteUser(targetUsername!);
// 从配置中移除用户
const userIndex = adminConfig.UserConfig.Users.findIndex(
(u) => u.username === targetUsername
);
if (userIndex > -1) {
adminConfig.UserConfig.Users.splice(userIndex, 1);
}
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
}
// 将更新后的配置写入数据库
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
return NextResponse.json(
{ ok: true },
{
headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存
},
}
);
} catch (error) {
console.error('用户管理操作失败:', error);
return NextResponse.json(
{
error: '用户管理操作失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+72
View File
@@ -0,0 +1,72 @@
/* eslint-disable no-console*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getStorage } from '@/lib/db';
import { IStorage } from '@/lib/types';
export const runtime = 'edge';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
// 不支持 localstorage 模式
if (storageType === 'localstorage') {
return NextResponse.json(
{
error: '不支持本地存储模式修改密码',
},
{ status: 400 }
);
}
try {
const body = await request.json();
const { newPassword } = body;
// 获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 验证新密码
if (!newPassword || typeof newPassword !== 'string') {
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
}
const username = authInfo.username;
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME
if (username === process.env.USERNAME) {
return NextResponse.json(
{ error: '站长不能通过此接口修改密码' },
{ status: 403 }
);
}
// 获取存储实例
const storage: IStorage | null = getStorage();
if (!storage || typeof storage.changePassword !== 'function') {
return NextResponse.json(
{ error: '存储服务不支持修改密码' },
{ status: 500 }
);
}
// 修改密码
await storage.changePassword(username, newPassword);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('修改密码失败:', error);
return NextResponse.json(
{
error: '修改密码失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}
+189
View File
@@ -0,0 +1,189 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { SearchResult } from '@/lib/types';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
console.log(request.url);
try {
console.log('Cron job triggered:', new Date().toISOString());
refreshRecordAndFavorites();
return NextResponse.json({
success: true,
message: 'Cron job executed successfully',
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Cron job failed:', error);
return NextResponse.json(
{
success: false,
message: 'Cron job failed',
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
}
}
async function refreshRecordAndFavorites() {
if (
(process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
) {
console.log('跳过刷新:当前使用 localstorage 存储模式');
return;
}
try {
const users = await db.getAllUsers();
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
users.push(process.env.USERNAME);
}
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
const detailCache = new Map<string, Promise<SearchResult | null>>();
// 获取详情 Promise(带缓存和错误处理)
const getDetail = async (
source: string,
id: string,
fallbackTitle: string
): Promise<SearchResult | null> => {
const key = `${source}+${id}`;
let promise = detailCache.get(key);
if (!promise) {
promise = fetchVideoDetail({
source,
id,
fallbackTitle: fallbackTitle.trim(),
})
.then((detail) => {
// 成功时才缓存结果
const successPromise = Promise.resolve(detail);
detailCache.set(key, successPromise);
return detail;
})
.catch((err) => {
console.error(`获取视频详情失败 (${source}+${id}):`, err);
return null;
});
}
return promise;
};
for (const user of users) {
console.log(`开始处理用户: ${user}`);
// 播放记录
try {
const playRecords = await db.getAllPlayRecords(user);
const totalRecords = Object.keys(playRecords).length;
let processedRecords = 0;
for (const [key, record] of Object.entries(playRecords)) {
try {
const [source, id] = key.split('+');
if (!source || !id) {
console.warn(`跳过无效的播放记录键: ${key}`);
continue;
}
const detail = await getDetail(source, id, record.title);
if (!detail) {
console.warn(`跳过无法获取详情的播放记录: ${key}`);
continue;
}
const episodeCount = detail.episodes?.length || 0;
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
await db.savePlayRecord(user, source, id, {
title: detail.title || record.title,
source_name: record.source_name,
cover: detail.poster || record.cover,
index: record.index,
total_episodes: episodeCount,
play_time: record.play_time,
year: detail.year || record.year,
total_time: record.total_time,
save_time: record.save_time,
search_title: record.search_title,
});
console.log(
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
);
}
processedRecords++;
} catch (err) {
console.error(`处理播放记录失败 (${key}):`, err);
// 继续处理下一个记录
}
}
console.log(`播放记录处理完成: ${processedRecords}/${totalRecords}`);
} catch (err) {
console.error(`获取用户播放记录失败 (${user}):`, err);
}
// 收藏
try {
const favorites = await db.getAllFavorites(user);
const totalFavorites = Object.keys(favorites).length;
let processedFavorites = 0;
for (const [key, fav] of Object.entries(favorites)) {
try {
const [source, id] = key.split('+');
if (!source || !id) {
console.warn(`跳过无效的收藏键: ${key}`);
continue;
}
const favDetail = await getDetail(source, id, fav.title);
if (!favDetail) {
console.warn(`跳过无法获取详情的收藏: ${key}`);
continue;
}
const favEpisodeCount = favDetail.episodes?.length || 0;
if (favEpisodeCount > 0 && favEpisodeCount !== fav.total_episodes) {
await db.saveFavorite(user, source, id, {
title: favDetail.title || fav.title,
source_name: fav.source_name,
cover: favDetail.poster || fav.cover,
year: favDetail.year || fav.year,
total_episodes: favEpisodeCount,
save_time: fav.save_time,
search_title: fav.search_title,
});
console.log(
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
);
}
processedFavorites++;
} catch (err) {
console.error(`处理收藏失败 (${key}):`, err);
// 继续处理下一个收藏
}
}
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
} catch (err) {
console.error(`获取用户收藏失败 (${user}):`, err);
}
}
console.log('刷新播放记录/收藏任务完成');
} catch (err) {
console.error('刷新播放记录/收藏任务启动失败', err);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
}
try {
const apiSites = await getAvailableApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
}
const result = await getDetailFromApi(apiSite, id);
const cacheTime = await getCacheTime();
return NextResponse.json(result, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
card_subtitle: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
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<DoubanCategoryApiResponse>(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) : '',
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
+130
View File
@@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanResult } from '@/lib/types';
interface DoubanRecommendApiResponse {
total: number;
items: Array<{
id: string;
title: string;
year: string;
type: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
// 获取参数
const kind = searchParams.get('kind');
const pageLimit = parseInt(searchParams.get('limit') || '20');
const pageStart = parseInt(searchParams.get('start') || '0');
const category =
searchParams.get('category') === 'all' ? '' : searchParams.get('category');
const format =
searchParams.get('format') === 'all' ? '' : searchParams.get('format');
const region =
searchParams.get('region') === 'all' ? '' : searchParams.get('region');
const year =
searchParams.get('year') === 'all' ? '' : searchParams.get('year');
const platform =
searchParams.get('platform') === 'all' ? '' : searchParams.get('platform');
const sort = searchParams.get('sort') === 'T' ? '' : searchParams.get('sort');
const label =
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
if (!kind) {
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
}
const selectedCategories = { 类型: category } as any;
if (format) {
selectedCategories['形式'] = format;
}
if (region) {
selectedCategories['地区'] = region;
}
const tags = [] as Array<string>;
if (category) {
tags.push(category);
}
if (!category && format) {
tags.push(format);
}
if (label) {
tags.push(label);
}
if (region) {
tags.push(region);
}
if (year) {
tags.push(year);
}
if (platform) {
tags.push(platform);
}
const baseUrl = `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
const params = new URLSearchParams();
params.append('refresh', '0');
params.append('start', pageStart.toString());
params.append('count', pageLimit.toString());
params.append('selected_categories', JSON.stringify(selectedCategories));
params.append('uncollect', 'false');
params.append('score_range', '0,10');
params.append('tags', tags.join(','));
if (sort) {
params.append('sort', sort);
}
const target = `${baseUrl}?${params.toString()}`;
console.log(target);
try {
const doubanData = await fetchDoubanData<DoubanRecommendApiResponse>(
target
);
const list = doubanData.items
.filter((item) => item.type == 'movie' || item.type == 'tv')
.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
year: item.year,
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import { NextResponse } from 'next/server';
import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban';
import { DoubanItem, DoubanResult } from '@/lib/types';
interface DoubanApiResponse {
subjects: Array<{
id: string;
title: string;
cover: string;
rate: string;
}>;
}
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
// 获取参数
const type = searchParams.get('type');
const tag = searchParams.get('tag');
const pageSize = parseInt(searchParams.get('pageSize') || '16');
const pageStart = parseInt(searchParams.get('pageStart') || '0');
// 验证参数
if (!type || !tag) {
return NextResponse.json(
{ error: '缺少必要参数: type 或 tag' },
{ status: 400 }
);
}
if (!['tv', 'movie'].includes(type)) {
return NextResponse.json(
{ error: 'type 参数必须是 tv 或 movie' },
{ status: 400 }
);
}
if (pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ error: 'pageSize 必须在 1-100 之间' },
{ status: 400 }
);
}
if (pageStart < 0) {
return NextResponse.json(
{ error: 'pageStart 不能小于 0' },
{ status: 400 }
);
}
if (tag === 'top250') {
return handleTop250(pageStart);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
// 调用豆瓣 API
const doubanData = await fetchDoubanData<DoubanApiResponse>(target);
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
year: '',
}));
const response: DoubanResult = {
code: 200,
message: '获取成功',
list: list,
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}
function handleTop250(pageStart: number) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
// 直接使用 fetch 获取 HTML 页面
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
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:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
},
};
return fetch(target, fetchOptions)
.then(async (fetchResponse) => {
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
// 获取 HTML 内容
const html = await fetchResponse.text();
// 通过正则同时捕获影片 id、标题、封面以及评分
const moviePattern =
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const id = match[1];
const title = match[2];
const cover = match[3];
const rate = match[4] || '';
// 处理图片 URL,确保使用 HTTPS
const processedCover = cover.replace(/^http:/, 'https:');
movies.push({
id: id,
title: title,
poster: processedCover,
rate: rate,
year: '',
});
}
const apiResponse: DoubanResult = {
code: 200,
message: '获取成功',
list: movies,
};
const cacheTime = await getCacheTime();
return NextResponse.json(apiResponse, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
})
.catch((error) => {
clearTimeout(timeoutId);
return NextResponse.json(
{
error: '获取豆瓣 Top250 数据失败',
details: (error as Error).message,
},
{ status: 500 }
);
});
}
+190
View File
@@ -0,0 +1,190 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { Favorite } from '@/lib/types';
export const runtime = 'edge';
/**
* GET /api/favorites
*
* 支持两种调用方式:
* 1. 不带 query,返回全部收藏列表(Record<string, Favorite>)。
* 2. 带 key=source+id,返回单条收藏(Favorite | null)。
*/
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
// 查询单条收藏
if (key) {
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
const fav = await db.getFavorite(authInfo.username, source, id);
return NextResponse.json(fav, { status: 200 });
}
// 查询全部收藏
const favorites = await db.getAllFavorites(authInfo.username);
return NextResponse.json(favorites, { status: 200 });
} catch (err) {
console.error('获取收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/favorites
* body: { key: string; favorite: Favorite }
*/
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, favorite }: { key: string; favorite: Favorite } = body;
if (!key || !favorite) {
return NextResponse.json(
{ error: 'Missing key or favorite' },
{ status: 400 }
);
}
// 验证必要字段
if (!favorite.title || !favorite.source_name) {
return NextResponse.json(
{ error: 'Invalid favorite data' },
{ status: 400 }
);
}
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
const finalFavorite = {
...favorite,
save_time: favorite.save_time ?? Date.now(),
} as Favorite;
await db.saveFavorite(authInfo.username, source, id, finalFavorite);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('保存收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* DELETE /api/favorites
*
* 1. 不带 query -> 清空全部收藏
* 2. 带 key=source+id -> 删除单条收藏
*/
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const username = authInfo.username;
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (key) {
// 删除单条
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.deleteFavorite(username, source, id);
} else {
// 清空全部
const all = await db.getAllFavorites(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deleteFavorite(username, s, i);
})
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除收藏失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
// OrionTV 兼容接口
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const imageUrl = searchParams.get('url');
if (!imageUrl) {
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
}
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: 'https://movie.douban.com/',
'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',
},
});
if (!imageResponse.ok) {
return NextResponse.json(
{ error: imageResponse.statusText },
{ status: imageResponse.status }
);
}
const contentType = imageResponse.headers.get('content-type');
if (!imageResponse.body) {
return NextResponse.json(
{ error: 'Image response has no body' },
{ status: 500 }
);
}
// 创建响应头
const headers = new Headers();
if (contentType) {
headers.set('Content-Type', contentType);
}
// 设置缓存头(可选)
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); // 缓存半年
headers.set('CDN-Cache-Control', 'public, s-maxage=15720000');
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
headers.set('Netlify-Vary', 'query');
// 直接返回图片流
return new Response(imageResponse.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: 'Error fetching image' },
{ status: 500 }
);
}
}
+208
View File
@@ -0,0 +1,208 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'edge';
// 读取存储类型环境变量,默认 localstorage
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| undefined) || 'localstorage';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie(带签名)
async function generateAuthCookie(
username?: string,
password?: string,
role?: 'owner' | 'admin' | 'user',
includePassword = false
): Promise<string> {
const authData: any = { role: role || 'user' };
// 只在需要时包含 password
if (includePassword && password) {
authData.password = password;
}
if (username && process.env.PASSWORD) {
authData.username = username;
// 使用密码作为密钥对用户名进行签名
const signature = await generateSignature(username, process.env.PASSWORD);
authData.signature = signature;
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
}
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(req: NextRequest) {
try {
// 本地 / localStorage 模式——仅校验固定密码
if (STORAGE_TYPE === 'localstorage') {
const envPassword = process.env.PASSWORD;
// 未配置 PASSWORD 时直接放行
if (!envPassword) {
const response = NextResponse.json({ ok: true });
// 清除可能存在的认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}
const { password } = await req.json();
if (typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
if (password !== envPassword) {
return NextResponse.json(
{ ok: false, error: '密码错误' },
{ status: 401 }
);
}
// 验证成功,设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
undefined,
password,
'user',
true
); // localstorage 模式包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}
// 数据库 / redis 模式——校验用户名并尝试连接数据库
const { username, password } = await req.json();
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
// 可能是站长,直接读环境变量
if (
username === process.env.USERNAME &&
password === process.env.PASSWORD
) {
// 验证成功,设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
username,
password,
'owner',
false
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
} else if (username === process.env.USERNAME) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
}
const config = await getConfig();
const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.banned) {
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
}
// 校验用户密码
try {
const pass = await db.verifyUser(username, password);
if (!pass) {
return NextResponse.json(
{ error: '用户名或密码错误' },
{ status: 401 }
);
}
// 验证成功,设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(
username,
password,
user?.role || 'user',
false
); // 数据库模式不包含 password
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
} catch (err) {
console.error('数据库验证失败', err);
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
}
} catch (error) {
console.error('登录接口异常', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function POST() {
const response = NextResponse.json({ ok: true });
// 清除认证cookie
response.cookies.set('auth', '', {
path: '/',
expires: new Date(0),
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
}
+159
View File
@@ -0,0 +1,159 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { PlayRecord } from '@/lib/types';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const records = await db.getAllPlayRecords(authInfo.username);
return NextResponse.json(records, { status: 200 });
} catch (err) {
console.error('获取播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, record }: { key: string; record: PlayRecord } = body;
if (!key || !record) {
return NextResponse.json(
{ error: 'Missing key or record' },
{ status: 400 }
);
}
// 验证播放记录数据
if (!record.title || !record.source_name || record.index < 1) {
return NextResponse.json(
{ error: 'Invalid record data' },
{ status: 400 }
);
}
// 从key中解析source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
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 });
} catch (err) {
console.error('保存播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const username = authInfo.username;
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (key) {
// 如果提供了 key,删除单条播放记录
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json(
{ error: 'Invalid key format' },
{ status: 400 }
);
}
await db.deletePlayRecord(username, source, id);
} else {
// 未提供 key,则清空全部播放记录
// 目前 DbManager 没有对应方法,这里直接遍历删除
const all = await db.getAllPlayRecords(username);
await Promise.all(
Object.keys(all).map(async (k) => {
const [s, i] = k.split('+');
if (s && i) await db.deletePlayRecord(username, s, i);
})
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除播放记录失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'edge';
// 读取存储类型环境变量,默认 localstorage
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| undefined) || 'localstorage';
// 生成签名
async function generateSignature(
data: string,
secret: string
): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
// 生成签名
const signature = await crypto.subtle.sign('HMAC', key, messageData);
// 转换为十六进制字符串
return Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// 生成认证Cookie(带签名)
async function generateAuthCookie(username: string): Promise<string> {
const authData: any = {
role: 'user',
username,
timestamp: Date.now(),
};
// 使用process.env.PASSWORD作为签名密钥,而不是用户密码
const signingKey = process.env.PASSWORD || '';
const signature = await generateSignature(username, signingKey);
authData.signature = signature;
return encodeURIComponent(JSON.stringify(authData));
}
export async function POST(req: NextRequest) {
try {
// localstorage 模式下不支持注册
if (STORAGE_TYPE === 'localstorage') {
return NextResponse.json(
{ error: '当前模式不支持注册' },
{ status: 400 }
);
}
const config = await getConfig();
// 校验是否开放注册
if (!config.UserConfig.AllowRegister) {
return NextResponse.json({ error: '当前未开放注册' }, { status: 400 });
}
const { username, password } = await req.json();
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
}
// 检查是否和管理员重复
if (username === process.env.USERNAME) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
}
try {
// 检查用户是否已存在
const exist = await db.checkUserExist(username);
if (exist) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
}
await db.registerUser(username, password);
// 添加到配置中并保存
config.UserConfig.Users.push({
username,
role: 'user',
});
await db.saveAdminConfig(config);
// 注册成功,设置认证cookie
const response = NextResponse.json({ ok: true });
const cookieValue = await generateAuthCookie(username);
const expires = new Date();
expires.setDate(expires.getDate() + 7); // 7天过期
response.cookies.set('auth', cookieValue, {
path: '/',
expires,
sameSite: 'lax', // 改为 lax 以支持 PWA
httpOnly: false, // PWA 需要客户端可访问
secure: false, // 根据协议自动设置
});
return response;
} catch (err) {
console.error('数据库注册失败', err);
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
}
} catch (error) {
console.error('注册接口异常', error);
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'edge';
// OrionTV 兼容接口
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const resourceId = searchParams.get('resourceId');
if (!query || !resourceId) {
const cacheTime = await getCacheTime();
return NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' },
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
}
);
}
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
try {
// 根据 resourceId 查找对应的 API 站点
const targetSite = apiSites.find((site) => site.key === resourceId);
if (!targetSite) {
return NextResponse.json(
{
error: `未找到指定的视频源: ${resourceId}`,
result: null,
},
{ status: 404 }
);
}
const results = await searchFromApi(targetSite, query);
let result = results.filter((r) => r.title === query);
if (!config.SiteConfig.DisableYellowFilter) {
result = result.filter((result) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
const cacheTime = await getCacheTime();
if (result.length === 0) {
return NextResponse.json(
{
error: '未找到结果',
result: null,
},
{ status: 404 }
);
} else {
return NextResponse.json(
{ results: result },
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
}
);
}
} catch (error) {
return NextResponse.json(
{
error: '搜索失败',
result: null,
},
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
export const runtime = 'edge';
// OrionTV 兼容接口
export async function GET() {
try {
const apiSites = await getAvailableApiSites();
const cacheTime = await getCacheTime();
return NextResponse.json(apiSites, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
}
}
+74
View File
@@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextResponse } from 'next/server';
import { getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query) {
const cacheTime = await getCacheTime();
return NextResponse.json(
{ results: [] },
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
}
);
}
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
// 添加超时控制和错误处理,避免慢接口拖累整体响应
const searchPromises = apiSites.map((site) =>
Promise.race([
searchFromApi(site, query),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
),
]).catch((err) => {
console.warn(`搜索失败 ${site.name}:`, err.message);
return []; // 返回空数组而不是抛出错误
})
);
try {
const results = await Promise.allSettled(searchPromises);
const successResults = results
.filter((result) => result.status === 'fulfilled')
.map((result) => (result as PromiseFulfilledResult<any>).value);
let flattenedResults = successResults.flat();
if (!config.SiteConfig.DisableYellowFilter) {
flattenedResults = flattenedResults.filter((result) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
const cacheTime = await getCacheTime();
return NextResponse.json(
{ results: flattenedResults },
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
}
);
} catch (error) {
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
}
}
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q')?.trim();
if (!query) {
return NextResponse.json({ suggestions: [] });
}
// 生成建议
const suggestions = await generateSuggestions(query);
// 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟)
const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
return NextResponse.json(
{ suggestions },
{
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
}
);
} catch (error) {
console.error('获取搜索建议失败', error);
return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 });
}
}
async function generateSuggestions(query: string): Promise<
Array<{
text: string;
type: 'exact' | 'related' | 'suggestion';
score: number;
}>
> {
const queryLower = query.toLowerCase();
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site: any) => !site.disabled);
let realKeywords: string[] = [];
if (apiSites.length > 0) {
// 取第一个可用的数据源进行搜索
const firstSite = apiSites[0];
const results = await searchFromApi(firstSite, query);
realKeywords = Array.from(
new Set(
results
.map((r: any) => r.title)
.filter(Boolean)
.flatMap((title: string) => title.split(/[ -::·、-]/))
.filter(
(w: string) => w.length > 1 && w.toLowerCase().includes(queryLower)
)
)
).slice(0, 8);
}
// 根据关键词与查询的匹配程度计算分数,并动态确定类型
const realSuggestions = realKeywords.map((word) => {
const wordLower = word.toLowerCase();
const queryWords = queryLower.split(/[ -::·、-]/);
// 计算匹配分数:完全匹配得分更高
let score = 1.0;
if (wordLower === queryLower) {
score = 2.0; // 完全匹配
} else if (
wordLower.startsWith(queryLower) ||
wordLower.endsWith(queryLower)
) {
score = 1.8; // 前缀或后缀匹配
} else if (queryWords.some((qw) => wordLower.includes(qw))) {
score = 1.5; // 包含查询词
}
// 根据匹配程度确定类型
let type: 'exact' | 'related' | 'suggestion' = 'related';
if (score >= 2.0) {
type = 'exact';
} else if (score >= 1.5) {
type = 'related';
} else {
type = 'suggestion';
}
return {
text: word,
type,
score,
};
});
// 按分数降序排列,相同分数按类型优先级排列
const sortedSuggestions = realSuggestions.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score; // 分数高的在前
}
// 分数相同时,按类型优先级:exact > related > suggestion
const typePriority = { exact: 3, related: 2, suggestion: 1 };
return typePriority[b.type] - typePriority[a.type];
});
return sortedSuggestions;
}
+133
View File
@@ -0,0 +1,133 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'edge';
// 最大保存条数(与客户端保持一致)
const HISTORY_LIMIT = 20;
/**
* GET /api/searchhistory
* 返回 string[]
*/
export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const history = await db.getSearchHistory(authInfo.username);
return NextResponse.json(history, { status: 200 });
} catch (err) {
console.error('获取搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/searchhistory
* body: { keyword: string }
*/
export async function POST(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const keyword: string = body.keyword?.trim();
if (!keyword) {
return NextResponse.json(
{ error: 'Keyword is required' },
{ status: 400 }
);
}
await db.addSearchHistory(authInfo.username, keyword);
// 再次获取最新列表,确保客户端与服务端同步
const history = await db.getSearchHistory(authInfo.username);
return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 });
} catch (err) {
console.error('添加搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* DELETE /api/searchhistory?keyword=<kw>
*
* 1. 不带 keyword -> 清空全部搜索历史
* 2. 带 keyword=<kw> -> 删除单条关键字
*/
export async function DELETE(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const kw = searchParams.get('keyword')?.trim();
await db.deleteSearchHistory(authInfo.username, kw || undefined);
return NextResponse.json({ success: true }, { status: 200 });
} catch (err) {
console.error('删除搜索历史失败', err);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
console.log('server-config called: ', request.url);
const config = await getConfig();
const result = {
SiteName: config.SiteConfig.SiteName,
StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
};
return NextResponse.json(result);
}
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { SkipConfig } from '@/lib/types';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const config = await getConfig();
if (config.UserConfig.Users) {
// 检查用户是否被封禁
const user = config.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const source = searchParams.get('source');
const id = searchParams.get('id');
if (source && id) {
// 获取单个配置
const config = await db.getSkipConfig(authInfo.username, source, id);
return NextResponse.json(config);
} else {
// 获取所有配置
const configs = await db.getAllSkipConfigs(authInfo.username);
return NextResponse.json(configs);
}
} catch (error) {
console.error('获取跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '获取跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const adminConfig = await getConfig();
if (adminConfig.UserConfig.Users) {
// 检查用户是否被封禁
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const body = await request.json();
const { key, config } = body;
if (!key || !config) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
// 验证配置格式
const skipConfig: SkipConfig = {
enable: Boolean(config.enable),
intro_time: Number(config.intro_time) || 0,
outro_time: Number(config.outro_time) || 0,
};
await db.setSkipConfig(authInfo.username, source, id, skipConfig);
return NextResponse.json({ success: true });
} catch (error) {
console.error('保存跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '保存跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
}
const adminConfig = await getConfig();
if (adminConfig.UserConfig.Users) {
// 检查用户是否被封禁
const user = adminConfig.UserConfig.Users.find(
(u) => u.username === authInfo.username
);
if (user && user.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
const { searchParams } = new URL(request.url);
const key = searchParams.get('key');
if (!key) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 解析key为source和id
const [source, id] = key.split('+');
if (!source || !id) {
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
}
await db.deleteSkipConfig(authInfo.username, source, id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('删除跳过片头片尾配置失败:', error);
return NextResponse.json(
{ error: '删除跳过片头片尾配置失败' },
{ status: 500 }
);
}
}
+822
View File
@@ -0,0 +1,822 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { GetBangumiCalendarData } from '@/lib/bangumi.client';
import {
getDoubanCategories,
getDoubanList,
getDoubanRecommends,
} from '@/lib/douban.client';
import { DoubanItem, DoubanResult } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import DoubanCustomSelector from '@/components/DoubanCustomSelector';
import DoubanSelector from '@/components/DoubanSelector';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
function DoubanPageClient() {
const searchParams = useSearchParams();
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectorsReady, setSelectorsReady] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 用于存储最新参数值的 refs
const currentParamsRef = useRef({
type: '',
primarySelection: '',
secondarySelection: '',
multiLevelSelection: {} as Record<string, string>,
selectedWeekday: '',
currentPage: 0,
});
const type = searchParams.get('type') || 'movie';
// 获取 runtimeConfig 中的自定义分类数据
const [customCategories, setCustomCategories] = useState<
Array<{ name: string; type: 'movie' | 'tv'; query: string }>
>([]);
// 选择器状态 - 完全独立,不依赖URL参数
const [primarySelection, setPrimarySelection] = useState<string>(() => {
if (type === 'movie') return '热门';
if (type === 'tv' || type === 'show') return '最近热门';
if (type === 'anime') return '每日放送';
return '';
});
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
if (type === 'movie') return '全部';
if (type === 'tv') return 'tv';
if (type === 'show') return 'show';
return '全部';
});
// MultiLevelSelector 状态
const [multiLevelValues, setMultiLevelValues] = useState<
Record<string, string>
>({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 星期选择器状态
const [selectedWeekday, setSelectedWeekday] = useState<string>('');
// 获取自定义分类数据
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
}
}, []);
// 同步最新参数值到 ref
useEffect(() => {
currentParamsRef.current = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage,
};
}, [
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
currentPage,
]);
// 初始化时标记选择器为准备好状态
useEffect(() => {
// 短暂延迟确保初始状态设置完成
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, []); // 只在组件挂载时执行一次
// type变化时立即重置selectorsReady(最高优先级)
useEffect(() => {
setSelectorsReady(false);
setLoading(true); // 立即显示loading状态
}, [type]);
// 当type变化时重置选择器状态
useEffect(() => {
if (type === 'custom' && customCategories.length > 0) {
// 自定义分类模式:优先选择 movie,如果没有 movie 则选择 tv
const types = Array.from(
new Set(customCategories.map((cat) => cat.type))
);
if (types.length > 0) {
// 优先选择 movie,如果没有 movie 则选择 tv
let selectedType = types[0]; // 默认选择第一个
if (types.includes('movie')) {
selectedType = 'movie';
} else {
selectedType = 'tv';
}
setPrimarySelection(selectedType);
// 设置选中类型的第一个分类的 query 作为二级选择
const firstCategory = customCategories.find(
(cat) => cat.type === selectedType
);
if (firstCategory) {
setSecondarySelection(firstCategory.query);
}
}
} else {
// 原有逻辑
if (type === 'movie') {
setPrimarySelection('热门');
setSecondarySelection('全部');
} else if (type === 'tv') {
setPrimarySelection('最近热门');
setSecondarySelection('tv');
} else if (type === 'show') {
setPrimarySelection('最近热门');
setSecondarySelection('show');
} else if (type === 'anime') {
setPrimarySelection('每日放送');
setSecondarySelection('全部');
} else {
setPrimarySelection('');
setSecondarySelection('全部');
}
}
// 清空 MultiLevelSelector 状态
setMultiLevelValues({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 使用短暂延迟确保状态更新完成后标记选择器准备好
const timer = setTimeout(() => {
setSelectorsReady(true);
}, 50);
return () => clearTimeout(timer);
}, [type, customCategories]);
// 生成骨架屏数据
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
// 参数快照比较函数
const isSnapshotEqual = useCallback(
(
snapshot1: {
type: string;
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record<string, string>;
selectedWeekday: string;
currentPage: number;
},
snapshot2: {
type: string;
primarySelection: string;
secondarySelection: string;
multiLevelSelection: Record<string, string>;
selectedWeekday: string;
currentPage: number;
}
) => {
return (
snapshot1.type === snapshot2.type &&
snapshot1.primarySelection === snapshot2.primarySelection &&
snapshot1.secondarySelection === snapshot2.secondarySelection &&
snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
snapshot1.currentPage === snapshot2.currentPage &&
JSON.stringify(snapshot1.multiLevelSelection) ===
JSON.stringify(snapshot2.multiLevelSelection)
);
},
[]
);
// 生成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 () => {
// 创建当前参数的快照
const requestSnapshot = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage: 0,
};
try {
setLoading(true);
// 确保在加载初始数据时重置页面状态
setDoubanData([]);
setCurrentPage(0);
setHasMore(true);
setIsLoadingMore(false);
let data: DoubanResult;
if (type === 'custom') {
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
const selectedCategory = customCategories.find(
(cat) =>
cat.type === primarySelection && cat.query === secondarySelection
);
if (selectedCategory) {
data = await getDoubanList({
tag: selectedCategory.query,
type: selectedCategory.type,
pageLimit: 25,
pageStart: 0,
});
} else {
throw new Error('没有找到对应的分类');
}
} else if (type === 'anime' && primarySelection === '每日放送') {
const calendarData = await GetBangumiCalendarData();
const weekdayData = calendarData.find(
(item) => item.weekday.en === selectedWeekday
);
if (weekdayData) {
data = {
code: 200,
message: 'success',
list: weekdayData.items.map((item) => ({
id: item.id?.toString() || '',
title: item.name_cn || item.name,
poster:
item.images.large ||
item.images.common ||
item.images.medium ||
item.images.small ||
item.images.grid,
rate: item.rating?.score?.toString() || '',
year: item.air_date?.split('-')?.[0] || '',
})),
};
} else {
throw new Error('没有找到对应的日期');
}
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
pageLimit: 25,
pageStart: 0,
category: '动画',
format: primarySelection === '番剧' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else if (primarySelection === '全部') {
data = await getDoubanRecommends({
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
pageLimit: 25,
pageStart: 0, // 初始数据加载始终从第一页开始
category: multiLevelValues.type
? (multiLevelValues.type as string)
: '',
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else {
data = await getDoubanCategories(getRequestParams(0));
}
if (data.code === 200) {
// 检查参数是否仍然一致,如果一致才设置数据
// 使用 ref 获取最新的当前值
const currentSnapshot = { ...currentParamsRef.current };
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
setDoubanData(data.list);
setHasMore(data.list.length !== 0);
setLoading(false);
} else {
console.log('参数不一致,不执行任何操作,避免设置过期数据');
}
// 如果参数不一致,不执行任何操作,避免设置过期数据
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
setLoading(false); // 发生错误时总是停止loading状态
}
}, [
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
getRequestParams,
customCategories,
]);
// 只在选择器准备好后才加载数据
useEffect(() => {
// 只有在选择器准备好时才开始加载
if (!selectorsReady) {
return;
}
// 清除之前的防抖定时器
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
debounceTimeoutRef.current = setTimeout(() => {
loadInitialData();
}, 100); // 100ms 防抖延迟
// 清理函数
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [
selectorsReady,
type,
primarySelection,
secondarySelection,
multiLevelValues,
selectedWeekday,
loadInitialData,
]);
// 单独处理 currentPage 变化(加载更多)
useEffect(() => {
if (currentPage > 0) {
const fetchMoreData = async () => {
// 创建当前参数的快照
const requestSnapshot = {
type,
primarySelection,
secondarySelection,
multiLevelSelection: multiLevelValues,
selectedWeekday,
currentPage,
};
try {
setIsLoadingMore(true);
let data: DoubanResult;
if (type === 'custom') {
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
const selectedCategory = customCategories.find(
(cat) =>
cat.type === primarySelection &&
cat.query === secondarySelection
);
if (selectedCategory) {
data = await getDoubanList({
tag: selectedCategory.query,
type: selectedCategory.type,
pageLimit: 25,
pageStart: currentPage * 25,
});
} else {
throw new Error('没有找到对应的分类');
}
} else if (type === 'anime' && primarySelection === '每日放送') {
// 每日放送模式下,不进行数据请求,返回空数据
data = {
code: 200,
message: 'success',
list: [],
};
} else if (type === 'anime') {
data = await getDoubanRecommends({
kind: primarySelection === '番剧' ? 'tv' : 'movie',
pageLimit: 25,
pageStart: currentPage * 25,
category: '动画',
format: primarySelection === '番剧' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year
? (multiLevelValues.year as string)
: '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort
? (multiLevelValues.sort as string)
: '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else if (primarySelection === '全部') {
data = await getDoubanRecommends({
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
pageLimit: 25,
pageStart: currentPage * 25,
category: multiLevelValues.type
? (multiLevelValues.type as string)
: '',
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
region: multiLevelValues.region
? (multiLevelValues.region as string)
: '',
year: multiLevelValues.year
? (multiLevelValues.year as string)
: '',
platform: multiLevelValues.platform
? (multiLevelValues.platform as string)
: '',
sort: multiLevelValues.sort
? (multiLevelValues.sort as string)
: '',
label: multiLevelValues.label
? (multiLevelValues.label as string)
: '',
});
} else {
data = await getDoubanCategories(
getRequestParams(currentPage * 25)
);
}
if (data.code === 200) {
// 检查参数是否仍然一致,如果一致才设置数据
// 使用 ref 获取最新的当前值
const currentSnapshot = { ...currentParamsRef.current };
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
setDoubanData((prev) => [...prev, ...data.list]);
setHasMore(data.list.length !== 0);
} else {
console.log('参数不一致,不执行任何操作,避免设置过期数据');
}
} else {
throw new Error(data.message || '获取数据失败');
}
} catch (err) {
console.error(err);
} finally {
setIsLoadingMore(false);
}
};
fetchMoreData();
}
}, [
currentPage,
type,
primarySelection,
secondarySelection,
customCategories,
multiLevelValues,
selectedWeekday,
]);
// 设置滚动监听
useEffect(() => {
// 如果没有更多数据或正在加载,则不设置监听
if (!hasMore || isLoadingMore || loading) {
return;
}
// 确保 loadingRef 存在
if (!loadingRef.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
setCurrentPage((prev) => prev + 1);
}
},
{ threshold: 0.1 }
);
observer.observe(loadingRef.current);
observerRef.current = observer;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasMore, isLoadingMore, loading]);
// 处理选择器变化
const handlePrimaryChange = useCallback(
(value: string) => {
// 只有当值真正改变时才设置loading状态
if (value !== primarySelection) {
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
// 清空 MultiLevelSelector 状态
setMultiLevelValues({
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
});
// 如果是自定义分类模式,同时更新一级和二级选择器
if (type === 'custom' && customCategories.length > 0) {
const firstCategory = customCategories.find(
(cat) => cat.type === value
);
if (firstCategory) {
// 批量更新状态,避免多次触发数据加载
setPrimarySelection(value);
setSecondarySelection(firstCategory.query);
} else {
setPrimarySelection(value);
}
} else {
// 电视剧和综艺切换到"最近热门"时,重置二级分类为第一个选项
if ((type === 'tv' || type === 'show') && value === '最近热门') {
setPrimarySelection(value);
if (type === 'tv') {
setSecondarySelection('tv');
} else if (type === 'show') {
setSecondarySelection('show');
}
} else {
setPrimarySelection(value);
}
}
}
},
[primarySelection, type, customCategories]
);
const handleSecondaryChange = useCallback(
(value: string) => {
// 只有当值真正改变时才设置loading状态
if (value !== secondarySelection) {
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
setSecondarySelection(value);
}
},
[secondarySelection]
);
const handleMultiLevelChange = useCallback(
(values: Record<string, string>) => {
// 比较两个对象是否相同,忽略顺序
const isEqual = (
obj1: Record<string, string>,
obj2: Record<string, string>
) => {
const keys1 = Object.keys(obj1).sort();
const keys2 = Object.keys(obj2).sort();
if (keys1.length !== keys2.length) return false;
return keys1.every((key) => obj1[key] === obj2[key]);
};
// 如果相同,则不设置loading状态
if (isEqual(values, multiLevelValues)) {
return;
}
setLoading(true);
// 立即重置页面状态,防止基于旧状态的请求
setCurrentPage(0);
setDoubanData([]);
setHasMore(true);
setIsLoadingMore(false);
setMultiLevelValues(values);
},
[multiLevelValues]
);
const handleWeekdayChange = useCallback((weekday: string) => {
setSelectedWeekday(weekday);
}, []);
const getPageTitle = () => {
// 根据 type 生成标题
return type === 'movie'
? '电影'
: type === 'tv'
? '电视剧'
: type === 'anime'
? '动漫'
: type === 'show'
? '综艺'
: '自定义';
};
const getPageDescription = () => {
if (type === 'anime' && primarySelection === '每日放送') {
return '来自 Bangumi 番组计划的精选内容';
}
return '来自豆瓣的精选内容';
};
const getActivePath = () => {
const params = new URLSearchParams();
if (type) params.set('type', type);
const queryString = params.toString();
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
return activePath;
};
return (
<PageLayout activePath={getActivePath()}>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 页面标题和选择器 */}
<div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
{/* 页面标题 */}
<div>
<h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
{getPageTitle()}
</h1>
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
{getPageDescription()}
</p>
</div>
{/* 选择器组件 */}
{type !== 'custom' ? (
<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' | 'anime'}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
onMultiLevelChange={handleMultiLevelChange}
onWeekdayChange={handleWeekdayChange}
/>
</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'>
<DoubanCustomSelector
customCategories={customCategories}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
)}
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{/* 内容网格 */}
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
? // 显示骨架屏
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={Number(item.id)}
rate={item.rate}
year={item.year}
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
isBangumi={
type === 'anime' && primarySelection === '每日放送'
}
/>
</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>
)}
{/* 没有更多数据提示 */}
{!hasMore && doubanData.length > 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
{/* 空状态 */}
{!loading && doubanData.length === 0 && (
<div className='text-center text-gray-500 py-8'></div>
)}
</div>
</div>
</PageLayout>
);
}
export default function DoubanPage() {
return (
<Suspense>
<DoubanPageClient />
</Suspense>
);
}
+176
View File
@@ -0,0 +1,176 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
}
:root {
--foreground-rgb: 255, 255, 255;
}
html,
body {
height: 100%;
overflow-x: hidden;
/* 阻止 iOS Safari 拉动回弹 */
overscroll-behavior: none;
}
body {
color: rgb(var(--foreground-rgb));
}
html:not(.dark) body {
background: linear-gradient(
180deg,
#e6f3fb 0%,
#eaf3f7 18%,
#f7f7f3 38%,
#e9ecef 60%,
#dbe3ea 80%,
#d3dde6 100%
);
background-attachment: fixed;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(75, 85, 99, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(107, 114, 128, 0.5);
}
/* 视频卡片悬停效果 */
.video-card-hover {
transition: transform 0.3s ease;
}
.video-card-hover:hover {
transform: scale(1.05);
}
/* 渐变遮罩 */
.gradient-overlay {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.8) 100%
);
}
/* 隐藏移动端(<768px)垂直滚动条 */
@media (max-width: 767px) {
html,
body {
-ms-overflow-style: none; /* IE & Edge */
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
}
/* 隐藏所有滚动条(兼容 WebKit、Firefox、IE/Edge */
* {
-ms-overflow-style: none; /* IE & Edge */
scrollbar-width: none; /* Firefox */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* View Transitions API 动画 */
@keyframes slide-from-top {
from {
clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
}
to {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
}
@keyframes slide-from-bottom {
from {
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
}
to {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: both;
}
/*
切换时,旧的视图不应该有动画,它应该在下面,等待被新的视图覆盖。
这可以防止在动画完成前,页面底部提前变色。
*/
::view-transition-old(root) {
animation: none;
}
/* 从浅色到深色:新内容(深色)从顶部滑入 */
html.dark::view-transition-new(root) {
animation-name: slide-from-top;
}
/* 从深色到浅色:新内容(浅色)从底部滑入 */
html:not(.dark)::view-transition-new(root) {
animation-name: slide-from-bottom;
}
/* 强制播放器内部的 video 元素高度为 100%,并保持内容完整显示 */
div[data-media-provider] video {
height: 100%;
object-fit: contain;
}
.art-poster {
background-size: contain !important; /* 使图片完整展示 */
background-position: center center !important; /* 居中显示 */
background-repeat: no-repeat !important; /* 防止重复 */
background-color: #000 !important; /* 其余区域填充为黑色 */
}
/* 隐藏移动端竖屏时的 pip 按钮 */
@media (max-width: 768px) {
.art-control-pip {
display: none !important;
}
.art-control-fullscreenWeb {
display: none !important;
}
.art-control-volume {
display: none !important;
}
}
+124
View File
@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import 'sweetalert2/dist/sweetalert2.min.css';
import { getConfig } from '@/lib/config';
import RuntimeConfig from '@/lib/runtime';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
const inter = Inter({ subsets: ['latin'] });
// 动态生成 metadata,支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
}
return {
title: siteName,
description: '影视聚合',
manifest: '/manifest.json',
};
}
export const viewport: Viewport = {
viewportFit: 'cover',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
let announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
let doubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct';
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let customCategories =
(RuntimeConfig as any).custom_category?.map((category: any) => ({
name: 'name' in category ? category.name : '',
type: category.type,
query: category.query,
})) || ([] as Array<{ name: string; type: 'movie' | 'tv'; query: string }>);
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
enableRegister = config.UserConfig.AllowRegister;
doubanProxyType = config.SiteConfig.DoubanProxyType;
doubanProxy = config.SiteConfig.DoubanProxy;
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
ENABLE_REGISTER: enableRegister,
DOUBAN_PROXY_TYPE: doubanProxyType,
DOUBAN_PROXY: doubanProxy,
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
};
return (
<html lang='zh-CN' suppressHydrationWarning>
<head>
<meta
name='viewport'
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
/>
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
</head>
<body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<SiteProvider siteName={siteName} announcement={announcement}>
{children}
<GlobalErrorIndicator />
</SiteProvider>
</ThemeProvider>
</body>
</html>
);
}
+245
View File
@@ -0,0 +1,245 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { AlertCircle, CheckCircle } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
// 版本显示组件
function VersionDisplay() {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (_) {
// do nothing
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
return (
<button
onClick={() =>
window.open('https://github.com/LunaTechLab/MoonTV', '_blank')
}
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`flex items-center gap-1.5 ${
updateStatus === UpdateStatus.HAS_UPDATE
? 'text-yellow-600 dark:text-yellow-400'
: updateStatus === UpdateStatus.NO_UPDATE
? 'text-green-600 dark:text-green-400'
: ''
}`}
>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<>
<AlertCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
{updateStatus === UpdateStatus.NO_UPDATE && (
<>
<CheckCircle className='w-3.5 h-3.5' />
<span className='font-semibold text-xs'></span>
</>
)}
</div>
)}
</button>
);
}
function LoginPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [shouldAskUsername, setShouldAskUsername] = useState(false);
const [enableRegister, setEnableRegister] = useState(false);
const { siteName } = useSite();
// 在客户端挂载后设置配置
useEffect(() => {
if (typeof window !== 'undefined') {
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
setShouldAskUsername(storageType && storageType !== 'localstorage');
setEnableRegister(
Boolean((window as any).RUNTIME_CONFIG?.ENABLE_REGISTER)
);
}
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!password || (shouldAskUsername && !username)) return;
try {
setLoading(true);
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password,
...(shouldAskUsername ? { username } : {}),
}),
});
if (res.ok) {
const redirect = searchParams.get('redirect') || '/';
router.replace(redirect);
} else if (res.status === 401) {
setError('密码错误');
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
// 处理注册逻辑
const handleRegister = async () => {
setError(null);
if (!password || !username) return;
try {
setLoading(true);
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
const redirect = searchParams.get('redirect') || '/';
router.replace(redirect);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error ?? '服务器错误');
}
} catch (error) {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<div className='absolute top-4 right-4'>
<ThemeToggle />
</div>
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
{siteName}
</h1>
<form onSubmit={handleSubmit} className='space-y-8'>
{shouldAskUsername && (
<div>
<label htmlFor='username' className='sr-only'>
</label>
<input
id='username'
type='text'
autoComplete='username'
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
placeholder='输入用户名'
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
)}
<div>
<label htmlFor='password' className='sr-only'>
</label>
<input
id='password'
type='password'
autoComplete='current-password'
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
placeholder='输入访问密码'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
)}
{/* 登录 / 注册按钮 */}
{shouldAskUsername && enableRegister ? (
<div className='flex gap-4'>
<button
type='button'
onClick={handleRegister}
disabled={!password || !username || loading}
className='flex-1 inline-flex justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '注册中...' : '注册'}
</button>
<button
type='submit'
disabled={
!password || loading || (shouldAskUsername && !username)
}
className='flex-1 inline-flex justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '登录中...' : '登录'}
</button>
</div>
) : (
<button
type='submit'
disabled={
!password || loading || (shouldAskUsername && !username)
}
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{loading ? '登录中...' : '登录'}
</button>
)}
</form>
</div>
{/* 版本信息显示 */}
<VersionDisplay />
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LoginPageClient />
</Suspense>
);
}
+491
View File
@@ -0,0 +1,491 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
'use client';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react';
import {
BangumiCalendarData,
GetBangumiCalendarData,
} from '@/lib/bangumi.client';
// 客户端收藏 API
import {
clearAllFavorites,
getAllFavorites,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { getDoubanCategories } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
function HomeClient() {
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
const [bangumiCalendarData, setBangumiCalendarData] = useState<
BangumiCalendarData[]
>([]);
const [loading, setLoading] = useState(true);
const { announcement } = useSite();
const [showAnnouncement, setShowAnnouncement] = useState(false);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
if (hasSeenAnnouncement !== announcement) {
setShowAnnouncement(true);
} else {
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
}
}
}, [announcement]);
// 收藏夹数据
type FavoriteItem = {
id: string;
source: string;
title: string;
poster: string;
episodes: number;
source_name: string;
currentEpisode?: number;
search_title?: string;
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
useEffect(() => {
const fetchRecommendData = async () => {
try {
setLoading(true);
// 并行获取热门电影、热门剧集和热门综艺
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
await Promise.all([
getDoubanCategories({
kind: 'movie',
category: '热门',
type: '全部',
}),
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
GetBangumiCalendarData(),
]);
if (moviesData.code === 200) {
setHotMovies(moviesData.list);
}
if (tvShowsData.code === 200) {
setHotTvShows(tvShowsData.list);
}
if (varietyShowsData.code === 200) {
setHotVarietyShows(varietyShowsData.list);
}
setBangumiCalendarData(bangumiCalendarData);
} catch (error) {
console.error('获取推荐数据失败:', error);
} finally {
setLoading(false);
}
};
fetchRecommendData();
}, []);
// 处理收藏数据更新的函数
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
};
// 当切换到收藏夹时加载收藏数据
useEffect(() => {
if (activeTab !== 'favorites') return;
const loadFavorites = async () => {
const allFavorites = await getAllFavorites();
await updateFavoriteItems(allFavorites);
};
loadFavorites();
// 监听收藏更新事件
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
updateFavoriteItems(newFavorites);
}
);
return unsubscribe;
}, [activeTab]);
const handleCloseAnnouncement = (announcement: string) => {
setShowAnnouncement(false);
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
};
return (
<PageLayout>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 顶部 Tab 切换 */}
<div className='mb-8 flex justify-center'>
<CapsuleSwitch
options={[
{ label: '首页', value: 'home' },
{ label: '收藏夹', value: 'favorites' },
]}
active={activeTab}
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
/>
</div>
<div className='max-w-[95%] mx-auto'>
{activeTab === 'favorites' ? (
// 收藏夹视图
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{favoriteItems.length > 0 && (
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={async () => {
await clearAllFavorites();
setFavoriteItems([]);
}}
>
</button>
)}
</div>
<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) => (
<div key={item.id + item.source} className='w-full'>
<VideoCard
query={item.search_title}
{...item}
from='favorite'
type={item.episodes > 1 ? 'tv' : ''}
/>
</div>
))}
{favoriteItems.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
)}
</div>
</section>
) : (
// 首页视图
<>
{/* 继续观看 */}
<ContinueWatching />
{/* 热门电影 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
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'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={movie.title}
poster={movie.poster}
douban_id={Number(movie.id)}
rate={movie.rate}
year={movie.year}
type='movie'
/>
</div>
))}
</ScrollableRow>
</section>
{/* 热门剧集 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
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'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
{/* 每日新番放送 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=anime'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 展示当前日期的番剧
(() => {
// 获取当前日期对应的星期
const today = new Date();
const weekdays = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
];
const currentWeekday = weekdays[today.getDay()];
// 找到当前星期对应的番剧数据
const todayAnimes =
bangumiCalendarData.find(
(item) => item.weekday.en === currentWeekday
)?.items || [];
return todayAnimes.map((anime, index) => (
<div
key={`${anime.id}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={anime.name_cn || anime.name}
poster={
anime.images.large ||
anime.images.common ||
anime.images.medium ||
anime.images.small ||
anime.images.grid
}
douban_id={anime.id}
rate={anime.rating?.score?.toString() || ''}
year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true}
/>
</div>
));
})()}
</ScrollableRow>
</section>
{/* 热门综艺 */}
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<Link
href='/douban?type=show'
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
>
<ChevronRight className='w-4 h-4 ml-1' />
</Link>
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotVarietyShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
</>
)}
</div>
</div>
{announcement && showAnnouncement && (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${
showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
>
<div className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'>
<div className='flex justify-between items-start mb-4'>
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>
</h3>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
aria-label='关闭'
></button>
</div>
<div className='mb-6'>
<div className='relative overflow-hidden rounded-lg mb-4 bg-green-50 dark:bg-green-900/20'>
<div className='absolute inset-y-0 left-0 w-1.5 bg-green-500 dark:bg-green-400'></div>
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
{announcement}
</p>
</div>
</div>
<button
onClick={() => handleCloseAnnouncement(announcement)}
className='w-full rounded-lg bg-gradient-to-r from-green-600 to-green-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-green-700 hover:to-green-800 dark:from-green-600 dark:to-green-700 dark:hover:from-green-700 dark:hover:to-green-800 transition-all duration-300 transform hover:-translate-y-0.5'
>
</button>
</div>
</div>
)}
</PageLayout>
);
}
export default function Home() {
return (
<Suspense>
<HomeClient />
</Suspense>
);
}
+2108
View File
@@ -0,0 +1,2108 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
'use client';
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import {
deleteFavorite,
deletePlayRecord,
deleteSkipConfig,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
saveSkipConfig,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
interface HTMLVideoElement {
hls?: any;
}
}
// Wake Lock API 类型声明
interface WakeLockSentinel {
released: boolean;
release(): Promise<void>;
addEventListener(type: 'release', listener: () => void): void;
removeEventListener(type: 'release', listener: () => void): void;
}
function PlayPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
// -----------------------------------------------------------------------------
// 状态变量(State
// -----------------------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [loadingStage, setLoadingStage] = useState<
'searching' | 'preferring' | 'fetching' | 'ready'
>('searching');
const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...');
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<SearchResult | null>(null);
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 跳过片头片尾配置
const [skipConfig, setSkipConfig] = useState<{
enable: boolean;
intro_time: number;
outro_time: number;
}>({
enable: false,
intro_time: 0,
outro_time: 0,
});
const skipConfigRef = useRef(skipConfig);
useEffect(() => {
skipConfigRef.current = skipConfig;
}, [
skipConfig,
skipConfig.enable,
skipConfig.intro_time,
skipConfig.outro_time,
]);
// 跳过检查的时间间隔控制
const lastSkipCheckRef = useRef(0);
// 去广告开关(从 localStorage 继承,默认 true
const [blockAdEnabled, setBlockAdEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('enable_blockad');
if (v !== null) return v === 'true';
}
return true;
});
const blockAdEnabledRef = useRef(blockAdEnabled);
useEffect(() => {
blockAdEnabledRef.current = blockAdEnabled;
}, [blockAdEnabled]);
// 视频基本信息
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
const [videoCover, setVideoCover] = useState('');
const [videoDoubanId, setVideoDoubanId] = useState(0);
// 当前源和ID
const [currentSource, setCurrentSource] = useState(
searchParams.get('source') || ''
);
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
// 是否需要优选
const [needPrefer, setNeedPrefer] = useState(
searchParams.get('prefer') === 'true'
);
const needPreferRef = useRef(needPrefer);
useEffect(() => {
needPreferRef.current = needPrefer;
}, [needPrefer]);
// 集数相关
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
const currentSourceRef = useRef(currentSource);
const currentIdRef = useRef(currentId);
const videoTitleRef = useRef(videoTitle);
const videoYearRef = useRef(videoYear);
const detailRef = useRef<SearchResult | null>(detail);
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
// 同步最新值到 refs
useEffect(() => {
currentSourceRef.current = currentSource;
currentIdRef.current = currentId;
detailRef.current = detail;
currentEpisodeIndexRef.current = currentEpisodeIndex;
videoTitleRef.current = videoTitle;
videoYearRef.current = videoYear;
}, [
currentSource,
currentId,
detail,
currentEpisodeIndex,
videoTitle,
videoYear,
]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
// 总集数
const totalEpisodes = detail?.episodes?.length || 0;
// 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null);
// 上次使用的音量,默认 0.7
const lastVolumeRef = useRef<number>(0.7);
// 上次使用的播放速率,默认 1.0
const lastPlaybackRateRef = useRef<number>(1.0);
// 换源相关状态
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
const [sourceSearchError, setSourceSearchError] = useState<string | 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重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
>(new Map());
// 折叠状态(仅在 lg 及以上屏幕有效)
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
useState(false);
// 换源加载状态
const [isVideoLoading, setIsVideoLoading] = useState(true);
const [videoLoadingStage, setVideoLoadingStage] = useState<
'initing' | 'sourceChanging'
>('initing');
// 播放进度保存相关
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastSaveTimeRef = useRef<number>(0);
const artPlayerRef = useRef<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
// Wake Lock 相关
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
// -----------------------------------------------------------------------------
// 工具函数(Utils
// -----------------------------------------------------------------------------
// 播放源优选函数
const preferBestSource = async (
sources: SearchResult[]
): Promise<SearchResult> => {
if (sources.length === 1) return sources[0];
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
} | null> = [];
for (let start = 0; start < sources.length; start += batchSize) {
const batchSources = sources.slice(start, start + batchSize);
const batchResults = await Promise.all(
batchSources.map(async (source) => {
try {
// 检查是否有第一集的播放地址
if (!source.episodes || source.episodes.length === 0) {
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
return null;
}
const episodeUrl =
source.episodes.length > 1
? source.episodes[1]
: source.episodes[0];
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
return {
source,
testResult,
};
} catch (error) {
return null;
}
})
);
allResults.push(...batchResults);
}
// 等待所有测速完成,包含成功和失败的结果
// 保存所有测速结果到 precomputedVideoInfo,供 EpisodeSelector 使用(包含错误结果)
const newVideoInfoMap = new Map<
string,
{
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean;
}
>();
allResults.forEach((result, index) => {
const source = sources[index];
const sourceKey = `${source.source}-${source.id}`;
if (result) {
// 成功的结果
newVideoInfoMap.set(sourceKey, result.testResult);
}
});
// 过滤出成功的结果用于优选计算
const successfulResults = allResults.filter(Boolean) as Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
}>;
setPrecomputedVideoInfo(newVideoInfoMap);
if (successfulResults.length === 0) {
console.warn('所有播放源测速都失败,使用第一个播放源');
return sources[0];
}
// 找出所有有效速度的最大值,用于线性映射
const validSpeeds = successfulResults
.map((result) => {
const speedStr = result.testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 0;
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2];
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
// 找出所有有效延迟的最小值和最大值,用于线性映射
const validPings = successfulResults
.map((result) => result.testResult.pingTime)
.filter((ping) => ping > 0);
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50;
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000;
// 计算每个结果的评分
const resultsWithScore = successfulResults.map((result) => ({
...result,
score: calculateSourceScore(
result.testResult,
maxSpeed,
minPing,
maxPing
),
}));
// 按综合评分排序,选择最佳播放源
resultsWithScore.sort((a, b) => b.score - a.score);
console.log('播放源评分排序结果:');
resultsWithScore.forEach((result, index) => {
console.log(
`${index + 1}. ${
result.source.source_name
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
result.testResult.loadSpeed
}, ${result.testResult.pingTime}ms)`
);
});
return resultsWithScore[0].source;
};
// 计算播放源综合评分
const calculateSourceScore = (
testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
},
maxSpeed: number,
minPing: number,
maxPing: number
): number => {
let score = 0;
// 分辨率评分 (40% 权重)
const qualityScore = (() => {
switch (testResult.quality) {
case '4K':
return 100;
case '2K':
return 85;
case '1080p':
return 75;
case '720p':
return 60;
case '480p':
return 40;
case 'SD':
return 20;
default:
return 0;
}
})();
score += qualityScore * 0.4;
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
const speedScore = (() => {
const speedStr = testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 30;
// 解析速度值
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 30;
const value = parseFloat(match[1]);
const unit = match[2];
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
// 基于最大速度线性映射,最高100分
const speedRatio = speedKBps / maxSpeed;
return Math.min(100, Math.max(0, speedRatio * 100));
})();
score += speedScore * 0.4;
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
const pingScore = (() => {
const ping = testResult.pingTime;
if (ping <= 0) return 0; // 无效延迟给默认分
// 如果所有延迟都相同,给满分
if (maxPing === minPing) return 100;
// 线性映射:最低延迟=100分,最高延迟=0分
const pingRatio = (maxPing - ping) / (maxPing - minPing);
return Math.min(100, Math.max(0, pingRatio * 100));
})();
score += pingScore * 0.2;
return Math.round(score * 100) / 100; // 保留两位小数
};
// 更新视频地址
const updateVideoUrl = (
detailData: SearchResult | null,
episodeIndex: number
) => {
if (
!detailData ||
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
setVideoUrl('');
return;
}
const newUrl = detailData?.episodes[episodeIndex] || '';
if (newUrl !== videoUrl) {
setVideoUrl(newUrl);
}
};
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
if (!video || !url) return;
const sources = Array.from(video.getElementsByTagName('source'));
const existed = sources.some((s) => s.src === url);
if (!existed) {
// 移除旧的 source,保持唯一
sources.forEach((s) => s.remove());
const sourceEl = document.createElement('source');
sourceEl.src = url;
video.appendChild(sourceEl);
}
// 始终允许远程播放(AirPlay / Cast
video.disableRemotePlayback = false;
// 如果曾经有禁用属性,移除之
if (video.hasAttribute('disableRemotePlayback')) {
video.removeAttribute('disableRemotePlayback');
}
};
// Wake Lock 相关函数
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as any).wakeLock.request(
'screen'
);
console.log('Wake Lock 已启用');
}
} catch (err) {
console.warn('Wake Lock 请求失败:', err);
}
};
const releaseWakeLock = async () => {
try {
if (wakeLockRef.current) {
await wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake Lock 已释放');
}
} catch (err) {
console.warn('Wake Lock 释放失败:', err);
}
};
// 清理播放器资源的统一函数
const cleanupPlayer = () => {
if (artPlayerRef.current) {
try {
// 销毁 HLS 实例
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
artPlayerRef.current.video.hls.destroy();
}
// 销毁 ArtPlayer 实例
artPlayerRef.current.destroy();
artPlayerRef.current = null;
console.log('播放器资源已清理');
} catch (err) {
console.warn('清理播放器资源时出错:', err);
artPlayerRef.current = null;
}
}
};
// 去广告相关函数
function filterAdsFromM3U8(m3u8Content: string): string {
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 只过滤#EXT-X-DISCONTINUITY标识
if (!line.includes('#EXT-X-DISCONTINUITY')) {
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// 跳过片头片尾配置相关函数
const handleSkipConfigChange = async (newConfig: {
enable: boolean;
intro_time: number;
outro_time: number;
}) => {
if (!currentSourceRef.current || !currentIdRef.current) return;
try {
setSkipConfig(newConfig);
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
artPlayerRef.current.setting.update({
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item: any) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
});
artPlayerRef.current.setting.update({
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
});
artPlayerRef.current.setting.update({
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
});
} else {
await saveSkipConfig(
currentSourceRef.current,
currentIdRef.current,
newConfig
);
}
console.log('跳过片头片尾配置已保存:', newConfig);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
}
};
const formatTime = (seconds: number): string => {
if (seconds === 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.round(seconds % 60);
if (hours === 0) {
// 不到一小时,格式为 00:00
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
} else {
// 超过一小时,格式为 00:00:00
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
constructor(config: any) {
super(config);
const load = this.load.bind(this);
this.load = function (context: any, config: any, callbacks: any) {
// 拦截manifest和level请求
if (
(context as any).type === 'manifest' ||
(context as any).type === 'level'
) {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function (
response: any,
stats: any,
context: any
) {
// 如果是m3u8文件,处理内容以移除广告分段
if (response.data && typeof response.data === 'string') {
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
response.data = filterAdsFromM3U8(response.data);
}
return onSuccess(response, stats, context, null);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
}
// 当集数索引变化时自动更新视频地址
useEffect(() => {
updateVideoUrl(detail, currentEpisodeIndex);
}, [detail, currentEpisodeIndex]);
// 进入页面时直接获取全部源信息
useEffect(() => {
const fetchSourceDetail = async (
source: string,
id: string
): Promise<SearchResult[]> => {
try {
const detailResponse = await fetch(
`/api/detail?source=${source}&id=${id}`
);
if (!detailResponse.ok) {
throw new Error('获取视频详情失败');
}
const detailData = (await detailResponse.json()) as SearchResult;
setAvailableSources([detailData]);
return [detailData];
} catch (err) {
console.error('获取视频详情失败:', err);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
// 处理搜索结果,根据规则过滤
const results = data.results.filter(
(result: SearchResult) =>
result.title.replaceAll(' ', '').toLowerCase() ===
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? (searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
);
setAvailableSources(results);
return results;
} catch (err) {
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
setAvailableSources([]);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const initAll = async () => {
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数');
setLoading(false);
return;
}
setLoading(true);
setLoadingStage(currentSource && currentId ? 'fetching' : 'searching');
setLoadingMessage(
currentSource && currentId
? '🎬 正在获取视频详情...'
: '🔍 正在搜索播放源...'
);
let sourcesInfo = await fetchSourcesData(searchTitle || videoTitle);
if (
currentSource &&
currentId &&
!sourcesInfo.some(
(source) => source.source === currentSource && source.id === currentId
)
) {
sourcesInfo = await fetchSourceDetail(currentSource, currentId);
}
if (sourcesInfo.length === 0) {
setError('未找到匹配结果');
setLoading(false);
return;
}
let detailData: SearchResult = sourcesInfo[0];
// 指定源和id且无需优选
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');
setLoadingMessage('⚡ 正在优选最佳播放源...');
detailData = await preferBestSource(sourcesInfo);
}
console.log(detailData.source, detailData.id);
setNeedPrefer(false);
setCurrentSource(detailData.source);
setCurrentId(detailData.id);
setVideoYear(detailData.year);
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoCover(detailData.poster);
setVideoDoubanId(detailData.douban_id || 0);
setDetail(detailData);
if (currentEpisodeIndex >= detailData.episodes.length) {
setCurrentEpisodeIndex(0);
}
// 规范URL参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', detailData.source);
newUrl.searchParams.set('id', detailData.id);
newUrl.searchParams.set('year', detailData.year);
newUrl.searchParams.set('title', detailData.title);
newUrl.searchParams.delete('prefer');
window.history.replaceState({}, '', newUrl.toString());
setLoadingStage('ready');
setLoadingMessage('✨ 准备就绪,即将开始播放...');
// 短暂延迟让用户看到完成状态
setTimeout(() => {
setLoading(false);
}, 1000);
};
initAll();
}, []);
// 播放记录处理
useEffect(() => {
// 仅在初次挂载时检查播放记录
const initFromHistory = async () => {
if (!currentSource || !currentId) return;
try {
const allRecords = await getAllPlayRecords();
const key = generateStorageKey(currentSource, currentId);
const record = allRecords[key];
if (record) {
const targetIndex = record.index - 1;
const targetTime = record.play_time;
// 更新当前选集索引
if (targetIndex !== currentEpisodeIndex) {
setCurrentEpisodeIndex(targetIndex);
}
// 保存待恢复的播放进度,待播放器就绪后跳转
resumeTimeRef.current = targetTime;
}
} catch (err) {
console.error('读取播放记录失败:', err);
}
};
initFromHistory();
}, []);
// 跳过片头片尾配置处理
useEffect(() => {
// 仅在初次挂载时检查跳过片头片尾配置
const initSkipConfig = async () => {
if (!currentSource || !currentId) return;
try {
const config = await getSkipConfig(currentSource, currentId);
if (config) {
setSkipConfig(config);
}
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
}
};
initSkipConfig();
}, []);
// 处理换源
const handleSourceChange = async (
newSource: string,
newId: string,
newTitle: string
) => {
try {
// 显示换源加载状态
setVideoLoadingStage('sourceChanging');
setIsVideoLoading(true);
// 记录当前播放进度(仅在同一集数切换时恢复)
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
console.log('换源前当前播放时间:', currentPlayTime);
// 清除前一个历史记录
if (currentSourceRef.current && currentIdRef.current) {
try {
await deletePlayRecord(
currentSourceRef.current,
currentIdRef.current
);
console.log('已清除前一个播放记录');
} catch (err) {
console.error('清除播放记录失败:', err);
}
}
// 清除并设置下一个跳过片头片尾配置
if (currentSourceRef.current && currentIdRef.current) {
try {
await deleteSkipConfig(
currentSourceRef.current,
currentIdRef.current
);
await saveSkipConfig(newSource, newId, skipConfigRef.current);
} catch (err) {
console.error('清除跳过片头片尾配置失败:', err);
}
}
const newDetail = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
if (!newDetail) {
setError('未找到匹配结果');
return;
}
// 尝试跳转到当前正在播放的集数
let targetIndex = currentEpisodeIndex;
// 如果当前集数超出新源的范围,则跳转到第一集
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
targetIndex = 0;
}
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
if (targetIndex !== currentEpisodeIndex) {
resumeTimeRef.current = 0;
} else if (
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
currentPlayTime > 1
) {
resumeTimeRef.current = currentPlayTime;
}
// 更新URL参数(不刷新页面)
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', newSource);
newUrl.searchParams.set('id', newId);
newUrl.searchParams.set('year', newDetail.year);
window.history.replaceState({}, '', newUrl.toString());
setVideoTitle(newDetail.title || newTitle);
setVideoYear(newDetail.year);
setVideoCover(newDetail.poster);
setVideoDoubanId(newDetail.douban_id || 0);
setCurrentSource(newSource);
setCurrentId(newId);
setDetail(newDetail);
setCurrentEpisodeIndex(targetIndex);
} catch (err) {
// 隐藏换源加载状态
setIsVideoLoading(false);
setError(err instanceof Error ? err.message : '换源失败');
}
};
useEffect(() => {
document.addEventListener('keydown', handleKeyboardShortcuts);
return () => {
document.removeEventListener('keydown', handleKeyboardShortcuts);
};
}, []);
// ---------------------------------------------------------------------------
// 集数切换
// ---------------------------------------------------------------------------
// 处理集数切换
const handleEpisodeChange = (episodeNumber: number) => {
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
// 在更换集数前保存当前播放进度
if (artPlayerRef.current && artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(episodeNumber);
}
};
const handlePreviousEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx > 0) {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(idx - 1);
}
};
const handleNextEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx < d.episodes.length - 1) {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(idx + 1);
}
};
// ---------------------------------------------------------------------------
// 键盘快捷键
// ---------------------------------------------------------------------------
// 处理全局快捷键
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
// 忽略输入框中的按键事件
if (
(e.target as HTMLElement).tagName === 'INPUT' ||
(e.target as HTMLElement).tagName === 'TEXTAREA'
)
return;
// Alt + 左箭头 = 上一集
if (e.altKey && e.key === 'ArrowLeft') {
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
handlePreviousEpisode();
e.preventDefault();
}
}
// Alt + 右箭头 = 下一集
if (e.altKey && e.key === 'ArrowRight') {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && idx < d.episodes.length - 1) {
handleNextEpisode();
e.preventDefault();
}
}
// 左箭头 = 快退
if (!e.altKey && e.key === 'ArrowLeft') {
if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) {
artPlayerRef.current.currentTime -= 10;
e.preventDefault();
}
}
// 右箭头 = 快进
if (!e.altKey && e.key === 'ArrowRight') {
if (
artPlayerRef.current &&
artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5
) {
artPlayerRef.current.currentTime += 10;
e.preventDefault();
}
}
// 上箭头 = 音量+
if (e.key === 'ArrowUp') {
if (artPlayerRef.current && artPlayerRef.current.volume < 1) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 下箭头 = 音量-
if (e.key === 'ArrowDown') {
if (artPlayerRef.current && artPlayerRef.current.volume > 0) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 空格 = 播放/暂停
if (e.key === ' ') {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
e.preventDefault();
}
}
// f 键 = 切换全屏
if (e.key === 'f' || e.key === 'F') {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
e.preventDefault();
}
}
};
// ---------------------------------------------------------------------------
// 播放记录相关
// ---------------------------------------------------------------------------
// 保存播放进度
const saveCurrentPlayProgress = async () => {
if (
!artPlayerRef.current ||
!currentSourceRef.current ||
!currentIdRef.current ||
!videoTitleRef.current ||
!detailRef.current?.source_name
) {
return;
}
const player = artPlayerRef.current;
const currentTime = player.currentTime || 0;
const duration = player.duration || 0;
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
if (currentTime < 1 || !duration) {
return;
}
try {
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
total_episodes: detailRef.current?.episodes.length || 1,
play_time: Math.floor(currentTime),
total_time: Math.floor(duration),
save_time: Date.now(),
search_title: searchTitle,
});
lastSaveTimeRef.current = Date.now();
console.log('播放进度已保存:', {
title: videoTitleRef.current,
episode: currentEpisodeIndexRef.current + 1,
year: detailRef.current?.year,
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
});
} catch (err) {
console.error('保存播放进度失败:', err);
}
};
useEffect(() => {
// 页面即将卸载时保存播放进度和清理资源
const handleBeforeUnload = () => {
saveCurrentPlayProgress();
releaseWakeLock();
cleanupPlayer();
};
// 页面可见性变化时保存播放进度和释放 Wake Lock
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveCurrentPlayProgress();
releaseWakeLock();
} else if (document.visibilityState === 'visible') {
// 页面重新可见时,如果正在播放则重新请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
}
};
// 添加事件监听器
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
// 清理事件监听器
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [currentEpisodeIndex, detail, artPlayerRef.current]);
// 清理定时器
useEffect(() => {
return () => {
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
};
}, []);
// ---------------------------------------------------------------------------
// 收藏相关
// ---------------------------------------------------------------------------
// 每当 source 或 id 变化时检查收藏状态
useEffect(() => {
if (!currentSource || !currentId) return;
(async () => {
try {
const fav = await isFavorited(currentSource, currentId);
setFavorited(fav);
} catch (err) {
console.error('检查收藏状态失败:', err);
}
})();
}, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏
const handleToggleFavorite = async () => {
if (
!videoTitleRef.current ||
!detailRef.current ||
!currentSourceRef.current ||
!currentIdRef.current
)
return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(currentSourceRef.current, currentIdRef.current);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
});
setFavorited(true);
}
} catch (err) {
console.error('切换收藏失败:', err);
}
};
useEffect(() => {
if (
!Artplayer ||
!Hls ||
!videoUrl ||
loading ||
currentEpisodeIndex === null ||
!artRef.current
) {
return;
}
// 确保选集索引有效
if (
!detail ||
!detail.episodes ||
currentEpisodeIndex >= detail.episodes.length ||
currentEpisodeIndex < 0
) {
setError(`选集索引无效,当前共 ${totalEpisodes}`);
return;
}
if (!videoUrl) {
setError('视频地址无效');
return;
}
console.log(videoUrl);
// 检测是否为WebKit浏览器
const isWebkit =
typeof window !== 'undefined' &&
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
// 非WebKit浏览器且播放器已存在,使用switch方法切换
if (!isWebkit && artPlayerRef.current) {
artPlayerRef.current.switch = videoUrl;
artPlayerRef.current.title = `${videoTitle} - 第${
currentEpisodeIndex + 1
}`;
artPlayerRef.current.poster = videoCover;
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
return;
}
// WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的
if (artPlayerRef.current) {
cleanupPlayer();
}
try {
// 创建新的播放器实例
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
Artplayer.USE_RAF = true;
artPlayerRef.current = new Artplayer({
container: artRef.current,
url: videoUrl,
poster: videoCover,
volume: 0.7,
isLive: false,
muted: false,
autoplay: true,
pip: true,
autoSize: false,
autoMini: false,
screenshot: false,
setting: true,
loop: false,
flip: false,
playbackRate: true,
aspectRatio: false,
fullscreen: true,
fullscreenWeb: true,
subtitleOffset: false,
miniProgressBar: false,
mutex: true,
playsInline: true,
autoPlayback: false,
airplay: true,
theme: '#22c55e',
lang: 'zh-cn',
hotkey: false,
fastForward: true,
autoOrientation: true,
lock: true,
moreVideoAttr: {
crossOrigin: 'anonymous',
},
// HLS 支持配置
customType: {
m3u8: function (video: HTMLVideoElement, url: string) {
if (!Hls) {
console.error('HLS.js 未加载');
return;
}
if (video.hls) {
video.hls.destroy();
}
const hls = new Hls({
debug: false, // 关闭日志
enableWorker: true, // WebWorker 解码,降低主线程压力
lowLatencyMode: true, // 开启低延迟 LL-HLS
/* 缓冲/内存相关 */
maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟
backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用
maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理
/* 自定义loader */
loader: blockAdEnabledRef.current
? CustomHlsJsLoader
: Hls.DefaultConfig.loader,
});
hls.loadSource(url);
hls.attachMedia(video);
video.hls = hls;
ensureVideoSource(video, url);
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
console.error('HLS Error:', event, data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('网络错误,尝试恢复...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('媒体错误,尝试恢复...');
hls.recoverMediaError();
break;
default:
console.log('无法恢复的错误');
hls.destroy();
break;
}
}
});
},
},
icons: {
loading:
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
},
settings: [
{
html: '去广告',
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
tooltip: blockAdEnabled ? '已开启' : '已关闭',
onClick() {
const newVal = !blockAdEnabled;
try {
localStorage.setItem('enable_blockad', String(newVal));
if (artPlayerRef.current) {
resumeTimeRef.current = artPlayerRef.current.currentTime;
if (
artPlayerRef.current.video &&
artPlayerRef.current.video.hls
) {
artPlayerRef.current.video.hls.destroy();
}
artPlayerRef.current.destroy();
artPlayerRef.current = null;
}
setBlockAdEnabled(newVal);
} catch (_) {
// ignore
}
return newVal ? '当前开启' : '当前关闭';
},
},
{
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
},
{
html: '删除跳过配置',
onClick: function () {
handleSkipConfigChange({
enable: false,
intro_time: 0,
outro_time: 0,
});
return '';
},
},
{
name: '设置片头',
html: '设置片头',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
},
{
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
},
],
// 控制栏配置
controls: [
{
position: 'left',
index: 13,
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
tooltip: '播放下一集',
click: function () {
handleNextEpisode();
},
},
],
});
// 监听播放器事件
artPlayerRef.current.on('ready', () => {
setError(null);
// 播放器就绪后,如果正在播放则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
});
// 监听播放状态变化,控制 Wake Lock
artPlayerRef.current.on('play', () => {
requestWakeLock();
});
artPlayerRef.current.on('pause', () => {
releaseWakeLock();
saveCurrentPlayProgress();
});
artPlayerRef.current.on('video:ended', () => {
releaseWakeLock();
});
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
artPlayerRef.current.on('video:volumechange', () => {
lastVolumeRef.current = artPlayerRef.current.volume;
});
artPlayerRef.current.on('video:ratechange', () => {
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
});
// 监听视频可播放事件,这时恢复播放进度更可靠
artPlayerRef.current.on('video:canplay', () => {
// 若存在需要恢复的播放进度,则跳转
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
try {
const duration = artPlayerRef.current.duration || 0;
let target = resumeTimeRef.current;
if (duration && target >= duration - 2) {
target = Math.max(0, duration - 5);
}
artPlayerRef.current.currentTime = target;
console.log('成功恢复播放进度到:', resumeTimeRef.current);
} catch (err) {
console.warn('恢复播放进度失败:', err);
}
}
resumeTimeRef.current = null;
setTimeout(() => {
if (
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
) {
artPlayerRef.current.volume = lastVolumeRef.current;
}
if (
Math.abs(
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
) > 0.01 &&
isWebkit
) {
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
}
artPlayerRef.current.notice.show = '';
}, 0);
// 隐藏换源加载状态
setIsVideoLoading(false);
});
// 监听视频时间更新事件,实现跳过片头片尾
artPlayerRef.current.on('video:timeupdate', () => {
if (!skipConfigRef.current.enable) return;
const currentTime = artPlayerRef.current.currentTime || 0;
const duration = artPlayerRef.current.duration || 0;
const now = Date.now();
// 限制跳过检查频率为1.5秒一次
if (now - lastSkipCheckRef.current < 1500) return;
lastSkipCheckRef.current = now;
// 跳过片头
if (
skipConfigRef.current.intro_time > 0 &&
currentTime < skipConfigRef.current.intro_time
) {
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
skipConfigRef.current.intro_time
)})`;
}
// 跳过片尾
if (
skipConfigRef.current.outro_time < 0 &&
duration > 0 &&
currentTime >
artPlayerRef.current.duration + skipConfigRef.current.outro_time
) {
if (
currentEpisodeIndexRef.current <
(detailRef.current?.episodes?.length || 1) - 1
) {
handleNextEpisode();
} else {
artPlayerRef.current.pause();
}
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
skipConfigRef.current.outro_time
)})`;
}
});
artPlayerRef.current.on('error', (err: any) => {
console.error('播放器错误:', err);
if (artPlayerRef.current.currentTime > 0) {
return;
}
});
// 监听视频播放结束事件,自动播放下一集
artPlayerRef.current.on('video:ended', () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx < d.episodes.length - 1) {
setTimeout(() => {
setCurrentEpisodeIndex(idx + 1);
}, 1000);
}
});
artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now();
let interval = 5000;
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
interval = 20000;
}
if (now - lastSaveTimeRef.current > interval) {
saveCurrentPlayProgress();
lastSaveTimeRef.current = now;
}
});
artPlayerRef.current.on('pause', () => {
saveCurrentPlayProgress();
});
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
} catch (err) {
console.error('创建播放器失败:', err);
setError('播放器初始化失败');
}
}, [Artplayer, Hls, videoUrl, loading, blockAdEnabled]);
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
useEffect(() => {
return () => {
// 清理定时器
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
// 释放 Wake Lock
releaseWakeLock();
// 销毁播放器实例
cleanupPlayer();
};
}, []);
if (loading) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>
{loadingStage === 'searching' && '🔍'}
{loadingStage === 'preferring' && '⚡'}
{loadingStage === 'fetching' && '🎬'}
{loadingStage === 'ready' && '✨'}
</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-green-500 scale-125'
: loadingStage === 'preferring' ||
loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'preferring'
? 'bg-green-500 scale-125'
: loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'ready'
? 'bg-green-500 scale-125'
: 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'searching' ||
loadingStage === 'fetching'
? '33%'
: loadingStage === 'preferring'
? '66%'
: '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
>
🔄
</button>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/play'>
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
{/* 第一行:影片标题 */}
<div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
{videoTitle || '影片标题'}
{totalEpisodes > 1 && (
<span className='text-gray-500 dark:text-gray-400'>
{` > 第 ${currentEpisodeIndex + 1}`}
</span>
)}
</h1>
</div>
{/* 第二行:播放器和选集 */}
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
</button>
</div>
<div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4'
}`}
>
{/* 播放器 */}
<div
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
}`}
>
<div className='relative w-full h-[300px] lg:h-full'>
<div
ref={artRef}
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
></div>
{/* 换源加载蒙层 */}
{isVideoLoading && (
<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='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🎬</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 换源消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
{videoLoadingStage === 'sourceChanging'
? '🔄 切换播放源...'
: '🔄 视频加载中...'}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}
>
<EpisodeSelector
totalEpisodes={totalEpisodes}
episodes_titles={detail?.episodes_titles || []}
value={currentEpisodeIndex + 1}
onChange={handleEpisodeChange}
onSourceChange={handleSourceChange}
currentSource={currentSource}
currentId={currentId}
videoTitle={searchTitle || videoTitle}
availableSources={availableSources}
sourceSearchLoading={sourceSearchLoading}
sourceSearchError={sourceSearchError}
precomputedVideoInfo={precomputedVideoInfo}
/>
</div>
</div>
</div>
{/* 详情展示 */}
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
{/* 文字区 */}
<div className='md:col-span-3'>
<div className='p-6 flex flex-col min-h-0'>
{/* 标题 */}
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
{videoTitle || '影片标题'}
<button
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite();
}}
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
>
<FavoriteIcon filled={favorited} />
</button>
</h1>
{/* 关键信息行 */}
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail?.class && (
<span className='text-green-600 font-semibold'>
{detail.class}
</span>
)}
{(detail?.year || videoYear) && (
<span>{detail?.year || videoYear}</span>
)}
{detail?.source_name && (
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
{detail.source_name}
</span>
)}
{detail?.type_name && <span>{detail.type_name}</span>}
</div>
{/* 剧情简介 */}
{detail?.desc && (
<div
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
style={{ whiteSpace: 'pre-line' }}
>
{detail.desc}
</div>
)}
</div>
</div>
{/* 封面展示 */}
<div className='hidden md:block md:col-span-1 md:order-first'>
<div className='pl-0 py-4 pr-6'>
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<>
<img
src={processImageUrl(videoCover)}
alt={videoTitle}
className='w-full h-full object-cover'
/>
{/* 豆瓣链接按钮 */}
{videoDoubanId !== 0 && (
<a
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
target='_blank'
rel='noopener noreferrer'
className='absolute top-3 left-3'
>
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<svg
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
</svg>
</div>
</a>
)}
</>
) : (
<span className='text-gray-600 dark:text-gray-400'>
</span>
)}
</div>
</div>
</div>
</div>
</div>
</PageLayout>
);
}
// FavoriteIcon 组件
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
if (filled) {
return (
<svg
className='h-7 w-7'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
fill='#ef4444' /* Tailwind red-500 */
stroke='#ef4444'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
}
return (
<Heart className='h-7 w-7 stroke-[1] text-gray-600 dark:text-gray-300' />
);
};
export default function PlayPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<PlayPageClient />
</Suspense>
);
}
+468
View File
@@ -0,0 +1,468 @@
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
'use client';
import { ChevronUp, Search, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react';
import {
addSearchHistory,
clearSearchHistory,
deleteSearchHistory,
getSearchHistory,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { yellowWords } from '@/lib/yellow';
import PageLayout from '@/components/PageLayout';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard from '@/components/VideoCard';
function SearchPageClient() {
// 搜索历史
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// 返回顶部按钮显示状态
const [showBackToTop, setShowBackToTop] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
if (typeof window !== 'undefined') {
const userSetting = localStorage.getItem('defaultAggregateSearch');
if (userSetting !== null) {
return JSON.parse(userSetting);
}
}
return true; // 默认启用聚合
};
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
const map = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
const key = `${item.title.replaceAll(' ', '')}-${
item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
const arr = map.get(key) || [];
arr.push(item);
map.set(key, arr);
});
return Array.from(map.entries()).sort((a, b) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a[1][0].title
.replaceAll(' ', '')
.includes(searchQuery.trim().replaceAll(' ', ''));
const bExactMatch = b[1][0].title
.replaceAll(' ', '')
.includes(searchQuery.trim().replaceAll(' ', ''));
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 年份排序
if (a[1][0].year === b[1][0].year) {
return a[0].localeCompare(b[0]);
} else {
// 处理 unknown 的情况
const aYear = a[1][0].year;
const bYear = b[1][0].year;
if (aYear === 'unknown' && bYear === 'unknown') {
return 0;
} else if (aYear === 'unknown') {
return 1; // a 排在后面
} else if (bYear === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return aYear > bYear ? -1 : 1;
}
}
});
}, [searchResults]);
useEffect(() => {
// 无搜索参数时聚焦搜索框
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
// 初始加载搜索历史
getSearchHistory().then(setSearchHistory);
// 监听搜索历史更新事件
const unsubscribe = subscribeToDataUpdates(
'searchHistoryUpdated',
(newHistory: string[]) => {
setSearchHistory(newHistory);
}
);
// 获取滚动位置的函数 - 专门针对 body 滚动
const getScrollTop = () => {
return document.body.scrollTop || 0;
};
// 使用 requestAnimationFrame 持续检测滚动位置
let isRunning = false;
const checkScrollPosition = () => {
if (!isRunning) return;
const scrollTop = getScrollTop();
const shouldShow = scrollTop > 300;
setShowBackToTop(shouldShow);
requestAnimationFrame(checkScrollPosition);
};
// 启动持续检测
isRunning = true;
checkScrollPosition();
// 监听 body 元素的滚动事件
const handleScroll = () => {
const scrollTop = getScrollTop();
setShowBackToTop(scrollTop > 300);
};
document.body.addEventListener('scroll', handleScroll, { passive: true });
return () => {
unsubscribe();
isRunning = false; // 停止 requestAnimationFrame 循环
// 移除 body 滚动事件监听器
document.body.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
// 当搜索参数变化时更新搜索状态
const query = searchParams.get('q');
if (query) {
setSearchQuery(query);
fetchSearchResults(query);
setShowSuggestions(false);
// 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(query);
} else {
setShowResults(false);
setShowSuggestions(false);
}
}, [searchParams]);
const fetchSearchResults = async (query: string) => {
try {
setIsLoading(true);
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
const data = await response.json();
let results = data.results;
if (
typeof window !== 'undefined' &&
!(window as any).RUNTIME_CONFIG?.DISABLE_YELLOW_FILTER
) {
results = results.filter((result: SearchResult) => {
const typeName = result.type_name || '';
return !yellowWords.some((word: string) => typeName.includes(word));
});
}
setSearchResults(
results.sort((a: SearchResult, b: SearchResult) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a.title === query.trim();
const bExactMatch = b.title === query.trim();
if (aExactMatch && !bExactMatch) return -1;
if (!aExactMatch && bExactMatch) return 1;
// 如果都匹配或都不匹配,则按原来的逻辑排序
if (a.year === b.year) {
return a.title.localeCompare(b.title);
} else {
// 处理 unknown 的情况
if (a.year === 'unknown' && b.year === 'unknown') {
return 0;
} else if (a.year === 'unknown') {
return 1; // a 排在后面
} else if (b.year === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
}
}
})
);
setShowResults(true);
} catch (error) {
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// 输入框内容变化时触发,显示搜索建议
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
if (value.trim()) {
setShowSuggestions(true);
} else {
setShowSuggestions(false);
}
};
// 搜索框聚焦时触发,显示搜索建议
const handleInputFocus = () => {
if (searchQuery.trim()) {
setShowSuggestions(true);
}
};
// 搜索表单提交时触发,处理搜索逻辑
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return;
// 回显搜索框
setSearchQuery(trimmed);
setIsLoading(true);
setShowResults(true);
setShowSuggestions(false);
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
// 直接发请求
fetchSearchResults(trimmed);
// 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(trimmed);
};
const handleSuggestionSelect = (suggestion: string) => {
setSearchQuery(suggestion);
setShowSuggestions(false);
// 自动执行搜索
setIsLoading(true);
setShowResults(true);
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
fetchSearchResults(suggestion);
addSearchHistory(suggestion);
};
// 返回顶部功能
const scrollToTop = () => {
try {
// 根据调试结果,真正的滚动容器是 document.body
document.body.scrollTo({
top: 0,
behavior: 'smooth',
});
} catch (error) {
// 如果平滑滚动完全失败,使用立即滚动
document.body.scrollTop = 0;
}
};
return (
<PageLayout activePath='/search'>
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
{/* 搜索框 */}
<div className='mb-8'>
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
<div className='relative'>
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
<input
id='searchInput'
type='text'
value={searchQuery}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder='搜索电影、电视剧...'
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
/>
{/* 搜索建议 */}
<SearchSuggestions
query={searchQuery}
isVisible={showSuggestions}
onSelect={handleSuggestionSelect}
onClose={() => setShowSuggestions(false)}
/>
</div>
</form>
</div>
{/* 搜索结果或搜索历史 */}
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
{isLoading ? (
<div className='flex justify-center items-center h-40'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
) : showResults ? (
<section className='mb-12'>
{/* 标题 + 聚合开关 */}
<div className='mb-8 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{/* 聚合开关 */}
<label className='flex items-center gap-2 cursor-pointer select-none'>
<span className='text-sm text-gray-700 dark:text-gray-300'>
</span>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={viewMode === 'agg'}
onChange={() =>
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
}
/>
<div className='w-9 h-5 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-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
</div>
</label>
</div>
<div
key={`search-results-${viewMode}`}
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'
>
{viewMode === 'agg'
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
from='search'
items={group}
query={
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
/>
</div>
);
})
: searchResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
))}
{searchResults.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
)}
</div>
</section>
) : searchHistory.length > 0 ? (
// 搜索历史
<section className='mb-12'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
{searchHistory.length > 0 && (
<button
onClick={() => {
clearSearchHistory(); // 事件监听会自动更新界面
}}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
>
</button>
)}
</h2>
<div className='flex flex-wrap gap-2'>
{searchHistory.map((item) => (
<div key={item} className='relative group'>
<button
onClick={() => {
setSearchQuery(item);
router.push(
`/search?q=${encodeURIComponent(item.trim())}`
);
}}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
>
{item}
</button>
{/* 删除按钮 */}
<button
aria-label='删除搜索历史'
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
deleteSearchHistory(item); // 事件监听会自动更新界面
}}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
>
<X className='w-3 h-3' />
</button>
</div>
))}
</div>
</section>
) : null}
</div>
</div>
{/* 返回顶部悬浮按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${
showBackToTop
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-4 pointer-events-none'
}`}
aria-label='返回顶部'
>
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
</button>
</PageLayout>
);
}
export default function SearchPage() {
return (
<Suspense>
<SearchPageClient />
</Suspense>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '安全警告 - MoonTV',
description: '站点安全配置警告',
};
export default function WarningPage() {
return (
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
{/* 警告图标 */}
<div className='flex justify-center mb-4 sm:mb-6'>
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
<svg
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
/>
</svg>
</div>
</div>
{/* 标题 */}
<div className='text-center mb-6 sm:mb-8'>
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
</h1>
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
</div>
{/* 警告内容 */}
<div className='space-y-4 sm:space-y-6 text-gray-700'>
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
</p>
<p className='text-sm sm:text-base text-red-700'>
访
</p>
</div>
<div className='space-y-3 sm:space-y-4'>
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
</h2>
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span>访</span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
<li className='flex items-start'>
<span className='text-red-500 mr-2 mt-0.5'></span>
<span></span>
</li>
</ul>
</div>
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
🔒
</h3>
<p className='text-sm sm:text-base text-yellow-700'>
{' '}
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
PASSWORD
</code>{' '}
访
</p>
</div>
</div>
{/* 底部装饰 */}
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
<div className='text-center text-xs sm:text-sm text-gray-500'>
<p></p>
</div>
</div>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { ArrowLeft } from 'lucide-react';
export function BackButton() {
return (
<button
onClick={() => window.history.back()}
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='Back'
>
<ArrowLeft className='w-full h-full' />
</button>
);
}
+103
View File
@@ -0,0 +1,103 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useEffect, useRef, useState } from 'react';
interface CapsuleSwitchProps {
options: { label: string; value: string }[];
active: string;
onChange: (value: string) => void;
className?: string;
}
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
options,
active,
onChange,
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 (
<div
ref={containerRef}
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
className || ''
}`}
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-1 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((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>
);
};
export default CapsuleSwitch;
+154
View File
@@ -0,0 +1,154 @@
/* eslint-disable no-console */
'use client';
import { useEffect, useState } from 'react';
import type { PlayRecord } from '@/lib/db.client';
import {
clearAllPlayRecords,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard';
interface ContinueWatchingProps {
className?: string;
}
export default function ContinueWatching({ className }: ContinueWatchingProps) {
const [playRecords, setPlayRecords] = useState<
(PlayRecord & { key: string })[]
>([]);
const [loading, setLoading] = useState(true);
// 处理播放记录数据更新的函数
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
// 将记录转换为数组并根据 save_time 由近到远排序
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
...record,
key,
}));
// 按 save_time 降序排序(最新的在前面)
const sortedRecords = recordsArray.sort(
(a, b) => b.save_time - a.save_time
);
setPlayRecords(sortedRecords);
};
useEffect(() => {
const fetchPlayRecords = async () => {
try {
setLoading(true);
// 从缓存或API获取所有播放记录
const allRecords = await getAllPlayRecords();
updatePlayRecords(allRecords);
} catch (error) {
console.error('获取播放记录失败:', error);
setPlayRecords([]);
} finally {
setLoading(false);
}
};
fetchPlayRecords();
// 监听播放记录更新事件
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
updatePlayRecords(newRecords);
}
);
return unsubscribe;
}, []);
// 如果没有播放记录,则不渲染组件
if (!loading && playRecords.length === 0) {
return null;
}
// 计算播放进度百分比
const getProgress = (record: PlayRecord) => {
if (record.total_time === 0) return 0;
return (record.play_time / record.total_time) * 100;
};
// 从 key 中解析 source 和 id
const parseKey = (key: string) => {
const [source, id] = key.split('+');
return { source, id };
};
return (
<section className={`mb-8 ${className || ''}`}>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{!loading && playRecords.length > 0 && (
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={async () => {
await clearAllPlayRecords();
setPlayRecords([]);
}}
>
</button>
)}
</div>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
playRecords.map((record) => {
const { source, id } = parseKey(record.key);
return (
<div
key={record.key}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
id={id}
title={record.title}
poster={record.cover}
year={record.year}
source={source}
source_name={record.source_name}
progress={getProgress(record)}
episodes={record.total_episodes}
currentEpisode={record.index}
query={record.search_title}
from='playrecord'
onDelete={() =>
setPlayRecords((prev) =>
prev.filter((r) => r.key !== record.key)
)
}
type={record.total_episodes > 1 ? 'tv' : ''}
/>
</div>
);
})}
</ScrollableRow>
</section>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
const DoubanCardSkeleton = () => {
return (
<div className='w-full'>
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
{/* 图片占位符 - 骨架屏效果 */}
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
{/* 信息层骨架 */}
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
<div className='flex flex-col items-center justify-center'>
<div className='h-4 w-24 sm:w-32 bg-gray-200 rounded animate-pulse mb-2'></div>
</div>
</div>
</div>
</div>
);
};
export default DoubanCardSkeleton;
+318
View File
@@ -0,0 +1,318 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface CustomCategory {
name: string;
type: 'movie' | 'tv';
query: string;
}
interface DoubanCustomSelectorProps {
customCategories: CustomCategory[];
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
}
const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
customCategories,
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 });
// 二级选择器滚动容器的ref
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
const primaryOptions = React.useMemo(() => {
const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
// 确保电影类型排在前面
const sortedTypes = types.sort((a, b) => {
if (a === 'movie' && b !== 'movie') return -1;
if (a !== 'movie' && b === 'movie') return 1;
return 0;
});
return sortedTypes.map((type) => ({
label: type === 'movie' ? '电影' : '剧集',
value: type,
}));
}, [customCategories]);
// 根据选中的一级选项生成二级选择器选项
const secondaryOptions = React.useMemo(() => {
if (!primarySelection) return [];
return customCategories
.filter((cat) => cat.type === primarySelection)
.map((cat) => ({
label: cat.name || cat.query,
value: cat.query,
}));
}, [customCategories, primarySelection]);
// 处理二级选择器的鼠标滚轮事件(原生 DOM 事件)
const handleSecondaryWheel = React.useCallback((e: WheelEvent) => {
e.preventDefault();
e.stopPropagation();
const container = secondaryScrollContainerRef.current;
if (container) {
const scrollAmount = e.deltaY * 2;
container.scrollLeft += scrollAmount;
}
}, []);
// 添加二级选择器的鼠标滚轮事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer) {
// 同时监听滚动容器和胶囊容器的滚轮事件
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel]);
// 当二级选项变化时重新添加事件监听器
useEffect(() => {
const scrollContainer = secondaryScrollContainerRef.current;
const capsuleContainer = secondaryContainerRef.current;
if (scrollContainer && capsuleContainer && secondaryOptions.length > 0) {
// 重新添加事件监听器
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
passive: false,
});
return () => {
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
};
}
}, [handleSecondaryWheel, secondaryOptions]);
// 更新指示器位置的通用函数
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 (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === (primarySelection || primaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
}
// 副选择器初始位置
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === (secondarySelection || secondaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
}
}, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算
// 监听主选择器变化
useEffect(() => {
if (primaryOptions.length > 0) {
const activeIndex = primaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
}
}, [primarySelection, primaryOptions]);
// 监听副选择器变化
useEffect(() => {
if (secondaryOptions.length > 0) {
const activeIndex = secondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
secondaryContainerRef,
secondaryButtonRefs,
setSecondaryIndicatorStyle
);
return cleanup;
}
}, [secondarySelection, secondaryOptions]);
// 渲染胶囊式选择器
const renderCapsuleSelector = (
options: { label: string; value: string }[],
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 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
// 如果没有自定义分类,则不渲染任何内容
if (!customCategories || customCategories.length === 0) {
return null;
}
return (
<div className='space-y-4 sm:space-y-6'>
{/* 两级选择器包装 */}
<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(
primaryOptions,
primarySelection || primaryOptions[0]?.value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
{secondaryOptions.length > 0 && (
<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 ref={secondaryScrollContainerRef} className='overflow-x-auto'>
{renderCapsuleSelector(
secondaryOptions,
secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
</div>
);
};
export default DoubanCustomSelector;
+567
View File
@@ -0,0 +1,567 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useRef, useState } from 'react';
import MultiLevelSelector from './MultiLevelSelector';
import WeekdaySelector from './WeekdaySelector';
interface SelectorOption {
label: string;
value: string;
}
interface DoubanSelectorProps {
type: 'movie' | 'tv' | 'show' | 'anime';
primarySelection?: string;
secondarySelection?: string;
onPrimaryChange: (value: string) => void;
onSecondaryChange: (value: string) => void;
onMultiLevelChange?: (values: Record<string, string>) => void;
onWeekdayChange: (weekday: string) => void;
}
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
type,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
onMultiLevelChange,
onWeekdayChange,
}) => {
// 为不同的选择器创建独立的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: '豆瓣高分' },
{ label: '冷门佳片', value: '冷门佳片' },
];
// 电影的二级选择器选项
const movieSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '华语', value: '华语' },
{ label: '欧美', value: '欧美' },
{ label: '韩国', value: '韩国' },
{ label: '日本', value: '日本' },
];
// 电视剧一级选择器选项
const tvPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' },
];
// 电视剧二级选择器选项
const tvSecondaryOptions: 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 showPrimaryOptions: SelectorOption[] = [
{ label: '全部', value: '全部' },
{ label: '最近热门', value: '最近热门' },
];
// 综艺二级选择器选项
const showSecondaryOptions: SelectorOption[] = [
{ label: '全部', value: 'show' },
{ label: '国内', value: 'show_domestic' },
{ label: '国外', value: 'show_foreign' },
];
// 动漫一级选择器选项
const animePrimaryOptions: SelectorOption[] = [
{ label: '每日放送', value: '每日放送' },
{ label: '番剧', value: '番剧' },
{ label: '剧场版', value: '剧场版' },
];
// 处理多级选择器变化
const handleMultiLevelChange = (values: Record<string, string>) => {
onMultiLevelChange?.(values);
};
// 更新指示器位置的通用函数
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
);
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || tvPrimaryOptions[1].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) =>
opt.value === (primarySelection || animePrimaryOptions[0].value)
);
updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.findIndex(
(opt) => opt.value === (primarySelection || showPrimaryOptions[1].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 = tvSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || tvSecondaryOptions[0].value)
);
} else if (type === 'show') {
secondaryActiveIndex = showSecondaryOptions.findIndex(
(opt) =>
opt.value === (secondarySelection || showSecondaryOptions[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;
} else if (type === 'tv') {
const activeIndex = tvPrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'anime') {
const activeIndex = animePrimaryOptions.findIndex(
(opt) => opt.value === primarySelection
);
const cleanup = updateIndicatorPosition(
activeIndex,
primaryContainerRef,
primaryButtonRefs,
setPrimaryIndicatorStyle
);
return cleanup;
} else if (type === 'show') {
const activeIndex = showPrimaryOptions.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 = tvSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = tvSecondaryOptions;
} else if (type === 'show') {
activeIndex = showSecondaryOptions.findIndex(
(opt) => opt.value === secondarySelection
);
options = showSecondaryOptions;
}
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 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{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>
{/* 二级选择器 - 只在非"全部"时显示 */}
{primarySelection !== '全部' ? (
<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 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'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
)}
</div>
)}
{/* 电视剧类型 - 显示两级选择器 */}
{type === 'tv' && (
<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(
tvPrimaryOptions,
primarySelection || tvPrimaryOptions[1].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
<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(
tvSecondaryOptions,
secondarySelection || tvSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */
<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'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
) : null}
</div>
)}
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
{type === 'anime' && (
<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(
animePrimaryOptions,
primarySelection || animePrimaryOptions[0].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
// 每日放送分类下显示星期选择器
<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'>
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
</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'>
{(primarySelection || animePrimaryOptions[0].value) ===
'番剧' ? (
<MultiLevelSelector
key={`anime-tv-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType='anime-tv'
/>
) : (
<MultiLevelSelector
key={`anime-movie-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType='anime-movie'
/>
)}
</div>
</div>
)}
</div>
)}
{/* 综艺类型 - 显示两级选择器 */}
{type === 'show' && (
<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(
showPrimaryOptions,
primarySelection || showPrimaryOptions[1].value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
<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(
showSecondaryOptions,
secondarySelection || showSecondaryOptions[0].value,
onSecondaryChange,
false
)}
</div>
</div>
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
/* 多级选择器 - 只在选中"全部"时显示 */
<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'>
<MultiLevelSelector
key={`${type}-${primarySelection}`}
onChange={handleMultiLevelChange}
contentType={type}
/>
</div>
</div>
) : null}
</div>
)}
</div>
);
};
export default DoubanSelector;
+640
View File
@@ -0,0 +1,640 @@
/* eslint-disable @next/next/no-img-element */
import { useRouter } from 'next/navigation';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型
interface VideoInfo {
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean; // 添加错误状态标识
}
interface EpisodeSelectorProps {
/** 总集数 */
totalEpisodes: number;
/** 剧集标题 */
episodes_titles: string[];
/** 每页显示多少集,默认 50 */
episodesPerPage?: number;
/** 当前选中的集数(1 开始) */
value?: number;
/** 用户点击选集后的回调 */
onChange?: (episodeNumber: number) => void;
/** 换源相关 */
onSourceChange?: (source: string, id: string, title: string) => void;
currentSource?: string;
currentId?: string;
videoTitle?: string;
videoYear?: string;
availableSources?: SearchResult[];
sourceSearchLoading?: boolean;
sourceSearchError?: string | null;
/** 预计算的测速结果,避免重复测速 */
precomputedVideoInfo?: Map<string, VideoInfo>;
}
/**
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
*/
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
totalEpisodes,
episodes_titles,
episodesPerPage = 50,
value = 1,
onChange,
onSourceChange,
currentSource,
currentId,
videoTitle,
availableSources = [],
sourceSearchLoading = false,
sourceSearchError = null,
precomputedVideoInfo,
}) => {
const router = useRouter();
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
// 存储每个源的视频信息
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
new Map()
);
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
new Set()
);
// 使用 ref 来避免闭包问题
const attemptedSourcesRef = useRef<Set<string>>(new Set());
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(new Map());
// 同步状态到 ref
useEffect(() => {
attemptedSourcesRef.current = attemptedSources;
}, [attemptedSources]);
useEffect(() => {
videoInfoMapRef.current = videoInfoMap;
}, [videoInfoMap]);
// 主要的 tab 状态:'episodes' 或 'sources'
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
totalEpisodes > 1 ? 'episodes' : 'sources'
);
// 当前分页索引(0 开始)
const initialPage = Math.floor((value - 1) / episodesPerPage);
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// 是否倒序显示
const [descending, setDescending] = useState<boolean>(false);
// 根据 descending 状态计算实际显示的分页索引
const displayPage = useMemo(() => {
if (descending) {
return pageCount - 1 - currentPage;
}
return currentPage;
}, [currentPage, descending, pageCount]);
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
const getVideoInfo = useCallback(async (source: SearchResult) => {
const sourceKey = `${source.source}-${source.id}`;
// 使用 ref 获取最新的状态,避免闭包问题
if (attemptedSourcesRef.current.has(sourceKey)) {
return;
}
// 获取第一集的URL
if (!source.episodes || source.episodes.length === 0) {
return;
}
const episodeUrl =
source.episodes.length > 1 ? source.episodes[1] : source.episodes[0];
// 标记为已尝试
setAttemptedSources((prev) => new Set(prev).add(sourceKey));
try {
const info = await getVideoResolutionFromM3u8(episodeUrl);
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
} catch (error) {
// 失败时保存错误状态
setVideoInfoMap((prev) =>
new Map(prev).set(sourceKey, {
quality: '错误',
loadSpeed: '未知',
pingTime: 0,
hasError: true,
})
);
}
}, []);
// 当有预计算结果时,先合并到videoInfoMap中
useEffect(() => {
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
// 原子性地更新两个状态,避免时序问题
setVideoInfoMap((prev) => {
const newMap = new Map(prev);
precomputedVideoInfo.forEach((value, key) => {
newMap.set(key, value);
});
return newMap;
});
setAttemptedSources((prev) => {
const newSet = new Set(prev);
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
newSet.add(key);
}
});
return newSet;
});
// 同步更新 ref,确保 getVideoInfo 能立即看到更新
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
attemptedSourcesRef.current.add(key);
}
});
}
}, [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 依赖避免循环触发
useEffect(() => {
const fetchVideoInfosInBatches = async () => {
if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0
)
return;
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
return !attemptedSourcesRef.current.has(sourceKey);
});
if (pendingSources.length === 0) return;
const batchSize = Math.ceil(pendingSources.length / 2);
for (let start = 0; start < pendingSources.length; start += batchSize) {
const batch = pendingSources.slice(start, start + batchSize);
await Promise.all(batch.map(getVideoInfo));
}
};
fetchVideoInfosInBatches();
// 依赖项保持与之前一致
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
// 升序分页标签
const categoriesAsc = useMemo(() => {
return Array.from({ length: pageCount }, (_, i) => {
const start = i * episodesPerPage + 1;
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
return { start, end };
});
}, [pageCount, episodesPerPage, totalEpisodes]);
// 根据 descending 状态决定分页标签的排序和内容
const categories = useMemo(() => {
if (descending) {
// 倒序时,label 也倒序显示
return [...categoriesAsc]
.reverse()
.map(({ start, end }) => `${end}-${start}`);
}
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
}, [categoriesAsc, descending]);
const categoryContainerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
// 当分页切换时,将激活的分页标签滚动到视口中间
useEffect(() => {
const btn = buttonRefs.current[displayPage];
const container = categoryContainerRef.current;
if (btn && container) {
// 手动计算滚动位置,只滚动分页标签容器
const containerRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const scrollLeft = container.scrollLeft;
// 计算按钮相对于容器的位置
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
const btnWidth = btnRect.width;
const containerWidth = containerRect.width;
// 计算目标滚动位置,使按钮居中
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
// 平滑滚动到目标位置
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
}
}, [displayPage, pageCount]);
// 处理换源tab点击,只在点击时才搜索
const handleSourceTabClick = () => {
setActiveTab('sources');
};
const handleCategoryClick = useCallback(
(index: number) => {
if (descending) {
// 在倒序时,需要将显示索引转换为实际索引
setCurrentPage(pageCount - 1 - index);
} else {
setCurrentPage(index);
}
},
[descending, pageCount]
);
const handleEpisodeClick = useCallback(
(episodeNumber: number) => {
onChange?.(episodeNumber);
},
[onChange]
);
const handleSourceClick = useCallback(
(source: SearchResult) => {
onSourceChange?.(source.source, source.id, source.title);
},
[onSourceChange]
);
const currentStart = currentPage * episodesPerPage + 1;
const currentEnd = Math.min(
currentStart + episodesPerPage - 1,
totalEpisodes
);
return (
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
{/* 主要的 Tab 切换 - 无缝融入设计 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
{totalEpisodes > 1 && (
<div
onClick={() => setActiveTab('episodes')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'episodes'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
)}
<div
onClick={handleSourceTabClick}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${
activeTab === 'sources'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
</div>
{/* 选集 Tab 内容 */}
{activeTab === 'episodes' && (
<>
{/* 分类标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
<div className='flex gap-2 min-w-max'>
{categories.map((label, idx) => {
const isActive = idx === displayPage;
return (
<button
key={label}
ref={(el) => {
buttonRefs.current[idx] = el;
}}
onClick={() => handleCategoryClick(idx)}
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
${
isActive
? 'text-green-500 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
}
`.trim()}
>
{label}
{isActive && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
)}
</button>
);
})}
</div>
</div>
{/* 向上/向下按钮 */}
<button
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
onClick={() => {
// 切换集数排序(正序/倒序)
setDescending((prev) => !prev);
}}
>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
/>
</svg>
</button>
</div>
{/* 集数网格 */}
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
{(() => {
const len = currentEnd - currentStart + 1;
const episodes = Array.from({ length: len }, (_, i) =>
descending ? currentEnd - i : currentStart + i
);
return episodes;
})().map((episodeNumber) => {
const isActive = episodeNumber === value;
return (
<button
key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono
${
isActive
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20'
}`.trim()}
>
{(() => {
const title = episodes_titles?.[episodeNumber - 1];
if (!title) {
return episodeNumber;
}
// 如果匹配"第X集"格式,提取中间的数字
const match = title.match(/第(\d+)集/);
if (match) {
return match[1];
}
return title;
})()}
</button>
);
})}
</div>
</>
)}
{/* 换源 Tab 内容 */}
{activeTab === 'sources' && (
<div className='flex flex-col h-full mt-4'>
{sourceSearchLoading && (
<div className='flex items-center justify-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
...
</span>
</div>
)}
{sourceSearchError && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-red-500 text-2xl mb-2'></div>
<p className='text-sm text-red-600 dark:text-red-400'>
{sourceSearchError}
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length === 0 && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-gray-400 text-2xl mb-2'>📺</div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length > 0 && (
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
{availableSources
.sort((a, b) => {
const aIsCurrent =
a.source?.toString() === currentSource?.toString() &&
a.id?.toString() === currentId?.toString();
const bIsCurrent =
b.source?.toString() === currentSource?.toString() &&
b.id?.toString() === currentId?.toString();
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return 0;
})
.map((source, index) => {
const isCurrentSource =
source.source?.toString() === currentSource?.toString() &&
source.id?.toString() === currentId?.toString();
return (
<div
key={`${source.source}-${source.id}`}
onClick={() =>
!isCurrentSource && handleSourceClick(source)
}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
${
isCurrentSource
? '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] cursor-pointer'
}`.trim()}
>
{/* 封面 */}
<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 && (
<img
src={processImageUrl(source.poster)}
alt={source.title}
className='w-full h-full object-cover'
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
</div>
{/* 信息区域 */}
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
{/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-3 h-6'>
<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'>
{source.title}
</h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */}
{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-[500] pointer-events-none'>
{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>
)}
</div>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) {
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 min-w-[50px] text-center'>
</div>
);
} else {
// 根据分辨率设置不同颜色:2K、4K为紫色,1080p、720p为绿色,其他为黄色
const isUltraHigh = ['4K', '2K'].includes(
videoInfo.quality
);
const isHigh = ['1080p', '720p'].includes(
videoInfo.quality
);
const textColorClasses = isUltraHigh
? 'text-purple-600 dark:text-purple-400'
: isHigh
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400';
return (
<div
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}
</div>
);
}
}
return null;
})()}
</div>
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
{source.source_name}
</span>
{source.episodes.length > 1 && (
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
{source.episodes.length}
</span>
)}
</div>
{/* 网络信息 - 底部 */}
<div className='flex items-end h-6'>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo) {
if (!videoInfo.hasError) {
return (
<div className='flex items-end gap-3 text-xs'>
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
{videoInfo.loadSpeed}
</div>
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
{videoInfo.pingTime}ms
</div>
</div>
);
} else {
return (
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
</div>
); // 占位div
}
}
})()}
</div>
</div>
</div>
);
})}
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
<button
onClick={() => {
if (videoTitle) {
router.push(
`/search?q=${encodeURIComponent(videoTitle)}`
);
}
}}
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
>
</button>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default EpisodeSelector;
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useEffect, useState } from 'react';
interface ErrorInfo {
id: string;
message: string;
timestamp: number;
}
export function GlobalErrorIndicator() {
const [currentError, setCurrentError] = useState<ErrorInfo | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isReplacing, setIsReplacing] = useState(false);
useEffect(() => {
// 监听自定义错误事件
const handleError = (event: CustomEvent) => {
const { message } = event.detail;
const newError: ErrorInfo = {
id: Date.now().toString(),
message,
timestamp: Date.now(),
};
// 如果已有错误,开始替换动画
if (currentError) {
setCurrentError(newError);
setIsReplacing(true);
// 动画完成后恢复正常
setTimeout(() => {
setIsReplacing(false);
}, 200);
} else {
// 第一次显示错误
setCurrentError(newError);
}
setIsVisible(true);
};
// 监听错误事件
window.addEventListener('globalError', handleError as EventListener);
return () => {
window.removeEventListener('globalError', handleError as EventListener);
};
}, [currentError]);
const handleClose = () => {
setIsVisible(false);
setCurrentError(null);
setIsReplacing(false);
};
if (!isVisible || !currentError) {
return null;
}
return (
<div className='fixed top-4 right-4 z-[2000]'>
{/* 错误卡片 */}
<div
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
} animate-fade-in`}
>
<span className='text-sm font-medium flex-1 mr-3'>
{currentError.message}
</span>
<button
onClick={handleClose}
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
aria-label='关闭错误提示'
>
<svg
className='w-5 h-5'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
</div>
);
}
// 全局错误触发函数
export function triggerGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
+40
View File
@@ -0,0 +1,40 @@
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
<div
className={`w-full ${aspectRatio} rounded-lg`}
style={{
background:
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
backgroundSize: '200% 100%',
animation: 'shine 1.5s infinite',
}}
>
<style>{`
@keyframes shine {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* 亮色模式变量 */
:root {
--skeleton-color: #f0f0f0;
--skeleton-highlight: #e0e0e0;
}
/* 暗色模式变量 */
@media (prefers-color-scheme: dark) {
:root {
--skeleton-color: #2d2d2d;
--skeleton-highlight: #3d3d3d;
}
}
.dark {
--skeleton-color: #2d2d2d;
--skeleton-highlight: #3d3d3d;
}
`}</style>
</div>
);
export { ImagePlaceholder };
+124
View File
@@ -0,0 +1,124 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Cat, Clover, Film, Home, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
interface MobileBottomNavProps {
/**
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
*/
activePath?: string;
}
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
const pathname = usePathname();
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
const currentActive = activePath ?? pathname;
const [navItems, setNavItems] = useState([
{ icon: Home, label: '首页', href: '/' },
{ icon: Search, label: '搜索', href: '/search' },
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
]);
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setNavItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
}, []);
const isActive = (href: string) => {
const typeMatch = href.match(/type=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(currentActive);
const decodedItemHref = decodeURIComponent(href);
return (
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`))
);
};
return (
<nav
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={{
/* 紧贴视口底部,同时在内部留出安全区高度 */
bottom: 0,
paddingBottom: 'env(safe-area-inset-bottom)',
minHeight: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
<ul className='flex items-center overflow-x-auto scrollbar-hide'>
{navItems.map((item) => {
const active = isActive(item.href);
return (
<li
key={item.href}
className='flex-shrink-0'
style={{ width: '20vw', minWidth: '20vw' }}
>
<Link
href={item.href}
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'
>
<item.icon
className={`h-6 w-6 ${
active
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
/>
<span
className={
active
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-gray-300'
}
>
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
};
export default MobileBottomNav;
+44
View File
@@ -0,0 +1,44 @@
'use client';
import Link from 'next/link';
import { BackButton } from './BackButton';
import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
interface MobileHeaderProps {
showBackButton?: boolean;
}
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite();
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'>
<div className='h-12 flex items-center justify-between px-4'>
{/* 左侧:返回按钮和设置按钮 */}
<div className='flex items-center gap-2'>
{showBackButton && <BackButton />}
</div>
{/* 右侧按钮 */}
<div className='flex items-center gap-2'>
<ThemeToggle />
<UserMenu />
</div>
</div>
{/* 中间:Logo(绝对居中) */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link
href='/'
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
>
{siteName}
</Link>
</div>
</header>
);
};
export default MobileHeader;
+592
View File
@@ -0,0 +1,592 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface MultiLevelOption {
label: string;
value: string;
}
interface MultiLevelCategory {
key: string;
label: string;
options: MultiLevelOption[];
multiSelect?: boolean;
}
interface MultiLevelSelectorProps {
onChange: (values: Record<string, string>) => void;
contentType?: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie';
}
const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
onChange,
contentType = 'movie',
}) => {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{
x: number;
y: number;
width: number;
}>({ x: 0, y: 0, width: 0 });
const [values, setValues] = useState<Record<string, string>>({});
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
const dropdownRef = useRef<HTMLDivElement>(null);
// 根据内容类型获取对应的类型选项
const getTypeOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
return [
...baseOptions,
{ label: '喜剧', value: 'comedy' },
{ label: '爱情', value: 'romance' },
{ label: '动作', value: 'action' },
{ label: '科幻', value: 'sci-fi' },
{ label: '悬疑', value: 'suspense' },
{ label: '犯罪', value: 'crime' },
{ label: '惊悚', value: 'thriller' },
{ label: '冒险', value: 'adventure' },
{ label: '音乐', value: 'music' },
{ label: '历史', value: 'history' },
{ label: '奇幻', value: 'fantasy' },
{ label: '恐怖', value: 'horror' },
{ label: '战争', value: 'war' },
{ label: '传记', value: 'biography' },
{ label: '歌舞', value: 'musical' },
{ label: '武侠', value: 'wuxia' },
{ label: '情色', value: 'erotic' },
{ label: '灾难', value: 'disaster' },
{ label: '西部', value: 'western' },
{ label: '纪录片', value: 'documentary' },
{ label: '短片', value: 'short' },
];
case 'tv':
return [
...baseOptions,
{ label: '喜剧', value: 'comedy' },
{ label: '爱情', value: 'romance' },
{ label: '悬疑', value: 'suspense' },
{ label: '武侠', value: 'wuxia' },
{ label: '古装', value: 'costume' },
{ label: '家庭', value: 'family' },
{ label: '犯罪', value: 'crime' },
{ label: '科幻', value: 'sci-fi' },
{ label: '恐怖', value: 'horror' },
{ label: '历史', value: 'history' },
{ label: '战争', value: 'war' },
{ label: '动作', value: 'action' },
{ label: '冒险', value: 'adventure' },
{ label: '传记', value: 'biography' },
{ label: '剧情', value: 'drama' },
{ label: '奇幻', value: 'fantasy' },
{ label: '惊悚', value: 'thriller' },
{ label: '灾难', value: 'disaster' },
{ label: '歌舞', value: 'musical' },
{ label: '音乐', value: 'music' },
];
case 'show':
return [
...baseOptions,
{ label: '真人秀', value: 'reality' },
{ label: '脱口秀', value: 'talkshow' },
{ label: '音乐', value: 'music' },
{ label: '歌舞', value: 'musical' },
];
case 'anime-tv':
case 'anime-movie':
default:
return baseOptions;
}
};
// 根据内容类型获取对应的地区选项
const getRegionOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
case 'anime-movie':
return [
...baseOptions,
{ label: '华语', value: 'chinese' },
{ label: '欧美', value: 'western' },
{ label: '韩国', value: 'korean' },
{ label: '日本', value: 'japanese' },
{ label: '中国大陆', value: 'mainland_china' },
{ label: '美国', value: 'usa' },
{ label: '中国香港', value: 'hong_kong' },
{ label: '中国台湾', value: 'taiwan' },
{ label: '英国', value: 'uk' },
{ label: '法国', value: 'france' },
{ label: '德国', value: 'germany' },
{ label: '意大利', value: 'italy' },
{ label: '西班牙', value: 'spain' },
{ label: '印度', value: 'india' },
{ label: '泰国', value: 'thailand' },
{ label: '俄罗斯', value: 'russia' },
{ label: '加拿大', value: 'canada' },
{ label: '澳大利亚', value: 'australia' },
{ label: '爱尔兰', value: 'ireland' },
{ label: '瑞典', value: 'sweden' },
{ label: '巴西', value: 'brazil' },
{ label: '丹麦', value: 'denmark' },
];
case 'tv':
case 'anime-tv':
case 'show':
return [
...baseOptions,
{ label: '华语', value: 'chinese' },
{ label: '欧美', value: 'western' },
{ label: '国外', value: 'foreign' },
{ label: '韩国', value: 'korean' },
{ label: '日本', value: 'japanese' },
{ label: '中国大陆', value: 'mainland_china' },
{ label: '中国香港', value: 'hong_kong' },
{ label: '美国', value: 'usa' },
{ label: '英国', value: 'uk' },
{ label: '泰国', value: 'thailand' },
{ label: '中国台湾', value: 'taiwan' },
{ label: '意大利', value: 'italy' },
{ label: '法国', value: 'france' },
{ label: '德国', value: 'germany' },
{ label: '西班牙', value: 'spain' },
{ label: '俄罗斯', value: 'russia' },
{ label: '瑞典', value: 'sweden' },
{ label: '巴西', value: 'brazil' },
{ label: '丹麦', value: 'denmark' },
{ label: '印度', value: 'india' },
{ label: '加拿大', value: 'canada' },
{ label: '爱尔兰', value: 'ireland' },
{ label: '澳大利亚', value: 'australia' },
];
default:
return baseOptions;
}
};
const getLabelOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'anime-movie':
return [
...baseOptions,
{ label: '定格动画', value: 'stop_motion' },
{ label: '传记', value: 'biography' },
{ label: '美国动画', value: 'us_animation' },
{ label: '爱情', value: 'romance' },
{ label: '黑色幽默', value: 'dark_humor' },
{ label: '歌舞', value: 'musical' },
{ label: '儿童', value: 'children' },
{ label: '二次元', value: 'anime' },
{ label: '动物', value: 'animal' },
{ label: '青春', value: 'youth' },
{ label: '历史', value: 'history' },
{ label: '励志', value: 'inspirational' },
{ label: '恶搞', value: 'parody' },
{ label: '治愈', value: 'healing' },
{ label: '运动', value: 'sports' },
{ label: '后宫', value: 'harem' },
{ label: '情色', value: 'erotic' },
{ label: '人性', value: 'human_nature' },
{ label: '悬疑', value: 'suspense' },
{ label: '恋爱', value: 'love' },
{ label: '魔幻', value: 'fantasy' },
{ label: '科幻', value: 'sci_fi' },
];
case 'anime-tv':
return [
...baseOptions,
{ label: '黑色幽默', value: 'dark_humor' },
{ label: '历史', value: 'history' },
{ label: '歌舞', value: 'musical' },
{ label: '励志', value: 'inspirational' },
{ label: '恶搞', value: 'parody' },
{ label: '治愈', value: 'healing' },
{ label: '运动', value: 'sports' },
{ label: '后宫', value: 'harem' },
{ label: '情色', value: 'erotic' },
{ label: '国漫', value: 'chinese_anime' },
{ label: '人性', value: 'human_nature' },
{ label: '悬疑', value: 'suspense' },
{ label: '恋爱', value: 'love' },
{ label: '魔幻', value: 'fantasy' },
{ label: '科幻', value: 'sci_fi' },
];
default:
return baseOptions;
}
};
// 根据内容类型获取对应的平台选项
const getPlatformOptions = (
contentType: 'movie' | 'tv' | 'show' | 'anime-tv' | 'anime-movie'
) => {
const baseOptions = [{ label: '全部', value: 'all' }];
switch (contentType) {
case 'movie':
return baseOptions; // 电影不需要平台选项
case 'tv':
case 'anime-tv':
case 'show':
return [
...baseOptions,
{ label: '腾讯视频', value: 'tencent' },
{ label: '爱奇艺', value: 'iqiyi' },
{ label: '优酷', value: 'youku' },
{ label: '湖南卫视', value: 'hunan_tv' },
{ label: 'Netflix', value: 'netflix' },
{ label: 'HBO', value: 'hbo' },
{ label: 'BBC', value: 'bbc' },
{ label: 'NHK', value: 'nhk' },
{ label: 'CBS', value: 'cbs' },
{ label: 'NBC', value: 'nbc' },
{ label: 'tvN', value: 'tvn' },
];
default:
return baseOptions;
}
};
// 分类配置
const categories: MultiLevelCategory[] = [
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
? [
{
key: 'type',
label: '类型',
options: getTypeOptions(contentType),
},
]
: [
{
key: 'label',
label: '类型',
options: getLabelOptions(contentType),
},
]),
{
key: 'region',
label: '地区',
options: getRegionOptions(contentType),
},
{
key: 'year',
label: '年代',
options: [
{ label: '全部', value: 'all' },
{ label: '2020年代', value: '2020s' },
{ label: '2025', value: '2025' },
{ label: '2024', value: '2024' },
{ label: '2023', value: '2023' },
{ label: '2022', value: '2022' },
{ label: '2021', value: '2021' },
{ label: '2020', value: '2020' },
{ label: '2019', value: '2019' },
{ label: '2010年代', value: '2010s' },
{ label: '2000年代', value: '2000s' },
{ label: '90年代', value: '1990s' },
{ label: '80年代', value: '1980s' },
{ label: '70年代', value: '1970s' },
{ label: '60年代', value: '1960s' },
{ label: '更早', value: 'earlier' },
],
},
// 只在电视剧和综艺时显示平台选项
...(contentType === 'tv' ||
contentType === 'show' ||
contentType === 'anime-tv'
? [
{
key: 'platform',
label: '平台',
options: getPlatformOptions(contentType),
},
]
: []),
{
key: 'sort',
label: '排序',
options: [
{ label: '综合排序', value: 'T' },
{ label: '近期热度', value: 'U' },
{
label:
contentType === 'tv' || contentType === 'show'
? '首播时间'
: '首映时间',
value: 'R',
},
{ label: '高分优先', value: 'S' },
],
},
];
// 计算下拉框位置
const calculateDropdownPosition = (categoryKey: string) => {
const element = categoryRefs.current[categoryKey];
if (element) {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth < 768; // md breakpoint
let x = rect.left;
let dropdownWidth = Math.max(rect.width, 300);
let useFixedWidth = false; // 标记是否使用固定宽度
// 移动端优化:防止下拉框被右侧视口截断
if (isMobile) {
const padding = 16; // 左右各留16px的边距
const maxWidth = viewportWidth - padding * 2;
dropdownWidth = Math.min(dropdownWidth, maxWidth);
useFixedWidth = true; // 移动端使用固定宽度
// 如果右侧超出视口,则调整x位置
if (x + dropdownWidth > viewportWidth - padding) {
x = viewportWidth - dropdownWidth - padding;
}
// 如果左侧超出视口,则贴左边
if (x < padding) {
x = padding;
}
}
setDropdownPosition({
x,
y: rect.bottom,
width: useFixedWidth ? dropdownWidth : rect.width, // PC端保持原有逻辑
});
}
};
// 处理分类点击
const handleCategoryClick = (categoryKey: string) => {
if (activeCategory === categoryKey) {
setActiveCategory(null);
} else {
setActiveCategory(categoryKey);
calculateDropdownPosition(categoryKey);
}
};
// 处理选项选择
const handleOptionSelect = (categoryKey: string, optionValue: string) => {
// 更新本地状态
const newValues = {
...values,
[categoryKey]: optionValue,
};
// 更新内部状态
setValues(newValues);
// 构建传递给父组件的值,排序传递 value,其他传递 label
const selectionsForParent: Record<string, string> = {
type: 'all',
region: 'all',
year: 'all',
platform: 'all',
label: 'all',
sort: 'T',
};
Object.entries(newValues).forEach(([key, value]) => {
if (value && value !== 'all' && (key !== 'sort' || value !== 'T')) {
const category = categories.find((cat) => cat.key === key);
if (category) {
const option = category.options.find((opt) => opt.value === value);
if (option) {
// 排序传递 value,其他传递 label
selectionsForParent[key] =
key === 'sort' ? option.value : option.label;
}
}
}
});
// 调用父组件的回调,传递处理后的选择值
onChange(selectionsForParent);
setActiveCategory(null);
};
// 获取显示文本
const getDisplayText = (categoryKey: string) => {
const category = categories.find((cat) => cat.key === categoryKey);
if (!category) return '';
const value = values[categoryKey];
if (
!value ||
value === 'all' ||
(categoryKey === 'sort' && value === 'T')
) {
return category.label;
}
const option = category.options.find((opt) => opt.value === value);
return option?.label || category.label;
};
// 检查是否为默认值
const isDefaultValue = (categoryKey: string) => {
const value = values[categoryKey];
return (
!value || value === 'all' || (categoryKey === 'sort' && value === 'T')
);
};
// 检查选项是否被选中
const isOptionSelected = (categoryKey: string, optionValue: string) => {
let value = values[categoryKey];
if (value === undefined) {
value = 'all';
if (categoryKey === 'sort') {
value = 'T';
}
}
return value === optionValue;
};
// 监听滚动和窗口大小变化事件,重新计算位置
useEffect(() => {
const handleScroll = () => {
if (activeCategory) {
calculateDropdownPosition(activeCategory);
}
};
const handleResize = () => {
if (activeCategory) {
calculateDropdownPosition(activeCategory);
}
};
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
};
}, [activeCategory]);
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
!Object.values(categoryRefs.current).some(
(ref) => ref && ref.contains(event.target as Node)
)
) {
setActiveCategory(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<>
{/* 胶囊样式筛选栏 */}
<div className='relative inline-flex rounded-full p-0.5 sm:p-1 bg-transparent gap-1 sm:gap-2'>
{categories.map((category) => (
<div
key={category.key}
ref={(el) => {
categoryRefs.current[category.key] = el;
}}
className='relative'
>
<button
onClick={() => handleCategoryClick(category.key)}
className={`relative z-10 px-1.5 py-0.5 sm:px-2 sm:py-1 md:px-4 md:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
activeCategory === category.key
? isDefaultValue(category.key)
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-green-600 dark:text-green-400 cursor-default'
: isDefaultValue(category.key)
? 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
: 'text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 cursor-pointer'
}`}
>
<span>{getDisplayText(category.key)}</span>
<svg
className={`inline-block w-2.5 h-2.5 sm:w-3 sm:h-3 ml-0.5 sm:ml-1 transition-transform duration-200 ${
activeCategory === category.key ? 'rotate-180' : ''
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</button>
</div>
))}
</div>
{/* 展开的筛选选项 - 悬浮显示 */}
{activeCategory &&
createPortal(
<div
ref={dropdownRef}
className='fixed z-[9999] bg-white/95 dark:bg-gray-800/95 rounded-xl border border-gray-200/50 dark:border-gray-700/50 backdrop-blur-sm'
style={{
left: `${dropdownPosition.x}px`,
top: `${dropdownPosition.y}px`,
...(window.innerWidth < 768
? { width: `${dropdownPosition.width}px` } // 移动端使用固定宽度
: { minWidth: `${Math.max(dropdownPosition.width, 300)}px` }), // PC端使用最小宽度
maxWidth: '600px',
position: 'fixed',
}}
>
<div className='p-2 sm:p-4'>
<div className='grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1 sm:gap-2'>
{categories
.find((cat) => cat.key === activeCategory)
?.options.map((option) => (
<button
key={option.value}
onClick={() =>
handleOptionSelect(activeCategory, option.value)
}
className={`px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm rounded-lg transition-all duration-200 text-left ${
isOptionSelected(activeCategory, option.value)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-700/80'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>,
document.body
)}
</>
);
};
export default MultiLevelSelector;
+61
View File
@@ -0,0 +1,61 @@
import { BackButton } from './BackButton';
import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader';
import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
interface PageLayoutProps {
children: React.ReactNode;
activePath?: string;
}
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
return (
<div className='w-full min-h-screen'>
{/* 移动端头部 */}
<MobileHeader showBackButton={['/play'].includes(activePath)} />
{/* 主要布局容器 */}
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
{/* 侧边栏 - 桌面端显示,移动端隐藏 */}
<div className='hidden md:block'>
<Sidebar activePath={activePath} />
</div>
{/* 主内容区域 */}
<div className='relative min-w-0 flex-1 transition-all duration-300'>
{/* 桌面端左上角返回按钮 */}
{['/play'].includes(activePath) && (
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
<BackButton />
</div>
)}
{/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<ThemeToggle />
<UserMenu />
</div>
{/* 主内容 */}
<main
className='flex-1 md:min-h-0 mb-14 md:mb-0'
style={{
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
}}
>
{children}
</main>
</div>
</div>
{/* 移动端底部导航 */}
<div className='md:hidden'>
<MobileBottomNav activePath={activePath} />
</div>
</div>
);
};
export default PageLayout;
+169
View File
@@ -0,0 +1,169 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
interface ScrollableRowProps {
children: React.ReactNode;
scrollDistance?: number;
}
export default function ScrollableRow({
children,
scrollDistance = 1000,
}: ScrollableRowProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [showLeftScroll, setShowLeftScroll] = useState(false);
const [showRightScroll, setShowRightScroll] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const checkScroll = () => {
if (containerRef.current) {
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
// 计算是否需要左右滚动按钮
const threshold = 1; // 容差值,避免浮点误差
const canScrollRight =
scrollWidth - (scrollLeft + clientWidth) > threshold;
const canScrollLeft = scrollLeft > threshold;
setShowRightScroll(canScrollRight);
setShowLeftScroll(canScrollLeft);
}
};
useEffect(() => {
// 多次延迟检查,确保内容已完全渲染
checkScroll();
// 监听窗口大小变化
window.addEventListener('resize', checkScroll);
// 创建一个 ResizeObserver 来监听容器大小变化
const resizeObserver = new ResizeObserver(() => {
// 延迟执行检查
checkScroll();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
window.removeEventListener('resize', checkScroll);
resizeObserver.disconnect();
};
}, [children]); // 依赖 children,当子组件变化时重新检查
// 添加一个额外的效果来监听子组件的变化
useEffect(() => {
if (containerRef.current) {
// 监听 DOM 变化
const observer = new MutationObserver(() => {
setTimeout(checkScroll, 100);
});
observer.observe(containerRef.current, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});
return () => observer.disconnect();
}
}, []);
const handleScrollRightClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({
left: scrollDistance,
behavior: 'smooth',
});
}
};
const handleScrollLeftClick = () => {
if (containerRef.current) {
containerRef.current.scrollBy({
left: -scrollDistance,
behavior: 'smooth',
});
}
};
return (
<div
className='relative'
onMouseEnter={() => {
setIsHovered(true);
// 当鼠标进入时重新检查一次
checkScroll();
}}
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
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}
>
{children}
</div>
{showLeftScroll && (
<div
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'
}`}
style={{
background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}}
>
<div
className='absolute inset-0 flex items-center justify-center'
style={{
top: '40%',
bottom: '60%',
left: '-4.5rem',
pointerEvents: 'auto',
}}
>
<button
onClick={handleScrollLeftClick}
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
>
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' />
</button>
</div>
</div>
)}
{showRightScroll && (
<div
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'
}`}
style={{
background: 'transparent',
pointerEvents: 'none', // 允许点击穿透
}}
>
<div
className='absolute inset-0 flex items-center justify-center'
style={{
top: '40%',
bottom: '60%',
right: '-4.5rem',
pointerEvents: 'auto',
}}
>
<button
onClick={handleScrollRightClick}
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
>
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' />
</button>
</div>
</div>
)}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SearchSuggestionsProps {
query: string;
isVisible: boolean;
onSelect: (suggestion: string) => void;
onClose: () => void;
}
interface SuggestionItem {
text: string;
type: 'related';
icon?: React.ReactNode;
}
export default function SearchSuggestions({
query,
isVisible,
onSelect,
onClose,
}: SearchSuggestionsProps) {
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
// 防抖定时器
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 用于中止旧请求
const abortControllerRef = useRef<AbortController | null>(null);
const fetchSuggestionsFromAPI = useCallback(async (searchQuery: string) => {
// 每次请求前取消上一次的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const response = await fetch(
`/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`,
{
signal: controller.signal,
}
);
if (response.ok) {
const data = await response.json();
const apiSuggestions = data.suggestions.map(
(item: { text: string }) => ({
text: item.text,
type: 'related' as const,
})
);
setSuggestions(apiSuggestions);
setSelectedIndex(-1);
}
} catch (err: unknown) {
// 类型保护判断 err 是否是 Error 类型
if (err instanceof Error) {
if (err.name !== 'AbortError') {
// 不是取消请求导致的错误才清空
setSuggestions([]);
setSelectedIndex(-1);
}
} else {
// 如果 err 不是 Error 类型,也清空提示
setSuggestions([]);
setSelectedIndex(-1);
}
}
}, []);
// 防抖触发
const debouncedFetchSuggestions = useCallback(
(searchQuery: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
if (searchQuery.trim() && isVisible) {
fetchSuggestionsFromAPI(searchQuery);
} else {
setSuggestions([]);
setSelectedIndex(-1);
}
}, 300); //300ms
},
[isVisible, fetchSuggestionsFromAPI]
);
useEffect(() => {
if (!query.trim() || !isVisible) {
setSuggestions([]);
setSelectedIndex(-1);
return;
}
debouncedFetchSuggestions(query);
// 清理定时器
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [query, isVisible, debouncedFetchSuggestions]);
// 键盘导航
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisible || suggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
onSelect(suggestions[selectedIndex].text);
} else {
onSelect(query);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isVisible, onClose]);
if (!isVisible || suggestions.length === 0) {
return null;
}
return (
<div
ref={containerRef}
className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
>
{suggestions.map((suggestion, index) => (
<button
key={`related-${suggestion.text}`}
onClick={() => onSelect(suggestion.text)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3 ${
selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
>
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
{suggestion.text}
</span>
</button>
))}
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Cat, Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { useSite } from './SiteProvider';
interface SidebarContextType {
isCollapsed: boolean;
}
const SidebarContext = createContext<SidebarContextType>({
isCollapsed: false,
});
export const useSidebar = () => useContext(SidebarContext);
// 可替换为你自己的 logo 图片
const Logo = () => {
const { siteName } = useSite();
return (
<Link
href='/'
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
>
<span className='text-2xl font-bold text-green-600 tracking-tight'>
{siteName}
</span>
</Link>
);
};
interface SidebarProps {
onToggle?: (collapsed: boolean) => void;
activePath?: string;
}
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
declare global {
interface Window {
__sidebarCollapsed?: boolean;
}
}
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (
typeof window !== 'undefined' &&
typeof window.__sidebarCollapsed === 'boolean'
) {
return window.__sidebarCollapsed;
}
return false; // 默认展开
});
// 首次挂载时读取 localStorage,以便刷新后仍保持上次的折叠状态
useLayoutEffect(() => {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
const val = JSON.parse(saved);
setIsCollapsed(val);
window.__sidebarCollapsed = val;
}
}, []);
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
useLayoutEffect(() => {
if (typeof document !== 'undefined') {
if (isCollapsed) {
document.documentElement.dataset.sidebarCollapsed = 'true';
} else {
delete document.documentElement.dataset.sidebarCollapsed;
}
}
}, [isCollapsed]);
const [active, setActive] = useState(activePath);
useEffect(() => {
// 优先使用传入的 activePath
if (activePath) {
setActive(activePath);
} else {
// 否则使用当前路径
const getCurrentFullPath = () => {
const queryString = searchParams.toString();
return queryString ? `${pathname}?${queryString}` : pathname;
};
const fullPath = getCurrentFullPath();
setActive(fullPath);
}
}, [activePath, pathname, searchParams]);
const handleToggle = useCallback(() => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
if (typeof window !== 'undefined') {
window.__sidebarCollapsed = newState;
}
onToggle?.(newState);
}, [isCollapsed, onToggle]);
const handleSearchClick = useCallback(() => {
router.push('/search');
}, [router]);
const contextValue = {
isCollapsed,
};
const [menuItems, setMenuItems] = useState([
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
]);
useEffect(() => {
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
setMenuItems((prevItems) => [
...prevItems,
{
icon: Star,
label: '自定义',
href: '/douban?type=custom',
},
]);
}
}, []);
return (
<SidebarContext.Provider value={contextValue}>
{/* 在移动端隐藏侧边栏 */}
<div className='hidden md:flex'>
<aside
data-sidebar
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${
isCollapsed ? 'w-16' : 'w-64'
}`}
style={{
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16'>
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<div className='w-[calc(100%-4rem)] flex justify-center'>
{!isCollapsed && <Logo />}
</div>
</div>
<button
onClick={handleToggle}
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
}`}
>
<Menu className='h-4 w-4' />
</button>
</div>
{/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'>
<Link
href='/'
onClick={() => setActive('/')}
data-active={active === '/'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
<Link
href='/search'
onClick={(e) => {
e.preventDefault();
handleSearchClick();
setActive('/search');
}}
data-active={active === '/search'}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'>
<div className='space-y-1'>
{menuItems.map((item) => {
// 检查当前路径是否匹配这个菜单项
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(active);
const decodedItemHref = decodeURIComponent(item.href);
const isActive =
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`));
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
onClick={() => setActive(item.href)}
data-active={isActive}
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
>
<div className='w-4 h-4 flex items-center justify-center'>
<Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
{item.label}
</span>
)}
</Link>
);
})}
</div>
</div>
</div>
</aside>
<div
className={`transition-all duration-300 sidebar-offset ${
isCollapsed ? 'w-16' : 'w-64'
}`}
></div>
</div>
</SidebarContext.Provider>
);
};
export default Sidebar;
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { createContext, ReactNode, useContext } from 'react';
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
// 默认值
siteName: 'MoonTV',
announcement:
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
});
export const useSite = () => useContext(SiteContext);
export function SiteProvider({
children,
siteName,
announcement,
}: {
children: ReactNode;
siteName: string;
announcement?: string;
}) {
return (
<SiteContext.Provider value={{ siteName, announcement }}>
{children}
</SiteContext.Provider>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import type { ThemeProviderProps } from 'next-themes';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import * as React from 'react';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
enableSystem
{...props}
>
{children}
</NextThemesProvider>
);
}
+70
View File
@@ -0,0 +1,70 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
'use client';
import { Moon, Sun } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { setTheme, resolvedTheme } = useTheme();
const pathname = usePathname();
const setThemeColor = (theme?: string) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
const meta = document.createElement('meta');
meta.name = 'theme-color';
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe';
document.head.appendChild(meta);
} else {
meta.setAttribute('content', theme === 'dark' ? '#0c111c' : '#f9fbfe');
}
};
useEffect(() => {
setMounted(true);
}, []);
// 监听主题变化和路由变化,确保主题色始终同步
useEffect(() => {
if (mounted) {
setThemeColor(resolvedTheme);
}
}, [mounted, resolvedTheme, pathname]);
if (!mounted) {
// 渲染一个占位符以避免布局偏移
return <div className='w-10 h-10' />;
}
const toggleTheme = () => {
// 检查浏览器是否支持 View Transitions API
const targetTheme = resolvedTheme === 'dark' ? 'light' : 'dark';
setThemeColor(targetTheme);
if (!(document as any).startViewTransition) {
setTheme(targetTheme);
return;
}
(document as any).startViewTransition(() => {
setTheme(targetTheme);
});
};
return (
<button
onClick={toggleTheme}
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='Toggle theme'
>
{resolvedTheme === 'dark' ? (
<Sun className='w-full h-full' />
) : (
<Moon className='w-full h-full' />
)}
</button>
);
}
+972
View File
@@ -0,0 +1,972 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
'use client';
import {
Check,
ChevronDown,
ExternalLink,
KeyRound,
LogOut,
Settings,
Shield,
User,
X,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
import { VersionPanel } from './VersionPanel';
interface AuthInfo {
username?: string;
role?: 'owner' | 'admin' | 'user';
}
export const UserMenu: React.FC = () => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [doubanDataSource, setDoubanDataSource] = useState('direct');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('direct');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false);
// 豆瓣数据源选项
const doubanDataSourceOptions = [
{ value: 'direct', label: '直连(服务器直接请求豆瓣)' },
{ value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'cors-anywhere', label: 'Cors Anywhere20 qpm' },
{ value: 'custom', label: '自定义代理' },
];
// 豆瓣图片代理选项
const doubanImageProxyTypeOptions = [
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
{
value: 'cmliussss-cdn-tencent',
label: '豆瓣 CDN By CMLiussss(腾讯云)',
},
{ value: 'cmliussss-cdn-ali', label: '豆瓣 CDN By CMLiussss(阿里云)' },
{ value: 'custom', label: '自定义代理' },
];
// 修改密码相关状态
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState('');
// 版本检查相关状态
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [isChecking, setIsChecking] = useState(true);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 获取认证信息和存储类型
useEffect(() => {
if (typeof window !== 'undefined') {
const auth = getAuthInfoFromBrowserCookie();
setAuthInfo(auth);
const type =
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
setStorageType(type);
}
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanDataSource = localStorage.getItem('doubanDataSource');
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
if (savedDoubanDataSource !== null) {
setDoubanDataSource(savedDoubanDataSource);
} else if (defaultDoubanProxyType) {
setDoubanDataSource(defaultDoubanProxyType);
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
} else if (defaultDoubanProxy) {
setDoubanProxyUrl(defaultDoubanProxy);
}
const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType'
);
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
if (savedDoubanImageProxyType !== null) {
setDoubanImageProxyType(savedDoubanImageProxyType);
} else if (defaultDoubanImageProxyType) {
setDoubanImageProxyType(defaultDoubanImageProxyType);
}
const savedDoubanImageProxyUrl = localStorage.getItem(
'doubanImageProxyUrl'
);
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
if (savedDoubanImageProxyUrl !== null) {
setDoubanImageProxyUrl(savedDoubanImageProxyUrl);
} else if (defaultDoubanImageProxyUrl) {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
}
const savedEnableOptimization =
localStorage.getItem('enableOptimization');
if (savedEnableOptimization !== null) {
setEnableOptimization(JSON.parse(savedEnableOptimization));
}
}
}, []);
// 版本检查
useEffect(() => {
const checkUpdate = async () => {
try {
const status = await checkForUpdates();
setUpdateStatus(status);
} catch (error) {
console.warn('版本检查失败:', error);
} finally {
setIsChecking(false);
}
};
checkUpdate();
}, []);
// 点击外部区域关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource"]')) {
setIsDoubanDropdownOpen(false);
}
}
};
if (isDoubanDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy"]')) {
setIsDoubanImageProxyDropdownOpen(false);
}
}
};
if (isDoubanImageProxyDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyDropdownOpen]);
const handleMenuClick = () => {
setIsOpen(!isOpen);
};
const handleCloseMenu = () => {
setIsOpen(false);
};
const handleLogout = async () => {
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
window.location.href = '/';
};
const handleAdminPanel = () => {
router.push('/admin');
};
const handleChangePassword = () => {
setIsOpen(false);
setIsChangePasswordOpen(true);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handleCloseChangePassword = () => {
setIsChangePasswordOpen(false);
setNewPassword('');
setConfirmPassword('');
setPasswordError('');
};
const handleSubmitChangePassword = async () => {
setPasswordError('');
// 验证密码
if (!newPassword) {
setPasswordError('新密码不得为空');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('两次输入的密码不一致');
return;
}
setPasswordLoading(true);
try {
const response = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
setPasswordError(data.error || '修改密码失败');
return;
}
// 修改成功,关闭弹窗并登出
setIsChangePasswordOpen(false);
await handleLogout();
} catch (error) {
setPasswordError('网络错误,请稍后重试');
} finally {
setPasswordLoading(false);
}
};
const handleSettings = () => {
setIsOpen(false);
setIsSettingsOpen(true);
};
const handleCloseSettings = () => {
setIsSettingsOpen(false);
};
// 设置相关的处理函数
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 handleOptimizationToggle = (value: boolean) => {
setEnableOptimization(value);
if (typeof window !== 'undefined') {
localStorage.setItem('enableOptimization', JSON.stringify(value));
}
};
const handleDoubanDataSourceChange = (value: string) => {
setDoubanDataSource(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSource', value);
}
};
const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyType', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrl', value);
}
};
// 获取感谢信息
const getThanksInfo = (dataSource: string) => {
switch (dataSource) {
case 'cors-proxy-zwei':
return {
text: 'Thanks to @Zwei',
url: 'https://github.com/bestzwei',
};
case 'cmliussss-cdn-tencent':
case 'cmliussss-cdn-ali':
return {
text: 'Thanks to @CMLiussss',
url: 'https://github.com/cmliu',
};
default:
return null;
}
};
const handleResetSettings = () => {
const defaultDoubanProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'direct';
const defaultDoubanProxy =
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
const defaultDoubanImageProxyType =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
const defaultDoubanImageProxyUrl =
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
}
};
// 检查是否显示管理面板按钮
const showAdminPanel =
authInfo?.role === 'owner' || authInfo?.role === 'admin';
// 检查是否显示修改密码按钮
const showChangePassword =
authInfo?.role !== 'owner' && storageType !== 'localstorage';
// 角色中文映射
const getRoleText = (role?: string) => {
switch (role) {
case 'owner':
return '站长';
case 'admin':
return '管理员';
case 'user':
return '用户';
default:
return '';
}
};
// 菜单面板内容
const menuPanel = (
<>
{/* 背景遮罩 - 普通菜单无需模糊 */}
<div
className='fixed inset-0 bg-transparent z-[1000]'
onClick={handleCloseMenu}
/>
{/* 菜单面板 */}
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
{/* 用户信息区域 */}
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
(authInfo?.role || 'user') === 'owner'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: (authInfo?.role || 'user') === 'admin'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
}`}
>
{getRoleText(authInfo?.role || 'user')}
</span>
</div>
<div className='flex items-center justify-between'>
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
{authInfo?.username || 'default'}
</div>
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
{storageType === 'localstorage' ? '本地' : storageType}
</div>
</div>
</div>
</div>
{/* 菜单项 */}
<div className='py-1'>
{/* 设置按钮 */}
<button
onClick={handleSettings}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
{/* 管理面板按钮 */}
{showAdminPanel && (
<button
onClick={handleAdminPanel}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
)}
{/* 修改密码按钮 */}
{showChangePassword && (
<button
onClick={handleChangePassword}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
)}
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 登出按钮 */}
<button
onClick={handleLogout}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
>
<LogOut className='w-4 h-4' />
<span className='font-medium'></span>
</button>
{/* 分割线 */}
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
{/* 版本信息 */}
<button
onClick={() => {
setIsVersionPanelOpen(true);
handleCloseMenu();
}}
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs'
>
<div className='flex items-center gap-1'>
<span className='font-mono'>v{CURRENT_VERSION}</span>
{!isChecking &&
updateStatus &&
updateStatus !== UpdateStatus.FETCH_FAILED && (
<div
className={`w-2 h-2 rounded-full -translate-y-2 ${
updateStatus === UpdateStatus.HAS_UPDATE
? 'bg-yellow-500'
: updateStatus === UpdateStatus.NO_UPDATE
? 'bg-green-400'
: ''
}`}
></div>
)}
</div>
</button>
</div>
</div>
</>
);
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseSettings}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6 overflow-y-auto'>
{/* 标题栏 */}
<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={handleCloseSettings}
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='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>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSource
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanDataSource === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanDataSource) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanDataSource === 'custom' && (
<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.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></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>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${
isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${
doubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanImageProxyType === 'custom' && (
<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.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl}
onChange={(e) =>
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></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={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>
{/* 底部说明 */}
<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>
</>
);
// 修改密码面板内容
const changePasswordPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseChangePassword}
/>
{/* 修改密码面板 */}
<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'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleCloseChangePassword}
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-4'>
{/* 新密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请输入新密码'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 确认密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请再次输入新密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 错误信息 */}
{passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
{passwordError}
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
disabled={passwordLoading}
>
</button>
<button
onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
disabled={passwordLoading || !newPassword || !confirmPassword}
>
{passwordLoading ? '修改中...' : '确认修改'}
</button>
</div>
{/* 底部说明 */}
<div className='mt-4 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 (
<>
<div className='relative'>
<button
onClick={handleMenuClick}
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='User Menu'
>
<User className='w-full h-full' />
</button>
{updateStatus === UpdateStatus.HAS_UPDATE && (
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
)}
</div>
{/* 使用 Portal 将菜单面板渲染到 document.body */}
{isOpen && mounted && createPortal(menuPanel, document.body)}
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
{isChangePasswordOpen &&
mounted &&
createPortal(changePasswordPanel, document.body)}
{/* 版本面板 */}
<VersionPanel
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
/>
</>
);
};
+520
View File
@@ -0,0 +1,520 @@
/* eslint-disable no-console,react-hooks/exhaustive-deps */
'use client';
import {
Bug,
CheckCircle,
ChevronDown,
ChevronUp,
Download,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { changelog, ChangelogEntry } from '@/lib/changelog';
import { compareVersions, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
interface VersionPanelProps {
isOpen: boolean;
onClose: () => void;
}
interface RemoteChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const VersionPanel: React.FC<VersionPanelProps> = ({
isOpen,
onClose,
}) => {
const [mounted, setMounted] = useState(false);
const [remoteChangelog, setRemoteChangelog] = useState<ChangelogEntry[]>([]);
const [hasUpdate, setIsHasUpdate] = useState(false);
const [latestVersion, setLatestVersion] = useState<string>('');
const [showRemoteContent, setShowRemoteContent] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
// 获取远程变更日志
useEffect(() => {
if (isOpen) {
fetchRemoteChangelog();
}
}, [isOpen]);
// 获取远程变更日志
const fetchRemoteChangelog = async () => {
try {
const response = await fetch(
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/CHANGELOG'
);
if (response.ok) {
const content = await response.text();
const parsed = parseChangelog(content);
setRemoteChangelog(parsed);
// 检查是否有更新
if (parsed.length > 0) {
const latest = parsed[0];
setLatestVersion(latest.version);
setIsHasUpdate(
compareVersions(latest.version) === UpdateStatus.HAS_UPDATE
);
}
} else {
console.error(
'获取远程变更日志失败:',
response.status,
response.statusText
);
}
} catch (error) {
console.error('获取远程变更日志失败:', error);
}
};
// 解析变更日志格式
const parseChangelog = (content: string): RemoteChangelogEntry[] => {
const lines = content.split('\n');
const versions: RemoteChangelogEntry[] = [];
let currentVersion: RemoteChangelogEntry | null = null;
let currentSection: string | null = null;
let inVersionContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = trimmedLine.match(
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
);
if (versionMatch) {
if (currentVersion) {
versions.push(currentVersion);
}
currentVersion = {
version: versionMatch[1],
date: versionMatch[2],
added: [],
changed: [],
fixed: [],
};
currentSection = null;
inVersionContent = true;
continue;
}
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
if (inVersionContent && currentVersion) {
// 匹配章节标题
if (trimmedLine === '### Added') {
currentSection = 'added';
continue;
} else if (trimmedLine === '### Changed') {
currentSection = 'changed';
continue;
} else if (trimmedLine === '### Fixed') {
currentSection = 'fixed';
continue;
}
// 匹配条目: - 内容
if (trimmedLine.startsWith('- ') && currentSection) {
const entry = trimmedLine.substring(2);
if (currentSection === 'added') {
currentVersion.added.push(entry);
} else if (currentSection === 'changed') {
currentVersion.changed.push(entry);
} else if (currentSection === 'fixed') {
currentVersion.fixed.push(entry);
}
}
}
}
// 添加最后一个版本
if (currentVersion) {
versions.push(currentVersion);
}
return versions;
};
// 渲染变更日志条目
const renderChangelogEntry = (
entry: ChangelogEntry | RemoteChangelogEntry,
isCurrentVersion = false,
isRemote = false
) => {
const isUpdate = isRemote && hasUpdate && entry.version === latestVersion;
return (
<div
key={entry.version}
className={`p-4 rounded-lg border ${
isCurrentVersion
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: isUpdate
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
>
{/* 版本标题 */}
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
v{entry.version}
</h4>
{isCurrentVersion && (
<span className='px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full'>
</span>
)}
{isUpdate && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Download className='w-3 h-3' />
</span>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
{entry.date}
</div>
</div>
{/* 变更内容 */}
<div className='space-y-3'>
{entry.added.length > 0 && (
<div>
<h5 className='text-sm font-medium text-green-700 dark:text-green-400 mb-2 flex items-center gap-1'>
<Plus className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.added.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.changed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-blue-700 dark:text-blue-400 mb-2 flex items-center gap-1'>
<RefreshCw className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.changed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<Bug className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.fixed.map((item, index) => (
<li
key={index}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
// 版本面板内容
const versionPanelContent = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
/>
{/* 版本面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
{/* 标题栏 */}
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2 sm:gap-3'>
<h3 className='text-lg sm:text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<div className='flex flex-wrap items-center gap-1 sm:gap-2'>
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full'>
v{CURRENT_VERSION}
</span>
{hasUpdate && (
<span className='px-2 sm:px-3 py-1 text-xs sm:text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
<span className='hidden sm:inline'></span>
<span className='sm:hidden'></span>
</span>
)}
</div>
</div>
<button
onClick={onClose}
className='w-6 h-6 sm:w-8 sm: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='关闭'
>
<X className='w-full h-full' />
</button>
</div>
{/* 内容区域 */}
<div className='p-3 sm:p-6 overflow-y-auto max-h-[calc(95vh-140px)] sm:max-h-[calc(90vh-120px)]'>
<div className='space-y-3 sm:space-y-6'>
{/* 远程更新信息 */}
{hasUpdate && (
<div className='bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-yellow-100 dark:bg-yellow-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<Download className='w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 dark:text-yellow-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-yellow-800 dark:text-yellow-200'>
</h4>
<p className='text-xs sm:text-sm text-yellow-700 dark:text-yellow-300 break-all'>
v{CURRENT_VERSION} v{latestVersion}
</p>
</div>
</div>
<a
href='https://github.com/LunaTechLab/MoonTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<Download className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
{/* 当前为最新版本信息 */}
{!hasUpdate && (
<div className='bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 sm:p-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 sm:gap-3'>
<div className='w-8 h-8 sm:w-10 sm:h-10 bg-green-100 dark:bg-green-800/40 rounded-full flex items-center justify-center flex-shrink-0'>
<CheckCircle className='w-4 h-4 sm:w-5 sm:h-5 text-green-600 dark:text-green-400' />
</div>
<div className='min-w-0 flex-1'>
<h4 className='text-sm sm:text-base font-semibold text-green-800 dark:text-green-200'>
</h4>
<p className='text-xs sm:text-sm text-green-700 dark:text-green-300 break-all'>
v{CURRENT_VERSION}
</p>
</div>
</div>
<a
href='https://github.com/LunaTechLab/MoonTV'
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
>
<CheckCircle className='w-3 h-3 sm:w-4 sm:h-4' />
</a>
</div>
</div>
)}
{/* 远程可更新内容 */}
{hasUpdate && (
<div className='space-y-4'>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-3'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 flex items-center gap-2'>
<Download className='w-5 h-5 text-yellow-500' />
</h4>
<button
onClick={() => setShowRemoteContent(!showRemoteContent)}
className='inline-flex items-center justify-center gap-2 px-3 py-1.5 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 dark:bg-yellow-800/30 dark:hover:bg-yellow-800/50 dark:text-yellow-200 rounded-lg transition-colors text-sm w-full sm:w-auto'
>
{showRemoteContent ? (
<>
<ChevronUp className='w-4 h-4' />
</>
) : (
<>
<ChevronDown className='w-4 h-4' />
</>
)}
</button>
</div>
{showRemoteContent && remoteChangelog.length > 0 && (
<div className='space-y-4'>
{remoteChangelog
.filter((entry) => {
// 找到第一个本地版本,过滤掉本地已有的版本
const localVersions = changelog.map(
(local) => local.version
);
return !localVersions.includes(entry.version);
})
.map((entry, index) => (
<div
key={index}
className={`p-4 rounded-lg border ${
entry.version === latestVersion
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-50 dark:bg-gray-800/60 border-gray-200 dark:border-gray-700'
}`}
>
<div className='flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
v{entry.version}
</h4>
{entry.version === latestVersion && (
<span className='px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full flex items-center gap-1'>
</span>
)}
</div>
<div className='flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400'>
{entry.date}
</div>
</div>
{entry.added && entry.added.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-green-600 dark:text-green-400 mb-2 flex items-center gap-1'>
<Plus className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.added.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-green-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.changed && entry.changed.length > 0 && (
<div className='mb-3'>
<h5 className='text-sm font-medium text-blue-600 dark:text-blue-400 mb-2 flex items-center gap-1'>
<RefreshCw className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.changed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-blue-400 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
{entry.fixed && entry.fixed.length > 0 && (
<div>
<h5 className='text-sm font-medium text-purple-700 dark:text-purple-400 mb-2 flex items-center gap-1'>
<Bug className='w-4 h-4' />
</h5>
<ul className='space-y-1'>
{entry.fixed.map((item, itemIndex) => (
<li
key={itemIndex}
className='text-sm text-gray-700 dark:text-gray-300 flex items-start gap-2'
>
<span className='w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0'></span>
{item}
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* 变更日志标题 */}
<div className='border-b border-gray-200 dark:border-gray-700 pb-4'>
<h4 className='text-lg font-semibold text-gray-800 dark:text-gray-200 pb-3 sm:pb-4'>
</h4>
<div className='space-y-4'>
{/* 本地变更日志 */}
{changelog.map((entry) =>
renderChangelogEntry(
entry,
entry.version === CURRENT_VERSION,
false
)
)}
</div>
</div>
</div>
</div>
</div>
</>
);
// 使用 Portal 渲染到 document.body
if (!mounted || !isOpen) return null;
return createPortal(versionPanelContent, document.body);
};
+403
View File
@@ -0,0 +1,403 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
deleteFavorite,
deletePlayRecord,
generateStorageKey,
isFavorited,
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { processImageUrl } from '@/lib/utils';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
interface VideoCardProps {
id?: string;
source?: string;
title?: string;
query?: string;
poster?: string;
episodes?: number;
source_name?: string;
progress?: number;
year?: string;
from: 'playrecord' | 'favorite' | 'search' | 'douban';
currentEpisode?: number;
douban_id?: number;
onDelete?: () => void;
rate?: string;
items?: SearchResult[];
type?: string;
isBangumi?: boolean;
}
export default function VideoCard({
id,
title = '',
query = '',
poster = '',
episodes,
source,
source_name,
progress = 0,
year,
from,
currentEpisode,
douban_id,
onDelete,
rate,
items,
type = '',
isBangumi = false,
}: VideoCardProps) {
const router = useRouter();
const [favorited, setFavorited] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isAggregate = from === 'search' && !!items?.length;
const aggregateData = useMemo(() => {
if (!isAggregate || !items) return null;
const countMap = new Map<number, number>();
const episodeCountMap = new Map<number, number>();
items.forEach((item) => {
if (item.douban_id && item.douban_id !== 0) {
countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1);
}
const len = item.episodes?.length || 0;
if (len > 0) {
episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1);
}
});
const getMostFrequent = (map: Map<number, number>) => {
let maxCount = 0;
let result: number | undefined;
map.forEach((cnt, key) => {
if (cnt > maxCount) {
maxCount = cnt;
result = key;
}
});
return result;
};
return {
first: items[0],
mostFrequentDoubanId: getMostFrequent(countMap),
mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
};
}, [isAggregate, items]);
const actualTitle = aggregateData?.first.title ?? title;
const actualPoster = aggregateData?.first.poster ?? poster;
const actualSource = aggregateData?.first.source ?? source;
const actualId = aggregateData?.first.id ?? id;
const actualDoubanId = aggregateData?.mostFrequentDoubanId ?? douban_id;
const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
const actualYear = aggregateData?.first.year ?? year;
const actualQuery = query || '';
const actualSearchType = isAggregate
? aggregateData?.first.episodes?.length === 1
? 'movie'
: 'tv'
: type;
// 获取收藏状态
useEffect(() => {
if (from === 'douban' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => {
try {
const fav = await isFavorited(actualSource, actualId);
setFavorited(fav);
} catch (err) {
throw new Error('检查收藏状态失败');
}
};
fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
);
return unsubscribe;
}, [from, actualSource, actualId]);
const handleToggleFavorite = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(actualSource, actualId);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(actualSource, actualId, {
title: actualTitle,
source_name: source_name || '',
year: actualYear || '',
cover: actualPoster,
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
setFavorited(true);
}
} catch (err) {
throw new Error('切换收藏状态失败');
}
},
[
from,
actualSource,
actualId,
actualTitle,
source_name,
actualYear,
actualPoster,
actualEpisodes,
favorited,
]
);
const handleDeleteRecord = useCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from !== 'playrecord' || !actualSource || !actualId) return;
try {
await deletePlayRecord(actualSource, actualId);
onDelete?.();
} catch (err) {
throw new Error('删除播放记录失败');
}
},
[from, actualSource, actualId, onDelete]
);
const handleClick = useCallback(() => {
if (from === 'douban') {
router.push(
`/play?title=${encodeURIComponent(actualTitle.trim())}${
actualYear ? `&year=${actualYear}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
);
} else if (actualSource && actualId) {
router.push(
`/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${
isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
);
}
}, [
from,
actualSource,
actualId,
router,
actualTitle,
actualYear,
isAggregate,
actualQuery,
actualSearchType,
]);
const config = useMemo(() => {
const configs = {
playrecord: {
showSourceName: true,
showProgress: true,
showPlayButton: true,
showHeart: true,
showCheckCircle: true,
showDoubanLink: false,
showRating: false,
},
favorite: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: true,
showCheckCircle: false,
showDoubanLink: false,
showRating: false,
},
search: {
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: !isAggregate,
showCheckCircle: false,
showDoubanLink: !!actualDoubanId,
showRating: false,
},
douban: {
showSourceName: false,
showProgress: false,
showPlayButton: true,
showHeart: false,
showCheckCircle: false,
showDoubanLink: true,
showRating: !!rate,
},
};
return configs[from] || configs.search;
}, [from, isAggregate, actualDoubanId, rate]);
return (
<div
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}
>
{/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 */}
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
{/* 图片 */}
<Image
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className='object-cover'
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onError={(e) => {
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
img.src = processImageUrl(actualPoster);
}, 2000);
}
}}
/>
{/* 悬浮遮罩 */}
<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 && (
<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
size={50}
strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
/>
</div>
)}
{/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && (
<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 && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
/>
)}
{config.showHeart && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${
favorited
? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400'
} hover:scale-[1.1]`}
/>
)}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<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}
</div>
)}
{actualEpisodes && actualEpisodes > 1 && (
<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'>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</div>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
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 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} />
</div>
</a>
)}
</div>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* 标题与来源 */}
<div className='mt-2 text-center'>
<div className='relative'>
<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'>
{actualTitle}
</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>
);
}
+74
View File
@@ -0,0 +1,74 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import React, { useEffect, useState } from 'react';
interface WeekdaySelectorProps {
onWeekdayChange: (weekday: string) => void;
className?: string;
}
const weekdays = [
{ value: 'Mon', label: '周一', shortLabel: '周一' },
{ value: 'Tue', label: '周二', shortLabel: '周二' },
{ value: 'Wed', label: '周三', shortLabel: '周三' },
{ value: 'Thu', label: '周四', shortLabel: '周四' },
{ value: 'Fri', label: '周五', shortLabel: '周五' },
{ value: 'Sat', label: '周六', shortLabel: '周六' },
{ value: 'Sun', label: '周日', shortLabel: '周日' },
];
const WeekdaySelector: React.FC<WeekdaySelectorProps> = ({
onWeekdayChange,
className = '',
}) => {
// 获取今天的星期数,默认选中今天
const getTodayWeekday = (): string => {
const today = new Date().getDay();
// getDay() 返回 0-60 是周日,1-6 是周一到周六
const weekdayMap = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return weekdayMap[today];
};
const [selectedWeekday, setSelectedWeekday] = useState<string>(
getTodayWeekday()
);
// 组件初始化时通知父组件默认选中的星期
useEffect(() => {
onWeekdayChange(getTodayWeekday());
}, []); // 只在组件挂载时执行一次
return (
<div
className={`relative inline-flex rounded-full p-0.5 sm:p-1 ${className}`}
>
{weekdays.map((weekday) => {
const isActive = selectedWeekday === weekday.value;
return (
<button
key={weekday.value}
onClick={() => {
setSelectedWeekday(weekday.value);
onWeekdayChange(weekday.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-green-600 dark:text-green-400 font-semibold'
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer'
}
`}
title={weekday.label}
>
{weekday.shortLabel}
</button>
);
})}
</div>
);
};
export default WeekdaySelector;
+41
View File
@@ -0,0 +1,41 @@
export interface AdminConfig {
SiteConfig: {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
};
UserConfig: {
AllowRegister: boolean;
Users: {
username: string;
role: 'user' | 'admin' | 'owner';
banned?: boolean;
}[];
};
SourceConfig: {
key: string;
name: string;
api: string;
detail?: string;
from: 'config' | 'custom';
disabled?: boolean;
}[];
CustomCategories: {
name?: string;
type: 'movie' | 'tv';
query: string;
from: 'config' | 'custom';
disabled?: boolean;
}[];
}
export interface AdminConfigResult {
Role: 'owner' | 'admin';
Config: AdminConfig;
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest } from 'next/server';
// 从cookie获取认证信息 (服务端使用)
export function getAuthInfoFromCookie(request: NextRequest): {
password?: string;
username?: string;
signature?: string;
timestamp?: number;
} | null {
const authCookie = request.cookies.get('auth');
if (!authCookie) {
return null;
}
try {
const decoded = decodeURIComponent(authCookie.value);
const authData = JSON.parse(decoded);
return authData;
} catch (error) {
return null;
}
}
// 从cookie获取认证信息 (客户端使用)
export function getAuthInfoFromBrowserCookie(): {
password?: string;
username?: string;
signature?: string;
timestamp?: number;
role?: 'owner' | 'admin' | 'user';
} | null {
if (typeof window === 'undefined') {
return null;
}
try {
// 解析 document.cookie
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
const trimmed = cookie.trim();
const firstEqualIndex = trimmed.indexOf('=');
if (firstEqualIndex > 0) {
const key = trimmed.substring(0, firstEqualIndex);
const value = trimmed.substring(firstEqualIndex + 1);
if (key && value) {
acc[key] = value;
}
}
return acc;
}, {} as Record<string, string>);
const authCookie = cookies['auth'];
if (!authCookie) {
return null;
}
// 处理可能的双重编码
let decoded = decodeURIComponent(authCookie);
// 如果解码后仍然包含 %,说明是双重编码,需要再次解码
if (decoded.includes('%')) {
decoded = decodeURIComponent(decoded);
}
const authData = JSON.parse(decoded);
return authData;
} catch (error) {
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
export interface BangumiCalendarData {
weekday: {
en: string;
};
items: {
id: number;
name: string;
name_cn: string;
rating: {
score: number;
};
air_date: string;
images: {
large: string;
common: string;
medium: string;
small: string;
grid: string;
};
}[];
}
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
const response = await fetch('https://api.bgm.tv/calendar');
const data = await response.json();
return data;
}
+122
View File
@@ -0,0 +1,122 @@
// 此文件由 scripts/convert-changelog.js 自动生成
// 请勿手动编辑
export interface ChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const changelog: ChangelogEntry[] = [
{
version: "1.1.1",
date: "2025-08-12",
added: [
// 无新增内容
],
changed: [
"修正 zwei 提供的 cors proxy 地址",
"移除废弃代码"
],
fixed: [
"[运维] docker workflow release 日期使用东八区日期"
]
},
{
version: "1.1.0",
date: "2025-08-12",
added: [
"每日新番放送功能,展示每日新番放送的番剧"
],
changed: [
// 无变更内容
],
fixed: [
"修复远程 CHANGELOG 无法提取变更内容的问题"
]
},
{
version: "1.0.5",
date: "2025-08-12",
added: [
// 无新增内容
],
changed: [
"实现基于 Git 标签的自动 Release 工作流"
],
fixed: [
// 无修复内容
]
},
{
version: "1.0.4",
date: "2025-08-11",
added: [
"优化版本管理工作流,实现单点修改"
],
changed: [
"版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt"
],
fixed: [
// 无修复内容
]
},
{
version: "1.0.3",
date: "2025-08-11",
added: [
// 无新增内容
],
changed: [
"升级播放器 Artplayer 至版本 5.2.5"
],
fixed: [
// 无修复内容
]
},
{
version: "1.0.2",
date: "2025-08-11",
added: [
// 无新增内容
],
changed: [
"版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新",
"[运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号"
],
fixed: [
// 无修复内容
]
},
{
version: "1.0.1",
date: "2025-08-11",
added: [
// 无新增内容
],
changed: [
// 无变更内容
],
fixed: [
"修复版本检查功能,只要与最新版本号不一致即认为有更新"
]
},
{
version: "1.0.0",
date: "2025-08-10",
added: [
"基于 Semantic Versioning 的版本号机制",
"版本信息面板,展示本地变更日志和远程更新日志"
],
changed: [
// 无变更内容
],
fixed: [
// 无修复内容
]
}
];
export default changelog;
+509
View File
@@ -0,0 +1,509 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
import { getStorage } from '@/lib/db';
import { AdminConfig } from './admin.types';
import runtimeConfig from './runtime';
export interface ApiSite {
key: string;
api: string;
name: string;
detail?: string;
}
interface ConfigFileStruct {
cache_time?: number;
api_site: {
[key: string]: ApiSite;
};
custom_category?: {
name?: string;
type: 'movie' | 'tv';
query: string;
}[];
}
export const API_CONFIG = {
search: {
path: '?ac=videolist&wd=',
pagePath: '?ac=videolist&wd={query}&pg={page}',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Accept: 'application/json',
},
},
detail: {
path: '?ac=videolist&ids=',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
Accept: 'application/json',
},
},
};
// 在模块加载时根据环境决定配置来源
let fileConfig: ConfigFileStruct;
let cachedConfig: AdminConfig;
async function initConfig() {
if (cachedConfig) {
return;
}
if (process.env.DOCKER_ENV === 'true') {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const _require = eval('require') as NodeRequire;
const fs = _require('fs') as typeof import('fs');
const path = _require('path') as typeof import('path');
const configPath = path.join(process.cwd(), 'config.json');
const raw = fs.readFileSync(configPath, 'utf-8');
fileConfig = JSON.parse(raw) as ConfigFileStruct;
console.log('load dynamic config success');
} else {
// 默认使用编译时生成的配置
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType !== 'localstorage') {
// 数据库存储,读取并补全管理员配置
const storage = getStorage();
try {
// 尝试从数据库获取管理员配置
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
// 获取所有用户名,用于补全 Users
let userNames: string[] = [];
if (storage && typeof (storage as any).getAllUsers === 'function') {
try {
userNames = await (storage as any).getAllUsers();
} catch (e) {
console.error('获取用户列表失败:', e);
}
}
// 从文件中获取源信息,用于补全源
const apiSiteEntries = Object.entries(fileConfig.api_site);
const customCategories = fileConfig.custom_category || [];
if (adminConfig) {
// 补全 SourceConfig
const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
);
apiSiteEntries.forEach(([key, site]) => {
sourceConfigMap.set(key, {
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
});
});
// 将 Map 转换回数组
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
adminConfig.SourceConfig.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
// 确保 CustomCategories 被初始化
if (!adminConfig.CustomCategories) {
adminConfig.CustomCategories = [];
}
// 补全 CustomCategories
const customCategoriesMap = new Map(
adminConfig.CustomCategories.map((c) => [c.query + c.type, c])
);
customCategories.forEach((category) => {
customCategoriesMap.set(category.query + category.type, {
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
});
});
// 检查现有 CustomCategories 是否在 fileConfig.custom_category 中,如果不在则标记为 custom
const customCategoriesKeys = new Set(
customCategories.map((c) => c.query + c.type)
);
customCategoriesMap.forEach((category) => {
if (!customCategoriesKeys.has(category.query + category.type)) {
category.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.CustomCategories = Array.from(customCategoriesMap.values());
const existedUsers = new Set(
(adminConfig.UserConfig.Users || []).map((u) => u.username)
);
userNames.forEach((uname) => {
if (!existedUsers.has(uname)) {
adminConfig!.UserConfig.Users.push({
username: uname,
role: 'user',
});
}
});
// 站长
const ownerUser = process.env.USERNAME;
if (ownerUser) {
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
(u) => u.username !== ownerUser
);
adminConfig!.UserConfig.Users.unshift({
username: ownerUser,
role: 'owner',
});
}
} else {
// 数据库中没有配置,创建新的管理员配置
let allUsers = userNames.map((uname) => ({
username: uname,
role: 'user',
}));
const ownerUser = process.env.USERNAME;
if (ownerUser) {
allUsers = allUsers.filter((u) => u.username !== ownerUser);
allUsers.unshift({
username: ownerUser,
role: 'owner',
});
}
adminConfig = {
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType:
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: allUsers as any,
},
SourceConfig: apiSiteEntries.map(([key, site]) => ({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
})),
CustomCategories: customCategories.map((category) => ({
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
})),
};
}
// 写回数据库(更新/创建)
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
// 更新缓存
cachedConfig = adminConfig;
} catch (err) {
console.error('加载管理员配置失败:', err);
}
} else {
// 本地存储直接使用文件配置
cachedConfig = {
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: [],
},
SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
})),
CustomCategories:
fileConfig.custom_category?.map((category) => ({
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
})) || [],
} as AdminConfig;
}
}
export async function getConfig(): Promise<AdminConfig> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
await initConfig();
return cachedConfig;
}
// 非 docker 环境且 DB 存储,直接读 db 配置
const storage = getStorage();
let adminConfig: AdminConfig | null = null;
if (storage && typeof (storage as any).getAdminConfig === 'function') {
adminConfig = await (storage as any).getAdminConfig();
}
if (adminConfig) {
// 确保 CustomCategories 被初始化
if (!adminConfig.CustomCategories) {
adminConfig.CustomCategories = [];
}
// 合并一些环境变量配置
adminConfig.SiteConfig.SiteName =
process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
adminConfig.SiteConfig.Announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
adminConfig.UserConfig.AllowRegister =
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
adminConfig.SiteConfig.DoubanProxyType =
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct';
adminConfig.SiteConfig.DoubanProxy =
process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
adminConfig.SiteConfig.DoubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct';
adminConfig.SiteConfig.DoubanImageProxy =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
adminConfig.SiteConfig.DisableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
// 合并文件中的源信息
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
const apiSiteEntries = Object.entries(fileConfig.api_site);
const sourceConfigMap = new Map(
(adminConfig.SourceConfig || []).map((s) => [s.key, s])
);
apiSiteEntries.forEach(([key, site]) => {
const existingSource = sourceConfigMap.get(key);
if (existingSource) {
// 如果已存在,只覆盖 name、api、detail 和 from
existingSource.name = site.name;
existingSource.api = site.api;
existingSource.detail = site.detail;
existingSource.from = 'config';
} else {
// 如果不存在,创建新条目
sourceConfigMap.set(key, {
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));
sourceConfigMap.forEach((source) => {
if (!apiSiteKeys.has(source.key)) {
source.from = 'custom';
}
});
// 将 Map 转换回数组
adminConfig.SourceConfig = Array.from(sourceConfigMap.values());
// 覆盖 CustomCategories
const customCategories = fileConfig.custom_category || [];
adminConfig.CustomCategories = customCategories.map((category) => ({
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
}));
const ownerUser = process.env.USERNAME || '';
// 检查配置中的站长用户是否和 USERNAME 匹配,如果不匹配则降级为普通用户
let containOwner = false;
adminConfig.UserConfig.Users.forEach((user) => {
if (user.username !== ownerUser && user.role === 'owner') {
user.role = 'user';
}
if (user.username === ownerUser) {
containOwner = true;
user.role = 'owner';
}
});
// 如果不在则添加
if (!containOwner) {
adminConfig.UserConfig.Users.unshift({
username: ownerUser,
role: 'owner',
});
}
cachedConfig = adminConfig;
} else {
// DB 无配置,执行一次初始化
await initConfig();
}
return cachedConfig;
}
export async function resetConfig() {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const storage = getStorage();
// 获取所有用户名,用于补全 Users
let userNames: string[] = [];
if (storage && typeof (storage as any).getAllUsers === 'function') {
try {
userNames = await (storage as any).getAllUsers();
} catch (e) {
console.error('获取用户列表失败:', e);
}
}
if (process.env.DOCKER_ENV === 'true') {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const _require = eval('require') as NodeRequire;
const fs = _require('fs') as typeof import('fs');
const path = _require('path') as typeof import('path');
const configPath = path.join(process.cwd(), 'config.json');
const raw = fs.readFileSync(configPath, 'utf-8');
fileConfig = JSON.parse(raw) as ConfigFileStruct;
console.log('load dynamic config success');
} else {
// 默认使用编译时生成的配置
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
}
const apiSiteEntries = Object.entries(fileConfig.api_site);
const customCategories = fileConfig.custom_category || [];
let allUsers = userNames.map((uname) => ({
username: uname,
role: 'user',
}));
const ownerUser = process.env.USERNAME;
if (ownerUser) {
allUsers = allUsers.filter((u) => u.username !== ownerUser);
allUsers.unshift({
username: ownerUser,
role: 'owner',
});
}
const adminConfig = {
SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV',
Announcement:
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'direct',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'direct',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
Users: allUsers as any,
},
SourceConfig: apiSiteEntries.map(([key, site]) => ({
key,
name: site.name,
api: site.api,
detail: site.detail,
from: 'config',
disabled: false,
})),
CustomCategories:
storageType === 'redis'
? customCategories?.map((category) => ({
name: category.name,
type: category.type,
query: category.query,
from: 'config',
disabled: false,
})) || []
: [],
} as AdminConfig;
if (storage && typeof (storage as any).setAdminConfig === 'function') {
await (storage as any).setAdminConfig(adminConfig);
}
if (cachedConfig == null) {
// serverless 环境,直接使用 adminConfig
cachedConfig = adminConfig;
}
cachedConfig.SiteConfig = adminConfig.SiteConfig;
cachedConfig.UserConfig = adminConfig.UserConfig;
cachedConfig.SourceConfig = adminConfig.SourceConfig;
cachedConfig.CustomCategories = adminConfig.CustomCategories;
}
export async function getCacheTime(): Promise<number> {
const config = await getConfig();
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
}
export async function getAvailableApiSites(): Promise<ApiSite[]> {
const config = await getConfig();
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
}));
}
+1657
View File
@@ -0,0 +1,1657 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
'use client';
/**
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
*
* 功能:
* 1. 获取全部播放记录(getAllPlayRecords)。
* 2. 保存播放记录(savePlayRecord)。
* 3. 数据库存储模式下的混合缓存策略,提升用户体验。
*
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
*/
import { getAuthInfoFromBrowserCookie } from './auth';
import { SkipConfig } from './types';
// 全局错误触发函数
function triggerGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
// ---- 类型 ----
export interface PlayRecord {
title: string;
source_name: string;
year: string;
cover: string;
index: number; // 第几集
total_episodes: number; // 总集数
play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title?: string; // 搜索时使用的标题
}
// ---- 收藏类型 ----
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string;
}
// ---- 缓存数据结构 ----
interface CacheData<T> {
data: T;
timestamp: number;
version: string;
}
interface UserCacheStore {
playRecords?: CacheData<Record<string, PlayRecord>>;
favorites?: CacheData<Record<string, Favorite>>;
searchHistory?: CacheData<string[]>;
skipConfigs?: CacheData<Record<string, SkipConfig>>;
}
// ---- 常量 ----
const PLAY_RECORDS_KEY = 'moontv_play_records';
const FAVORITES_KEY = 'moontv_favorites';
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 缓存相关常量
const CACHE_PREFIX = 'moontv_cache_';
const CACHE_VERSION = '1.0.0';
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
// ---- 环境变量 ----
const STORAGE_TYPE = (() => {
const raw =
(typeof window !== 'undefined' &&
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
(process.env.STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| undefined) ||
'localstorage';
return raw;
})();
// ---------------- 搜索历史相关常量 ----------------
// 搜索历史最大保存条数
const SEARCH_HISTORY_LIMIT = 20;
// ---- 缓存管理器 ----
class HybridCacheManager {
private static instance: HybridCacheManager;
static getInstance(): HybridCacheManager {
if (!HybridCacheManager.instance) {
HybridCacheManager.instance = new HybridCacheManager();
}
return HybridCacheManager.instance;
}
/**
* 获取当前用户名
*/
private getCurrentUsername(): string | null {
const authInfo = getAuthInfoFromBrowserCookie();
return authInfo?.username || null;
}
/**
* 生成用户专属的缓存key
*/
private getUserCacheKey(username: string): string {
return `${CACHE_PREFIX}${username}`;
}
/**
* 获取用户缓存数据
*/
private getUserCache(username: string): UserCacheStore {
if (typeof window === 'undefined') return {};
try {
const cacheKey = this.getUserCacheKey(username);
const cached = localStorage.getItem(cacheKey);
return cached ? JSON.parse(cached) : {};
} catch (error) {
console.warn('获取用户缓存失败:', error);
return {};
}
}
/**
* 保存用户缓存数据
*/
private saveUserCache(username: string, cache: UserCacheStore): void {
if (typeof window === 'undefined') return;
try {
// 检查缓存大小,超过15MB时清理旧数据
const cacheSize = JSON.stringify(cache).length;
if (cacheSize > 15 * 1024 * 1024) {
console.warn('缓存过大,清理旧数据');
this.cleanOldCache(cache);
}
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (error) {
console.warn('保存用户缓存失败:', error);
// 存储空间不足时清理缓存后重试
if (
error instanceof DOMException &&
error.name === 'QuotaExceededError'
) {
this.clearAllCache();
try {
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (retryError) {
console.error('重试保存缓存仍然失败:', retryError);
}
}
}
}
/**
* 清理过期缓存数据
*/
private cleanOldCache(cache: UserCacheStore): void {
const now = Date.now();
const maxAge = 60 * 24 * 60 * 60 * 1000; // 两个月
// 清理过期的播放记录缓存
if (cache.playRecords && now - cache.playRecords.timestamp > maxAge) {
delete cache.playRecords;
}
// 清理过期的收藏缓存
if (cache.favorites && now - cache.favorites.timestamp > maxAge) {
delete cache.favorites;
}
}
/**
* 清理所有缓存
*/
private clearAllCache(): void {
const keys = Object.keys(localStorage);
keys.forEach((key) => {
if (key.startsWith('moontv_cache_')) {
localStorage.removeItem(key);
}
});
}
/**
* 检查缓存是否有效
*/
private isCacheValid<T>(cache: CacheData<T>): boolean {
const now = Date.now();
return (
cache.version === CACHE_VERSION &&
now - cache.timestamp < CACHE_EXPIRE_TIME
);
}
/**
* 创建缓存数据
*/
private createCacheData<T>(data: T): CacheData<T> {
return {
data,
timestamp: Date.now(),
version: CACHE_VERSION,
};
}
/**
* 获取缓存的播放记录
*/
getCachedPlayRecords(): Record<string, PlayRecord> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.playRecords;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存播放记录
*/
cachePlayRecords(data: Record<string, PlayRecord>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.playRecords = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的收藏
*/
getCachedFavorites(): Record<string, Favorite> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.favorites;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存收藏
*/
cacheFavorites(data: Record<string, Favorite>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.favorites = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的搜索历史
*/
getCachedSearchHistory(): string[] | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.searchHistory;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存搜索历史
*/
cacheSearchHistory(data: string[]): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.searchHistory = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的跳过片头片尾配置
*/
getCachedSkipConfigs(): Record<string, SkipConfig> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.skipConfigs;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存跳过片头片尾配置
*/
cacheSkipConfigs(data: Record<string, SkipConfig>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.skipConfigs = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 清除指定用户的所有缓存
*/
clearUserCache(username?: string): void {
const targetUsername = username || this.getCurrentUsername();
if (!targetUsername) return;
try {
const cacheKey = this.getUserCacheKey(targetUsername);
localStorage.removeItem(cacheKey);
} catch (error) {
console.warn('清除用户缓存失败:', error);
}
}
/**
* 清除所有过期缓存
*/
clearExpiredCaches(): void {
if (typeof window === 'undefined') return;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
try {
const cache = JSON.parse(localStorage.getItem(key) || '{}');
// 检查是否有任何缓存数据过期
let hasValidData = false;
for (const [, cacheData] of Object.entries(cache)) {
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
hasValidData = true;
break;
}
}
if (!hasValidData) {
keysToRemove.push(key);
}
} catch {
// 解析失败的缓存也删除
keysToRemove.push(key);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.warn('清除过期缓存失败:', error);
}
}
}
// 获取缓存管理器实例
const cacheManager = HybridCacheManager.getInstance();
// ---- 错误处理辅助函数 ----
/**
* 数据库操作失败时的通用错误处理
* 立即从数据库刷新对应类型的缓存以保持数据一致性
*/
async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory',
error: any
): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error);
triggerGlobalError(`数据库操作失败`);
try {
let freshData: any;
let eventName: string;
switch (dataType) {
case 'playRecords':
freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
eventName = 'playRecordsUpdated';
break;
case 'favorites':
freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
eventName = 'favoritesUpdated';
break;
case 'searchHistory':
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
eventName = 'searchHistoryUpdated';
break;
}
// 触发更新事件通知组件
window.dispatchEvent(
new CustomEvent(eventName, {
detail: freshData,
})
);
} catch (refreshErr) {
console.error(`刷新${dataType}缓存失败:`, refreshErr);
triggerGlobalError(`刷新${dataType}缓存失败`);
}
}
// 页面加载时清理过期缓存
if (typeof window !== 'undefined') {
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
}
// ---- 工具函数 ----
/**
* 通用的 fetch 函数,处理 401 状态码自动跳转登录
*/
async function fetchWithAuth(
url: string,
options?: RequestInit
): Promise<Response> {
const res = await fetch(url, options);
if (!res.ok) {
// 如果是 401 未授权,跳转到登录页面
if (res.status === 401) {
// 调用 logout 接口
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('注销请求失败:', error);
}
const currentUrl = window.location.pathname + window.location.search;
const loginUrl = new URL('/login', window.location.origin);
loginUrl.searchParams.set('redirect', currentUrl);
window.location.href = loginUrl.toString();
throw new Error('用户未授权,已跳转到登录页面');
}
throw new Error(`请求 ${url} 失败: ${res.status}`);
}
return res;
}
async function fetchFromApi<T>(path: string): Promise<T> {
const res = await fetchWithAuth(path);
return (await res.json()) as T;
}
/**
* 生成存储key
*/
export function generateStorageKey(source: string, id: string): string {
return `${source}+${id}`;
}
// ---- API ----
/**
* 读取全部播放记录。
* 非本地存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
*/
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedPlayRecords();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cachePlayRecords(freshData);
// 触发数据更新事件,供组件监听
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步播放记录失败:', err);
triggerGlobalError('后台同步播放记录失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
return freshData;
} catch (err) {
console.error('获取播放记录失败:', err);
triggerGlobalError('获取播放记录失败');
return {};
}
}
}
// localstorage 模式
try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, PlayRecord>;
} catch (err) {
console.error('读取播放记录失败:', err);
triggerGlobalError('读取播放记录失败');
return {};
}
}
/**
* 保存播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
*/
export async function savePlayRecord(
source: string,
id: string,
record: PlayRecord
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
cachedRecords[key] = record;
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/playrecords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, record }),
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('保存播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
allRecords[key] = record;
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('保存播放记录失败:', err);
triggerGlobalError('保存播放记录失败');
throw err;
}
}
/**
* 删除播放记录。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deletePlayRecord(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
delete cachedRecords[key];
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
return;
}
// localstorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage');
return;
}
try {
const allRecords = await getAllPlayRecords();
delete allRecords[key];
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: allRecords,
})
);
} catch (err) {
console.error('删除播放记录失败:', err);
triggerGlobalError('删除播放记录失败');
throw err;
}
}
/* ---------------- 搜索历史相关 API ---------------- */
/**
* 获取搜索历史。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getSearchHistory(): Promise<string[]> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return [];
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSearchHistory();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<string[]>(`/api/searchhistory`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSearchHistory(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步搜索历史失败:', err);
triggerGlobalError('后台同步搜索历史失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
return freshData;
} catch (err) {
console.error('获取搜索历史失败:', err);
triggerGlobalError('获取搜索历史失败');
return [];
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return [];
const arr = JSON.parse(raw) as string[];
// 仅返回字符串数组
return Array.isArray(arr) ? arr : [];
} catch (err) {
console.error('读取搜索历史失败:', err);
triggerGlobalError('读取搜索历史失败');
return [];
}
}
/**
* 将关键字添加到搜索历史。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function addSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/searchhistory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keyword: trimmed }),
});
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
try {
const history = await getSearchHistory();
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) {
console.error('保存搜索历史失败:', err);
triggerGlobalError('保存搜索历史失败');
}
}
/**
* 清空搜索历史。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function clearSearchHistory(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheSearchHistory([]);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/searchhistory`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(SEARCH_HISTORY_KEY);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
}
/**
* 删除单条搜索历史。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deleteSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = cachedHistory.filter((k) => k !== trimmed);
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
{
method: 'DELETE',
}
);
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
try {
const history = await getSearchHistory();
const newHistory = history.filter((k) => k !== trimmed);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
} catch (err) {
console.error('删除搜索历史失败:', err);
triggerGlobalError('删除搜索历史失败');
}
}
// ---------------- 收藏相关 API ----------------
/**
* 获取全部收藏。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedFavorites();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步收藏失败:', err);
triggerGlobalError('后台同步收藏失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
return freshData;
} catch (err) {
console.error('获取收藏失败:', err);
triggerGlobalError('获取收藏失败');
return {};
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem(FAVORITES_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, Favorite>;
} catch (err) {
console.error('读取收藏失败:', err);
triggerGlobalError('读取收藏失败');
return {};
}
}
/**
* 保存收藏。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function saveFavorite(
source: string,
id: string,
favorite: Favorite
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
cachedFavorites[key] = favorite;
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, favorite }),
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('保存收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存收藏到 localStorage');
return;
}
try {
const allFavorites = await getAllFavorites();
allFavorites[key] = favorite;
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) {
console.error('保存收藏失败:', err);
triggerGlobalError('保存收藏失败');
throw err;
}
}
/**
* 删除收藏。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deleteFavorite(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
delete cachedFavorites[key];
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/favorites?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('删除收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除收藏到 localStorage');
return;
}
try {
const allFavorites = await getAllFavorites();
delete allFavorites[key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: allFavorites,
})
);
} catch (err) {
console.error('删除收藏失败:', err);
triggerGlobalError('删除收藏失败');
throw err;
}
}
/**
* 判断是否已收藏。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function isFavorited(
source: string,
id: string
): Promise<boolean> {
const key = generateStorageKey(source, id);
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
const cachedFavorites = cacheManager.getCachedFavorites();
if (cachedFavorites) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步收藏失败:', err);
triggerGlobalError('后台同步收藏失败');
});
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);
triggerGlobalError('检查收藏状态失败');
return false;
}
}
}
// localStorage 模式
const allFavorites = await getAllFavorites();
return !!allFavorites[key];
}
/**
* 清空全部播放记录
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function clearAllPlayRecords(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cachePlayRecords({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/playrecords`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
triggerGlobalError('清空播放记录失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(PLAY_RECORDS_KEY);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: {},
})
);
}
/**
* 清空全部收藏
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function clearAllFavorites(): Promise<void> {
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
cacheManager.cacheFavorites({});
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/favorites`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
triggerGlobalError('清空收藏失败');
throw err;
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') return;
localStorage.removeItem(FAVORITES_KEY);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: {},
})
);
}
// ---------------- 混合缓存辅助函数 ----------------
/**
* 清除当前用户的所有缓存数据
* 用于用户登出时清理缓存
*/
export function clearUserCache(): void {
if (STORAGE_TYPE !== 'localstorage') {
cacheManager.clearUserCache();
}
}
/**
* 手动刷新所有缓存数据
* 强制从服务器重新获取数据并更新缓存
*/
export async function refreshAllCache(): Promise<void> {
if (STORAGE_TYPE === 'localstorage') return;
try {
// 并行刷新所有数据
const [playRecords, favorites, searchHistory, skipConfigs] =
await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<string[]>(`/api/searchhistory`),
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`),
]);
if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: playRecords.value,
})
);
}
if (favorites.status === 'fulfilled') {
cacheManager.cacheFavorites(favorites.value);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: favorites.value,
})
);
}
if (searchHistory.status === 'fulfilled') {
cacheManager.cacheSearchHistory(searchHistory.value);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: searchHistory.value,
})
);
}
if (skipConfigs.status === 'fulfilled') {
cacheManager.cacheSkipConfigs(skipConfigs.value);
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: skipConfigs.value,
})
);
}
} catch (err) {
console.error('刷新缓存失败:', err);
triggerGlobalError('刷新缓存失败');
}
}
/**
* 获取缓存状态信息
* 用于调试和监控缓存健康状态
*/
export function getCacheStatus(): {
hasPlayRecords: boolean;
hasFavorites: boolean;
hasSearchHistory: boolean;
hasSkipConfigs: boolean;
username: string | null;
} {
if (STORAGE_TYPE === 'localstorage') {
return {
hasPlayRecords: false,
hasFavorites: false,
hasSearchHistory: false,
hasSkipConfigs: false,
username: null,
};
}
const authInfo = getAuthInfoFromBrowserCookie();
return {
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
hasFavorites: !!cacheManager.getCachedFavorites(),
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
hasSkipConfigs: !!cacheManager.getCachedSkipConfigs(),
username: authInfo?.username || null,
};
}
// ---------------- React Hook 辅助类型 ----------------
export type CacheUpdateEvent =
| 'playRecordsUpdated'
| 'favoritesUpdated'
| 'searchHistoryUpdated'
| 'skipConfigsUpdated';
/**
* 用于 React 组件监听数据更新的事件监听器
* 使用方法:
*
* useEffect(() => {
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
* setPlayRecords(data);
* });
* return unsubscribe;
* }, []);
*/
export function subscribeToDataUpdates<T>(
eventType: CacheUpdateEvent,
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const handleUpdate = (event: CustomEvent) => {
callback(event.detail);
};
window.addEventListener(eventType, handleUpdate as EventListener);
return () => {
window.removeEventListener(eventType, handleUpdate as EventListener);
};
}
/**
* 预加载所有用户数据到缓存
* 适合在应用启动时调用,提升后续访问速度
*/
export async function preloadUserData(): Promise<void> {
if (STORAGE_TYPE === 'localstorage') return;
// 检查是否已有有效缓存,避免重复请求
const status = getCacheStatus();
if (
status.hasPlayRecords &&
status.hasFavorites &&
status.hasSearchHistory &&
status.hasSkipConfigs
) {
return;
}
// 后台静默预加载,不阻塞界面
refreshAllCache().catch((err) => {
console.warn('预加载用户数据失败:', err);
triggerGlobalError('预加载用户数据失败');
});
}
// ---------------- 跳过片头片尾配置相关 API ----------------
/**
* 获取跳过片头片尾配置。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getSkipConfig(
source: string,
id: string
): Promise<SkipConfig | null> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return null;
}
const key = generateStorageKey(source, id);
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSkipConfigs();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSkipConfigs(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步跳过片头片尾配置失败:', err);
});
return cachedData[key] || null;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
`/api/skipconfigs`
);
cacheManager.cacheSkipConfigs(freshData);
return freshData[key] || null;
} catch (err) {
console.error('获取跳过片头片尾配置失败:', err);
triggerGlobalError('获取跳过片头片尾配置失败');
return null;
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (!raw) return null;
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
return configs[key] || null;
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
triggerGlobalError('读取跳过片头片尾配置失败');
return null;
}
}
/**
* 保存跳过片头片尾配置。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function saveSkipConfig(
source: string,
id: string,
config: SkipConfig
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
cachedConfigs[key] = config;
cacheManager.cacheSkipConfigs(cachedConfigs);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: cachedConfigs,
})
);
// 异步同步到数据库
try {
await fetchWithAuth('/api/skipconfigs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, config }),
});
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
triggerGlobalError('保存跳过片头片尾配置失败');
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端保存跳过片头片尾配置到 localStorage');
return;
}
try {
const raw = localStorage.getItem('moontv_skip_configs');
const configs = raw ? (JSON.parse(raw) as Record<string, SkipConfig>) : {};
configs[key] = config;
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: configs,
})
);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
triggerGlobalError('保存跳过片头片尾配置失败');
throw err;
}
}
/**
* 获取所有跳过片头片尾配置。
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/
export async function getAllSkipConfigs(): Promise<Record<string, SkipConfig>> {
// 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSkipConfigs();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, SkipConfig>>(`/api/skipconfigs`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSkipConfigs(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步跳过片头片尾配置失败:', err);
triggerGlobalError('后台同步跳过片头片尾配置失败');
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, SkipConfig>>(
`/api/skipconfigs`
);
cacheManager.cacheSkipConfigs(freshData);
return freshData;
} catch (err) {
console.error('获取跳过片头片尾配置失败:', err);
triggerGlobalError('获取跳过片头片尾配置失败');
return {};
}
}
}
// localStorage 模式
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (!raw) return {};
return JSON.parse(raw) as Record<string, SkipConfig>;
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
triggerGlobalError('读取跳过片头片尾配置失败');
return {};
}
}
/**
* 删除跳过片头片尾配置。
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/
export async function deleteSkipConfig(
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash
if (STORAGE_TYPE !== 'localstorage') {
// 立即更新缓存
const cachedConfigs = cacheManager.getCachedSkipConfigs() || {};
delete cachedConfigs[key];
cacheManager.cacheSkipConfigs(cachedConfigs);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: cachedConfigs,
})
);
// 异步同步到数据库
try {
await fetchWithAuth(`/api/skipconfigs?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
} catch (err) {
console.error('删除跳过片头片尾配置失败:', err);
triggerGlobalError('删除跳过片头片尾配置失败');
}
return;
}
// localStorage 模式
if (typeof window === 'undefined') {
console.warn('无法在服务端删除跳过片头片尾配置到 localStorage');
return;
}
try {
const raw = localStorage.getItem('moontv_skip_configs');
if (raw) {
const configs = JSON.parse(raw) as Record<string, SkipConfig>;
delete configs[key];
localStorage.setItem('moontv_skip_configs', JSON.stringify(configs));
window.dispatchEvent(
new CustomEvent('skipConfigsUpdated', {
detail: configs,
})
);
}
} catch (err) {
console.error('删除跳过片头片尾配置失败:', err);
triggerGlobalError('删除跳过片头片尾配置失败');
throw err;
}
}
+224
View File
@@ -0,0 +1,224 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { AdminConfig } from './admin.types';
import { RedisStorage } from './redis.db';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
| 'localstorage'
| 'redis'
| 'upstash'
| undefined) || 'localstorage';
// 创建存储实例
function createStorage(): IStorage {
switch (STORAGE_TYPE) {
case 'redis':
return new RedisStorage();
case 'upstash':
return new UpstashRedisStorage();
case 'localstorage':
default:
return null as unknown as IStorage;
}
}
// 单例存储实例
let storageInstance: IStorage | null = null;
export function getStorage(): IStorage {
if (!storageInstance) {
storageInstance = createStorage();
}
return storageInstance;
}
// 工具函数:生成存储key
export function generateStorageKey(source: string, id: string): string {
return `${source}+${id}`;
}
// 导出便捷方法
export class DbManager {
private storage: IStorage;
constructor() {
this.storage = getStorage();
}
// 播放记录相关方法
async getPlayRecord(
userName: string,
source: string,
id: string
): Promise<PlayRecord | null> {
const key = generateStorageKey(source, id);
return this.storage.getPlayRecord(userName, key);
}
async savePlayRecord(
userName: string,
source: string,
id: string,
record: PlayRecord
): Promise<void> {
const key = generateStorageKey(source, id);
await this.storage.setPlayRecord(userName, key, record);
}
async getAllPlayRecords(userName: string): Promise<{
[key: string]: PlayRecord;
}> {
return this.storage.getAllPlayRecords(userName);
}
async deletePlayRecord(
userName: string,
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
await this.storage.deletePlayRecord(userName, key);
}
// 收藏相关方法
async getFavorite(
userName: string,
source: string,
id: string
): Promise<Favorite | null> {
const key = generateStorageKey(source, id);
return this.storage.getFavorite(userName, key);
}
async saveFavorite(
userName: string,
source: string,
id: string,
favorite: Favorite
): Promise<void> {
const key = generateStorageKey(source, id);
await this.storage.setFavorite(userName, key, favorite);
}
async getAllFavorites(
userName: string
): Promise<{ [key: string]: Favorite }> {
return this.storage.getAllFavorites(userName);
}
async deleteFavorite(
userName: string,
source: string,
id: string
): Promise<void> {
const key = generateStorageKey(source, id);
await this.storage.deleteFavorite(userName, key);
}
async isFavorited(
userName: string,
source: string,
id: string
): Promise<boolean> {
const favorite = await this.getFavorite(userName, source, id);
return favorite !== null;
}
// ---------- 用户相关 ----------
async registerUser(userName: string, password: string): Promise<void> {
await this.storage.registerUser(userName, password);
}
async verifyUser(userName: string, password: string): Promise<boolean> {
return this.storage.verifyUser(userName, password);
}
// 检查用户是否已存在
async checkUserExist(userName: string): Promise<boolean> {
return this.storage.checkUserExist(userName);
}
// ---------- 搜索历史 ----------
async getSearchHistory(userName: string): Promise<string[]> {
return this.storage.getSearchHistory(userName);
}
async addSearchHistory(userName: string, keyword: string): Promise<void> {
await this.storage.addSearchHistory(userName, keyword);
}
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
await this.storage.deleteSearchHistory(userName, keyword);
}
// 获取全部用户名
async getAllUsers(): Promise<string[]> {
if (typeof (this.storage as any).getAllUsers === 'function') {
return (this.storage as any).getAllUsers();
}
return [];
}
// ---------- 管理员配置 ----------
async getAdminConfig(): Promise<AdminConfig | null> {
if (typeof (this.storage as any).getAdminConfig === 'function') {
return (this.storage as any).getAdminConfig();
}
return null;
}
async saveAdminConfig(config: AdminConfig): Promise<void> {
if (typeof (this.storage as any).setAdminConfig === 'function') {
await (this.storage as any).setAdminConfig(config);
}
}
// ---------- 跳过片头片尾配置 ----------
async getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null> {
if (typeof (this.storage as any).getSkipConfig === 'function') {
return (this.storage as any).getSkipConfig(userName, source, id);
}
return null;
}
async setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void> {
if (typeof (this.storage as any).setSkipConfig === 'function') {
await (this.storage as any).setSkipConfig(userName, source, id, config);
}
}
async deleteSkipConfig(
userName: string,
source: string,
id: string
): Promise<void> {
if (typeof (this.storage as any).deleteSkipConfig === 'function') {
await (this.storage as any).deleteSkipConfig(userName, source, id);
}
}
async getAllSkipConfigs(
userName: string
): Promise<{ [key: string]: SkipConfig }> {
if (typeof (this.storage as any).getAllSkipConfigs === 'function') {
return (this.storage as any).getAllSkipConfigs(userName);
}
return {};
}
}
// 导出默认实例
export const db = new DbManager();
+479
View File
@@ -0,0 +1,479 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,no-case-declarations */
import { DoubanItem, DoubanResult } from './types';
interface DoubanCategoriesParams {
kind: 'tv' | 'movie';
category: string;
type: string;
pageLimit?: number;
pageStart?: number;
}
interface DoubanCategoryApiResponse {
total: number;
items: Array<{
id: string;
title: string;
card_subtitle: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
interface DoubanListApiResponse {
total: number;
subjects: Array<{
id: string;
title: string;
card_subtitle: string;
cover: string;
rate: string;
}>;
}
interface DoubanRecommendApiResponse {
total: number;
items: Array<{
id: string;
title: string;
year: string;
type: string;
pic: {
large: string;
normal: string;
};
rating: {
value: number;
};
}>;
}
/**
* 带超时的 fetch 请求
*/
async function fetchWithTimeout(
url: string,
proxyUrl: string
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 检查是否使用代理
const finalUrl =
proxyUrl === 'https://cors-anywhere.com/'
? `${proxyUrl}${url}`
: proxyUrl
? `${proxyUrl}${encodeURIComponent(url)}`
: url;
const fetchOptions: RequestInit = {
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, */*',
},
};
try {
const response = await fetch(finalUrl, fetchOptions);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
function getDoubanProxyConfig(): {
proxyType:
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
proxyUrl: string;
} {
const doubanProxyType =
localStorage.getItem('doubanDataSource') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE ||
'direct';
const doubanProxy =
localStorage.getItem('doubanProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
'';
return {
proxyType: doubanProxyType,
proxyUrl: doubanProxy,
};
}
/**
* 浏览器端豆瓣分类数据获取函数
*/
export async function fetchDoubanCategories(
params: DoubanCategoriesParams,
proxyUrl: string,
useTencentCDN = false,
useAliCDN = false
): 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 = useTencentCDN
? `https://m.douban.cmliussss.net/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
: useAliCDN
? `https://m.douban.cmliussss.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`
: `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,
useTencentCDN || useAliCDN ? '' : proxyUrl
);
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) : '',
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣分类数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
/**
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanCategories(
params: DoubanCategoriesParams
): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanCategories(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanCategories(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanCategories(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanCategories(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
);
return response.json();
}
}
interface DoubanListParams {
tag: string;
type: string;
pageLimit?: number;
pageStart?: number;
}
export async function getDoubanList(
params: DoubanListParams
): Promise<DoubanResult> {
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanList(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanList(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanList(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanList(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
);
return response.json();
}
}
export async function fetchDoubanList(
params: DoubanListParams,
proxyUrl: string,
useTencentCDN = false,
useAliCDN = false
): Promise<DoubanResult> {
const { tag, type, pageLimit = 20, pageStart = 0 } = params;
// 验证参数
if (!tag || !type) {
throw new Error('tag 和 type 参数不能为空');
}
if (!['tv', 'movie'].includes(type)) {
throw new Error('type 参数必须是 tv 或 movie');
}
if (pageLimit < 1 || pageLimit > 100) {
throw new Error('pageLimit 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
const target = useTencentCDN
? `https://movie.douban.cmliussss.net/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
: useAliCDN
? `https://movie.douban.cmliussss.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`
: `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
try {
const response = await fetchWithTimeout(
target,
useTencentCDN || useAliCDN ? '' : proxyUrl
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanListApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣列表数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
}
}
interface DoubanRecommendsParams {
kind: 'tv' | 'movie';
pageLimit?: number;
pageStart?: number;
category?: string;
format?: string;
label?: string;
region?: string;
year?: string;
platform?: string;
sort?: string;
}
export async function getDoubanRecommends(
params: DoubanRecommendsParams
): Promise<DoubanResult> {
const {
kind,
pageLimit = 20,
pageStart = 0,
category,
format,
label,
region,
year,
platform,
sort,
} = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig();
switch (proxyType) {
case 'cors-proxy-zwei':
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/');
case 'cmliussss-cdn-tencent':
return fetchDoubanRecommends(params, '', true, false);
case 'cmliussss-cdn-ali':
return fetchDoubanRecommends(params, '', false, true);
case 'cors-anywhere':
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/');
case 'custom':
return fetchDoubanRecommends(params, proxyUrl);
case 'direct':
default:
const response = await fetch(
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
);
return response.json();
}
}
async function fetchDoubanRecommends(
params: DoubanRecommendsParams,
proxyUrl: string,
useTencentCDN = false,
useAliCDN = false
): Promise<DoubanResult> {
const { kind, pageLimit = 20, pageStart = 0 } = params;
let { category, format, region, year, platform, sort, label } = params;
if (category === 'all') {
category = '';
}
if (format === 'all') {
format = '';
}
if (label === 'all') {
label = '';
}
if (region === 'all') {
region = '';
}
if (year === 'all') {
year = '';
}
if (platform === 'all') {
platform = '';
}
if (sort === 'T') {
sort = '';
}
const selectedCategories = { 类型: category } as any;
if (format) {
selectedCategories['形式'] = format;
}
if (region) {
selectedCategories['地区'] = region;
}
const tags = [] as Array<string>;
if (category) {
tags.push(category);
}
if (!category && format) {
tags.push(format);
}
if (label) {
tags.push(label);
}
if (region) {
tags.push(region);
}
if (year) {
tags.push(year);
}
if (platform) {
tags.push(platform);
}
const baseUrl = useTencentCDN
? `https://m.douban.cmliussss.net/rexxar/api/v2/${kind}/recommend`
: useAliCDN
? `https://m.douban.cmliussss.com/rexxar/api/v2/${kind}/recommend`
: `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
const reqParams = new URLSearchParams();
reqParams.append('refresh', '0');
reqParams.append('start', pageStart.toString());
reqParams.append('count', pageLimit.toString());
reqParams.append('selected_categories', JSON.stringify(selectedCategories));
reqParams.append('uncollect', 'false');
reqParams.append('score_range', '0,10');
reqParams.append('tags', tags.join(','));
if (sort) {
reqParams.append('sort', sort);
}
const target = `${baseUrl}?${reqParams.toString()}`;
console.log(target);
try {
const response = await fetchWithTimeout(
target,
useTencentCDN || useAliCDN ? '' : proxyUrl
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanRecommendApiResponse = await response.json();
const list: DoubanItem[] = doubanData.items
.filter((item) => item.type == 'movie' || item.type == 'tv')
.map((item) => ({
id: item.id,
title: item.title,
poster: item.pic?.normal || item.pic?.large || '',
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
year: item.year,
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣推荐数据失败: ${(error as Error).message}`);
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 通用的豆瓣数据获取函数
* @param url 请求的URL
* @returns Promise<T> 返回指定类型的数据
*/
export async function fetchDoubanData<T>(url: string): Promise<T> {
// 添加超时控制
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 {
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;
}
}
+384
View File
@@ -0,0 +1,384 @@
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
import { SearchResult } from '@/lib/types';
import { cleanHtmlTags } from '@/lib/utils';
interface ApiSearchItem {
vod_id: string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_play_url?: string;
vod_class?: string;
vod_year?: string;
vod_content?: string;
vod_douban_id?: number;
type_name?: string;
}
export async function searchFromApi(
apiSite: ApiSite,
query: string
): Promise<SearchResult[]> {
try {
const apiBaseUrl = apiSite.api;
const apiUrl =
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
const apiName = apiSite.name;
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(apiUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
return [];
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
let titles: string[] = [];
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
// 先用 $$$ 分割
const vod_play_url_array = item.vod_play_url.split('$$$');
// 分集之间#分割,标题和播放链接 $ 分割
vod_play_url_array.forEach((url: string) => {
const matchEpisodes: string[] = [];
const matchTitles: string[] = [];
const title_url_array = url.split('#');
title_url_array.forEach((title_url: string) => {
const episode_title_url = title_url.split('$');
if (
episode_title_url.length === 2 &&
episode_title_url[1].endsWith('.m3u8')
) {
matchTitles.push(episode_title_url[0]);
matchEpisodes.push(episode_title_url[1]);
}
});
if (matchEpisodes.length > episodes.length) {
episodes = matchEpisodes;
titles = matchTitles;
}
});
}
return {
id: item.vod_id.toString(),
title: item.vod_name.trim().replace(/\s+/g, ' '),
poster: item.vod_pic,
episodes,
episodes_titles: titles,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year
? item.vod_year.match(/\d{4}/)?.[0] || ''
: 'unknown',
desc: cleanHtmlTags(item.vod_content || ''),
type_name: item.type_name,
douban_id: item.vod_douban_id,
};
});
const config = await getConfig();
const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage;
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
const pageUrl =
apiBaseUrl +
API_CONFIG.search.pagePath
.replace('{query}', encodeURIComponent(query))
.replace('{page}', page.toString());
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(
() => pageController.abort(),
8000
);
const pageResponse = await fetch(pageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal,
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
let titles: string[] = [];
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
// 先用 $$$ 分割
const vod_play_url_array = item.vod_play_url.split('$$$');
// 分集之间#分割,标题和播放链接 $ 分割
vod_play_url_array.forEach((url: string) => {
const matchEpisodes: string[] = [];
const matchTitles: string[] = [];
const title_url_array = url.split('#');
title_url_array.forEach((title_url: string) => {
const episode_title_url = title_url.split('$');
if (
episode_title_url.length === 2 &&
episode_title_url[1].endsWith('.m3u8')
) {
matchTitles.push(episode_title_url[0]);
matchEpisodes.push(episode_title_url[1]);
}
});
if (matchEpisodes.length > episodes.length) {
episodes = matchEpisodes;
titles = matchTitles;
}
});
}
return {
id: item.vod_id.toString(),
title: item.vod_name.trim().replace(/\s+/g, ' '),
poster: item.vod_pic,
episodes,
episodes_titles: titles,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year
? item.vod_year.match(/\d{4}/)?.[0] || ''
: 'unknown',
desc: cleanHtmlTags(item.vod_content || ''),
type_name: item.type_name,
douban_id: item.vod_douban_id,
};
});
} catch (error) {
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
// 等待所有额外页的结果
const additionalResults = await Promise.all(additionalPagePromises);
// 合并所有页的结果
additionalResults.forEach((pageResults) => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
return results;
} catch (error) {
return [];
}
}
// 匹配 m3u8 链接的正则
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
export async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<SearchResult> {
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error('获取到的详情内容无效');
}
const videoDetail = data.list[0];
let episodes: string[] = [];
let titles: string[] = [];
// 处理播放源拆分
if (videoDetail.vod_play_url) {
// 先用 $$$ 分割
const vod_play_url_array = videoDetail.vod_play_url.split('$$$');
// 分集之间#分割,标题和播放链接 $ 分割
vod_play_url_array.forEach((url: string) => {
const matchEpisodes: string[] = [];
const matchTitles: string[] = [];
const title_url_array = url.split('#');
title_url_array.forEach((title_url: string) => {
const episode_title_url = title_url.split('$');
if (
episode_title_url.length === 2 &&
episode_title_url[1].endsWith('.m3u8')
) {
matchTitles.push(episode_title_url[0]);
matchEpisodes.push(episode_title_url[1]);
}
});
if (matchEpisodes.length > episodes.length) {
episodes = matchEpisodes;
titles = matchTitles;
}
});
}
// 如果播放源为空,则尝试从内容中解析 m3u8
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
}
return {
id: id.toString(),
title: videoDetail.vod_name,
poster: videoDetail.vod_pic,
episodes,
episodes_titles: titles,
source: apiSite.key,
source_name: apiSite.name,
class: videoDetail.vod_class,
year: videoDetail.vod_year
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
: 'unknown',
desc: cleanHtmlTags(videoDetail.vod_content),
type_name: videoDetail.type_name,
douban_id: videoDetail.vod_douban_id,
};
}
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<SearchResult> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === 'ffzy') {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
// 去重并清理链接前缀
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf('(');
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
// 根据 matches 数量生成剧集标题
const episodes_titles = Array.from({ length: matches.length }, (_, i) =>
(i + 1).toString()
);
// 提取标题
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
// 提取描述
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
// 提取封面
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
// 提取年份
const yearMatch = html.match(/>(\d{4})</);
const yearText = yearMatch ? yearMatch[1] : 'unknown';
return {
id,
title: titleText,
poster: coverUrl,
episodes: matches,
episodes_titles,
source: apiSite.key,
source_name: apiSite.name,
class: '',
year: yearText,
desc: descText,
type_name: '',
douban_id: 0,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { getAvailableApiSites } from '@/lib/config';
import { SearchResult } from '@/lib/types';
import { getDetailFromApi, searchFromApi } from './downstream';
interface FetchVideoDetailOptions {
source: string;
id: string;
fallbackTitle?: string;
}
/**
* 根据 source 与 id 获取视频详情。
* 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
* 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
*/
export async function fetchVideoDetail({
source,
id,
fallbackTitle = '',
}: FetchVideoDetailOptions): Promise<SearchResult> {
// 优先通过搜索接口查找精确匹配
const apiSites = await getAvailableApiSites();
const apiSite = apiSites.find((site) => site.key === source);
if (!apiSite) {
throw new Error('无效的API来源');
}
if (fallbackTitle) {
try {
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
const exactMatch = searchData.find(
(item: SearchResult) =>
item.source.toString() === source.toString() &&
item.id.toString() === id.toString()
);
if (exactMatch) {
return exactMatch;
}
} catch (error) {
// do nothing
}
}
// 调用 /api/detail 接口
const detail = await getDetailFromApi(apiSite, id);
if (!detail) {
throw new Error('获取视频详情失败');
}
return detail;
}
+434
View File
@@ -0,0 +1,434 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
// 数据类型转换辅助函数
function ensureString(value: any): string {
return String(value);
}
function ensureStringArray(value: any[]): string[] {
return value.map((item) => String(item));
}
// 添加Redis操作重试包装器
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (err: any) {
const isLastAttempt = i === maxRetries - 1;
const isConnectionError =
err.message?.includes('Connection') ||
err.message?.includes('ECONNREFUSED') ||
err.message?.includes('ENOTFOUND') ||
err.code === 'ECONNRESET' ||
err.code === 'EPIPE';
if (isConnectionError && !isLastAttempt) {
console.log(
`Redis operation failed, retrying... (${i + 1}/${maxRetries})`
);
console.error('Error:', err.message);
// 等待一段时间后重试
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
// 尝试重新连接
try {
const client = getRedisClient();
if (!client.isOpen) {
await client.connect();
}
} catch (reconnectErr) {
console.error('Failed to reconnect:', reconnectErr);
}
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
export class RedisStorage implements IStorage {
private client: RedisClientType;
constructor() {
this.client = getRedisClient();
}
// ---------- 播放记录 ----------
private prKey(user: string, key: string) {
return `u:${user}:pr:${key}`; // u:username:pr:source+id
}
async getPlayRecord(
userName: string,
key: string
): Promise<PlayRecord | null> {
const val = await withRetry(() =>
this.client.get(this.prKey(userName, key))
);
return val ? (JSON.parse(val) as PlayRecord) : null;
}
async setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void> {
await withRetry(() =>
this.client.set(this.prKey(userName, key), JSON.stringify(record))
);
}
async getAllPlayRecords(
userName: string
): Promise<Record<string, PlayRecord>> {
const pattern = `u:${userName}:pr:*`;
const keys: string[] = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) return {};
const values = await withRetry(() => this.client.mGet(keys));
const result: Record<string, PlayRecord> = {};
keys.forEach((fullKey: string, idx: number) => {
const raw = values[idx];
if (raw) {
const rec = JSON.parse(raw) as PlayRecord;
// 截取 source+id 部分
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = rec;
}
});
return result;
}
async deletePlayRecord(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.prKey(userName, key)));
}
// ---------- 收藏 ----------
private favKey(user: string, key: string) {
return `u:${user}:fav:${key}`;
}
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
const val = await withRetry(() =>
this.client.get(this.favKey(userName, key))
);
return val ? (JSON.parse(val) as Favorite) : null;
}
async setFavorite(
userName: string,
key: string,
favorite: Favorite
): Promise<void> {
await withRetry(() =>
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
);
}
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
const pattern = `u:${userName}:fav:*`;
const keys: string[] = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) return {};
const values = await withRetry(() => this.client.mGet(keys));
const result: Record<string, Favorite> = {};
keys.forEach((fullKey: string, idx: number) => {
const raw = values[idx];
if (raw) {
const fav = JSON.parse(raw) as Favorite;
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
result[keyPart] = fav;
}
});
return result;
}
async deleteFavorite(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.favKey(userName, key)));
}
// ---------- 用户注册 / 登录 ----------
private userPwdKey(user: string) {
return `u:${user}:pwd`;
}
async registerUser(userName: string, password: string): Promise<void> {
// 简单存储明文密码,生产环境应加密
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
}
async verifyUser(userName: string, password: string): Promise<boolean> {
const stored = await withRetry(() =>
this.client.get(this.userPwdKey(userName))
);
if (stored === null) return false;
// 确保比较时都是字符串类型
return ensureString(stored) === password;
}
// 检查用户是否存在
async checkUserExist(userName: string): Promise<boolean> {
// 使用 EXISTS 判断 key 是否存在
const exists = await withRetry(() =>
this.client.exists(this.userPwdKey(userName))
);
return exists === 1;
}
// 修改用户密码
async changePassword(userName: string, newPassword: string): Promise<void> {
// 简单存储明文密码,生产环境应加密
await withRetry(() =>
this.client.set(this.userPwdKey(userName), newPassword)
);
}
// 删除用户及其所有数据
async deleteUser(userName: string): Promise<void> {
// 删除用户密码
await withRetry(() => this.client.del(this.userPwdKey(userName)));
// 删除搜索历史
await withRetry(() => this.client.del(this.shKey(userName)));
// 删除播放记录
const playRecordPattern = `u:${userName}:pr:*`;
const playRecordKeys = await withRetry(() =>
this.client.keys(playRecordPattern)
);
if (playRecordKeys.length > 0) {
await withRetry(() => this.client.del(playRecordKeys));
}
// 删除收藏夹
const favoritePattern = `u:${userName}:fav:*`;
const favoriteKeys = await withRetry(() =>
this.client.keys(favoritePattern)
);
if (favoriteKeys.length > 0) {
await withRetry(() => this.client.del(favoriteKeys));
}
// 删除跳过片头片尾配置
const skipConfigPattern = `u:${userName}:skip:*`;
const skipConfigKeys = await withRetry(() =>
this.client.keys(skipConfigPattern)
);
if (skipConfigKeys.length > 0) {
await withRetry(() => this.client.del(skipConfigKeys));
}
}
// ---------- 搜索历史 ----------
private shKey(user: string) {
return `u:${user}:sh`; // u:username:sh
}
async getSearchHistory(userName: string): Promise<string[]> {
const result = await withRetry(() =>
this.client.lRange(this.shKey(userName), 0, -1)
);
// 确保返回的都是字符串类型
return ensureStringArray(result as any[]);
}
async addSearchHistory(userName: string, keyword: string): Promise<void> {
const key = this.shKey(userName);
// 先去重
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
// 插入到最前
await withRetry(() => this.client.lPush(key, ensureString(keyword)));
// 限制最大长度
await withRetry(() => this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
}
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
const key = this.shKey(userName);
if (keyword) {
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
} else {
await withRetry(() => this.client.del(key));
}
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
return keys
.map((k) => {
const match = k.match(/^u:(.+?):pwd$/);
return match ? ensureString(match[1]) : undefined;
})
.filter((u): u is string => typeof u === 'string');
}
// ---------- 管理员配置 ----------
private adminConfigKey() {
return 'admin:config';
}
async getAdminConfig(): Promise<AdminConfig | null> {
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
return val ? (JSON.parse(val) as AdminConfig) : null;
}
async setAdminConfig(config: AdminConfig): Promise<void> {
await withRetry(() =>
this.client.set(this.adminConfigKey(), JSON.stringify(config))
);
}
// ---------- 跳过片头片尾配置 ----------
private skipConfigKey(user: string, source: string, id: string) {
return `u:${user}:skip:${source}+${id}`;
}
async getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null> {
const val = await withRetry(() =>
this.client.get(this.skipConfigKey(userName, source, id))
);
return val ? (JSON.parse(val) as SkipConfig) : null;
}
async setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void> {
await withRetry(() =>
this.client.set(
this.skipConfigKey(userName, source, id),
JSON.stringify(config)
)
);
}
async deleteSkipConfig(
userName: string,
source: string,
id: string
): Promise<void> {
await withRetry(() =>
this.client.del(this.skipConfigKey(userName, source, id))
);
}
async getAllSkipConfigs(
userName: string
): Promise<{ [key: string]: SkipConfig }> {
const pattern = `u:${userName}:skip:*`;
const keys = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) {
return {};
}
const configs: { [key: string]: SkipConfig } = {};
// 批量获取所有配置
const values = await withRetry(() => this.client.mGet(keys));
keys.forEach((key, index) => {
const value = values[index];
if (value) {
// 从key中提取source+id
const match = key.match(/^u:.+?:skip:(.+)$/);
if (match) {
const sourceAndId = match[1];
configs[sourceAndId] = JSON.parse(value as string) as SkipConfig;
}
}
});
return configs;
}
}
// 单例 Redis 客户端
function getRedisClient(): RedisClientType {
const globalKey = Symbol.for('__MOONTV_REDIS_CLIENT__');
let client: RedisClientType | undefined = (global as any)[globalKey];
if (!client) {
const url = process.env.REDIS_URL;
if (!url) {
throw new Error('REDIS_URL env variable not set');
}
// 创建客户端,配置重连策略
client = createClient({
url,
socket: {
// 重连策略:指数退避,最大30秒
reconnectStrategy: (retries: number) => {
console.log(`Redis reconnection attempt ${retries + 1}`);
if (retries > 10) {
console.error('Redis max reconnection attempts exceeded');
return false; // 停止重连
}
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
},
connectTimeout: 10000, // 10秒连接超时
// 设置no delay,减少延迟
noDelay: true,
},
// 添加其他配置
pingInterval: 30000, // 30秒ping一次,保持连接活跃
});
// 添加错误事件监听
client.on('error', (err) => {
console.error('Redis client error:', err);
});
client.on('connect', () => {
console.log('Redis connected');
});
client.on('reconnecting', () => {
console.log('Redis reconnecting...');
});
client.on('ready', () => {
console.log('Redis ready');
});
// 初始连接,带重试机制
const connectWithRetry = async () => {
try {
await client!.connect();
console.log('Redis connected successfully');
} catch (err) {
console.error('Redis initial connection failed:', err);
console.log('Will retry in 5 seconds...');
setTimeout(connectWithRetry, 5000);
}
};
connectWithRetry();
(global as any)[globalKey] = client;
}
return client;
}
+120
View File
@@ -0,0 +1,120 @@
import { AdminConfig } from './admin.types';
// 播放记录数据结构
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
year: string;
index: number; // 第几集
total_episodes: number; // 总集数
play_time: number; // 播放进度(秒)
total_time: number; // 总进度(秒)
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
}
// 收藏数据结构
export interface Favorite {
source_name: string;
total_episodes: number; // 总集数
title: string;
year: string;
cover: string;
save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题
}
// 存储接口
export interface IStorage {
// 播放记录相关
getPlayRecord(userName: string, key: string): Promise<PlayRecord | null>;
setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void>;
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
deletePlayRecord(userName: string, key: string): Promise<void>;
// 收藏相关
getFavorite(userName: string, key: string): Promise<Favorite | null>;
setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>;
getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>;
deleteFavorite(userName: string, key: string): Promise<void>;
// 用户相关
registerUser(userName: string, password: string): Promise<void>;
verifyUser(userName: string, password: string): Promise<boolean>;
// 检查用户是否存在(无需密码)
checkUserExist(userName: string): Promise<boolean>;
// 修改用户密码
changePassword(userName: string, newPassword: string): Promise<void>;
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
deleteUser(userName: string): Promise<void>;
// 搜索历史相关
getSearchHistory(userName: string): Promise<string[]>;
addSearchHistory(userName: string, keyword: string): Promise<void>;
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
// 用户列表
getAllUsers(): Promise<string[]>;
// 管理员配置相关
getAdminConfig(): Promise<AdminConfig | null>;
setAdminConfig(config: AdminConfig): Promise<void>;
// 跳过片头片尾配置相关
getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null>;
setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void>;
deleteSkipConfig(userName: string, source: string, id: string): Promise<void>;
getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>;
}
// 搜索结果数据结构
export interface SearchResult {
id: string;
title: string;
poster: string;
episodes: string[];
episodes_titles: string[];
source: string;
source_name: string;
class?: string;
year: string;
desc?: string;
type_name?: string;
douban_id?: number;
}
// 豆瓣数据结构
export interface DoubanItem {
id: string;
title: string;
poster: string;
rate: string;
year: string;
}
export interface DoubanResult {
code: number;
message: string;
list: DoubanItem[];
}
// 跳过片头片尾配置数据结构
export interface SkipConfig {
enable: boolean; // 是否启用跳过片头片尾
intro_time: number; // 片头时间(秒)
outro_time: number; // 片尾时间(秒)
}
+381
View File
@@ -0,0 +1,381 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
import { Redis } from '@upstash/redis';
import { AdminConfig } from './admin.types';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
// 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20;
// 数据类型转换辅助函数
function ensureString(value: any): string {
return String(value);
}
function ensureStringArray(value: any[]): string[] {
return value.map((item) => String(item));
}
// 添加Upstash Redis操作重试包装器
async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (err: any) {
const isLastAttempt = i === maxRetries - 1;
const isConnectionError =
err.message?.includes('Connection') ||
err.message?.includes('ECONNREFUSED') ||
err.message?.includes('ENOTFOUND') ||
err.code === 'ECONNRESET' ||
err.code === 'EPIPE' ||
err.name === 'UpstashError';
if (isConnectionError && !isLastAttempt) {
console.log(
`Upstash Redis operation failed, retrying... (${i + 1}/${maxRetries})`
);
console.error('Error:', err.message);
// 等待一段时间后重试
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
export class UpstashRedisStorage implements IStorage {
private client: Redis;
constructor() {
this.client = getUpstashRedisClient();
}
// ---------- 播放记录 ----------
private prKey(user: string, key: string) {
return `u:${user}:pr:${key}`; // u:username:pr:source+id
}
async getPlayRecord(
userName: string,
key: string
): Promise<PlayRecord | null> {
const val = await withRetry(() =>
this.client.get(this.prKey(userName, key))
);
return val ? (val as PlayRecord) : null;
}
async setPlayRecord(
userName: string,
key: string,
record: PlayRecord
): Promise<void> {
await withRetry(() => this.client.set(this.prKey(userName, key), record));
}
async getAllPlayRecords(
userName: string
): Promise<Record<string, PlayRecord>> {
const pattern = `u:${userName}:pr:*`;
const keys: string[] = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) return {};
const result: Record<string, PlayRecord> = {};
for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) {
// 截取 source+id 部分
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = value as PlayRecord;
}
}
return result;
}
async deletePlayRecord(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.prKey(userName, key)));
}
// ---------- 收藏 ----------
private favKey(user: string, key: string) {
return `u:${user}:fav:${key}`;
}
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
const val = await withRetry(() =>
this.client.get(this.favKey(userName, key))
);
return val ? (val as Favorite) : null;
}
async setFavorite(
userName: string,
key: string,
favorite: Favorite
): Promise<void> {
await withRetry(() =>
this.client.set(this.favKey(userName, key), favorite)
);
}
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
const pattern = `u:${userName}:fav:*`;
const keys: string[] = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) return {};
const result: Record<string, Favorite> = {};
for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey));
if (value) {
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
result[keyPart] = value as Favorite;
}
}
return result;
}
async deleteFavorite(userName: string, key: string): Promise<void> {
await withRetry(() => this.client.del(this.favKey(userName, key)));
}
// ---------- 用户注册 / 登录 ----------
private userPwdKey(user: string) {
return `u:${user}:pwd`;
}
async registerUser(userName: string, password: string): Promise<void> {
// 简单存储明文密码,生产环境应加密
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
}
async verifyUser(userName: string, password: string): Promise<boolean> {
const stored = await withRetry(() =>
this.client.get(this.userPwdKey(userName))
);
if (stored === null) return false;
// 确保比较时都是字符串类型
return ensureString(stored) === password;
}
// 检查用户是否存在
async checkUserExist(userName: string): Promise<boolean> {
// 使用 EXISTS 判断 key 是否存在
const exists = await withRetry(() =>
this.client.exists(this.userPwdKey(userName))
);
return exists === 1;
}
// 修改用户密码
async changePassword(userName: string, newPassword: string): Promise<void> {
// 简单存储明文密码,生产环境应加密
await withRetry(() =>
this.client.set(this.userPwdKey(userName), newPassword)
);
}
// 删除用户及其所有数据
async deleteUser(userName: string): Promise<void> {
// 删除用户密码
await withRetry(() => this.client.del(this.userPwdKey(userName)));
// 删除搜索历史
await withRetry(() => this.client.del(this.shKey(userName)));
// 删除播放记录
const playRecordPattern = `u:${userName}:pr:*`;
const playRecordKeys = await withRetry(() =>
this.client.keys(playRecordPattern)
);
if (playRecordKeys.length > 0) {
await withRetry(() => this.client.del(...playRecordKeys));
}
// 删除收藏夹
const favoritePattern = `u:${userName}:fav:*`;
const favoriteKeys = await withRetry(() =>
this.client.keys(favoritePattern)
);
if (favoriteKeys.length > 0) {
await withRetry(() => this.client.del(...favoriteKeys));
}
// 删除跳过片头片尾配置
const skipConfigPattern = `u:${userName}:skip:*`;
const skipConfigKeys = await withRetry(() =>
this.client.keys(skipConfigPattern)
);
if (skipConfigKeys.length > 0) {
await withRetry(() => this.client.del(...skipConfigKeys));
}
}
// ---------- 搜索历史 ----------
private shKey(user: string) {
return `u:${user}:sh`; // u:username:sh
}
async getSearchHistory(userName: string): Promise<string[]> {
const result = await withRetry(() =>
this.client.lrange(this.shKey(userName), 0, -1)
);
// 确保返回的都是字符串类型
return ensureStringArray(result as any[]);
}
async addSearchHistory(userName: string, keyword: string): Promise<void> {
const key = this.shKey(userName);
// 先去重
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
// 插入到最前
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
// 限制最大长度
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
}
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
const key = this.shKey(userName);
if (keyword) {
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
} else {
await withRetry(() => this.client.del(key));
}
}
// ---------- 获取全部用户 ----------
async getAllUsers(): Promise<string[]> {
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
return keys
.map((k) => {
const match = k.match(/^u:(.+?):pwd$/);
return match ? ensureString(match[1]) : undefined;
})
.filter((u): u is string => typeof u === 'string');
}
// ---------- 管理员配置 ----------
private adminConfigKey() {
return 'admin:config';
}
async getAdminConfig(): Promise<AdminConfig | null> {
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
return val ? (val as AdminConfig) : null;
}
async setAdminConfig(config: AdminConfig): Promise<void> {
await withRetry(() => this.client.set(this.adminConfigKey(), config));
}
// ---------- 跳过片头片尾配置 ----------
private skipConfigKey(user: string, source: string, id: string) {
return `u:${user}:skip:${source}+${id}`;
}
async getSkipConfig(
userName: string,
source: string,
id: string
): Promise<SkipConfig | null> {
const val = await withRetry(() =>
this.client.get(this.skipConfigKey(userName, source, id))
);
return val ? (val as SkipConfig) : null;
}
async setSkipConfig(
userName: string,
source: string,
id: string,
config: SkipConfig
): Promise<void> {
await withRetry(() =>
this.client.set(this.skipConfigKey(userName, source, id), config)
);
}
async deleteSkipConfig(
userName: string,
source: string,
id: string
): Promise<void> {
await withRetry(() =>
this.client.del(this.skipConfigKey(userName, source, id))
);
}
async getAllSkipConfigs(
userName: string
): Promise<{ [key: string]: SkipConfig }> {
const pattern = `u:${userName}:skip:*`;
const keys = await withRetry(() => this.client.keys(pattern));
if (keys.length === 0) {
return {};
}
const configs: { [key: string]: SkipConfig } = {};
// 批量获取所有配置
const values = await withRetry(() => this.client.mget(keys));
keys.forEach((key, index) => {
const value = values[index];
if (value) {
// 从key中提取source+id
const match = key.match(/^u:.+?:skip:(.+)$/);
if (match) {
const sourceAndId = match[1];
configs[sourceAndId] = value as SkipConfig;
}
}
});
return configs;
}
}
// 单例 Upstash Redis 客户端
function getUpstashRedisClient(): Redis {
const globalKey = Symbol.for('__MOONTV_UPSTASH_REDIS_CLIENT__');
let client: Redis | undefined = (global as any)[globalKey];
if (!client) {
const upstashUrl = process.env.UPSTASH_URL;
const upstashToken = process.env.UPSTASH_TOKEN;
if (!upstashUrl || !upstashToken) {
throw new Error(
'UPSTASH_URL and UPSTASH_TOKEN env variables must be set'
);
}
// 创建 Upstash Redis 客户端
client = new Redis({
url: upstashUrl,
token: upstashToken,
// 可选配置
retry: {
retries: 3,
backoff: (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 30000),
},
});
console.log('Upstash Redis client created successfully');
(global as any)[globalKey] = client;
}
return client;
}
+233
View File
@@ -0,0 +1,233 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import he from 'he';
import Hls from 'hls.js';
function getDoubanImageProxyConfig(): {
proxyType:
| 'direct'
| 'server'
| 'img3'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'custom';
proxyUrl: string;
} {
const doubanImageProxyType =
localStorage.getItem('doubanImageProxyType') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
'direct';
const doubanImageProxy =
localStorage.getItem('doubanImageProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
'';
return {
proxyType: doubanImageProxyType,
proxyUrl: doubanImageProxy,
};
}
/**
* 处理图片 URL,如果设置了图片代理则使用代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
// 仅处理豆瓣图片代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
}
}
/**
* 从m3u8地址获取视频质量等级和网络信息
* @param m3u8Url m3u8播放列表的URL
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
*/
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
quality: string; // 如720p、1080p等
loadSpeed: string; // 自动转换为KB/s或MB/s
pingTime: number; // 网络延迟(毫秒)
}> {
try {
// 直接使用m3u8 URL作为视频源,避免CORS问题
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.muted = true;
video.preload = 'metadata';
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
const pingStart = performance.now();
let pingTime = 0;
// 测量ping时间(使用m3u8 URL
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
.then(() => {
pingTime = performance.now() - pingStart;
})
.catch(() => {
pingTime = performance.now() - pingStart; // 记录到失败为止的时间
});
// 固定使用hls.js加载
const hls = new Hls();
// 设置超时处理
const timeout = setTimeout(() => {
hls.destroy();
video.remove();
reject(new Error('Timeout loading video metadata'));
}, 4000);
video.onerror = () => {
clearTimeout(timeout);
hls.destroy();
video.remove();
reject(new Error('Failed to load video metadata'));
};
let actualLoadSpeed = '未知';
let hasSpeedCalculated = false;
let hasMetadataLoaded = false;
let fragmentStartTime = 0;
// 检查是否可以返回结果
const checkAndResolve = () => {
if (
hasMetadataLoaded &&
(hasSpeedCalculated || actualLoadSpeed !== '未知')
) {
clearTimeout(timeout);
const width = video.videoWidth;
if (width && width > 0) {
hls.destroy();
video.remove();
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
const quality =
width >= 3840
? '4K' // 4K: 3840x2160
: width >= 2560
? '2K' // 2K: 2560x1440
: width >= 1920
? '1080p' // 1080p: 1920x1080
: width >= 1280
? '720p' // 720p: 1280x720
: width >= 854
? '480p'
: 'SD'; // 480p: 854x480
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
});
} else {
// webkit 无法获取尺寸,直接返回
resolve({
quality: '未知',
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
});
}
}
};
// 监听片段加载开始
hls.on(Hls.Events.FRAG_LOADING, () => {
fragmentStartTime = performance.now();
});
// 监听片段加载完成,只需首个分片即可计算速度
hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => {
if (
fragmentStartTime > 0 &&
data &&
data.payload &&
!hasSpeedCalculated
) {
const loadTime = performance.now() - fragmentStartTime;
const size = data.payload.byteLength || 0;
if (loadTime > 0 && size > 0) {
const speedKBps = size / 1024 / (loadTime / 1000);
// 立即计算速度,无需等待更多分片
const avgSpeedKBps = speedKBps;
if (avgSpeedKBps >= 1024) {
actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`;
} else {
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
}
hasSpeedCalculated = true;
checkAndResolve(); // 尝试返回结果
}
}
});
hls.loadSource(m3u8Url);
hls.attachMedia(video);
// 监听hls.js错误
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
console.error('HLS错误:', data);
if (data.fatal) {
clearTimeout(timeout);
hls.destroy();
video.remove();
reject(new Error(`HLS播放失败: ${data.type}`));
}
});
// 监听视频元数据加载完成
video.onloadedmetadata = () => {
hasMetadataLoaded = true;
checkAndResolve(); // 尝试返回结果
};
});
} catch (error) {
throw new Error(
`Error getting video resolution: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
export function cleanHtmlTags(text: string): string {
if (!text) return '';
const cleanedText = text
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
.trim(); // 去掉首尾空格
// 使用 he 库解码 HTML 实体
return he.decode(cleanedText);
}
+152
View File
@@ -0,0 +1,152 @@
/* eslint-disable no-console */
'use client';
const CURRENT_VERSION = '1.1.1';
// 版本检查结果枚举
export enum UpdateStatus {
HAS_UPDATE = 'has_update', // 有新版本
NO_UPDATE = 'no_update', // 无新版本
FETCH_FAILED = 'fetch_failed', // 获取失败
}
// 远程版本检查URL配置
const VERSION_CHECK_URLS = [
'https://raw.githubusercontent.com/LunaTechLab/MoonTV/main/VERSION.txt',
'https://cdn.jsdelivr.net/gh/LunaTechLab/moontv/VERSION.txt',
];
/**
* 检查是否有新版本可用
* @returns Promise<UpdateStatus> - 返回版本检查状态
*/
export async function checkForUpdates(): Promise<UpdateStatus> {
try {
// 尝试从主要URL获取版本信息
const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]);
if (primaryVersion) {
return compareVersions(primaryVersion);
}
// 如果主要URL失败,尝试备用URL
const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]);
if (backupVersion) {
return compareVersions(backupVersion);
}
// 如果两个URL都失败,返回获取失败状态
return UpdateStatus.FETCH_FAILED;
} catch (error) {
console.error('版本检查失败:', error);
return UpdateStatus.FETCH_FAILED;
}
}
/**
* 从指定URL获取版本信息
* @param url - 版本信息URL
* @returns Promise<string | null> - 版本字符串或null
*/
async function fetchVersionFromUrl(url: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
// 添加时间戳参数以避免缓存
const timestamp = Date.now();
const urlWithTimestamp = url.includes('?')
? `${url}&_t=${timestamp}`
: `${url}?_t=${timestamp}`;
const response = await fetch(urlWithTimestamp, {
method: 'GET',
signal: controller.signal,
headers: {
'Content-Type': 'text/plain',
},
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const version = await response.text();
return version.trim();
} catch (error) {
console.warn(`${url} 获取版本信息失败:`, error);
return null;
}
}
/**
* 比较版本号
* @param remoteVersion - 远程版本号
* @returns UpdateStatus - 返回版本比较结果
*/
function compareVersions(remoteVersion: string): UpdateStatus {
// 如果版本号相同,无需更新
if (remoteVersion === CURRENT_VERSION) {
return UpdateStatus.NO_UPDATE;
}
try {
// 解析版本号为数字数组 [X, Y, Z]
const currentParts = CURRENT_VERSION.split('.').map((part) => {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0) {
throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`);
}
return num;
});
const remoteParts = remoteVersion.split('.').map((part) => {
const num = parseInt(part, 10);
if (isNaN(num) || num < 0) {
throw new Error(`无效的版本号格式: ${remoteVersion}`);
}
return num;
});
// 标准化版本号到3个部分
const normalizeVersion = (parts: number[]) => {
if (parts.length >= 3) {
return parts.slice(0, 3); // 取前三个元素
} else {
// 不足3个的部分补0
const normalized = [...parts];
while (normalized.length < 3) {
normalized.push(0);
}
return normalized;
}
};
const normalizedCurrent = normalizeVersion(currentParts);
const normalizedRemote = normalizeVersion(remoteParts);
// 逐级比较版本号
for (let i = 0; i < 3; i++) {
if (normalizedRemote[i] > normalizedCurrent[i]) {
return UpdateStatus.HAS_UPDATE;
} else if (normalizedRemote[i] < normalizedCurrent[i]) {
return UpdateStatus.NO_UPDATE;
}
// 如果当前级别相等,继续比较下一级
}
// 所有级别都相等,无需更新
return UpdateStatus.NO_UPDATE;
} catch (error) {
console.error('版本号比较失败:', error);
// 如果版本号格式无效,回退到字符串比较
return remoteVersion !== CURRENT_VERSION
? UpdateStatus.HAS_UPDATE
: UpdateStatus.NO_UPDATE;
}
}
// 导出当前版本号供其他地方使用
export { compareVersions, CURRENT_VERSION };
+22
View File
@@ -0,0 +1,22 @@
export const yellowWords = [
'伦理片',
'福利',
'里番动漫',
'门事件',
'萝莉少女',
'制服诱惑',
'国产传媒',
'cosplay',
'黑丝诱惑',
'无码',
'日本无码',
'有码',
'日本有码',
'SWAG',
'网红主播',
'色情片',
'同性片',
'福利视频',
'福利片',
'写真热舞',
];
+138
View File
@@ -0,0 +1,138 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过不需要认证的路径
if (shouldSkipAuth(pathname)) {
return NextResponse.next();
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (!process.env.PASSWORD) {
// 如果没有设置密码,重定向到警告页面
const warningUrl = new URL('/warning', request.url);
return NextResponse.redirect(warningUrl);
}
// 从cookie获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
return handleAuthFailure(request, pathname);
}
// localstorage模式:在middleware中完成验证
if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return handleAuthFailure(request, pathname);
}
return NextResponse.next();
}
// 其他模式:只验证签名
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
if (!authInfo.username || !authInfo.signature) {
return handleAuthFailure(request, pathname);
}
// 验证签名(如果存在)
if (authInfo.signature) {
const isValidSignature = await verifySignature(
authInfo.username,
authInfo.signature,
process.env.PASSWORD || ''
);
// 签名验证通过即可
if (isValidSignature) {
return NextResponse.next();
}
}
// 签名验证失败或不存在签名
return handleAuthFailure(request, pathname);
}
// 验证签名
async function verifySignature(
data: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
try {
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// 将十六进制字符串转换为Uint8Array
const signatureBuffer = new Uint8Array(
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
);
// 验证签名
return await crypto.subtle.verify(
'HMAC',
key,
signatureBuffer,
messageData
);
} catch (error) {
console.error('签名验证失败:', error);
return false;
}
}
// 处理认证失败的情况
function handleAuthFailure(
request: NextRequest,
pathname: string
): NextResponse {
// 如果是 API 路由,返回 401 状态码
if (pathname.startsWith('/api')) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 否则重定向到登录页面
const loginUrl = new URL('/login', request.url);
// 保留完整的URL,包括查询参数
const fullUrl = `${pathname}${request.nextUrl.search}`;
loginUrl.searchParams.set('redirect', fullUrl);
return NextResponse.redirect(loginUrl);
}
// 判断是否需要跳过认证的路径
function shouldSkipAuth(pathname: string): boolean {
const skipPaths = [
'/_next',
'/favicon.ico',
'/robots.txt',
'/manifest.json',
'/icons/',
'/logo.png',
'/screenshot.png',
];
return skipPaths.some((path) => pathname.startsWith(path));
}
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
],
};
+550
View File
@@ -0,0 +1,550 @@
/* //!STARTERCONF Remove this file after copying your desired color, this is a large file you should remove it. */
.slate {
--tw-color-primary-50: 248 250 252;
--tw-color-primary-100: 241 245 249;
--tw-color-primary-200: 226 232 240;
--tw-color-primary-300: 203 213 225;
--tw-color-primary-400: 148 163 184;
--tw-color-primary-500: 100 116 139;
--tw-color-primary-600: 71 85 105;
--tw-color-primary-700: 51 65 85;
--tw-color-primary-800: 30 41 59;
--tw-color-primary-900: 15 23 42;
--tw-color-primary-950: 2 6 23;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f8fafc */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f1f5f9 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e2e8f0 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #cbd5e1 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #94a3b8 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #64748b */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #475569 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #334155 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e293b */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0f172a */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #020617 */
}
.gray {
--tw-color-primary-50: 249 250 251;
--tw-color-primary-100: 243 244 246;
--tw-color-primary-200: 229 231 235;
--tw-color-primary-300: 209 213 219;
--tw-color-primary-400: 156 163 175;
--tw-color-primary-500: 107 114 128;
--tw-color-primary-600: 75 85 99;
--tw-color-primary-700: 55 65 81;
--tw-color-primary-800: 31 41 55;
--tw-color-primary-900: 17 24 39;
--tw-color-primary-950: 3 7 18;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f9fafb */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3f4f6 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e7eb */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d1d5db */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #9ca3af */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6b7280 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4b5563 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #374151 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1f2937 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #111827 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #030712 */
}
.zinc {
--tw-color-primary-50: 250 250 250;
--tw-color-primary-100: 244 244 245;
--tw-color-primary-200: 228 228 231;
--tw-color-primary-300: 212 212 216;
--tw-color-primary-400: 161 161 170;
--tw-color-primary-500: 113 113 122;
--tw-color-primary-600: 82 82 91;
--tw-color-primary-700: 63 63 70;
--tw-color-primary-800: 39 39 42;
--tw-color-primary-900: 24 24 27;
--tw-color-primary-950: 9 9 11;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f4f4f5 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e4e4e7 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d8 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a1a1aa */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #71717a */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #52525b */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #3f3f46 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #27272a */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #18181b */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #09090b */
}
.neutral {
--tw-color-primary-50: 250 250 250;
--tw-color-primary-100: 245 245 245;
--tw-color-primary-200: 229 229 229;
--tw-color-primary-300: 212 212 212;
--tw-color-primary-400: 163 163 163;
--tw-color-primary-500: 115 115 115;
--tw-color-primary-600: 82 82 82;
--tw-color-primary-700: 64 64 64;
--tw-color-primary-800: 38 38 38;
--tw-color-primary-900: 23 23 23;
--tw-color-primary-950: 10 10 10;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f5 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e5e5 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d4 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3a3a3 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #737373 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #525252 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #404040 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #262626 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #171717 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0a0a0a */
}
.stone {
--tw-color-primary-50: 250 250 249;
--tw-color-primary-100: 245 245 244;
--tw-color-primary-200: 231 229 228;
--tw-color-primary-300: 214 211 209;
--tw-color-primary-400: 168 162 158;
--tw-color-primary-500: 120 113 108;
--tw-color-primary-600: 87 83 78;
--tw-color-primary-700: 68 64 60;
--tw-color-primary-800: 41 37 36;
--tw-color-primary-900: 28 25 23;
--tw-color-primary-950: 12 10 9;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafaf9 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f4 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e7e5e4 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d6d3d1 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a8a29e */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #78716c */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #57534e */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #44403c */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #292524 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1c1917 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0c0a09 */
}
.red {
--tw-color-primary-50: 254 242 242;
--tw-color-primary-100: 254 226 226;
--tw-color-primary-200: 254 202 202;
--tw-color-primary-300: 252 165 165;
--tw-color-primary-400: 248 113 113;
--tw-color-primary-500: 239 68 68;
--tw-color-primary-600: 220 38 38;
--tw-color-primary-700: 185 28 28;
--tw-color-primary-800: 153 27 27;
--tw-color-primary-900: 127 29 29;
--tw-color-primary-950: 69 10 10;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fef2f2 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fee2e2 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecaca */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fca5a5 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f87171 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ef4444 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #dc2626 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b91c1c */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #991b1b */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7f1d1d */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #450a0a */
}
.orange {
--tw-color-primary-50: 255 247 237;
--tw-color-primary-100: 255 237 213;
--tw-color-primary-200: 254 215 170;
--tw-color-primary-300: 253 186 116;
--tw-color-primary-400: 251 146 60;
--tw-color-primary-500: 249 115 22;
--tw-color-primary-600: 234 88 12;
--tw-color-primary-700: 194 65 12;
--tw-color-primary-800: 154 52 18;
--tw-color-primary-900: 124 45 18;
--tw-color-primary-950: 67 20 7;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff7ed */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffedd5 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fed7aa */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fdba74 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb923c */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f97316 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ea580c */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #c2410c */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9a3412 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7c2d12 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #431407 */
}
.amber {
--tw-color-primary-50: 255 251 235;
--tw-color-primary-100: 254 243 199;
--tw-color-primary-200: 253 230 138;
--tw-color-primary-300: 252 211 77;
--tw-color-primary-400: 251 191 36;
--tw-color-primary-500: 245 158 11;
--tw-color-primary-600: 217 119 6;
--tw-color-primary-700: 180 83 9;
--tw-color-primary-800: 146 64 14;
--tw-color-primary-900: 120 53 15;
--tw-color-primary-950: 69 26 3;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fffbeb */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef3c7 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fde68a */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fcd34d */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fbbf24 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f59e0b */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #d97706 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b45309 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #92400e */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #78350f */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #451a03 */
}
.yellow {
--tw-color-primary-50: 254 252 232;
--tw-color-primary-100: 254 249 195;
--tw-color-primary-200: 254 240 138;
--tw-color-primary-300: 253 224 71;
--tw-color-primary-400: 250 204 21;
--tw-color-primary-500: 234 179 8;
--tw-color-primary-600: 202 138 4;
--tw-color-primary-700: 161 98 7;
--tw-color-primary-800: 133 77 14;
--tw-color-primary-900: 113 63 18;
--tw-color-primary-950: 66 32 6;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fefce8 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef9c3 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fef08a */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fde047 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #facc15 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #eab308 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ca8a04 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a16207 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #854d0e */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #713f12 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #422006 */
}
.lime {
--tw-color-primary-50: 247 254 231;
--tw-color-primary-100: 236 252 203;
--tw-color-primary-200: 217 249 157;
--tw-color-primary-300: 190 242 100;
--tw-color-primary-400: 163 230 53;
--tw-color-primary-500: 132 204 22;
--tw-color-primary-600: 101 163 13;
--tw-color-primary-700: 77 124 15;
--tw-color-primary-800: 63 98 18;
--tw-color-primary-900: 54 83 20;
--tw-color-primary-950: 26 46 5;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f7fee7 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ecfccb */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #d9f99d */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #bef264 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3e635 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #84cc16 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #65a30d */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4d7c0f */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3f6212 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #365314 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1a2e05 */
}
.green {
--tw-color-primary-50: 240 253 244;
--tw-color-primary-100: 220 252 231;
--tw-color-primary-200: 187 247 208;
--tw-color-primary-300: 134 239 172;
--tw-color-primary-400: 74 222 128;
--tw-color-primary-500: 34 197 94;
--tw-color-primary-600: 22 163 74;
--tw-color-primary-700: 21 128 61;
--tw-color-primary-800: 22 101 52;
--tw-color-primary-900: 20 83 45;
--tw-color-primary-950: 5 46 22;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdf4 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dcfce7 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bbf7d0 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #86efac */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #4ade80 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #22c55e */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #16a34a */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #15803d */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #166534 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #14532d */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #052e16 */
}
.emerald {
--tw-color-primary-50: 236 253 245;
--tw-color-primary-100: 209 250 229;
--tw-color-primary-200: 167 243 208;
--tw-color-primary-300: 110 231 183;
--tw-color-primary-400: 52 211 153;
--tw-color-primary-500: 16 185 129;
--tw-color-primary-600: 5 150 105;
--tw-color-primary-700: 4 120 87;
--tw-color-primary-800: 6 95 70;
--tw-color-primary-900: 6 78 59;
--tw-color-primary-950: 2 44 34;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfdf5 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #d1fae5 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a7f3d0 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #6ee7b7 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #34d399 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #10b981 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #059669 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #047857 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #065f46 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #064e3b */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #022c22 */
}
.teal {
--tw-color-primary-50: 240 253 250;
--tw-color-primary-100: 204 251 241;
--tw-color-primary-200: 153 246 228;
--tw-color-primary-300: 94 234 212;
--tw-color-primary-400: 45 212 191;
--tw-color-primary-500: 20 184 166;
--tw-color-primary-600: 13 148 136;
--tw-color-primary-700: 15 118 110;
--tw-color-primary-800: 17 94 89;
--tw-color-primary-900: 19 78 74;
--tw-color-primary-950: 4 47 46;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdfa */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ccfbf1 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #99f6e4 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #5eead4 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #2dd4bf */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #14b8a6 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0d9488 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0f766e */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #115e59 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #134e4a */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #042f2e */
}
.cyan {
--tw-color-primary-50: 236 254 255;
--tw-color-primary-100: 207 250 254;
--tw-color-primary-200: 165 243 252;
--tw-color-primary-300: 103 232 249;
--tw-color-primary-400: 34 211 238;
--tw-color-primary-500: 6 182 212;
--tw-color-primary-600: 8 145 178;
--tw-color-primary-700: 14 116 144;
--tw-color-primary-800: 21 94 117;
--tw-color-primary-900: 22 78 99;
--tw-color-primary-950: 8 51 68;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfeff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #cffafe */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a5f3fc */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #67e8f9 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #22d3ee */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #06b6d4 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0891b2 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0e7490 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #155e75 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #164e63 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #083344 */
}
.sky {
--tw-color-primary-50: 240 249 255;
--tw-color-primary-100: 224 242 254;
--tw-color-primary-200: 186 230 253;
--tw-color-primary-300: 125 211 252;
--tw-color-primary-400: 56 189 248;
--tw-color-primary-500: 14 165 233;
--tw-color-primary-600: 2 132 199;
--tw-color-primary-700: 3 105 161;
--tw-color-primary-800: 7 89 133;
--tw-color-primary-900: 12 74 110;
--tw-color-primary-950: 8 47 73;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #082f49 */
}
.blue {
--tw-color-primary-50: 239 246 255;
--tw-color-primary-100: 219 234 254;
--tw-color-primary-200: 191 219 254;
--tw-color-primary-300: 147 197 253;
--tw-color-primary-400: 96 165 250;
--tw-color-primary-500: 59 130 246;
--tw-color-primary-600: 37 99 235;
--tw-color-primary-700: 29 78 216;
--tw-color-primary-800: 30 64 175;
--tw-color-primary-900: 30 58 138;
--tw-color-primary-950: 23 37 84;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eff6ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dbeafe */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bfdbfe */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #93c5fd */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #60a5fa */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #3b82f6 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #2563eb */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #1d4ed8 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e40af */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1e3a8a */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #172554 */
}
.indigo {
--tw-color-primary-50: 238 242 255;
--tw-color-primary-100: 224 231 255;
--tw-color-primary-200: 199 210 254;
--tw-color-primary-300: 165 180 252;
--tw-color-primary-400: 129 140 248;
--tw-color-primary-500: 99 102 241;
--tw-color-primary-600: 79 70 229;
--tw-color-primary-700: 67 56 202;
--tw-color-primary-800: 55 48 163;
--tw-color-primary-900: 49 46 129;
--tw-color-primary-950: 30 27 75;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eef2ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0e7ff */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #c7d2fe */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #a5b4fc */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #818cf8 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6366f1 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4f46e5 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4338ca */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3730a3 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #312e81 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1e1b4b */
}
.violet {
--tw-color-primary-50: 245 243 255;
--tw-color-primary-100: 237 233 254;
--tw-color-primary-200: 221 214 254;
--tw-color-primary-300: 196 181 253;
--tw-color-primary-400: 167 139 250;
--tw-color-primary-500: 139 92 246;
--tw-color-primary-600: 124 58 237;
--tw-color-primary-700: 109 40 217;
--tw-color-primary-800: 91 33 182;
--tw-color-primary-900: 76 29 149;
--tw-color-primary-950: 46 16 101;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f5f3ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ede9fe */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #ddd6fe */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #c4b5fd */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a78bfa */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #8b5cf6 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #7c3aed */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #6d28d9 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #5b21b6 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #4c1d95 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #2e1065 */
}
.purple {
--tw-color-primary-50: 250 245 255;
--tw-color-primary-100: 243 232 255;
--tw-color-primary-200: 233 213 255;
--tw-color-primary-300: 216 180 254;
--tw-color-primary-400: 192 132 252;
--tw-color-primary-500: 168 85 247;
--tw-color-primary-600: 147 51 234;
--tw-color-primary-700: 126 34 206;
--tw-color-primary-800: 107 33 168;
--tw-color-primary-900: 88 28 135;
--tw-color-primary-950: 59 7 100;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #faf5ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3e8ff */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e9d5ff */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d8b4fe */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #c084fc */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #a855f7 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #9333ea */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #7e22ce */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #6b21a8 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #581c87 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #3b0764 */
}
.fuchsia {
--tw-color-primary-50: 253 244 255;
--tw-color-primary-100: 250 232 255;
--tw-color-primary-200: 245 208 254;
--tw-color-primary-300: 240 171 252;
--tw-color-primary-400: 232 121 249;
--tw-color-primary-500: 217 70 239;
--tw-color-primary-600: 192 38 211;
--tw-color-primary-700: 162 28 175;
--tw-color-primary-800: 134 25 143;
--tw-color-primary-900: 112 26 117;
--tw-color-primary-950: 74 4 78;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf4ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fae8ff */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #f5d0fe */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f0abfc */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #e879f9 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #d946ef */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #c026d3 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a21caf */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #86198f */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #701a75 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4a044e */
}
.pink {
--tw-color-primary-50: 253 242 248;
--tw-color-primary-100: 252 231 243;
--tw-color-primary-200: 251 207 232;
--tw-color-primary-300: 249 168 212;
--tw-color-primary-400: 244 114 182;
--tw-color-primary-500: 236 72 153;
--tw-color-primary-600: 219 39 119;
--tw-color-primary-700: 190 24 93;
--tw-color-primary-800: 157 23 77;
--tw-color-primary-900: 131 24 67;
--tw-color-primary-950: 80 4 36;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf2f8 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fce7f3 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fbcfe8 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f9a8d4 */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f472b6 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ec4899 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #db2777 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be185d */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9d174d */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #831843 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #500724 */
}
.rose {
--tw-color-primary-50: 255 241 242;
--tw-color-primary-100: 255 228 230;
--tw-color-primary-200: 254 205 211;
--tw-color-primary-300: 253 164 175;
--tw-color-primary-400: 251 113 133;
--tw-color-primary-500: 244 63 94;
--tw-color-primary-600: 225 29 72;
--tw-color-primary-700: 190 18 60;
--tw-color-primary-800: 159 18 57;
--tw-color-primary-900: 136 19 55;
--tw-color-primary-950: 76 5 25;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff1f2 */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffe4e6 */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecdd3 */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fda4af */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb7185 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f43f5e */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #e11d48 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be123c */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9f1239 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #881337 */
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4c0519 */
}
+118
View File
@@ -0,0 +1,118 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* #region /**=========== Primary Color =========== */
/* !STARTERCONF Customize these variable, copy and paste from /styles/colors.css for list of colors */
--tw-color-primary-50: 240 249 255;
--tw-color-primary-100: 224 242 254;
--tw-color-primary-200: 186 230 253;
--tw-color-primary-300: 125 211 252;
--tw-color-primary-400: 56 189 248;
--tw-color-primary-500: 14 165 233;
--tw-color-primary-600: 2 132 199;
--tw-color-primary-700: 3 105 161;
--tw-color-primary-800: 7 89 133;
--tw-color-primary-900: 12 74 110;
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
/* #endregion /**======== Primary Color =========== */
}
@layer base {
/* inter var - latin */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: block;
src: url('/fonts/inter-var-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
.cursor-newtab {
cursor: url('/images/new-tab.png') 10 10, pointer;
}
/* #region /**=========== Typography =========== */
.h0 {
@apply font-primary text-3xl font-bold md:text-5xl;
}
h1,
.h1 {
@apply font-primary text-2xl font-bold md:text-4xl;
}
h2,
.h2 {
@apply font-primary text-xl font-bold md:text-3xl;
}
h3,
.h3 {
@apply font-primary text-lg font-bold md:text-2xl;
}
h4,
.h4 {
@apply font-primary text-base font-bold md:text-lg;
}
body,
.p {
@apply font-primary text-sm md:text-base;
}
/* #endregion /**======== Typography =========== */
.layout {
/* 1100px */
max-width: 68.75rem;
@apply mx-auto w-11/12;
}
.bg-dark a.custom-link {
@apply border-gray-200 hover:border-gray-200/0;
}
/* Class to adjust with sticky footer */
.min-h-main {
@apply min-h-[calc(100vh-56px)];
}
}
@layer utilities {
.animated-underline {
background-image: linear-gradient(#33333300, #33333300),
linear-gradient(
to right,
var(--color-primary-400),
var(--color-primary-500)
);
background-size: 100% 2px, 0 2px;
background-position: 100% 100%, 0 100%;
background-repeat: no-repeat;
}
@media (prefers-reduced-motion: no-preference) {
.animated-underline {
transition: 0.3s ease;
transition-property: background-size, color, background-color,
border-color;
}
}
.animated-underline:hover,
.animated-underline:focus-visible {
background-size: 0 2px, 100% 2px;
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/* eslint-disable no-console,@typescript-eslint/no-var-requires */
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`
require('./server.js');
// 每 1 秒轮询一次,直到请求成功
const TARGET_URL = `http://${process.env.HOSTNAME || 'localhost'}:${
process.env.PORT || 3000
}/login`;
const intervalId = setInterval(() => {
console.log(`Fetching ${TARGET_URL} ...`);
const req = http.get(TARGET_URL, (res) => {
// 当返回 2xx 状态码时认为成功,然后停止轮询
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Server is up, stop polling.');
clearInterval(intervalId);
// 服务器启动后,立即执行一次 cron 任务
executeCronJob();
// 然后设置每小时执行一次 cron 任务
setInterval(() => {
executeCronJob();
}, 60 * 60 * 1000); // 每小时执行一次
}
});
req.setTimeout(2000, () => {
req.destroy();
});
}, 1000);
// 执行 cron 任务的函数
function executeCronJob() {
const cronUrl = `http://${process.env.HOSTNAME || 'localhost'}:${
process.env.PORT || 3000
}/api/cron`;
console.log(`Executing cron job: ${cronUrl}`);
const req = http.get(cronUrl, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Cron job executed successfully:', data);
} else {
console.error('Cron job failed:', res.statusCode, data);
}
});
});
req.on('error', (err) => {
console.error('Error executing cron job:', err);
});
req.setTimeout(30000, () => {
console.error('Cron job timeout');
req.destroy();
});
}
+91
View File
@@ -0,0 +1,91 @@
import type { Config } from 'tailwindcss';
import defaultTheme from 'tailwindcss/defaultTheme';
const config: Config = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
screens: {
'mobile-landscape': {
raw: '(orientation: landscape) and (max-height: 700px)',
},
},
fontFamily: {
primary: ['Inter', ...defaultTheme.fontFamily.sans],
},
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
dark: '#222222',
},
keyframes: {
flicker: {
'0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100%': {
opacity: '0.99',
filter:
'drop-shadow(0 0 1px rgba(252, 211, 77)) drop-shadow(0 0 15px rgba(245, 158, 11)) drop-shadow(0 0 1px rgba(252, 211, 77))',
},
'20%, 21.999%, 63%, 63.999%, 65%, 69.999%': {
opacity: '0.4',
filter: 'none',
},
},
shimmer: {
'0%': {
backgroundPosition: '-700px 0',
},
'100%': {
backgroundPosition: '700px 0',
},
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideInFromRight: {
'0%': { transform: 'translateX(100%)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' },
},
},
animation: {
flicker: 'flicker 3s linear infinite',
shimmer: 'shimmer 1.3s linear infinite',
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-in-out',
'slide-down': 'slideDown 0.3s ease-in-out',
'slide-in-from-right': 'slideInFromRight 0.3s ease-out',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [require('@tailwindcss/forms')],
} satisfies Config;
export default config;
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "node16",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"~/*": ["./public/*"]
},
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"moduleResolution": ["node_modules", ".next", "node"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"headers": [
{
"source": "/fonts/inter-var-latin.woff2",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
"crons": [
{
"path": "/api/cron",
"schedule": "0 1 * * *"
}
]
}