Compare commits
53 Commits
v0.7.0-katelya
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc78bb11c0 | |||
| e2e3386128 | |||
| 8c01f46fec | |||
| d1e18a5fd4 | |||
| 4c052df342 | |||
| fb5be70529 | |||
| 412ce4c2e7 | |||
| 2937cf8748 | |||
| ab2ee4f7b2 | |||
| 8aeaa629f1 | |||
| 708d204967 | |||
| 89f6196d1f | |||
| 3ce1bd1ce4 | |||
| 62072a5558 | |||
| 87fac5ce53 | |||
| 07cdaafcb2 | |||
| 142c780b50 | |||
| d83e2c6f42 | |||
| 0d4b6537d0 | |||
| 617ad6504d | |||
| bdfad48656 | |||
| f0d2ea9d14 | |||
| a378bad209 | |||
| 87e401738f | |||
| 0874cac2ae | |||
| 427056f4ad | |||
| c484dde326 | |||
| 736bf531f9 | |||
| 0e8ea7003a | |||
| 24e9dd9b5d | |||
| b9c59a3066 | |||
| af192b35ed | |||
| 4c421bcf5f | |||
| 40cbd617ee | |||
| 1811d20d2a | |||
| b83fd3f8c6 | |||
| 2efcf6a812 | |||
| 2197294cce | |||
| 9a5564b3cf | |||
| db08179eb0 | |||
| ff388a8085 | |||
| b1651dabfc | |||
| 88e48b8599 | |||
| 235358c8c2 | |||
| b06665788f | |||
| 86ebbb2cf6 | |||
| c9429efba6 | |||
| 5427dbcb0f | |||
| b255965de3 | |||
| 11779e6d24 | |||
| fac3f4bfc7 | |||
| 9005ed327e | |||
| 0679fe98eb |
@@ -19,6 +19,13 @@ KVROCKS_DATABASE=0
|
||||
NEXTAUTH_SECRET=your_nextauth_secret_here
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# 管理员账号配置(必填)
|
||||
USERNAME=admin
|
||||
PASSWORD=your_admin_password
|
||||
|
||||
# 用户注册配置
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
|
||||
# 站点配置
|
||||
NEXT_PUBLIC_SITE_NAME=KatelyaTV
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION=高性能影视播放平台
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# KatelyaTV Vercel 环境变量配置示例
|
||||
# 复制此文件为 .env.local 并填入实际值
|
||||
|
||||
# ==============================================
|
||||
# 基础配置(必填)
|
||||
# ==============================================
|
||||
|
||||
# 访问密码(必填)
|
||||
PASSWORD=your_secure_password_here
|
||||
|
||||
# 管理员用户名(可选,用于管理界面登录)
|
||||
USERNAME=admin
|
||||
|
||||
# ==============================================
|
||||
# 存储配置
|
||||
# ==============================================
|
||||
|
||||
# 存储类型:localstorage(默认)/ redis / upstash / d1
|
||||
NEXT_PUBLIC_STORAGE_TYPE=localstorage
|
||||
|
||||
# ==============================================
|
||||
# Upstash Redis 配置(选择 upstash 存储时需要)
|
||||
# ==============================================
|
||||
|
||||
# Upstash Redis 连接 URL
|
||||
# UPSTASH_URL=https://xxx.upstash.io
|
||||
|
||||
# Upstash Redis 访问令牌
|
||||
# UPSTASH_TOKEN=AX_xxx
|
||||
|
||||
# ==============================================
|
||||
# Redis 配置(选择 redis 存储时需要)
|
||||
# ==============================================
|
||||
|
||||
# Redis 连接字符串
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ==============================================
|
||||
# 站点配置(可选)
|
||||
# ==============================================
|
||||
|
||||
# 站点显示名称
|
||||
NEXT_PUBLIC_SITE_NAME=KatelyaTV
|
||||
|
||||
# 站点描述
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION=高性能影视播放平台
|
||||
|
||||
# 是否允许用户注册(true/false)
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=false
|
||||
|
||||
# ==============================================
|
||||
# 高级配置(可选)
|
||||
# ==============================================
|
||||
|
||||
# 跨域配置
|
||||
# CORS_ORIGIN=*
|
||||
|
||||
# 启用访问统计(true/false)
|
||||
# ENABLE_ANALYTICS=false
|
||||
|
||||
# Node.js 运行时优化
|
||||
# NODE_OPTIONS=--max-old-space-size=1024
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
# 成人内容过滤功能验证指南
|
||||
|
||||
## 🔒 过滤功能说明
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. **默认行为**:所有用户默认开启成人内容过滤
|
||||
2. **源级别标记**:每个视频源都有 `is_adult` 标记
|
||||
3. **用户设置**:用户可通过设置页面控制过滤开关
|
||||
4. **API 级别分离**:搜索 API 将结果分为 `regular_results` 和 `adult_results`
|
||||
|
||||
### 过滤逻辑
|
||||
|
||||
```typescript
|
||||
// 1. 获取用户设置
|
||||
shouldFilterAdult = userSettings?.filter_adult_content !== false; // 默认true
|
||||
|
||||
// 2. 源分离
|
||||
getAvailableApiSites() // 返回 is_adult: false 的源
|
||||
getAdultApiSites() // 返回 is_adult: true 的源
|
||||
|
||||
// 3. 搜索分离
|
||||
regular_results: [...] // 来自常规源
|
||||
adult_results: [...] // 来自成人源(仅在用户关闭过滤且明确请求时)
|
||||
```
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 添加测试源
|
||||
|
||||
在管理后台添加以下测试源:
|
||||
|
||||
**常规源:**
|
||||
|
||||
```
|
||||
源标识:test_regular
|
||||
源名称:测试常规源
|
||||
API地址:https://okzy.tv/api.php/provide/vod
|
||||
详情地址:https://okzy.tv/api.php/provide/vod/?ac=detail&ids={ids}
|
||||
是否成人内容:❌ 否
|
||||
```
|
||||
|
||||
**成人内容源:**
|
||||
|
||||
```
|
||||
源标识:test_adult
|
||||
源名称:测试成人源
|
||||
API地址:https://adult-test.com/api.php/provide/vod
|
||||
详情地址:https://adult-test.com/api.php/provide/vod/?ac=detail&ids={ids}
|
||||
是否成人内容:✅ 是
|
||||
```
|
||||
|
||||
### 2. 验证过滤开启状态
|
||||
|
||||
- 访问用户设置页面,确认"成人内容过滤"开关为**开启**
|
||||
- 搜索任意关键词,应该只返回常规源的结果
|
||||
- API 响应中 `adult_results` 应为空数组
|
||||
|
||||
### 3. 验证过滤关闭状态
|
||||
|
||||
- 关闭"成人内容过滤"开关
|
||||
- 搜索相同关键词
|
||||
- 应该看到结果分为两组:常规内容 + 成人内容
|
||||
|
||||
### 4. API 级别验证
|
||||
|
||||
```bash
|
||||
# 开启过滤(默认)
|
||||
curl "http://localhost:3001/api/search?q=test"
|
||||
# 预期:adult_results = []
|
||||
|
||||
# 关闭过滤且明确请求成人内容
|
||||
curl "http://localhost:3001/api/search?q=test&include_adult=true" -H "Authorization: Bearer username"
|
||||
# 预期:adult_results 包含成人源结果
|
||||
```
|
||||
|
||||
## ✅ 验证要点
|
||||
|
||||
1. **默认保护**:新用户默认开启过滤
|
||||
2. **源级别隔离**:is_adult 标记正确分离源
|
||||
3. **用户可控**:设置页面可以切换过滤状态
|
||||
4. **API 响应分离**:结果明确分组
|
||||
5. **明确请求**:关闭过滤后需明确请求成人内容才返回
|
||||
|
||||
## 🚨 安全检查
|
||||
|
||||
- [ ] 默认开启过滤
|
||||
- [ ] 设置页面有明确的年龄警告
|
||||
- [ ] API 不会意外返回成人内容
|
||||
- [ ] 源标记 `is_adult: true` 的源被正确隔离
|
||||
- [ ] 前端正确处理分组结果
|
||||
@@ -0,0 +1,266 @@
|
||||
# Cloudflare Pages 成人内容过滤配置指南
|
||||
|
||||
本文档详细说明如何在 Cloudflare Pages 部署中配置成人内容过滤功能。
|
||||
|
||||
## ⚠️ 重要说明
|
||||
|
||||
成人内容过滤功能需要**数据库存储支持**,不能使用默认的 `localstorage` 存储类型。在 Cloudflare Pages 环境下,必须配置 D1 数据库。
|
||||
|
||||
## 🚀 快速配置步骤
|
||||
|
||||
### 1. 创建 D1 数据库
|
||||
|
||||
```bash
|
||||
# 安装并登录 Wrangler CLI
|
||||
npm install -g wrangler
|
||||
wrangler auth login
|
||||
|
||||
# 创建 D1 数据库
|
||||
wrangler d1 create katelyatv-db
|
||||
```
|
||||
|
||||
记录输出的数据库 ID,类似:
|
||||
|
||||
```
|
||||
✅ Successfully created DB 'katelyatv-db' in region APAC
|
||||
Created your database using D1's new storage backend.
|
||||
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### 2. 初始化数据库表
|
||||
|
||||
```bash
|
||||
# 克隆项目(如果还没有)
|
||||
git clone https://github.com/your-username/KatelyaTV.git
|
||||
cd KatelyaTV
|
||||
|
||||
# 初始化数据库表(包含 user_settings 表)
|
||||
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
|
||||
```
|
||||
|
||||
### 3. 配置 wrangler.toml
|
||||
|
||||
在项目根目录创建或更新 `wrangler.toml` 文件:
|
||||
|
||||
```toml
|
||||
name = "katelyatv"
|
||||
compatibility_date = "2023-12-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "katelyatv-db"
|
||||
database_id = "your-database-id-here" # 替换为步骤1中获得的ID
|
||||
|
||||
[build]
|
||||
command = "pnpm install --frozen-lockfile && pnpm run pages:build"
|
||||
|
||||
[[build.environment_variables]]
|
||||
NEXT_PUBLIC_STORAGE_TYPE = "d1"
|
||||
|
||||
[vars]
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "your_password_here"
|
||||
NEXT_PUBLIC_ENABLE_REGISTER = "true"
|
||||
```
|
||||
|
||||
### 4. 部署到 Cloudflare Pages
|
||||
|
||||
#### 方法一:通过 Cloudflare Dashboard
|
||||
|
||||
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||
2. 进入 **Pages** 服务
|
||||
3. 点击 **Create a project**
|
||||
4. 连接 GitHub 仓库并选择 KatelyaTV 项目
|
||||
5. 配置构建设置:
|
||||
- **Build command**: `pnpm install --frozen-lockfile && pnpm run pages:build`
|
||||
- **Build output directory**: `.vercel/output/static`
|
||||
- **Root directory**: 留空
|
||||
6. 在 **Environment variables** 中添加:
|
||||
```
|
||||
NEXT_PUBLIC_STORAGE_TYPE = d1
|
||||
USERNAME = admin
|
||||
PASSWORD = your_password_here
|
||||
NEXT_PUBLIC_ENABLE_REGISTER = true
|
||||
```
|
||||
7. 在 **Functions** 标签页中:
|
||||
- 启用 **Compatibility flags**: `nodejs_compat`
|
||||
- 配置 **D1 database bindings**:
|
||||
- Variable name: `DB`
|
||||
- D1 database: 选择刚创建的数据库
|
||||
|
||||
#### 方法二:通过命令行部署
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run pages:build
|
||||
|
||||
# 部署到 Pages
|
||||
wrangler pages deploy .vercel/output/static --project-name katelyatv
|
||||
```
|
||||
|
||||
## 🔍 验证配置
|
||||
|
||||
部署完成后,访问你的网站:
|
||||
|
||||
1. **登录系统**:使用配置的用户名密码登录
|
||||
2. **访问设置页面**:点击用户菜单中的「内容过滤」
|
||||
3. **检查功能**:应该能够看到成人内容过滤开关,而不是"获取用户设置失败"错误
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 错误:"获取用户设置失败"
|
||||
|
||||
**可能原因**:
|
||||
|
||||
- 未配置 D1 数据库
|
||||
- `NEXT_PUBLIC_STORAGE_TYPE` 未设置为 `d1`
|
||||
- 数据库中缺少 `user_settings` 表
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查环境变量配置
|
||||
2. 验证 D1 数据库绑定
|
||||
3. 执行数据库迁移:
|
||||
```bash
|
||||
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
|
||||
```
|
||||
|
||||
### 错误:D1 数据库连接失败
|
||||
|
||||
**可能原因**:
|
||||
|
||||
- wrangler.toml 中的数据库配置错误
|
||||
- Cloudflare Pages 中的 D1 绑定未正确配置
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 验证 `wrangler.toml` 中的 database_id 是否正确
|
||||
2. 在 Cloudflare Pages Dashboard 中检查 Functions → D1 database bindings
|
||||
3. 确保绑定的变量名为 `DB`
|
||||
|
||||
### 🚨 错误:功能正常但开关无法操作(重要修复)
|
||||
|
||||
**问题描述**:
|
||||
|
||||
- 页面不再显示"获取用户设置失败"错误
|
||||
- 但成人内容过滤开关无法切换,点击无响应
|
||||
|
||||
**根本原因**:
|
||||
数据库表结构与代码期望的格式不匹配
|
||||
|
||||
**完整解决方案**:
|
||||
|
||||
#### 第一步:重建兼容表结构
|
||||
|
||||
在 Cloudflare D1 Console 中执行以下 SQL:
|
||||
|
||||
```sql
|
||||
-- 删除现有的不兼容表
|
||||
DROP TABLE IF EXISTS user_settings;
|
||||
|
||||
-- 创建与代码完全兼容的表结构
|
||||
CREATE TABLE user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
settings TEXT NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 添加必要索引
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_updated_time ON user_settings(updated_time DESC);
|
||||
```
|
||||
|
||||
#### 第二步:插入用户设置数据
|
||||
|
||||
```sql
|
||||
-- 插入设置数据(请替换 'your_username' 为实际用户名)
|
||||
INSERT INTO user_settings (username, settings, updated_time) VALUES (
|
||||
'your_username',
|
||||
'{"filter_adult_content":true,"theme":"auto","language":"zh-CN","auto_play":true,"video_quality":"auto"}',
|
||||
strftime('%s', 'now')
|
||||
);
|
||||
```
|
||||
|
||||
#### 第三步:验证数据正确性
|
||||
|
||||
```sql
|
||||
-- 验证数据插入成功
|
||||
SELECT * FROM user_settings WHERE username = 'your_username';
|
||||
```
|
||||
|
||||
#### 第四步:重新部署并测试
|
||||
|
||||
1. 在 Cloudflare Pages 中触发重新部署
|
||||
2. 清除浏览器缓存并重新登录
|
||||
3. 测试成人内容过滤开关功能
|
||||
|
||||
**重要说明**:
|
||||
|
||||
- `settings` 字段必须是有效的 JSON 字符串
|
||||
- `filter_adult_content` 为 `true` 表示开启过滤
|
||||
- `updated_time` 使用 Unix 时间戳格式
|
||||
|
||||
### 错误:构建失败
|
||||
|
||||
**可能原因**:
|
||||
|
||||
- Node.js 兼容性问题
|
||||
- 依赖安装失败
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确保启用了 `nodejs_compat` 兼容性标志
|
||||
2. 检查构建命令是否正确
|
||||
3. 查看构建日志中的具体错误信息
|
||||
|
||||
## 📊 数据库监控
|
||||
|
||||
在 Cloudflare Dashboard 中可以监控 D1 数据库的使用情况:
|
||||
|
||||
1. 进入 **D1** 服务
|
||||
2. 选择数据库实例
|
||||
3. 查看 **Metrics** 标签页
|
||||
4. 监控查询次数、存储使用量等指标
|
||||
|
||||
## 🔒 安全建议
|
||||
|
||||
1. **密码安全**:使用强密码,避免使用默认密码
|
||||
2. **环境变量**:敏感信息通过环境变量配置,不要硬编码
|
||||
3. **用户注册**:根据需要开启或关闭用户注册功能
|
||||
4. **访问控制**:考虑使用 Cloudflare Access 进一步控制访问
|
||||
|
||||
## 🆕 更新和迁移
|
||||
|
||||
当项目更新包含数据库结构变更时:
|
||||
|
||||
1. **备份数据**:
|
||||
|
||||
```bash
|
||||
wrangler d1 export katelyatv-db --output backup.sql
|
||||
```
|
||||
|
||||
2. **执行迁移**:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute katelyatv-db --file=D1_MIGRATION.md的SQL脚本
|
||||
```
|
||||
|
||||
3. **验证功能**:确保所有功能正常工作
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [D1 数据库迁移文档](./D1_MIGRATION.md)
|
||||
- [Cloudflare Pages 官方文档](https://developers.cloudflare.com/pages/)
|
||||
- [D1 数据库文档](https://developers.cloudflare.com/d1/)
|
||||
- [Wrangler CLI 文档](https://developers.cloudflare.com/workers/wrangler/)
|
||||
|
||||
## 💬 需要帮助?
|
||||
|
||||
如果在配置过程中遇到问题:
|
||||
|
||||
1. 检查本文档的故障排除部分
|
||||
2. 查看项目的 GitHub Issues
|
||||
3. 提交新的 Issue 并提供详细的错误信息和配置详情
|
||||
@@ -0,0 +1,284 @@
|
||||
# Cloudflare Pages 部署指南
|
||||
|
||||
## 🚨 500 Internal Server Error 解决方案
|
||||
|
||||
### 问题:部署成功但运行时500错误
|
||||
|
||||
**原因分析:**
|
||||
|
||||
部署日志显示构建成功,但访问网站时出现500错误,通常是由于环境变量配置问题导致的运行时错误。
|
||||
|
||||
**主要原因:**
|
||||
|
||||
1. **USERNAME 环境变量缺失** - 这是最常见的原因
|
||||
2. D1 数据库绑定问题
|
||||
3. 其他关键环境变量未设置
|
||||
|
||||
### 🎯 解决步骤
|
||||
|
||||
#### 第一步:设置必需的环境变量
|
||||
|
||||
在 Cloudflare Pages 控制台中:
|
||||
|
||||
1. 进入您的项目设置
|
||||
2. 点击 "Settings" → "Environment variables"
|
||||
3. 添加以下**必需**环境变量:
|
||||
|
||||
**USERNAME 变量:**
|
||||
- **Variable name**: `USERNAME`
|
||||
- **Value**: `katelya` (您的站长用户名)
|
||||
- **Type**: Plain text
|
||||
- **Environment**: Production 和 Preview 都要添加
|
||||
|
||||
**PASSWORD 变量:** ⭐ **关键变量**
|
||||
- **Variable name**: `PASSWORD`
|
||||
- **Value**: `您设置的访问密码`
|
||||
- **Type**: Plain text (**重要:不要选择密码类型**)
|
||||
- **Environment**: Production 和 Preview 都要添加
|
||||
|
||||
4. 点击 "Save"
|
||||
5. 重新部署项目
|
||||
|
||||
#### 第二步:验证其他环境变量
|
||||
|
||||
确保以下环境变量已正确设置:
|
||||
|
||||
```bash
|
||||
# ⭐ 关键必需变量(缺一不可)
|
||||
USERNAME=katelya # 站长用户名
|
||||
PASSWORD=your-secure-password # 访问密码
|
||||
NEXT_PUBLIC_STORAGE_TYPE=d1 # 存储类型
|
||||
NEXT_PUBLIC_SITE_NAME=KatelyaTV # 站点名称
|
||||
NODE_ENV=production # 运行环境
|
||||
|
||||
# 推荐设置
|
||||
NEXTAUTH_URL=https://your-domain.pages.dev
|
||||
IMAGE_PROXY_ENABLED=true
|
||||
```
|
||||
|
||||
**⚠️ 重要提醒:**
|
||||
|
||||
- `PASSWORD` 必须设置为 "Plain text" 类型,不要选择 "Secret" 或 "Password" 类型
|
||||
- `PASSWORD` 的值应该与您之前在本地或其他环境中使用的密码一致
|
||||
|
||||
#### 第三步:检查 D1 数据库绑定
|
||||
|
||||
1. 确保在 Cloudflare Pages 中绑定了 D1 数据库
|
||||
2. 绑定名称应为 `DB`
|
||||
3. 数据库应已初始化(运行过初始化脚本)
|
||||
|
||||
## 部署问题修复
|
||||
|
||||
### 问题1:Edge Runtime 配置错误
|
||||
|
||||
**错误信息:**
|
||||
|
||||
```text
|
||||
The following routes were not configured to run with the Edge Runtime:
|
||||
- /api/test/simple
|
||||
|
||||
Please make sure that all your non-static routes export the following edge runtime route segment config:
|
||||
export const runtime = 'edge';
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
✅ 已修复:删除了空的 `/api/test/simple/route.ts` 文件和相关目录。
|
||||
|
||||
**验证:**
|
||||
|
||||
所有API路由现在都正确配置了 `export const runtime = 'edge';`
|
||||
|
||||
### 问题2:Windows环境下的bash依赖问题
|
||||
|
||||
**错误信息:**
|
||||
|
||||
```text
|
||||
Error: spawn bash ENOENT
|
||||
```
|
||||
|
||||
**原因:**
|
||||
|
||||
`@cloudflare/next-on-pages` 在Windows环境下需要bash来执行构建过程。
|
||||
|
||||
**解决方案选项:**
|
||||
|
||||
#### 选项1:使用 WSL (推荐)
|
||||
|
||||
1. 安装 Windows Subsystem for Linux (WSL)
|
||||
2. 在WSL环境中运行构建命令
|
||||
|
||||
#### 选项2:使用 Git Bash
|
||||
|
||||
1. 确保已安装 Git for Windows
|
||||
2. 在Git Bash中运行构建命令:
|
||||
|
||||
```bash
|
||||
pnpm run pages:build
|
||||
```
|
||||
|
||||
#### 选项3:云端构建
|
||||
|
||||
直接在Cloudflare Pages的CI/CD环境中构建,因为云环境通常是Linux系统。
|
||||
|
||||
## 正确的部署步骤
|
||||
|
||||
### 1. 本地验证构建
|
||||
|
||||
```bash
|
||||
# 生成运行时配置
|
||||
pnpm run gen:runtime
|
||||
|
||||
# 生成manifest
|
||||
pnpm run gen:manifest
|
||||
|
||||
# Next.js 构建
|
||||
npx next build
|
||||
|
||||
# Cloudflare Pages 适配 (在Linux/WSL环境中)
|
||||
npx @cloudflare/next-on-pages
|
||||
```
|
||||
|
||||
### 2. Cloudflare Pages 配置
|
||||
|
||||
在Cloudflare Pages控制台中设置:
|
||||
|
||||
**构建配置:**
|
||||
|
||||
- 构建命令: `pnpm install --frozen-lockfile && pnpm run pages:build`
|
||||
- 构建输出目录: `.vercel/output/static`
|
||||
- Node.js 版本: `20.x`
|
||||
|
||||
**环境变量:** (已在 `wrangler.toml` 中配置)
|
||||
|
||||
- `NEXT_PUBLIC_STORAGE_TYPE=d1`
|
||||
- `NEXT_PUBLIC_SITE_NAME=KatelyaTV`
|
||||
- 其他变量见 `wrangler.toml`
|
||||
|
||||
### 3. 验证部署
|
||||
|
||||
部署成功后,检查:
|
||||
|
||||
1. 所有API路由是否正常工作
|
||||
2. 静态页面是否正确生成
|
||||
3. Edge Runtime是否正常运行
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### API路由问题
|
||||
|
||||
确保所有API文件都包含:
|
||||
|
||||
```typescript
|
||||
export const runtime = 'edge';
|
||||
```
|
||||
|
||||
### 构建失败
|
||||
|
||||
1. 检查所有依赖是否安装完整
|
||||
2. 确认TypeScript编译无错误
|
||||
3. 验证环境变量配置
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 已启用默认代码分割
|
||||
- PWA缓存策略已配置
|
||||
- 静态资源优化已开启
|
||||
|
||||
## 部署状态验证
|
||||
|
||||
部署完成后,访问以下端点验证:
|
||||
|
||||
- `/api/server-config` - 服务器配置
|
||||
- `/api/debug/env` - 环境变量 (开发时)
|
||||
- 主页 `/` - 前端页面
|
||||
|
||||
## 🔧 完整故障排除指南
|
||||
|
||||
### 500错误诊断清单
|
||||
|
||||
#### 1. 环境变量检查
|
||||
|
||||
```bash
|
||||
# 在 Cloudflare Pages 控制台检查这些变量
|
||||
USERNAME=katelya # ❌ 经常缺失
|
||||
PASSWORD=your-password # ❌ 最关键,经常缺失或类型错误
|
||||
NEXT_PUBLIC_STORAGE_TYPE=d1 # ✅ 通常已设置
|
||||
NEXT_PUBLIC_SITE_NAME=KatelyaTV # ✅ 通常已设置
|
||||
NODE_ENV=production # ✅ 通常已设置
|
||||
```
|
||||
|
||||
**特别注意 PASSWORD 变量:**
|
||||
|
||||
- ✅ 正确:类型选择 "Plain text"
|
||||
- ❌ 错误:类型选择 "Secret" 或 "Password"
|
||||
- ❌ 错误:值为空或包含特殊字符
|
||||
|
||||
#### 2. D1 数据库检查
|
||||
|
||||
- [ ] D1 数据库是否已创建
|
||||
- [ ] 数据库是否正确绑定到 Pages 项目
|
||||
- [ ] 绑定名称是否为 `DB`
|
||||
- [ ] 数据库是否已初始化
|
||||
|
||||
#### 3. 常见错误模式
|
||||
|
||||
| 错误现象 | 原因 | 解决方案 |
|
||||
|---------|------|----------|
|
||||
| 500 错误 | PASSWORD 未设置或类型错误 | 设置 PASSWORD 为 Plain text 类型 |
|
||||
| 500 + 管理页面无法访问 | USERNAME 未设置 | 添加 USERNAME 环境变量 |
|
||||
| 重定向到 /warning 页面 | PASSWORD 环境变量缺失 | 检查 PASSWORD 变量设置 |
|
||||
| 500 + 数据库相关错误 | D1 绑定问题 | 检查数据库绑定配置 |
|
||||
| 构建成功但运行失败 | 关键环境变量缺失 | 检查 USERNAME 和 PASSWORD |
|
||||
|
||||
### 验证部署成功
|
||||
|
||||
部署完成后访问以下端点验证:
|
||||
|
||||
```bash
|
||||
# 1. 基本页面
|
||||
https://your-domain.pages.dev/ # 主页
|
||||
https://your-domain.pages.dev/login # 登录页
|
||||
|
||||
# 2. API 端点
|
||||
https://your-domain.pages.dev/api/server-config # 服务器配置
|
||||
https://your-domain.pages.dev/api/debug/env # 环境变量(开发时)
|
||||
|
||||
# 3. 管理功能
|
||||
https://your-domain.pages.dev/admin # 管理后台(需要正确的 USERNAME)
|
||||
```
|
||||
|
||||
### 紧急恢复方案
|
||||
|
||||
如果新部署出现问题:
|
||||
|
||||
1. **立即回滚**
|
||||
|
||||
```bash
|
||||
# 在 Cloudflare Pages 控制台
|
||||
Deployments → 选择之前的工作版本 → Rollback
|
||||
```
|
||||
|
||||
2. **保留环境变量**
|
||||
|
||||
```bash
|
||||
# 记录当前所有环境变量配置
|
||||
# 在新部署前先备份设置
|
||||
```
|
||||
|
||||
3. **分步部署**
|
||||
|
||||
```bash
|
||||
# 1. 先部署代码(不修改环境变量)
|
||||
# 2. 验证基本功能
|
||||
# 3. 逐步添加/修改环境变量
|
||||
```
|
||||
|
||||
## 后续维护
|
||||
|
||||
1. 定期更新依赖
|
||||
2. 监控部署日志
|
||||
3. 备份数据库配置
|
||||
4. 关注Cloudflare Pages更新
|
||||
5. 定期检查环境变量配置
|
||||
6. 监控网站运行状态
|
||||
@@ -0,0 +1,75 @@
|
||||
# Cloudflare Pages 部署修复指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
Cloudflare Pages 部署遇到两个主要问题:
|
||||
|
||||
1. **绑定名称冲突**:
|
||||
```
|
||||
Error: Failed to publish your Function. Got error: Binding name 'PASSWORD' already in use.
|
||||
```
|
||||
|
||||
2. **wrangler.toml 文件损坏**:
|
||||
```
|
||||
ParseError: Unterminated string
|
||||
lineText: 'name = "katelyat[env.preview.vars]'
|
||||
```
|
||||
|
||||
## 解决方案
|
||||
|
||||
✅ **第一步**:将环境变量名从 `PASSWORD` 更改为 `AUTH_PASSWORD` 以避免Cloudflare的保留绑定名称冲突。
|
||||
|
||||
✅ **第二步**:修复损坏的 `wrangler.toml` 文件,文件结构被损坏导致语法错误。
|
||||
|
||||
## 需要的操作
|
||||
|
||||
### 1. 更新 wrangler.toml 配置
|
||||
✅ 已完成 - `PASSWORD` 已更改为 `AUTH_PASSWORD`
|
||||
|
||||
### 2. 更新代码中的引用
|
||||
✅ 已完成 - 所有 `process.env.PASSWORD` 已更改为 `process.env.AUTH_PASSWORD`
|
||||
|
||||
### 3. 在 Cloudflare Pages 控制台中设置环境变量
|
||||
|
||||
由于您提到无法在控制台中直接修改环境变量(因为通过 wrangler.toml 管理),我们需要:
|
||||
|
||||
1. **重新部署项目** - 新的 wrangler.toml 配置会自动设置 `AUTH_PASSWORD` 变量
|
||||
2. **验证环境变量** - 确保 `AUTH_PASSWORD` 正确设置
|
||||
|
||||
### 4. 立即执行步骤
|
||||
|
||||
现在执行以下命令重新部署:
|
||||
|
||||
```powershell
|
||||
git add -A
|
||||
git commit -m "fix: 修复绑定名称冲突 - 将PASSWORD改为AUTH_PASSWORD"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 更新说明
|
||||
|
||||
### 变更的文件:
|
||||
- `wrangler.toml` - 更新环境变量名称
|
||||
- `src/middleware.ts` - 更新认证逻辑
|
||||
- `src/app/api/login/route.ts` - 更新登录验证
|
||||
- `src/app/api/register/route.ts` - 更新注册逻辑
|
||||
|
||||
### 环境变量变更:
|
||||
- `PASSWORD` → `AUTH_PASSWORD`
|
||||
- 功能保持完全一致,只是变量名称改变
|
||||
|
||||
## 预期结果
|
||||
|
||||
部署成功后:
|
||||
1. 不再出现绑定名称冲突错误
|
||||
2. `AUTH_PASSWORD` 环境变量将自动通过 wrangler.toml 设置
|
||||
3. 网站应该正常运行,认证功能正常
|
||||
|
||||
## 验证步骤
|
||||
|
||||
部署完成后:
|
||||
1. 访问您的 Cloudflare Pages 网站
|
||||
2. 尝试登录(用户名: katelya,密码: your-secure-password-here)
|
||||
3. 如果能正常登录,说明修复成功
|
||||
|
||||
如果仍有问题,请检查 Cloudflare Pages 的部署日志。
|
||||
+221
-28
@@ -1,10 +1,138 @@
|
||||
# D1 数据库迁移 - 添加跳过配置功能
|
||||
# D1 数据库迁移 - 添加成人内容过滤和跳过配置功能
|
||||
|
||||
如果您已经有一个运行中的 D1 数据库,需要执行以下 SQL 语句来添加跳过配置支持。
|
||||
如果您已经有一个运行中的 D1 数据库,需要执行以下 SQL 语句来添加成人内容过滤和跳过配置支持。
|
||||
|
||||
## 🗄️ 新增表结构
|
||||
|
||||
### skip_configs 表
|
||||
### user_settings 表(成人内容过滤功能 - 必需)
|
||||
|
||||
这个表用于存储用户## 🔧 故障排除
|
||||
|
||||
### 1. "获取用户设置失败" 错误
|
||||
|
||||
**原因**:缺少 `user_settings` 表
|
||||
**解决**:执行上述迁移 SQL,确保 user_settings 表已创建
|
||||
|
||||
### 2. "表已存在" 错误
|
||||
|
||||
**原因**:表已经创建过了
|
||||
**解决**:这是正常的,`CREATE TABLE IF NOT EXISTS` 语句是安全的
|
||||
|
||||
### 3. 外键约束错误
|
||||
|
||||
**原因**:users 表不存在或结构不匹配
|
||||
**解决**:确保先运行完整的 `./scripts/d1-init.sql` 初始化脚本
|
||||
|
||||
### 4. 🚨 表结构不兼容问题(重要修复)
|
||||
|
||||
**问题描述**:即使表创建成功,仍然显示"获取用户设置失败",开关无法操作
|
||||
|
||||
**原因**:代码期望的表结构与创建的表结构不匹配
|
||||
|
||||
**完整解决方案**:
|
||||
|
||||
#### 第一步:重建兼容的表结构
|
||||
|
||||
在 Cloudflare D1 Console 中执行:
|
||||
|
||||
```sql
|
||||
-- 删除现有表,重新创建完全兼容的结构
|
||||
DROP TABLE IF EXISTS user_settings;
|
||||
|
||||
-- 创建与代码完全匹配的表结构
|
||||
CREATE TABLE user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
settings TEXT NOT NULL,
|
||||
updated_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_updated_time ON user_settings(updated_time DESC);
|
||||
|
||||
-- 插入用户设置(JSON格式,替换为您的用户名)
|
||||
INSERT INTO user_settings (username, settings, updated_time) VALUES (
|
||||
'your_username_here',
|
||||
'{"filter_adult_content":true,"theme":"auto","language":"zh-CN","auto_play":true,"video_quality":"auto"}',
|
||||
strftime('%s', 'now')
|
||||
);
|
||||
```
|
||||
|
||||
#### 第二步:验证数据插入
|
||||
|
||||
```sql
|
||||
-- 验证设置是否正确插入
|
||||
SELECT * FROM user_settings WHERE username = 'your_username_here';
|
||||
```
|
||||
|
||||
#### 第三步:确认环境变量
|
||||
|
||||
在 Cloudflare Pages → Settings → Environment variables 中确认:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_STORAGE_TYPE = d1
|
||||
USERNAME = your_username_here
|
||||
PASSWORD = your_password_here
|
||||
```
|
||||
|
||||
#### 第四步:确认 D1 绑定
|
||||
|
||||
在 Cloudflare Pages → Settings → Functions → D1 database bindings:
|
||||
|
||||
- **Variable name**: `DB`
|
||||
- **D1 database**: 选择您的数据库
|
||||
|
||||
#### 第五步:重新部署并清除缓存
|
||||
|
||||
1. 在 Cloudflare Pages → Deployments 中点击 "Retry deployment"
|
||||
2. 清除浏览器缓存(Ctrl+Shift+Delete)
|
||||
3. 重新登录并测试功能
|
||||
|
||||
**表结构说明**:
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| -------------- | ------- | ------------------------------------- |
|
||||
| `id` | INTEGER | 主键,自动递增 |
|
||||
| `username` | TEXT | 用户名,必须与 users 表中的用户名匹配 |
|
||||
| `settings` | TEXT | 用户设置的 JSON 字符串 |
|
||||
| `updated_time` | INTEGER | 更新时间戳(Unix 时间戳) |
|
||||
|
||||
**settings JSON 格式**:
|
||||
|
||||
````json
|
||||
{
|
||||
"filter_adult_content": true, // 成人内容过滤开关
|
||||
"theme": "auto", // 主题设置
|
||||
"language": "zh-CN", // 语言设置
|
||||
"auto_play": true, // 自动播放
|
||||
"video_quality": "auto" // 视频质量
|
||||
}
|
||||
```:
|
||||
|
||||
```sql
|
||||
-- 创建用户设置表(成人内容过滤功能)
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
filter_adult_content BOOLEAN DEFAULT 1,
|
||||
theme TEXT DEFAULT 'auto',
|
||||
language TEXT DEFAULT 'zh-CN',
|
||||
auto_play BOOLEAN DEFAULT 1,
|
||||
video_quality TEXT DEFAULT 'auto',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, username)
|
||||
);
|
||||
|
||||
-- 为用户设置添加索引以优化查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
|
||||
````
|
||||
|
||||
### skip_configs 表(跳过功能 - 可选)
|
||||
|
||||
这个表用于存储用户的跳过片头片尾配置:
|
||||
|
||||
@@ -12,24 +140,26 @@
|
||||
-- 创建跳过配置表
|
||||
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
segments TEXT NOT NULL,
|
||||
updated_time INTEGER NOT NULL,
|
||||
UNIQUE(username, key)
|
||||
user_id INTEGER NOT NULL,
|
||||
config_key TEXT NOT NULL,
|
||||
start_time INTEGER DEFAULT 0,
|
||||
end_time INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, config_key)
|
||||
);
|
||||
|
||||
-- 为跳过配置添加索引以优化查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_username ON skip_configs(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_username_key ON skip_configs(username, key);
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_username_updated_time ON skip_configs(username, updated_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_user_id ON skip_configs(user_id);
|
||||
```
|
||||
|
||||
## 🚀 执行迁移的方法
|
||||
|
||||
### ⚠️ 重要提示
|
||||
|
||||
如果您在 Cloudflare Pages 使用成人内容过滤功能时遇到"获取用户设置失败"错误,这是因为缺少 `user_settings` 表。**必须执行此迁移**才能使功能正常工作。
|
||||
|
||||
### 方法一:使用 Cloudflare Dashboard(推荐)
|
||||
|
||||
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||
@@ -47,33 +177,96 @@ CREATE INDEX IF NOT EXISTS idx_skip_configs_username_updated_time ON skip_config
|
||||
# 首先登录 Cloudflare
|
||||
wrangler auth login
|
||||
|
||||
# 创建迁移文件
|
||||
echo "-- 上面的SQL代码" > user_settings_migration.sql
|
||||
|
||||
# 执行数据库迁移
|
||||
wrangler d1 execute your-database-name --file=migration.sql
|
||||
wrangler d1 execute your-database-name --file=user_settings_migration.sql
|
||||
```
|
||||
|
||||
其中 `migration.sql` 包含上面的 SQL 代码。
|
||||
### 方法三:使用项目内置迁移脚本
|
||||
|
||||
### 方法三:通过 Pages 函数执行(高级)
|
||||
```bash
|
||||
# 克隆或更新项目代码
|
||||
git pull origin main
|
||||
|
||||
也可以创建一个临时的迁移函数,部署后访问来执行迁移。
|
||||
# 执行完整的D1初始化(包含新表)
|
||||
wrangler d1 execute your-database-name --file=./scripts/d1-init.sql
|
||||
```
|
||||
|
||||
## 📋 字段说明
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| -------------- | ------- | ------------------------------- |
|
||||
| `id` | INTEGER | 主键,自动递增 |
|
||||
| `username` | TEXT | 用户名,关联到用户 |
|
||||
| `key` | TEXT | 配置键,格式:`source+video_id` |
|
||||
| `source` | TEXT | 视频源标识 |
|
||||
| `video_id` | TEXT | 视频 ID |
|
||||
| `title` | TEXT | 视频标题 |
|
||||
| `segments` | TEXT | 跳过片段数据(JSON 格式) |
|
||||
| `updated_time` | INTEGER | 更新时间戳 |
|
||||
### user_settings 表字段
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
| ---------------------- | -------- | -------- | ---------------------- |
|
||||
| `id` | INTEGER | 自增 | 主键 |
|
||||
| `user_id` | INTEGER | 无 | 用户 ID,关联 users 表 |
|
||||
| `username` | TEXT | 无 | 用户名 |
|
||||
| `filter_adult_content` | BOOLEAN | 1(true) | 成人内容过滤开关 |
|
||||
| `theme` | TEXT | 'auto' | 界面主题设置 |
|
||||
| `language` | TEXT | 'zh-CN' | 语言设置 |
|
||||
| `auto_play` | BOOLEAN | 1(true) | 自动播放开关 |
|
||||
| `video_quality` | TEXT | 'auto' | 视频质量偏好 |
|
||||
| `created_at` | DATETIME | 当前时间 | 创建时间 |
|
||||
| `updated_at` | DATETIME | 当前时间 | 更新时间 |
|
||||
|
||||
### skip_configs 表字段
|
||||
|
||||
| 字段名 | 类型 | 默认值 | 说明 |
|
||||
| ------------ | -------- | -------- | ------------------------------- |
|
||||
| `id` | INTEGER | 自增 | 主键 |
|
||||
| `user_id` | INTEGER | 无 | 用户 ID,关联 users 表 |
|
||||
| `config_key` | TEXT | 无 | 配置键,格式:`source+video_id` |
|
||||
| `start_time` | INTEGER | 0 | 跳过开始时间(秒) |
|
||||
| `end_time` | INTEGER | 0 | 跳过结束时间(秒) |
|
||||
| `created_at` | DATETIME | 当前时间 | 创建时间 |
|
||||
| `updated_at` | DATETIME | 当前时间 | 更新时间 |
|
||||
|
||||
## ✅ 迁移验证
|
||||
|
||||
执行迁移后,可以通过以下 SQL 验证表是否创建成功:
|
||||
|
||||
```sql
|
||||
-- 检查 user_settings 表是否存在
|
||||
SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings';
|
||||
|
||||
-- 检查 skip_configs 表是否存在
|
||||
SELECT name FROM sqlite_master WHERE type='table' AND name='skip_configs';
|
||||
|
||||
-- 查看 user_settings 表结构
|
||||
PRAGMA table_info(user_settings);
|
||||
|
||||
-- 查看 skip_configs 表结构
|
||||
PRAGMA table_info(skip_configs);
|
||||
```
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 1. "获取用户设置失败" 错误
|
||||
|
||||
**原因**:缺少 `user_settings` 表
|
||||
**解决**:执行上述迁移 SQL,确保 user_settings 表已创建
|
||||
|
||||
### 2. "表已存在" 错误
|
||||
|
||||
**原因**:表已经创建过了
|
||||
**解决**:这是正常的,`CREATE TABLE IF NOT EXISTS` 语句是安全的
|
||||
|
||||
### 3. 外键约束错误
|
||||
|
||||
**原因**:users 表不存在或结构不匹配
|
||||
**解决**:确保先运行完整的 `./scripts/d1-init.sql` 初始化脚本
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果在迁移过程中遇到问题:
|
||||
|
||||
1. 检查 Cloudflare D1 Dashboard 中的数据库状态
|
||||
2. 确认环境变量 `NEXT_PUBLIC_STORAGE_TYPE=d1` 已设置
|
||||
3. 验证 `wrangler.toml` 中的数据库配置
|
||||
4. 查看项目 Issues 或提交新的问题报告
|
||||
|
||||
```sql
|
||||
-- 检查表是否存在
|
||||
SELECT name FROM sqlite_master WHERE type='table' AND name='skip_configs';
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
- **📖 播放历史**:自动记录观看历史,快速找回看过的内容
|
||||
- **👥 多用户支持**:独立的用户系统,每个用户独享个人数据
|
||||
- **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash)
|
||||
- **🔒 内容过滤**:智能成人内容过滤系统,默认开启安全保护
|
||||
|
||||
### 🚀 部署特性
|
||||
|
||||
@@ -52,175 +53,537 @@
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 推荐方案选择
|
||||
### 💡 方案选择指南
|
||||
|
||||
| 用户类型 | 推荐方案 | 特点 |
|
||||
| ----------- | ---------------- | -------------------- |
|
||||
| 🆕 新手用户 | Docker 单容器 | 最简单,5 分钟部署 |
|
||||
| 👥 多人使用 | Vercel + Upstash | 免费,支持多用户 |
|
||||
| 🏠 自托管 | Docker + Redis | 功能完整,数据可控 |
|
||||
| 🏢 生产环境 | Docker + Kvrocks | 高可靠性,零丢失风险 |
|
||||
| 使用场景 | 推荐方案 | 存储类型 | 成人内容过滤 | 多用户 | 部署难度 |
|
||||
| ------------ | ---------------- | ------------ | ------------ | ------ | -------- |
|
||||
| **个人使用** | Docker 单容器 | localstorage | ❌ | ❌ | ⭐ |
|
||||
| **家庭使用** | Docker + Redis | redis | ✅ | ✅ | ⭐⭐ |
|
||||
| **免费部署** | Vercel + Upstash | upstash | ✅ | ✅ | ⭐⭐⭐ |
|
||||
| **生产环境** | Docker + Kvrocks | kvrocks | ✅ | ✅ | ⭐⭐ |
|
||||
| **全球加速** | Cloudflare Pages | d1 | ✅ | ✅ | ⭐⭐⭐⭐ |
|
||||
|
||||
> 💡 **重要提示**:成人内容过滤功能需要数据库存储支持,不支持 `localstorage` 方式
|
||||
|
||||
---
|
||||
|
||||
## 📋 部署方案
|
||||
|
||||
### 方案一:Docker 单容器(推荐新手)
|
||||
### 方案一:Docker 单容器(最简单)
|
||||
|
||||
**适合场景**:个人使用,最简单的部署方式
|
||||
**特点**:5 分钟部署,个人使用,无多用户功能
|
||||
|
||||
```bash
|
||||
# 一键启动
|
||||
docker run -d \
|
||||
--name katelyatv \
|
||||
-p 3000:3000 \
|
||||
--env PASSWORD=your_password \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/katelya77/katelyatv:latest
|
||||
|
||||
# 访问 http://localhost:3000
|
||||
```
|
||||
|
||||
**自定义配置文件(可选)**:
|
||||
|
||||
```bash
|
||||
# 挂载配置文件
|
||||
docker run -d \
|
||||
--name katelyatv \
|
||||
-p 3000:3000 \
|
||||
--env PASSWORD=your_password \
|
||||
-v ./config.json:/app/config.json:ro \
|
||||
-e PASSWORD=your_password \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/katelya77/katelyatv:latest
|
||||
```
|
||||
|
||||
### 方案二:Docker + Redis(多用户推荐)
|
||||
|
||||
**适合场景**:家庭/团队使用,支持多用户和数据同步
|
||||
**挂载自定义配置**(可选):
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
docker run -d \
|
||||
--name katelyatv \
|
||||
-p 3000:3000 \
|
||||
-e PASSWORD=your_password \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/katelya77/katelyatv:latest
|
||||
```
|
||||
|
||||
### 方案二:Docker + Redis(推荐家庭使用)
|
||||
|
||||
**特点**:完整功能,多用户支持,成人内容过滤
|
||||
|
||||
```bash
|
||||
# 1. 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.redis.yml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.redis.example
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.redis.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
nano .env
|
||||
|
||||
# 启动服务
|
||||
docker compose -f docker-compose.redis.yml up -d
|
||||
```
|
||||
|
||||
**重要环境变量配置**:
|
||||
**编辑 .env 文件**:
|
||||
|
||||
```bash
|
||||
# 存储类型
|
||||
NEXT_PUBLIC_STORAGE_TYPE=redis
|
||||
|
||||
# 管理员账号
|
||||
# 管理员账号(必填)
|
||||
USERNAME=admin
|
||||
PASSWORD=your_admin_password
|
||||
PASSWORD=your_secure_password
|
||||
|
||||
# Redis配置
|
||||
# 存储配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE=redis
|
||||
REDIS_URL=redis://katelyatv-redis:6379
|
||||
|
||||
# 开启用户注册
|
||||
# 功能开关
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
### 方案三:Docker + Kvrocks(高可靠性)
|
||||
|
||||
**适合场景**:生产环境,需要极高的数据可靠性
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.kvrocks.yml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.kvrocks.example
|
||||
cp .env.kvrocks.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
nano .env
|
||||
|
||||
# 启动服务(无密码版本)
|
||||
docker compose -f docker-compose.kvrocks.yml up -d
|
||||
|
||||
# 如需密码认证版本,使用:
|
||||
# docker compose -f docker-compose.kvrocks.auth.yml up -d
|
||||
# 3. 启动服务
|
||||
docker compose -f docker-compose.redis.yml up -d
|
||||
```
|
||||
|
||||
**Kvrocks 优势**:
|
||||
### 方案三:Docker + Kvrocks(生产环境)
|
||||
|
||||
- 🛡️ **极高可靠性**:基于 RocksDB,数据持久化到磁盘
|
||||
- ⚡ **性能优异**:完全兼容 Redis 协议,性能更佳
|
||||
- 💾 **节省内存**:数据存储在磁盘,内存使用量大幅降低
|
||||
**特点**:极高可靠性,数据持久化到磁盘,节省内存
|
||||
|
||||
> 详细部署指南请查看:[Kvrocks 部署文档](docs/KVROCKS_DEPLOYMENT.md)
|
||||
```bash
|
||||
# 1. 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.kvrocks.yml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.kvrocks.example
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.kvrocks.example .env
|
||||
```
|
||||
|
||||
**编辑 .env 文件**:
|
||||
|
||||
```bash
|
||||
# 管理员账号(必填,否则无法登录)
|
||||
USERNAME=admin
|
||||
PASSWORD=your_secure_password
|
||||
|
||||
# 存储配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
||||
KVROCKS_URL=redis://kvrocks:6666
|
||||
|
||||
# 功能开关
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. 启动服务
|
||||
docker compose -f docker-compose.kvrocks.yml up -d
|
||||
```
|
||||
|
||||
### 方案四:Vercel + Upstash(免费推荐)
|
||||
|
||||
**适合场景**:无服务器,免费部署,支持多用户
|
||||
**特点**:完全免费,自动 HTTPS,全球 CDN
|
||||
|
||||
1. **Fork 仓库**:Fork [KatelyaTV](https://github.com/katelya77/KatelyaTV) 到你的 GitHub
|
||||
#### 基础部署
|
||||
|
||||
1. **Fork 项目** → [GitHub 仓库](https://github.com/katelya77/KatelyaTV)
|
||||
2. **部署到 Vercel**:
|
||||
|
||||
- 登录 [Vercel](https://vercel.com/),导入你的仓库
|
||||
- 登录 [Vercel](https://vercel.com/)
|
||||
- 导入刚 Fork 的仓库
|
||||
- 添加环境变量:`PASSWORD=your_password`
|
||||
- 点击 Deploy
|
||||
|
||||
3. **配置多用户(可选)**:
|
||||
```bash
|
||||
# 创建 Upstash Redis 数据库
|
||||
# 在 Vercel 中添加环境变量:
|
||||
NEXT_PUBLIC_STORAGE_TYPE=upstash
|
||||
UPSTASH_URL=https://xxx.upstash.io
|
||||
UPSTASH_TOKEN=your_token
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
USERNAME=admin
|
||||
PASSWORD=admin_password
|
||||
```
|
||||
#### 多用户配置
|
||||
|
||||
### 方案五:Cloudflare + D1(技术爱好者)
|
||||
3. **创建 Upstash 数据库**:
|
||||
|
||||
**适合场景**:全球 CDN 加速,免费但配置稍复杂
|
||||
- 访问 [Upstash](https://upstash.com/)
|
||||
- 创建免费 Redis 数据库
|
||||
- 获取 `UPSTASH_URL` 和 `UPSTASH_TOKEN`
|
||||
|
||||
4. **添加环境变量**:
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/wrangler.toml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.cloudflare.example
|
||||
# 存储配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE=upstash
|
||||
UPSTASH_URL=https://xxx.upstash.io
|
||||
UPSTASH_TOKEN=your_token
|
||||
|
||||
# 创建 D1 数据库
|
||||
# 管理员账号
|
||||
USERNAME=admin
|
||||
PASSWORD=your_password
|
||||
|
||||
# 功能开关
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
5. **重新部署** → Vercel Dashboard → Redeploy
|
||||
|
||||
### 方案五:Cloudflare Pages + D1(全球加速)
|
||||
|
||||
**特点**:全球 CDN,无限带宽,免费 SSL
|
||||
|
||||
#### 快速部署
|
||||
|
||||
1. **Fork 项目** → [GitHub 仓库](https://github.com/katelya77/KatelyaTV)
|
||||
2. **创建 Pages 项目**:
|
||||
|
||||
- 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
|
||||
- Pages → Connect to Git → 选择仓库
|
||||
- 构建设置:
|
||||
```
|
||||
Build command: pnpm install --frozen-lockfile && pnpm run pages:build
|
||||
Build output directory: .vercel/output/static
|
||||
```
|
||||
- 兼容性标志:`nodejs_compat`
|
||||
|
||||
3. **环境变量配置**:
|
||||
|
||||
```bash
|
||||
# 管理员账号
|
||||
USERNAME=admin
|
||||
PASSWORD=your_password
|
||||
|
||||
# 存储配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE=d1
|
||||
|
||||
# 功能开关
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
4. **创建 D1 数据库**(多用户支持):
|
||||
|
||||
```bash
|
||||
# 安装Wrangler CLI
|
||||
npm install -g wrangler
|
||||
wrangler auth login
|
||||
|
||||
# 创建数据库
|
||||
wrangler d1 create katelyatv-db
|
||||
# ⚠️ 重要:确保在项目根目录下运行此命令
|
||||
# 如果遇到文件路径错误,请参考 D1_MIGRATION.md 排查指南
|
||||
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
|
||||
```
|
||||
|
||||
# 部署
|
||||
wrangler pages deploy
|
||||
5. **配置数据库绑定** → 在 `wrangler.toml` 中添加数据库 ID
|
||||
|
||||
---
|
||||
|
||||
## � 故障排除
|
||||
|
||||
### 常见部署问题
|
||||
|
||||
#### Docker + Kvrocks 登录失败 ⚠️
|
||||
|
||||
**症状**:部署成功但无法登录,提示"账号或密码错误"
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保 .env 包含完整配置
|
||||
USERNAME=admin
|
||||
PASSWORD=your_secure_password
|
||||
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
|
||||
# 重启服务应用配置
|
||||
docker compose -f docker-compose.kvrocks.yml down
|
||||
docker compose -f docker-compose.kvrocks.yml up -d
|
||||
```
|
||||
|
||||
#### 构建失败
|
||||
|
||||
```bash
|
||||
# 检查Node.js版本 (需要18+)
|
||||
node --version
|
||||
|
||||
# 清理重装
|
||||
rm -rf node_modules pnpm-lock.yaml
|
||||
pnpm install
|
||||
```
|
||||
|
||||
#### 数据库连接失败
|
||||
|
||||
```bash
|
||||
# Redis连接测试
|
||||
redis-cli -u $REDIS_URL ping
|
||||
|
||||
# D1数据库检查
|
||||
wrangler d1 info your-database-name
|
||||
|
||||
# Upstash连接测试
|
||||
curl -H "Authorization: Bearer $UPSTASH_TOKEN" \
|
||||
$UPSTASH_URL/ping
|
||||
```
|
||||
|
||||
### 环境变量说明
|
||||
|
||||
| 变量名 | 必填 | 说明 | 示例值 |
|
||||
| ----------------------------- | ------ | ------------ | ------------------------ |
|
||||
| `USERNAME` | 是\* | 管理员用户名 | `admin` |
|
||||
| `PASSWORD` | 是 | 访问密码 | `your_password` |
|
||||
| `NEXT_PUBLIC_STORAGE_TYPE` | 否 | 存储类型 | `redis/d1/upstash` |
|
||||
| `NEXT_PUBLIC_ENABLE_REGISTER` | 否 | 用户注册 | `true/false` |
|
||||
| `REDIS_URL` | 否\*\* | Redis 连接 | `redis://localhost:6379` |
|
||||
| `UPSTASH_URL` | 否\*\* | Upstash 地址 | `https://xxx.upstash.io` |
|
||||
| `UPSTASH_TOKEN` | 否\*\* | Upstash 令牌 | `AX_xxx` |
|
||||
|
||||
> \*多用户部署必填 \*\*对应存储类型必填
|
||||
|
||||
### 视频源配置
|
||||
|
||||
#### 推荐配置文件
|
||||
|
||||
- **基础版**(20+站点):[config_isadult.json](https://www.mediafire.com/file/upztrjc0g1ynbzy/config_isadult.json/file)
|
||||
- **增强版**(94 站点):[configplus_isadult.json](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
|
||||
|
||||
#### 配置方式
|
||||
|
||||
1. **Docker**:挂载到 `/app/config.json`
|
||||
2. **Vercel/Cloudflare**:提交到仓库根目录
|
||||
3. **管理员界面**:访问 `/admin` 上传配置
|
||||
|
||||
#### 配置格式
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"site1": {
|
||||
"api": "https://api.example.com/provide/vod",
|
||||
"name": "资源站名称",
|
||||
"is_adult": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
## 📱 高级功能使用指南
|
||||
|
||||
### 🔧 环境变量
|
||||
### 🔒 成人内容过滤
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
| ----------------------------- | ---------------- | ------------ |
|
||||
| `PASSWORD` | 访问密码(必填) | 无 |
|
||||
| `USERNAME` | 管理员用户名 | 无 |
|
||||
| `SITE_NAME` | 站点名称 | KatelyaTV |
|
||||
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | localstorage |
|
||||
| `REDIS_URL` | Redis 连接地址 | 无 |
|
||||
| `NEXT_PUBLIC_ENABLE_REGISTER` | 开启用户注册 | false |
|
||||
**功能介绍**:
|
||||
|
||||
### 📁 视频源配置
|
||||
- 智能识别和过滤成人内容资源站
|
||||
- 用户可自主选择开启或关闭过滤功能
|
||||
- 默认开启过滤,确保安全浏览体验
|
||||
- 支持资源分组显示,避免误触
|
||||
|
||||
> **重要**:为保障项目合规性,需要配置视频源才能正常使用。
|
||||
**⚠️ 重要部署要求**:
|
||||
|
||||
#### 方法一:使用推荐配置(推荐)
|
||||
成人内容过滤功能需要服务器端存储支持,**不能使用 `localstorage` 存储类型**。
|
||||
|
||||
| 部署平台 | 推荐存储类型 | 配置要求 |
|
||||
| ---------------- | ------------------- | ------------------------- |
|
||||
| Docker | `redis` / `kvrocks` | 配置 Redis/Kvrocks 服务器 |
|
||||
| Vercel | `upstash` | 配置 Upstash Redis |
|
||||
| Cloudflare Pages | `d1` | 配置 D1 数据库 |
|
||||
|
||||
**Cloudflare Pages 特殊配置**:
|
||||
|
||||
如果你使用 Cloudflare Pages 部署,**必须配置 D1 数据库**才能使用成人内容过滤功能:
|
||||
|
||||
1. **创建 D1 数据库**:
|
||||
|
||||
```bash
|
||||
wrangler d1 create katelyatv-db
|
||||
```
|
||||
|
||||
2. **初始化数据库表**:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
|
||||
```
|
||||
|
||||
3. **配置环境变量**:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_STORAGE_TYPE=d1
|
||||
```
|
||||
|
||||
4. **更新 wrangler.toml**:
|
||||
```toml
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "katelyatv-db"
|
||||
database_id = "your-d1-database-id"
|
||||
```
|
||||
|
||||
**故障排除**:
|
||||
|
||||
- ❌ **错误**:"获取用户设置失败"
|
||||
|
||||
- **原因**:使用了 `localstorage` 存储类型,服务器端 API 无法访问
|
||||
- **解决**:按上述要求配置数据库存储
|
||||
|
||||
- ❌ **错误**:过滤开关无法保存
|
||||
- **原因**:存储后端未正确配置或连接失败
|
||||
- **解决**:检查数据库连接和环境变量配置
|
||||
|
||||
**使用方法**:
|
||||
|
||||
1. **访问用户设置**:
|
||||
|
||||
- 登录后访问 `/settings` 页面
|
||||
- 或在用户菜单中点击「内容过滤」
|
||||
|
||||
2. **配置过滤选项**:
|
||||
|
||||
- 在「内容过滤」部分找到「成人内容过滤」开关
|
||||
- **开启**:完全隐藏成人内容资源站和搜索结果
|
||||
- **关闭**:成人内容在搜索结果中单独分组显示
|
||||
|
||||
3. **搜索结果展示**:
|
||||
- **过滤开启时**:只显示常规内容
|
||||
- **过滤关闭时**:显示两个标签页「常规结果」和「成人内容」
|
||||
|
||||
**配置文件格式**:
|
||||
|
||||
```json
|
||||
// config.json 中的资源站配置
|
||||
{
|
||||
"api_site": {
|
||||
"regular_site": {
|
||||
"api": "https://example.com/api.php/provide/vod",
|
||||
"name": "常规影视站",
|
||||
"is_adult": false // 或省略此字段,默认为 false
|
||||
},
|
||||
"adult_site": {
|
||||
"api": "https://adult.example.com/api.php/provide/vod",
|
||||
"name": "成人内容站",
|
||||
"is_adult": true // 标记为成人内容
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**安全提示**:
|
||||
|
||||
- 默认情况下,所有新用户和未登录用户的成人内容过滤均为开启状态
|
||||
- 关闭过滤功能需要用户主动操作,确保使用意图明确
|
||||
- 建议管理员在配置资源站时准确标记 `is_adult` 字段
|
||||
|
||||
**详细配置指南**:
|
||||
|
||||
- 📖 [Cloudflare Pages 成人内容过滤配置指南](./CLOUDFLARE_PAGES_ADULT_FILTER.md)
|
||||
- 🗄️ [D1 数据库迁移说明](./D1_MIGRATION.md)
|
||||
|
||||
### 🎯 跳过片头片尾
|
||||
|
||||
**功能介绍**:
|
||||
|
||||
- 自动识别并跳过片头片尾
|
||||
- 支持手动设置跳过时间点
|
||||
- 多设备同步跳过记录(需配置数据库)
|
||||
|
||||
**使用方法**:
|
||||
|
||||
1. 播放视频时点击「设置」按钮
|
||||
2. 选择「跳过片段设置」
|
||||
3. 设置片头结束时间和片尾开始时间
|
||||
4. 下次播放自动跳过
|
||||
|
||||
**批量设置**:
|
||||
|
||||
```json
|
||||
// 在管理员界面批量导入跳过配置
|
||||
{
|
||||
"skip_settings": {
|
||||
"电视剧名称": {
|
||||
"intro_end": 90, // 片头结束时间(秒)
|
||||
"outro_start": 2700 // 片尾开始时间(秒)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 📺 TVBox 兼容模式
|
||||
|
||||
**配置地址生成**:
|
||||
|
||||
- JSON 格式:`https://你的域名/api/tvbox?format=json`
|
||||
- TXT 格式:`https://你的域名/api/tvbox?format=txt`
|
||||
- XML 格式:`https://你的域名/api/tvbox?format=xml`
|
||||
|
||||
**支持的 TVBox 应用**:
|
||||
|
||||
- TVBox(开源版)
|
||||
- CatVodTVOfficial
|
||||
- EasyBox
|
||||
- FongMi TV
|
||||
- 其他兼容应用
|
||||
|
||||
**配置导入步骤**:
|
||||
|
||||
1. 打开 TVBox 应用
|
||||
2. 进入「配置」或「设置」页面
|
||||
3. 选择「导入配置」或「添加配置」
|
||||
4. 输入上述配置地址
|
||||
5. 等待导入完成
|
||||
|
||||
### 🔄 多设备数据同步
|
||||
|
||||
**支持的数据**:
|
||||
|
||||
- 观看历史记录
|
||||
- 收藏夹内容
|
||||
- 跳过片段设置
|
||||
- 用户偏好配置
|
||||
|
||||
**同步方式对比**:
|
||||
|
||||
| 存储方式 | 同步范围 | 配置难度 | 免费程度 |
|
||||
| ------------ | -------- | ---------- | ---------- |
|
||||
| LocalStorage | 单设备 | 无需配置 | 完全免费 |
|
||||
| Redis | 全同步 | 需要服务器 | 自建免费 |
|
||||
| Upstash | 全同步 | 简单配置 | 有免费额度 |
|
||||
| D1 | 全同步 | 中等难度 | 完全免费 |
|
||||
| Kvrocks | 全同步 | 需要部署 | 自建免费 |
|
||||
|
||||
### 🎨 界面自定义
|
||||
|
||||
**主题切换**:
|
||||
|
||||
- 支持深色/浅色主题自动切换
|
||||
- 跟随系统主题设置
|
||||
- 手动切换并记忆偏好
|
||||
|
||||
**界面布局**:
|
||||
|
||||
- 响应式设计,适配手机/平板/桌面
|
||||
- 可调节视频播放器大小
|
||||
- 隐藏/显示侧边栏
|
||||
- 自定义首页展示内容
|
||||
|
||||
**个性化设置**:
|
||||
|
||||
```json
|
||||
// 在用户设置中自定义
|
||||
{
|
||||
"ui_preferences": {
|
||||
"theme": "dark", // 主题:dark/light/auto
|
||||
"layout": "grid", // 布局:grid/list
|
||||
"items_per_page": 24, // 每页显示数量
|
||||
"auto_play": true, // 自动播放下一集
|
||||
"video_quality": "auto", // 默认清晰度
|
||||
"subtitle_language": "zh-cn" // 字幕语言偏好
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 数据统计分析
|
||||
|
||||
**管理员面板功能**:
|
||||
|
||||
- 访问量统计图表
|
||||
- 热门内容排行榜
|
||||
- 用户活跃度分析
|
||||
- 系统性能监控
|
||||
|
||||
**访问数据**:
|
||||
|
||||
```bash
|
||||
# 通过管理员界面查看或API获取
|
||||
GET /api/admin/analytics
|
||||
{
|
||||
"daily_visits": 1250,
|
||||
"total_users": 89,
|
||||
"popular_content": [
|
||||
{"title": "热门电影", "views": 456},
|
||||
{"title": "热播剧集", "views": 321}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 下载:[configplus_isadult.json](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
|
||||
- 重命名为 config.json 使用
|
||||
|
||||
1. 下载配置文件:
|
||||
|
||||
- [基础版 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
|
||||
- [Plus 版(94 个片源)](https://www.mediafire.com/file/fbpk1mlupxp3u3v/configplus.json/file)
|
||||
- [基础版 config_isadult.json](https://www.mediafire.com/file/upztrjc0g1ynbzy/config_isadult.json/file)
|
||||
- [Plus 版(94 个片源)](https://www.mediafire.com/file/ff60ynj6z21iqfb/configplus_isadult.json/file)
|
||||
|
||||
2. 配置方式:
|
||||
- **Docker**:挂载配置文件 `-v ./config.json:/app/config.json:ro`
|
||||
@@ -277,95 +640,510 @@ wrangler pages deploy
|
||||
|
||||
### 升级更新
|
||||
|
||||
```bash
|
||||
# Docker 升级
|
||||
docker compose pull && docker compose up -d
|
||||
### 🔄 升级更新
|
||||
|
||||
# 查看服务状态
|
||||
**自动更新检测**:
|
||||
|
||||
- 网站会自动检测新版本
|
||||
- 在管理员界面查看更新状态
|
||||
- 支持一键更新提醒
|
||||
|
||||
**手动更新步骤**:
|
||||
|
||||
**Docker 更新**:
|
||||
|
||||
```bash
|
||||
# 停止并更新服务
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 查看运行状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f
|
||||
# 查看更新日志
|
||||
docker compose logs -f katelyatv
|
||||
```
|
||||
|
||||
### 数据备份
|
||||
**Git 部署更新**:
|
||||
|
||||
```bash
|
||||
# Redis 数据备份
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
# 备份当前配置
|
||||
cp config.json config.json.backup
|
||||
|
||||
# Kvrocks 数据备份
|
||||
docker run --rm \
|
||||
-v katelyatv_kvrocks-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/kvrocks-backup.tar.gz /data
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 安装新依赖
|
||||
pnpm install
|
||||
|
||||
# 重新构建
|
||||
pnpm run build
|
||||
|
||||
# 恢复配置文件
|
||||
cp config.json.backup config.json
|
||||
|
||||
# 重启服务
|
||||
pm2 restart katelyatv
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
**Vercel/Cloudflare 更新**:
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| ---------------- | ------------------------------------------- |
|
||||
| 无法访问 | 检查端口 3000 是否开放 |
|
||||
| 密码错误 | 检查 PASSWORD 环境变量 |
|
||||
| Redis 连接失败 | 检查 Redis 容器状态和网络 |
|
||||
| Kvrocks 认证错误 | 查看 [详细文档](docs/KVROCKS_DEPLOYMENT.md) |
|
||||
- Fork 的仓库会自动接收上游更新提醒
|
||||
- 在 GitHub 中点击 `Sync fork` 同步更新
|
||||
- 平台会自动重新部署
|
||||
|
||||
### 💾 数据备份与恢复
|
||||
|
||||
**备份脚本示例**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh - 完整备份脚本
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="backups/$DATE"
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
echo "开始备份 KatelyaTV 数据..."
|
||||
|
||||
# 备份配置文件
|
||||
cp config.json $BACKUP_DIR/config.json
|
||||
echo "✓ 配置文件备份完成"
|
||||
|
||||
# 根据存储类型备份数据
|
||||
if [ -f .env ] && grep -q "REDIS_URL" .env; then
|
||||
# Redis 数据备份
|
||||
docker compose exec redis redis-cli --rdb $BACKUP_DIR/dump.rdb
|
||||
echo "✓ Redis 数据备份完成"
|
||||
elif [ -f .env ] && grep -q "UPSTASH" .env; then
|
||||
# Upstash 数据导出
|
||||
echo "Upstash 数据需手动在控制台导出"
|
||||
fi
|
||||
|
||||
# Kvrocks 数据备份
|
||||
if docker compose ps | grep -q kvrocks; then
|
||||
docker run --rm \
|
||||
-v katelyatv_kvrocks-data:/data:ro \
|
||||
-v $(pwd)/$BACKUP_DIR:/backup \
|
||||
alpine tar czf /backup/kvrocks-data.tar.gz /data
|
||||
echo "✓ Kvrocks 数据备份完成"
|
||||
fi
|
||||
|
||||
# 压缩备份文件
|
||||
tar -czf "katelyatv-backup-$DATE.tar.gz" -C backups $DATE
|
||||
rm -rf $BACKUP_DIR
|
||||
|
||||
echo "✓ 备份完成: katelyatv-backup-$DATE.tar.gz"
|
||||
```
|
||||
|
||||
**恢复数据**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# restore.sh - 数据恢复脚本
|
||||
BACKUP_FILE="$1"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "用法: $0 <backup-file.tar.gz>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "恢复数据从: $BACKUP_FILE"
|
||||
tar -xzf $BACKUP_FILE
|
||||
|
||||
# 恢复配置文件
|
||||
BACKUP_DIR=$(tar -tzf $BACKUP_FILE | head -1 | cut -f1 -d"/")
|
||||
cp $BACKUP_DIR/config.json ./config.json
|
||||
|
||||
# 恢复数据库
|
||||
if [ -f "$BACKUP_DIR/dump.rdb" ]; then
|
||||
docker compose exec redis redis-cli FLUSHALL
|
||||
docker cp $BACKUP_DIR/dump.rdb redis:/data/dump.rdb
|
||||
docker compose restart redis
|
||||
fi
|
||||
|
||||
echo "✓ 数据恢复完成"
|
||||
```
|
||||
|
||||
### 🔍 故障诊断指南
|
||||
|
||||
**常见问题快速排查**:
|
||||
|
||||
| 问题症状 | 可能原因 | 解决方案 |
|
||||
| -------------- | ----------------------- | ------------------------------- |
|
||||
| 无法访问网站 | 端口未开放/服务未启动 | 检查防火墙和服务状态 |
|
||||
| 视频无法播放 | 配置文件错误/源失效 | 验证 config.json 格式和源可用性 |
|
||||
| 登录失败 | 密码错误/环境变量未设置 | 检查 PASSWORD 环境变量 |
|
||||
| 数据库连接失败 | 连接信息错误/服务未启动 | 验证连接字符串和服务状态 |
|
||||
| 页面加载缓慢 | 内存不足/缓存失效 | 重启服务或清理缓存 |
|
||||
|
||||
**诊断命令**:
|
||||
|
||||
```bash
|
||||
# 系统状态检查
|
||||
docker compose ps
|
||||
docker compose logs --tail=50
|
||||
|
||||
# 网络连通性测试
|
||||
curl -I http://localhost:3000
|
||||
wget --spider http://localhost:3000
|
||||
|
||||
# 数据库连接测试
|
||||
# Redis
|
||||
redis-cli -u $REDIS_URL ping
|
||||
# 或者
|
||||
docker compose exec redis redis-cli ping
|
||||
|
||||
# 配置文件验证
|
||||
cat config.json | jq '.'
|
||||
# 如果没有 jq,可以用 python
|
||||
python -m json.tool config.json
|
||||
|
||||
# 端口占用检查
|
||||
netstat -tlnp | grep 3000
|
||||
ss -tlnp | grep 3000
|
||||
```
|
||||
|
||||
### 📊 性能监控与优化
|
||||
|
||||
**监控指标**:
|
||||
|
||||
```bash
|
||||
# 实时系统监控脚本
|
||||
#!/bin/bash
|
||||
# monitor.sh
|
||||
while true; do
|
||||
echo "=== $(date) ==="
|
||||
|
||||
# Docker 容器状态
|
||||
echo "容器资源使用:"
|
||||
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
||||
|
||||
# 系统负载
|
||||
echo -e "\n系统负载:"
|
||||
uptime
|
||||
|
||||
# 磁盘使用
|
||||
echo -e "\n磁盘使用:"
|
||||
df -h / | tail -1
|
||||
|
||||
# 内存使用
|
||||
echo -e "\n内存使用:"
|
||||
free -h | head -2
|
||||
|
||||
echo "================================"
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
|
||||
**性能优化建议**:
|
||||
|
||||
1. **内存优化**:
|
||||
|
||||
```bash
|
||||
# Node.js 内存限制
|
||||
export NODE_OPTIONS="--max-old-space-size=1024"
|
||||
|
||||
# Docker 内存限制
|
||||
docker run --memory=1g --memory-swap=1.5g ...
|
||||
```
|
||||
|
||||
2. **缓存优化**:
|
||||
|
||||
```json
|
||||
// config.json 中增加缓存时间
|
||||
{
|
||||
"cache_time": 21600, // 6小时缓存
|
||||
"api_cache_time": 3600 // API缓存1小时
|
||||
}
|
||||
```
|
||||
|
||||
3. **网络优化**:
|
||||
- 使用 CDN 加速静态资源
|
||||
- 启用 Gzip/Brotli 压缩
|
||||
- 配置适当的缓存头
|
||||
|
||||
### 🚨 安全加固
|
||||
|
||||
**生产环境安全检查清单**:
|
||||
|
||||
- [ ] 设置强密码策略(至少 12 位包含特殊字符)
|
||||
- [ ] 启用 HTTPS(使用 Let's Encrypt 或 Cloudflare)
|
||||
- [ ] 配置防火墙规则(仅开放必要端口)
|
||||
- [ ] 定期更新系统和依赖包
|
||||
- [ ] 设置访问日志监控
|
||||
- [ ] 配置自动备份策略
|
||||
- [ ] 限制管理员界面访问(IP 白名单)
|
||||
- [ ] 启用 fail2ban 防止暴力破解
|
||||
|
||||
**安全配置示例**:
|
||||
|
||||
```bash
|
||||
# nginx 配置增强安全性
|
||||
# /etc/nginx/sites-available/katelyatv
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# SSL 配置
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
|
||||
|
||||
# 限制请求大小
|
||||
client_max_body_size 10M;
|
||||
|
||||
# 速率限制
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
|
||||
|
||||
location /api/ {
|
||||
limit_req zone=api burst=5 nodelay;
|
||||
proxy_pass http://localhost:3000;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
# 仅允许特定IP访问管理界面
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
proxy_pass http://localhost:3000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全提醒
|
||||
## 📚 扩展文档
|
||||
|
||||
### 强烈建议
|
||||
### 📖 详细指南
|
||||
|
||||
- **设置密码**:避免公开访问,防范法律风险
|
||||
- **个人使用**:请勿公开分享实例链接
|
||||
- **遵守法律**:确保使用行为符合当地法规
|
||||
**功能配置**:
|
||||
|
||||
### 免责声明
|
||||
- [📺 TVBox 兼容配置指南](docs/TVBOX.md)
|
||||
- [💾 Kvrocks 高性能部署](docs/KVROCKS.md)
|
||||
- [🗄️ D1 数据库迁移指南](D1_MIGRATION.md)
|
||||
|
||||
- 本项目仅供学习和个人使用
|
||||
- 请勿用于商业用途或公开服务
|
||||
- 用户需对使用行为承担法律责任
|
||||
**故障排除**:
|
||||
|
||||
- [🔧 Docker 故障排除手册](DOCKER_TROUBLESHOOTING.md)
|
||||
- [⚠️ 兼容性问题解决](DEPLOYMENT_COMPATIBILITY.md)
|
||||
|
||||
### 🎯 最佳实践
|
||||
|
||||
**新手快速上手路径**:
|
||||
|
||||
1. 选择 Vercel + 基础配置(最简单)
|
||||
2. 升级到 Vercel + Upstash(支持多用户)
|
||||
3. 进阶到 Docker 自建(完全控制)
|
||||
4. 终极配置:Kvrocks 集群(高可用)
|
||||
|
||||
**生产环境推荐方案**:
|
||||
|
||||
- **小型个人站**:Vercel + Upstash
|
||||
- **中型团队使用**:Docker + Redis Cluster
|
||||
- **大型服务**:Kubernetes + Kvrocks 集群
|
||||
- **全球服务**:Cloudflare Pages + D1
|
||||
|
||||
### 🔗 相关资源
|
||||
|
||||
**官方资源**:
|
||||
|
||||
- [📦 GitHub 仓库](https://github.com/katelya77/KatelyaTV)
|
||||
- [🐳 Docker Hub](https://hub.docker.com/r/katelya77/katelyatv)
|
||||
- [📊 GitHub Container Registry](https://github.com/katelya77/KatelyaTV/pkgs/container/katelyatv)
|
||||
- [📋 版本发布页](https://github.com/katelya77/KatelyaTV/releases)
|
||||
|
||||
**社区支持**:
|
||||
|
||||
- [💬 Discussions 讨论区](https://github.com/katelya77/KatelyaTV/discussions)
|
||||
- [🐛 Issues 问题反馈](https://github.com/katelya77/KatelyaTV/issues)
|
||||
- [📖 Wiki 知识库](https://github.com/katelya77/KatelyaTV/wiki)
|
||||
- [💡 Feature Requests](https://github.com/katelya77/KatelyaTV/issues?q=label%3Aenhancement)
|
||||
|
||||
**在线演示**:
|
||||
|
||||
- [🎬 官方演示站点](https://katelyatv-demo.pages.dev/) (密码: `demo123`)
|
||||
- [📱 PWA 功能演示](https://katelyatv-pwa.vercel.app/)
|
||||
- [🎨 主题预览站点](https://katelyatv-themes.pages.dev/)
|
||||
|
||||
### 🤝 参与贡献
|
||||
|
||||
**贡献方式**:
|
||||
|
||||
- ⭐ 给项目点 Star
|
||||
- 🐛 报告 Bug 和问题
|
||||
- 💡 提出新功能建议
|
||||
- 📝 完善文档和翻译
|
||||
- 💻 贡献代码和修复
|
||||
|
||||
**开发者指南**:
|
||||
|
||||
```bash
|
||||
# 本地开发环境搭建
|
||||
git clone https://github.com/katelya77/KatelyaTV.git
|
||||
cd KatelyaTV
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
|
||||
# 运行测试
|
||||
pnpm test
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 代码格式化
|
||||
pnpm lint --fix
|
||||
pnpm format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献与支持
|
||||
## 🔒 安全与合规
|
||||
|
||||
### 技术栈
|
||||
### 🚨 重要提醒
|
||||
|
||||
- **前端**:Next.js 14, TypeScript, Tailwind CSS
|
||||
- **播放器**:ArtPlayer, HLS.js
|
||||
- **存储**:Redis, Kvrocks, Cloudflare D1, Upstash
|
||||
- **部署**:Docker, Vercel, Cloudflare Pages
|
||||
**强烈建议**:
|
||||
|
||||
### Star History
|
||||
- ✅ **设置强密码**:避免公开访问,保护个人隐私
|
||||
- ✅ **个人使用**:请勿公开分享实例链接或商业使用
|
||||
- ✅ **遵守法律**:确保使用行为符合当地法律法规
|
||||
- ✅ **版权意识**:尊重内容版权,支持正版
|
||||
|
||||
[](https://star-history.com/#katelya77/KatelyaTV&Date)
|
||||
**安全配置**:
|
||||
|
||||
### 支持项目
|
||||
- 启用 HTTPS 加密传输
|
||||
- 设置访问密码和用户认证
|
||||
- 配置 IP 访问限制
|
||||
- 定期更新和安全检查
|
||||
|
||||
如果这个项目对您有帮助,欢迎给个 ⭐ Star!
|
||||
### ⚖️ 免责声明
|
||||
|
||||
- 本项目仅供个人学习、研究和合法使用
|
||||
- 用户需对自己的使用行为承担完全法律责任
|
||||
- 开发者不对用户的任何违法行为承担责任
|
||||
- 请确保遵守所在地区的法律法规
|
||||
|
||||
---
|
||||
|
||||
## 🌟 致谢与支持
|
||||
|
||||
### 🙏 特别感谢
|
||||
|
||||
感谢以下优秀的开源项目和技术社区:
|
||||
|
||||
**核心依赖**:
|
||||
|
||||
- [Next.js](https://nextjs.org/) — 强大的 React 全栈框架
|
||||
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 功能丰富的 HTML5 视频播放器
|
||||
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
|
||||
- [TypeScript](https://www.typescriptlang.org/) — JavaScript 的超集
|
||||
|
||||
**基础设施**:
|
||||
|
||||
- [Cloudflare](https://cloudflare.com/) — 全球 CDN 和边缘计算
|
||||
- [Vercel](https://vercel.com/) — 现代化的部署平台
|
||||
- [Docker](https://docker.com/) — 容器化部署方案
|
||||
- [Redis](https://redis.io/) — 高性能内存数据库
|
||||
|
||||
**项目启发**:
|
||||
|
||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 提供设计理念
|
||||
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 项目基础架构
|
||||
|
||||
### 💝 支持项目发展
|
||||
|
||||
如果 KatelyaTV 对您有帮助,欢迎通过以下方式支持项目:
|
||||
|
||||
**免费支持**:
|
||||
|
||||
- ⭐ [GitHub 点 Star](https://github.com/katelya77/KatelyaTV/stargazers)
|
||||
- 🍴 [Fork 项目](https://github.com/katelya77/KatelyaTV/fork)
|
||||
- 💬 [参与讨论](https://github.com/katelya77/KatelyaTV/discussions)
|
||||
- 📖 [完善文档](https://github.com/katelya77/KatelyaTV/tree/main/docs)
|
||||
- 🔗 [推荐朋友](https://github.com/katelya77/KatelyaTV)
|
||||
|
||||
**赞助支持**:
|
||||
|
||||
<div align="center">
|
||||
<img src="public/wechat.jpg" alt="微信支付" width="200">
|
||||
<img src="public/wechat.jpg" alt="微信赞赏码" width="200">
|
||||
<br>
|
||||
<strong>请开发者喝杯咖啡 ☕</strong>
|
||||
<p><em>您的支持是项目持续发展的动力</em></p>
|
||||
</div>
|
||||
|
||||
**企业赞助**:
|
||||
如果您的企业希望赞助 KatelyaTV 项目,请通过 [GitHub Sponsors](https://github.com/sponsors/katelya77) 或发邮件联系我们。
|
||||
|
||||
### � 项目统计
|
||||
|
||||
[](https://github.com/katelya77/KatelyaTV/stargazers)
|
||||
[](https://github.com/katelya77/KatelyaTV/network/members)
|
||||
[](https://github.com/katelya77/KatelyaTV/watchers)
|
||||
|
||||
[](https://github.com/katelya77/KatelyaTV/releases)
|
||||
[](https://hub.docker.com/r/katelya77/katelyatv)
|
||||
[](https://github.com/katelya77/KatelyaTV/issues)
|
||||
[](https://github.com/katelya77/KatelyaTV/blob/main/LICENSE)
|
||||
|
||||
**Star History**:
|
||||
[](https://star-history.com/#katelya77/KatelyaTV&Date)
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
## 📄 开源协议
|
||||
|
||||
[MIT](LICENSE) © 2025 KatelyaTV & Contributors
|
||||
本项目基于 **MIT License** 开源协议发布。
|
||||
|
||||
## 🙏 致谢
|
||||
```
|
||||
MIT License
|
||||
|
||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 项目启发
|
||||
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 原始项目基础
|
||||
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 强大的网页视频播放器
|
||||
- 感谢所有贡献者和支持者
|
||||
Copyright (c) 2025 KatelyaTV & Contributors
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<p>❤️ Made with love by KatelyaTV Community</p>
|
||||
<h2>🎉 感谢您选择 KatelyaTV!</h2>
|
||||
<p>
|
||||
<strong>如果项目对您有帮助,请给个 ⭐ Star 支持一下!</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/katelya77/KatelyaTV">🏠 项目首页</a>
|
||||
•
|
||||
<a href="https://github.com/katelya77/KatelyaTV/issues">🐛 问题反馈</a>
|
||||
•
|
||||
<a href="https://github.com/katelya77/KatelyaTV/discussions">💬 讨论交流</a>
|
||||
•
|
||||
<a href="https://github.com/katelya77/KatelyaTV/wiki">📚 使用文档</a>
|
||||
</p>
|
||||
<br>
|
||||
<p>
|
||||
<em>❤️ Made with love by KatelyaTV Community ❤️</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
-371
@@ -1,371 +0,0 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.png" alt="KatelyaTV Logo" width="128" />
|
||||
|
||||
<h1>KatelyaTV</h1>
|
||||
<p><strong>跨平台 · 聚合搜索 · 即开即用 · 自托管影视聚合播放器</strong></p>
|
||||
<p>基于 <code>Next.js 14</code> · <code>TypeScript</code> · <code>Tailwind CSS</code> · 多源聚合 / 播放记录 / 收藏同步 / 跳过片头片尾 / PWA</p>
|
||||
|
||||
<p>
|
||||
<a href="#-快速开始">🚀 快速开始</a> ·
|
||||
<a href="#-功能特性">✨ 功能特性</a> ·
|
||||
<a href="#-部署方案">📋 部署方案</a> ·
|
||||
<a href="#-配置说明">⚙️ 配置说明</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📰 项目声明
|
||||
|
||||
本项目自「MoonTV」演进而来,为其二创/继承版本,持续维护与改进功能与体验。保留并致谢原作者与社区贡献者。
|
||||
|
||||
> **🔔 重要变更**:应用户社区建议,为确保项目长期稳定运行和合规性,内置视频源已移除。现需要用户自行配置资源站以使用完整功能。我们提供了经过测试的推荐配置文件,让您快速上手使用。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 🎬 核心功能
|
||||
|
||||
- **🔍 聚合搜索**:整合多个影视资源站,一键搜索全网内容
|
||||
- **📺 高清播放**:基于 ArtPlayer 的强大播放器,支持多种格式
|
||||
- **⏭️ 智能跳过**:自动检测并跳过片头片尾,手动设置跳过时间段
|
||||
- **🎯 断点续播**:自动记录播放进度,跨设备同步观看位置
|
||||
- **📱 响应式设计**:完美适配手机、平板、电脑各种屏幕
|
||||
|
||||
### 💾 数据管理
|
||||
|
||||
- **⭐ 收藏功能**:收藏喜欢的影视作品,支持跨设备同步
|
||||
- **📖 播放历史**:自动记录观看历史,快速找回看过的内容
|
||||
- **👥 多用户支持**:独立的用户系统,每个用户独享个人数据
|
||||
- **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash)
|
||||
|
||||
### 🚀 部署特性
|
||||
|
||||
- **🐳 Docker 一键部署**:提供完整的 Docker 镜像,开箱即用
|
||||
- **☁️ 多平台支持**:Vercel、Docker、Cloudflare Pages 全兼容
|
||||
- **🔧 灵活配置**:支持自定义资源站、代理设置、主题配置
|
||||
- **📱 PWA 支持**:可安装为桌面/手机应用
|
||||
- **📺 TVBox 兼容**:支持 TVBox 配置接口
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 推荐方案选择
|
||||
|
||||
| 用户类型 | 推荐方案 | 特点 |
|
||||
| ----------- | ---------------- | -------------------- |
|
||||
| 🆕 新手用户 | Docker 单容器 | 最简单,5 分钟部署 |
|
||||
| 👥 多人使用 | Vercel + Upstash | 免费,支持多用户 |
|
||||
| 🏠 自托管 | Docker + Redis | 功能完整,数据可控 |
|
||||
| 🏢 生产环境 | Docker + Kvrocks | 高可靠性,零丢失风险 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 部署方案
|
||||
|
||||
### 方案一:Docker 单容器(推荐新手)
|
||||
|
||||
**适合场景**:个人使用,最简单的部署方式
|
||||
|
||||
```bash
|
||||
# 一键启动
|
||||
docker run -d \
|
||||
--name katelyatv \
|
||||
-p 3000:3000 \
|
||||
--env PASSWORD=your_password \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/katelya77/katelyatv:latest
|
||||
|
||||
# 访问 http://localhost:3000
|
||||
```
|
||||
|
||||
**自定义配置文件(可选)**:
|
||||
|
||||
```bash
|
||||
# 挂载配置文件
|
||||
docker run -d \
|
||||
--name katelyatv \
|
||||
-p 3000:3000 \
|
||||
--env PASSWORD=your_password \
|
||||
-v ./config.json:/app/config.json:ro \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/katelya77/katelyatv:latest
|
||||
```
|
||||
|
||||
### 方案二:Docker + Redis(多用户推荐)
|
||||
|
||||
**适合场景**:家庭/团队使用,支持多用户和数据同步
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.redis.yml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.redis.example
|
||||
cp .env.redis.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
nano .env
|
||||
|
||||
# 启动服务
|
||||
docker compose -f docker-compose.redis.yml up -d
|
||||
```
|
||||
|
||||
**重要环境变量配置**:
|
||||
|
||||
```bash
|
||||
# 存储类型
|
||||
NEXT_PUBLIC_STORAGE_TYPE=redis
|
||||
|
||||
# 管理员账号
|
||||
USERNAME=admin
|
||||
PASSWORD=your_admin_password
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://katelyatv-redis:6379
|
||||
|
||||
# 开启用户注册
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
### 方案三:Docker + Kvrocks(高可靠性)
|
||||
|
||||
**适合场景**:生产环境,需要极高的数据可靠性
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/docker-compose.kvrocks.yml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.kvrocks.example
|
||||
cp .env.kvrocks.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
nano .env
|
||||
|
||||
# 启动服务(无密码版本)
|
||||
docker compose -f docker-compose.kvrocks.yml up -d
|
||||
|
||||
# 如需密码认证版本,使用:
|
||||
# docker compose -f docker-compose.kvrocks.auth.yml up -d
|
||||
```
|
||||
|
||||
**Kvrocks 优势**:
|
||||
|
||||
- 🛡️ **极高可靠性**:基于 RocksDB,数据持久化到磁盘
|
||||
- ⚡ **性能优异**:完全兼容 Redis 协议,性能更佳
|
||||
- 💾 **节省内存**:数据存储在磁盘,内存使用量大幅降低
|
||||
|
||||
> 详细部署指南请查看:[Kvrocks 部署文档](docs/KVROCKS_DEPLOYMENT.md)
|
||||
|
||||
### 方案四:Vercel + Upstash(免费推荐)
|
||||
|
||||
**适合场景**:无服务器,免费部署,支持多用户
|
||||
|
||||
1. **Fork 仓库**:Fork [KatelyaTV](https://github.com/katelya77/KatelyaTV) 到你的 GitHub
|
||||
2. **部署到 Vercel**:
|
||||
|
||||
- 登录 [Vercel](https://vercel.com/),导入你的仓库
|
||||
- 添加环境变量:`PASSWORD=your_password`
|
||||
- 点击 Deploy
|
||||
|
||||
3. **配置多用户(可选)**:
|
||||
```bash
|
||||
# 创建 Upstash Redis 数据库
|
||||
# 在 Vercel 中添加环境变量:
|
||||
NEXT_PUBLIC_STORAGE_TYPE=upstash
|
||||
UPSTASH_URL=https://xxx.upstash.io
|
||||
UPSTASH_TOKEN=your_token
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
USERNAME=admin
|
||||
PASSWORD=admin_password
|
||||
```
|
||||
|
||||
### 方案五:Cloudflare + D1(技术爱好者)
|
||||
|
||||
**适合场景**:全球 CDN 加速,免费但配置稍复杂
|
||||
|
||||
```bash
|
||||
# 下载配置文件
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/wrangler.toml
|
||||
curl -O https://raw.githubusercontent.com/katelya77/KatelyaTV/main/.env.cloudflare.example
|
||||
|
||||
# 创建 D1 数据库
|
||||
wrangler d1 create katelyatv-db
|
||||
wrangler d1 execute katelyatv-db --file=./scripts/d1-init.sql
|
||||
|
||||
# 部署
|
||||
wrangler pages deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 🔧 环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
| ----------------------------- | ---------------- | ------------ |
|
||||
| `PASSWORD` | 访问密码(必填) | 无 |
|
||||
| `USERNAME` | 管理员用户名 | 无 |
|
||||
| `SITE_NAME` | 站点名称 | KatelyaTV |
|
||||
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | localstorage |
|
||||
| `REDIS_URL` | Redis 连接地址 | 无 |
|
||||
| `NEXT_PUBLIC_ENABLE_REGISTER` | 开启用户注册 | false |
|
||||
|
||||
### 📁 视频源配置
|
||||
|
||||
> **重要**:为保障项目合规性,需要配置视频源才能正常使用。
|
||||
|
||||
#### 方法一:使用推荐配置(推荐)
|
||||
|
||||
1. 下载配置文件:
|
||||
|
||||
- [基础版 config.json](https://www.mediafire.com/file/xl3yo7la2ci378w/config.json/file)
|
||||
- [Plus 版(94 个片源)](https://www.mediafire.com/file/fbpk1mlupxp3u3v/configplus.json/file)
|
||||
|
||||
2. 配置方式:
|
||||
- **Docker**:挂载配置文件 `-v ./config.json:/app/config.json:ro`
|
||||
- **Vercel**:替换仓库中的 `config.json` 文件内容
|
||||
- **管理员界面**:登录后台 `/admin` 导入配置
|
||||
|
||||
#### 方法二:手动配置
|
||||
|
||||
编辑 `config.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"example": {
|
||||
"api": "https://example.com/api.php/provide/vod",
|
||||
"name": "示例资源站",
|
||||
"detail": "https://example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 高级功能
|
||||
|
||||
### TVBox 兼容
|
||||
|
||||
支持 TVBox 配置接口,可以将视频源导入到各种电视盒子应用:
|
||||
|
||||
- **配置地址**:`https://your-domain.com/api/tvbox?format=json`
|
||||
- **详细说明**:查看 [TVBox 配置指南](docs/TVBOX.md)
|
||||
|
||||
### 跳过片头片尾
|
||||
|
||||
智能跳过片头片尾功能:
|
||||
|
||||
- 🎯 自动检测已设置的跳过片段
|
||||
- ⚙️ 手动设置跳过时间段(精确到秒)
|
||||
- 🔄 支持多设备同步(需配置 Redis/D1/Upstash)
|
||||
|
||||
### AndroidTV 支持
|
||||
|
||||
配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用:
|
||||
|
||||
1. 在 OrionTV 中填入 KatelyaTV 部署地址
|
||||
2. 输入设置的 PASSWORD
|
||||
3. 即可在电视上观看
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 管理与维护
|
||||
|
||||
### 升级更新
|
||||
|
||||
```bash
|
||||
# Docker 升级
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# 查看服务状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 数据备份
|
||||
|
||||
```bash
|
||||
# Redis 数据备份
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
|
||||
# Kvrocks 数据备份
|
||||
docker run --rm \
|
||||
-v katelyatv_kvrocks-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/kvrocks-backup.tar.gz /data
|
||||
```
|
||||
|
||||
### 常见问题
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
| ---------------- | ------------------------------------------- |
|
||||
| 无法访问 | 检查端口 3000 是否开放 |
|
||||
| 密码错误 | 检查 PASSWORD 环境变量 |
|
||||
| Redis 连接失败 | 检查 Redis 容器状态和网络 |
|
||||
| Kvrocks 认证错误 | 查看 [详细文档](docs/KVROCKS_DEPLOYMENT.md) |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全提醒
|
||||
|
||||
### 强烈建议
|
||||
|
||||
- **设置密码**:避免公开访问,防范法律风险
|
||||
- **个人使用**:请勿公开分享实例链接
|
||||
- **遵守法律**:确保使用行为符合当地法规
|
||||
|
||||
### 免责声明
|
||||
|
||||
- 本项目仅供学习和个人使用
|
||||
- 请勿用于商业用途或公开服务
|
||||
- 用户需对使用行为承担法律责任
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献与支持
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **前端**:Next.js 14, TypeScript, Tailwind CSS
|
||||
- **播放器**:ArtPlayer, HLS.js
|
||||
- **存储**:Redis, Kvrocks, Cloudflare D1, Upstash
|
||||
- **部署**:Docker, Vercel, Cloudflare Pages
|
||||
|
||||
### Star History
|
||||
|
||||
[](https://star-history.com/#katelya77/KatelyaTV&Date)
|
||||
|
||||
### 支持项目
|
||||
|
||||
如果这个项目对您有帮助,欢迎给个 ⭐ Star!
|
||||
|
||||
<div align="center">
|
||||
<img src="public/wechat.jpg" alt="微信支付" width="200">
|
||||
<br>
|
||||
<strong>请开发者喝杯咖啡 ☕</strong>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](LICENSE) © 2025 KatelyaTV & Contributors
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 项目启发
|
||||
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 原始项目基础
|
||||
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 强大的网页视频播放器
|
||||
- 感谢所有贡献者和支持者
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<p>❤️ Made with love by KatelyaTV Community</p>
|
||||
</div>
|
||||
+1
-1
@@ -1 +1 @@
|
||||
20250904151930
|
||||
20250904200125
|
||||
@@ -0,0 +1,44 @@
|
||||
# 视频源配置说明
|
||||
|
||||
## 配置方式
|
||||
|
||||
您有两种方式配置视频源:
|
||||
|
||||
### 方式 1:通过管理后台配置(推荐)
|
||||
|
||||
1. 访问 `/admin` 管理后台
|
||||
2. 在"源管理"部分添加视频源
|
||||
3. 支持实时启用/禁用
|
||||
4. 数据保存在数据库中,重启不丢失
|
||||
|
||||
### 方式 2:通过 config.json 配置
|
||||
|
||||
参考 `config.example.json` 文件的格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"source_key": {
|
||||
"api": "https://your-api.com/api.php/provide/vod",
|
||||
"name": "源名称",
|
||||
"detail": "https://your-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
|
||||
"is_adult": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
- `source_key`: 源的唯一标识符
|
||||
- `api`: 视频 API 的搜索接口地址
|
||||
- `name`: 源的显示名称
|
||||
- `detail`: 视频详情接口地址({ids} 会被替换为视频 ID)
|
||||
- `is_adult`: 是否为成人内容源(true/false)
|
||||
|
||||
## 推荐设置
|
||||
|
||||
- 建议保持 `config.json` 为空配置:`{"cache_time": 7200, "api_site": {}}`
|
||||
- 通过管理后台动态添加和管理视频源
|
||||
- 这样更灵活,支持实时启用/禁用,无需重启服务
|
||||
@@ -0,0 +1,142 @@
|
||||
-- ========================================
|
||||
-- KatelyaTV Cloudflare D1 数据库初始化脚本
|
||||
-- 版本: 2025-09-05 (适配当前代码结构)
|
||||
-- ========================================
|
||||
|
||||
-- 1. 用户表 (必需)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
login_count INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
role TEXT DEFAULT 'user'
|
||||
);
|
||||
|
||||
-- 2. 用户设置表 (成人内容过滤必需)
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
filter_adult_content BOOLEAN DEFAULT 1,
|
||||
can_disable_filter BOOLEAN DEFAULT 1,
|
||||
managed_by_admin BOOLEAN DEFAULT 0,
|
||||
last_filter_change DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3. 播放记录表 (观看历史)
|
||||
CREATE TABLE IF NOT EXISTS play_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
video_title TEXT,
|
||||
video_url TEXT,
|
||||
video_cover TEXT,
|
||||
current_time REAL DEFAULT 0,
|
||||
duration REAL DEFAULT 0,
|
||||
progress REAL DEFAULT 0,
|
||||
episode_index INTEGER DEFAULT 0,
|
||||
episode_url TEXT,
|
||||
last_watched DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
watch_count INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||
UNIQUE(username, video_id)
|
||||
);
|
||||
|
||||
-- 4. 收藏表
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
video_title TEXT,
|
||||
video_cover TEXT,
|
||||
video_url TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
area TEXT,
|
||||
category TEXT,
|
||||
actors TEXT,
|
||||
director TEXT,
|
||||
description TEXT,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(username, video_id),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 5. 搜索历史表
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL,
|
||||
search_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 6. 跳过配置表 (跳过片头片尾)
|
||||
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
skip_start INTEGER DEFAULT 0,
|
||||
skip_end INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(username, video_id),
|
||||
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 索引优化
|
||||
-- ========================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_play_records_last_watched ON play_records(last_watched);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_username ON skip_configs(username);
|
||||
|
||||
-- ========================================
|
||||
-- 触发器
|
||||
-- ========================================
|
||||
|
||||
-- 自动更新 user_settings 时间戳
|
||||
CREATE TRIGGER IF NOT EXISTS update_user_settings_timestamp
|
||||
AFTER UPDATE ON user_settings
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE user_settings SET updated_at = CURRENT_TIMESTAMP WHERE username = NEW.username;
|
||||
END;
|
||||
|
||||
-- 新用户注册时创建默认设置
|
||||
CREATE TRIGGER IF NOT EXISTS create_default_user_settings
|
||||
AFTER INSERT ON users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO user_settings (username, filter_adult_content, can_disable_filter, managed_by_admin)
|
||||
VALUES (NEW.username, 1, 1, 0);
|
||||
END;
|
||||
|
||||
-- 更新播放记录时间戳
|
||||
CREATE TRIGGER IF NOT EXISTS update_play_records_timestamp
|
||||
AFTER UPDATE ON play_records
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE play_records SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- 更新跳过配置时间戳
|
||||
CREATE TRIGGER IF NOT EXISTS update_skip_configs_timestamp
|
||||
AFTER UPDATE ON skip_configs
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE skip_configs SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"example_source": {
|
||||
"api": "https://your-video-api.com/api.php/provide/vod",
|
||||
"name": "示例视频源",
|
||||
"detail": "https://your-video-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
|
||||
"is_adult": false
|
||||
},
|
||||
"another_example": {
|
||||
"api": "https://another-api.com/api.php/provide/vod",
|
||||
"name": "另一个示例源",
|
||||
"detail": "https://another-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
|
||||
"is_adult": false
|
||||
},
|
||||
"adult_example": {
|
||||
"api": "https://adult-content-api.com/api.php/provide/vod",
|
||||
"name": "成人内容源示例",
|
||||
"detail": "https://adult-content-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
|
||||
"is_adult": true
|
||||
},
|
||||
"test_adult_source": {
|
||||
"api": "https://test-adult-api.com/api.php/provide/vod",
|
||||
"name": "测试成人源(用于验证过滤)",
|
||||
"detail": "https://test-adult-api.com/api.php/provide/vod/?ac=detail&ids={ids}",
|
||||
"is_adult": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-4
@@ -1,10 +1,30 @@
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"example_test": {
|
||||
"api": "https://example.com/api.php/provide/vod",
|
||||
"name": "示例视频源",
|
||||
"detail": "https://example.com"
|
||||
"hnzy": {
|
||||
"api": "https://hnzyapi.com/api.php/provide/vod",
|
||||
"name": "火鸟资源",
|
||||
"is_adult": false
|
||||
},
|
||||
"lzzy": {
|
||||
"api": "https://api.liangzizy.com/inc/apijson_vod.php",
|
||||
"name": "量子资源",
|
||||
"is_adult": false
|
||||
},
|
||||
"ffzy": {
|
||||
"api": "https://ffzyapi.com/api.php/provide/vod",
|
||||
"name": "非凡资源",
|
||||
"is_adult": false
|
||||
},
|
||||
"ykzy": {
|
||||
"api": "https://api.yongjiuzy.cc/provide/vod",
|
||||
"name": "永久资源",
|
||||
"is_adult": false
|
||||
},
|
||||
"bdzy": {
|
||||
"api": "https://api.1080zyku.com/inc/apijson_vod.php",
|
||||
"name": "百度资源",
|
||||
"is_adult": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"example_test": {
|
||||
"api": "https://example.com/api.php/provide/vod",
|
||||
"name": "示例视频源",
|
||||
"detail": "https://example.com",
|
||||
"is_adult": false
|
||||
},
|
||||
"adult_example": {
|
||||
"api": "https://adult-example.com/api.php/provide/vod",
|
||||
"name": "成人内容源(仅供示例)",
|
||||
"detail": "https://adult-example.com",
|
||||
"is_adult": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,12 @@ services:
|
||||
KVROCKS_PASSWORD: ${KVROCKS_PASSWORD:-}
|
||||
KVROCKS_DATABASE: 0
|
||||
|
||||
# 站点访问密码配置
|
||||
PASSWORD: ${PASSWORD:-}
|
||||
# 管理员账号配置(必填)
|
||||
USERNAME: ${USERNAME:-admin}
|
||||
PASSWORD: ${PASSWORD}
|
||||
|
||||
# 站点配置
|
||||
NEXT_PUBLIC_ENABLE_REGISTER: ${NEXT_PUBLIC_ENABLE_REGISTER:-true}
|
||||
|
||||
# 其他必要的环境变量
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
本文档介绍如何使用 Docker + Kvrocks 部署 KatelyaTV。
|
||||
|
||||
> **⚠️ 重要提醒**:Kvrocks 部署需要配置管理员账号(`USERNAME` 和 `PASSWORD`),否则会出现"页面显示账号密码登录但无法登录"的问题!
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方案一:无密码部署(推荐用于开发环境)
|
||||
@@ -22,10 +24,17 @@ nano .env
|
||||
# 数据库配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
||||
KVROCKS_URL=redis://kvrocks:6666
|
||||
# 不设置密码
|
||||
# 不设置 Kvrocks 密码
|
||||
# KVROCKS_PASSWORD=
|
||||
KVROCKS_DATABASE=0
|
||||
|
||||
# 管理员账号配置(必填!)
|
||||
USERNAME=admin
|
||||
PASSWORD=your_admin_password
|
||||
|
||||
# 用户注册配置
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
|
||||
# 应用配置
|
||||
NEXTAUTH_SECRET=your_nextauth_secret_here
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
@@ -72,7 +81,29 @@ docker-compose -f docker-compose.kvrocks.auth.yml up -d
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 问题 1:密码认证错误
|
||||
### 问题 1:页面显示账号密码登录但无法登录
|
||||
|
||||
**现象:**
|
||||
|
||||
- 部署后页面显示用户名+密码登录界面
|
||||
- 但是只配置了 `PASSWORD` 环境变量
|
||||
- 无法登录或提示"用户名或密码错误"
|
||||
|
||||
**原因:**
|
||||
|
||||
- Kvrocks 部署属于多用户模式,需要配置管理员账号
|
||||
- 缺少 `USERNAME` 环境变量导致系统无法识别管理员
|
||||
|
||||
**解决方案:**
|
||||
|
||||
```bash
|
||||
# 在 .env 文件中添加管理员账号配置
|
||||
USERNAME=admin
|
||||
PASSWORD=your_admin_password
|
||||
NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||
```
|
||||
|
||||
### 问题 2:密码认证错误
|
||||
|
||||
```
|
||||
❌ Kvrocks Client Error: [Error]: ERR Client sent AUTH, but no password is set
|
||||
@@ -85,7 +116,7 @@ docker-compose -f docker-compose.kvrocks.auth.yml up -d
|
||||
- 无密码部署使用:`docker-compose.kvrocks.yml`
|
||||
- 密码认证部署使用:`docker-compose.kvrocks.auth.yml`
|
||||
|
||||
### 问题 2:连接超时
|
||||
### 问题 3:连接超时
|
||||
|
||||
```
|
||||
❌ Failed to connect to Kvrocks: connect ECONNREFUSED
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# TVBox 配置生成问题修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户反馈 TVBox 配置生成失败,错误信息:
|
||||
```
|
||||
{"error":"TVBox配置生成失败","details":"D1_ERROR: no such table: admin_config: SQLITE_ERROR"}
|
||||
```
|
||||
|
||||
## 问题原因
|
||||
|
||||
这是一个数据库表名不一致的问题:
|
||||
|
||||
1. **SQL初始化脚本** (`scripts/d1-init.sql`):创建的表名是 `admin_configs`(复数)
|
||||
2. **应用代码** (`src/lib/d1.db.ts`):查询的表名是 `admin_config`(单数)
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. 代码修复
|
||||
已修改 `src/lib/d1.db.ts` 中的 `getAdminConfig()` 和 `setAdminConfig()` 方法,使其使用正确的表名 `admin_configs`。
|
||||
|
||||
### 2. 数据迁移
|
||||
创建了迁移脚本 `scripts/d1-migrate-admin-config.sql` 来处理现有数据。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 对于新部署用户
|
||||
直接使用最新版本部署即可,无需额外操作。
|
||||
|
||||
### 对于现有用户
|
||||
需要运行数据迁移脚本:
|
||||
|
||||
```bash
|
||||
# 运行迁移脚本
|
||||
wrangler d1 execute your-database-name --file=./scripts/d1-migrate-admin-config.sql
|
||||
```
|
||||
|
||||
## 验证修复
|
||||
修复后,TVBox 配置生成应该能正常工作:
|
||||
|
||||
```bash
|
||||
# 测试 TVBox 配置 API
|
||||
curl "https://your-domain.pages.dev/api/tvbox?format=json"
|
||||
```
|
||||
|
||||
## 影响范围
|
||||
- 仅影响使用 Cloudflare Pages + D1 部署的用户
|
||||
- 其他部署方式(Docker + Redis、Vercel + Upstash 等)不受影响
|
||||
- 不影响其他功能(用户认证、播放记录、收藏等)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "katelyatv",
|
||||
"version": "0.6.0-katelya",
|
||||
"version": "0.7.0-katelya",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run gen:runtime && npm run gen:manifest && next dev -H 0.0.0.0",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"7db9e4ef70cbcab3780d6c680dd662a2"},{url:"/_next/static/_tNEn3OI_gHK8Eg73JHHK/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/_tNEn3OI_gHK8Eg73JHHK/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-9df7e37d43792a8e.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/154-de4a84fd5b2e0100.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/29-0844689411ca7d55.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/459-6bec40a8423cc309.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/51b697cb-f464f3017ac1ea30.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/900-fb094d8873768e88.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/967-217cdcb80ae3beeb.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/998-568996670b543597.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/_not-found/page-ac328df06cf68f14.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/admin/page-d05d4621a6953d54.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/config/page-11f6321397ad65b1.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/douban/page-2d0023184aa37aff.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/layout-bd0bfbfdb401e15f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/login/page-1638e1d936c78592.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/page-6a58e37ab3250691.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/play/page-586b6c5a6381cf6d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/search/page-63fe30b91e0539a7.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/tvbox/page-3a990d4dba7ad091.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/main-459a10fe41fde25d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"_tNEn3OI_gHK8Eg73JHHK"},{url:"/_next/static/css/00661c2d88d90da0.css",revision:"00661c2d88d90da0"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{url:"/_next/static/css/275ed64cc4367444.css",revision:"275ed64cc4367444"},{url:"/_next/static/media/26a46d62cd723877-s.woff2",revision:"befd9c0fdfa3d8a645d5f95717ed6420"},{url:"/_next/static/media/55c55f0601d81cf3-s.woff2",revision:"43828e14271c77b87e3ed582dbff9f74"},{url:"/_next/static/media/581909926a08bbc8-s.woff2",revision:"f0b86e7c24f455280b8df606b89af891"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/97e0cb1ae144a2a9-s.woff2",revision:"e360c61c5bd8d90639fd4503c829c2dc"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/favicon.ico",revision:"c5de6e56c5664adda146825f75ea6ecf"},{url:"/icons/icon-192x192.png",revision:"4a56c090828a1ad254c903c7aec0389d"},{url:"/icons/icon-256x256.png",revision:"f6409eb1a001f754121e3a8281c0319c"},{url:"/icons/icon-384x384.png",revision:"f6efc3e357b9ffdf4e0d8c14b2ed0ac1"},{url:"/icons/icon-512x512.png",revision:"9c008cbbeb6a576fe07bb1284a83f4d2"},{url:"/logo.png",revision:"40de611b143c47c6291c7bdad2c959ca"},{url:"/manifest.json",revision:"7bd3dabc1cfbfe40f09577efca223d31"},{url:"/robots.txt",revision:"e2b2cd8514443456bc6fb9d77b3b1f3e"},{url:"/screenshot1.png",revision:"10572bfcea54dc93ac4c5f7c9057fc98"},{url:"/screenshot2.png",revision:"f815a8990973a221899976867365c239"},{url:"/screenshot3.png",revision:"49709e96345dfeeab1d8083821d4b44e"},{url:"/screenshot4.png",revision:"a76c751e41e37556048a487e4f8b8b1c"},{url:"/wechat.jpg",revision:"d0f601311802667cd6ca5a37dc69bfa7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
|
||||
if(!self.define){let e,s={};const a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(n,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>a(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-e9849328"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"05b02c433504d113bbbf7464726a4330"},{url:"/_next/static/UkQ9tJsupBw6-055mAaeW/_buildManifest.js",revision:"046380ae5bc74b46b6d5eac3eed65355"},{url:"/_next/static/UkQ9tJsupBw6-055mAaeW/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/110-89e2e67f2e3bcaaa.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/154-211e189482cc0258.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/29-2acace5e289d422b.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/459-b5005e79594397e4.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/51b697cb-24a59f0c53e2e105.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/682-d1dca8d17a3a8e6f.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/711-9ae080cb4f6a9355.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/900-c7c9e505cc903ead.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/998-f22ebd15e7bac0f0.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/_not-found/page-4dc7d52fd5d943cc.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/admin/page-7f30b4abb7bde63b.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/config/page-578e0487b53650a4.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/douban/page-6b5d567834ba726e.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/layout-d530e785c3fe67fb.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/login/page-530049e8ddbbd780.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/page-a401624afa29aef0.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/play/page-6297f80eaa080bf5.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/search/page-cafa7a89158278cb.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/settings/page-d73b9df16c781bd2.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/tvbox/page-443d4dd8e3c842b3.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/app/warning/page-11cba4cf9332a238.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/c72274ce-06682d6fc8197e6d.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/da9543df-bf6da1a431d8604f.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/framework-6e06c675866dc992.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/main-app-dbd320e104e1a5dc.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/main-c5fb3cb103d3b800.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/pages/_app-792b631a362c29e1.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/pages/_error-9fde6601392a2a99.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-17170f1d90853b2d.js",revision:"UkQ9tJsupBw6-055mAaeW"},{url:"/_next/static/css/23100062f5d4aac0.css",revision:"23100062f5d4aac0"},{url:"/_next/static/css/7cca8e2c5137bd71.css",revision:"7cca8e2c5137bd71"},{url:"/_next/static/css/cfee8a0b55b735f1.css",revision:"cfee8a0b55b735f1"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/favicon.ico",revision:"c5de6e56c5664adda146825f75ea6ecf"},{url:"/icons/icon-192x192.png",revision:"4a56c090828a1ad254c903c7aec0389d"},{url:"/icons/icon-256x256.png",revision:"f6409eb1a001f754121e3a8281c0319c"},{url:"/icons/icon-384x384.png",revision:"f6efc3e357b9ffdf4e0d8c14b2ed0ac1"},{url:"/icons/icon-512x512.png",revision:"9c008cbbeb6a576fe07bb1284a83f4d2"},{url:"/logo.png",revision:"40de611b143c47c6291c7bdad2c959ca"},{url:"/manifest.json",revision:"7bd3dabc1cfbfe40f09577efca223d31"},{url:"/robots.txt",revision:"e2b2cd8514443456bc6fb9d77b3b1f3e"},{url:"/screenshot1.png",revision:"10572bfcea54dc93ac4c5f7c9057fc98"},{url:"/screenshot2.png",revision:"f815a8990973a221899976867365c239"},{url:"/screenshot3.png",revision:"49709e96345dfeeab1d8083821d4b44e"},{url:"/screenshot4.png",revision:"a76c751e41e37556048a487e4f8b8b1c"},{url:"/wechat.jpg",revision:"d0f601311802667cd6ca5a37dc69bfa7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});
|
||||
|
||||
@@ -68,6 +68,22 @@ CREATE TABLE IF NOT EXISTS skip_configs (
|
||||
UNIQUE (user_id, config_key)
|
||||
);
|
||||
|
||||
-- 用户设置表
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
filter_adult_content BOOLEAN DEFAULT 1,
|
||||
theme TEXT DEFAULT 'auto',
|
||||
language TEXT DEFAULT 'zh-CN',
|
||||
auto_play BOOLEAN DEFAULT 1,
|
||||
video_quality TEXT DEFAULT 'auto',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE (user_id, username)
|
||||
);
|
||||
|
||||
-- 管理员配置表
|
||||
CREATE TABLE IF NOT EXISTS admin_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -92,6 +108,8 @@ CREATE INDEX IF NOT EXISTS idx_play_records_record_key ON play_records(record_ke
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_user_id ON favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_user_id ON search_history(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skip_configs_user_id ON skip_configs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_username ON user_settings(username);
|
||||
|
||||
-- 创建视图以简化查询
|
||||
CREATE VIEW IF NOT EXISTS user_stats AS
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- D1 数据库迁移脚本:修复 admin_config 表名问题
|
||||
-- 将旧的 admin_config 表数据迁移到新的 admin_configs 表结构
|
||||
|
||||
-- 首先确保新的 admin_configs 表存在
|
||||
CREATE TABLE IF NOT EXISTS admin_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_key TEXT UNIQUE NOT NULL,
|
||||
config_value TEXT,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 检查是否存在旧的 admin_config 表
|
||||
-- 如果存在,迁移数据到新表
|
||||
INSERT OR IGNORE INTO admin_configs (config_key, config_value, description)
|
||||
SELECT
|
||||
'main_config' as config_key,
|
||||
config as config_value,
|
||||
'从旧表迁移的主要管理员配置' as description
|
||||
FROM admin_config
|
||||
WHERE id = 1;
|
||||
|
||||
-- 插入默认管理员配置(如果不存在)
|
||||
INSERT OR IGNORE INTO admin_configs (config_key, config_value, description) VALUES
|
||||
('site_name', 'KatelyaTV', '站点名称'),
|
||||
('site_description', '高性能影视播放平台', '站点描述'),
|
||||
('enable_register', 'true', '是否允许用户注册'),
|
||||
('max_users', '100', '最大用户数量'),
|
||||
('cache_ttl', '3600', '缓存时间(秒)');
|
||||
|
||||
-- 可选:删除旧表(请谨慎使用,建议先备份数据)
|
||||
-- DROP TABLE IF EXISTS admin_config;
|
||||
+28
-3
@@ -70,6 +70,7 @@ interface DataSource {
|
||||
detail?: string;
|
||||
disabled?: boolean;
|
||||
from: 'config' | 'custom';
|
||||
is_adult?: boolean; // 添加成人内容标记字段
|
||||
}
|
||||
|
||||
// 可折叠标签组件
|
||||
@@ -643,6 +644,7 @@ const VideoSourceConfig = ({
|
||||
detail: '',
|
||||
disabled: false,
|
||||
from: 'config',
|
||||
is_adult: false, // 默认不是成人内容
|
||||
});
|
||||
|
||||
// dnd-kit 传感器
|
||||
@@ -721,6 +723,7 @@ const VideoSourceConfig = ({
|
||||
name: newSource.name,
|
||||
api: newSource.api,
|
||||
detail: newSource.detail,
|
||||
is_adult: newSource.is_adult, // 传递成人内容标记
|
||||
})
|
||||
.then(() => {
|
||||
setNewSource({
|
||||
@@ -730,6 +733,7 @@ const VideoSourceConfig = ({
|
||||
detail: '',
|
||||
disabled: false,
|
||||
from: 'custom',
|
||||
is_adult: false, // 重置为默认值
|
||||
});
|
||||
setShowAddForm(false);
|
||||
})
|
||||
@@ -866,7 +870,8 @@ const VideoSourceConfig = ({
|
||||
exportConfig.api_site[source.key] = {
|
||||
api: source.api,
|
||||
name: source.name,
|
||||
...(source.detail && { detail: source.detail })
|
||||
...(source.detail && { detail: source.detail }),
|
||||
...(source.is_adult !== undefined && { is_adult: source.is_adult }) // 确保导出 is_adult 字段
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -939,7 +944,7 @@ const VideoSourceConfig = ({
|
||||
throw new Error(`${key}: 无效的配置对象`);
|
||||
}
|
||||
|
||||
const sourceObj = source as { api?: string; name?: string; detail?: string };
|
||||
const sourceObj = source as { api?: string; name?: string; detail?: string; is_adult?: boolean };
|
||||
|
||||
if (!sourceObj.api || !sourceObj.name) {
|
||||
throw new Error(`${key}: 缺少必要字段 api 或 name`);
|
||||
@@ -950,7 +955,8 @@ const VideoSourceConfig = ({
|
||||
key: key,
|
||||
name: sourceObj.name,
|
||||
api: sourceObj.api,
|
||||
detail: sourceObj.detail || ''
|
||||
detail: sourceObj.detail || '',
|
||||
is_adult: sourceObj.is_adult || false // 确保处理 is_adult 字段
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
@@ -1246,6 +1252,25 @@ const VideoSourceConfig = ({
|
||||
}
|
||||
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 className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='is_adult'
|
||||
checked={newSource.is_adult || false}
|
||||
onChange={(e) =>
|
||||
setNewSource((prev) => ({ ...prev, is_adult: e.target.checked }))
|
||||
}
|
||||
className='w-4 h-4 text-red-600 bg-gray-100 border-gray-300 rounded focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600'
|
||||
/>
|
||||
<label
|
||||
htmlFor='is_adult'
|
||||
className='text-sm font-medium text-gray-900 dark:text-gray-300'
|
||||
>
|
||||
🔞 成人内容资源站
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
@@ -59,11 +59,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
const { key, name, api, detail } = body as {
|
||||
const { key, name, api, detail, is_adult } = body as {
|
||||
key?: string;
|
||||
name?: string;
|
||||
api?: string;
|
||||
detail?: string;
|
||||
is_adult?: boolean;
|
||||
};
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
@@ -78,6 +79,7 @@ export async function POST(request: NextRequest) {
|
||||
detail,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
is_adult: is_adult || false, // 确保处理 is_adult 字段
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { User } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 检查是否为站长账户
|
||||
function isOwnerAccount(username: string): boolean {
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
return username === ownerUsername;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从Authorization头获取当前用户
|
||||
const auth = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: '需要认证' }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentUsername = decodeURIComponent(auth);
|
||||
|
||||
// 检查是否为站长账户
|
||||
if (!isOwnerAccount(currentUsername)) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 获取所有用户及其设置
|
||||
const storage = getStorage();
|
||||
const users: User[] = await storage.getAllUsers();
|
||||
const usersWithSettings = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const settings = await storage.getUserSettings(user.username);
|
||||
return {
|
||||
username: user.username,
|
||||
role: user.role || 'user',
|
||||
created_at: user.created_at,
|
||||
filter_adult_content: settings?.filter_adult_content ?? true,
|
||||
can_disable_filter: settings?.can_disable_filter ?? true,
|
||||
managed_by_admin: settings?.managed_by_admin ?? false,
|
||||
last_filter_change: settings?.last_filter_change
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
users: usersWithSettings,
|
||||
total: usersWithSettings.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从Authorization头获取当前用户
|
||||
const auth = request.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: '需要认证' }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentUsername = decodeURIComponent(auth);
|
||||
|
||||
// 检查是否为站长账户
|
||||
if (!isOwnerAccount(currentUsername)) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 403 });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const { action, username, settings } = await request.json();
|
||||
|
||||
switch (action) {
|
||||
case 'update_settings': {
|
||||
// 更新用户设置
|
||||
const currentSettings = await storage.getUserSettings(username);
|
||||
const newSettings = {
|
||||
...currentSettings,
|
||||
...settings,
|
||||
last_filter_change: new Date().toISOString()
|
||||
};
|
||||
|
||||
await storage.setUserSettings(username, newSettings);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `已更新用户 ${username} 的设置`
|
||||
});
|
||||
}
|
||||
|
||||
case 'force_filter': {
|
||||
// 强制开启某用户的成人内容过滤
|
||||
const currentSettings = await storage.getUserSettings(username) || {
|
||||
filter_adult_content: true,
|
||||
theme: 'auto' as const,
|
||||
language: 'zh-CN',
|
||||
auto_play: false,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
|
||||
await storage.setUserSettings(username, {
|
||||
...currentSettings,
|
||||
filter_adult_content: true,
|
||||
can_disable_filter: false,
|
||||
managed_by_admin: true,
|
||||
last_filter_change: new Date().toISOString()
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `已强制开启用户 ${username} 的成人内容过滤`
|
||||
});
|
||||
}
|
||||
|
||||
case 'allow_disable': {
|
||||
// 允许用户自己管理过滤设置
|
||||
const existingSettings = await storage.getUserSettings(username) || {
|
||||
filter_adult_content: true,
|
||||
theme: 'auto' as const,
|
||||
language: 'zh-CN',
|
||||
auto_play: false,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
|
||||
await storage.setUserSettings(username, {
|
||||
...existingSettings,
|
||||
filter_adult_content: existingSettings.filter_adult_content ?? true,
|
||||
theme: existingSettings.theme || 'auto',
|
||||
language: existingSettings.language || 'zh-CN',
|
||||
auto_play: existingSettings.auto_play ?? false,
|
||||
video_quality: existingSettings.video_quality || 'auto',
|
||||
can_disable_filter: true,
|
||||
managed_by_admin: false,
|
||||
last_filter_change: new Date().toISOString()
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `已允许用户 ${username} 自己管理过滤设置`
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('用户管理操作失败:', error);
|
||||
return NextResponse.json({ error: '操作失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
runtime: 'edge',
|
||||
storageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
hasDB: !!(globalThis as any).DB,
|
||||
nodeEnv: process.env.NODE_ENV
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: 'Debug failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -56,10 +56,10 @@ async function generateAuthCookie(
|
||||
authData.password = password;
|
||||
}
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
if (username && process.env.AUTH_PASSWORD) {
|
||||
authData.username = username;
|
||||
// 使用密码作为密钥对用户名进行签名
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
const signature = await generateSignature(username, process.env.AUTH_PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
|
||||
}
|
||||
@@ -71,9 +71,9 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 本地 / localStorage 模式——仅校验固定密码
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
const envPassword = process.env.PASSWORD;
|
||||
const envPassword = process.env.AUTH_PASSWORD;
|
||||
|
||||
// 未配置 PASSWORD 时直接放行
|
||||
// 未配置 AUTH_PASSWORD 时直接放行
|
||||
if (!envPassword) {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function POST(req: NextRequest) {
|
||||
// 可能是站长,直接读环境变量
|
||||
if (
|
||||
username === process.env.USERNAME &&
|
||||
password === process.env.PASSWORD
|
||||
password === process.env.AUTH_PASSWORD
|
||||
) {
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
@@ -50,8 +50,8 @@ async function generateAuthCookie(username: string): Promise<string> {
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 使用process.env.PASSWORD作为签名密钥,而不是用户密码
|
||||
const signingKey = process.env.PASSWORD || '';
|
||||
// 使用process.env.AUTH_PASSWORD作为签名密钥,而不是用户密码
|
||||
const signingKey = process.env.AUTH_PASSWORD || '';
|
||||
const signature = await generateSignature(username, signingKey);
|
||||
authData.signature = signature;
|
||||
|
||||
|
||||
+72
-10
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { getAvailableApiSites,getCacheTime } from '@/lib/config';
|
||||
import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors';
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@@ -14,11 +15,23 @@ export async function OPTIONS() {
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
// 从 Authorization header 或 query parameter 获取用户名
|
||||
let userName: string | undefined = searchParams.get('user') || undefined;
|
||||
if (!userName) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
userName = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
const cacheTime = await getCacheTime();
|
||||
const response = NextResponse.json(
|
||||
{ results: [] },
|
||||
{
|
||||
regular_results: [],
|
||||
adult_results: []
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
@@ -30,16 +43,58 @@ export async function GET(request: Request) {
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||
|
||||
try {
|
||||
const results = await Promise.all(searchPromises);
|
||||
const flattenedResults = results.flat();
|
||||
const cacheTime = await getCacheTime();
|
||||
// 检查是否明确要求包含成人内容(用于关闭过滤时的明确请求)
|
||||
const includeAdult = searchParams.get('include_adult') === 'true';
|
||||
|
||||
// 获取用户的成人内容过滤设置
|
||||
let shouldFilterAdult = true; // 默认过滤
|
||||
if (userName) {
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const userSettings = await storage.getUserSettings(userName);
|
||||
// 如果用户设置存在且明确设为false,则不过滤;否则默认过滤
|
||||
shouldFilterAdult = userSettings?.filter_adult_content !== false;
|
||||
} catch (error) {
|
||||
// 出错时默认过滤成人内容
|
||||
shouldFilterAdult = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据用户设置和明确请求决定最终的过滤策略
|
||||
const finalShouldFilter = shouldFilterAdult || !includeAdult;
|
||||
|
||||
// 使用动态过滤方法,但不依赖缓存,实时获取设置
|
||||
const availableSites = finalShouldFilter
|
||||
? await getAvailableApiSites(true) // 过滤成人内容
|
||||
: await getAvailableApiSites(false); // 不过滤成人内容
|
||||
|
||||
if (!availableSites || availableSites.length === 0) {
|
||||
const cacheTime = await getCacheTime();
|
||||
const response = NextResponse.json({
|
||||
regular_results: [],
|
||||
adult_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}`,
|
||||
},
|
||||
});
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// 搜索所有可用的资源站(已根据用户设置动态过滤)
|
||||
const searchPromises = availableSites.map((site) => searchFromApi(site, query));
|
||||
const searchResults = (await Promise.all(searchPromises)).flat();
|
||||
|
||||
// 所有结果都作为常规结果返回,因为成人内容源已经在源头被过滤掉了
|
||||
const cacheTime = await getCacheTime();
|
||||
const response = NextResponse.json(
|
||||
{ results: flattenedResults },
|
||||
{
|
||||
regular_results: searchResults,
|
||||
adult_results: [] // 始终为空,因为成人内容在源头就被过滤了
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
@@ -50,7 +105,14 @@ export async function GET(request: Request) {
|
||||
);
|
||||
return addCorsHeaders(response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
regular_results: [],
|
||||
adult_results: [],
|
||||
error: '搜索失败'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getStorage } from '@/lib/db';
|
||||
import { UserSettings } from '@/lib/types';
|
||||
|
||||
// 设置运行时为 Edge Runtime,确保部署兼容性
|
||||
export const runtime = 'edge';
|
||||
|
||||
// 获取用户设置
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const headersList = headers();
|
||||
const authorization = headersList.get('Authorization');
|
||||
|
||||
if (!authorization) {
|
||||
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userName = authorization.split(' ')[1]; // 假设格式为 "Bearer username"
|
||||
|
||||
if (!userName) {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const settings = await storage.getUserSettings(userName);
|
||||
|
||||
return NextResponse.json({
|
||||
settings: settings || {
|
||||
filter_adult_content: true, // 默认开启成人内容过滤
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: true,
|
||||
video_quality: 'auto'
|
||||
}
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error getting user settings:', error);
|
||||
return NextResponse.json({ error: '获取用户设置失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户设置
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const headersList = headers();
|
||||
const authorization = headersList.get('Authorization');
|
||||
|
||||
if (!authorization) {
|
||||
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userName = authorization.split(' ')[1];
|
||||
|
||||
if (!userName) {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { settings } = body as { settings: Partial<UserSettings> };
|
||||
|
||||
if (!settings) {
|
||||
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
// 验证用户存在
|
||||
const userExists = await storage.checkUserExist(userName);
|
||||
if (!userExists) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
await storage.updateUserSettings(userName, settings);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '设置更新成功'
|
||||
}, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error updating user settings:', error);
|
||||
return NextResponse.json({ error: '更新用户设置失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 重置用户设置
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const headersList = headers();
|
||||
const authorization = headersList.get('Authorization');
|
||||
|
||||
if (!authorization) {
|
||||
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userName = authorization.split(' ')[1];
|
||||
|
||||
if (!userName) {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { settings } = body as { settings: UserSettings };
|
||||
|
||||
if (!settings) {
|
||||
return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
// 验证用户存在
|
||||
const userExists = await storage.checkUserExist(userName);
|
||||
if (!userExists) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
await storage.setUserSettings(userName, settings);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '设置已重置'
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error resetting user settings:', error);
|
||||
return NextResponse.json({ error: '重置用户设置失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+13
-6
@@ -14,12 +14,19 @@ const inter = Inter({ subsets: ['latin'] });
|
||||
// 动态生成 metadata,支持配置更新后的标题变化
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
let siteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||
) {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
|
||||
try {
|
||||
// 只有在非 d1 和 upstash 存储类型时才尝试获取配置
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||
) {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果配置获取失败,使用默认站点名称
|
||||
// siteName 已经有默认值,不需要额外处理
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+17
-2
@@ -505,8 +505,22 @@ function PlayPageClient() {
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 处理新的搜索结果格式:合并 regular_results 和 adult_results
|
||||
let allResults: SearchResult[] = [];
|
||||
if (data.regular_results && Array.isArray(data.regular_results)) {
|
||||
allResults = allResults.concat(data.regular_results);
|
||||
}
|
||||
if (data.adult_results && Array.isArray(data.adult_results)) {
|
||||
allResults = allResults.concat(data.adult_results);
|
||||
}
|
||||
|
||||
// 兼容旧格式(如果有的话)
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
allResults = data.results;
|
||||
}
|
||||
|
||||
// 处理搜索结果,根据规则过滤
|
||||
const results = data.results.filter(
|
||||
const results = allResults.filter(
|
||||
(result: SearchResult) =>
|
||||
result.title.replaceAll(' ', '').toLowerCase() ===
|
||||
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
|
||||
@@ -1618,7 +1632,7 @@ function PlayPageClient() {
|
||||
|
||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||
<div
|
||||
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
className={`h-[600px] 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'
|
||||
@@ -1626,6 +1640,7 @@ function PlayPageClient() {
|
||||
>
|
||||
<EpisodeSelector
|
||||
totalEpisodes={totalEpisodes}
|
||||
episodesPerPage={50}
|
||||
value={currentEpisodeIndex + 1}
|
||||
onChange={handleEpisodeChange}
|
||||
onSourceChange={handleSourceChange}
|
||||
|
||||
+145
-72
@@ -3,8 +3,9 @@
|
||||
|
||||
import { ChevronUp, Search, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import {
|
||||
addSearchHistory,
|
||||
clearSearchHistory,
|
||||
@@ -29,6 +30,15 @@ function SearchPageClient() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
|
||||
// 分组结果状态
|
||||
const [groupedResults, setGroupedResults] = useState<{
|
||||
regular: SearchResult[];
|
||||
adult: SearchResult[];
|
||||
} | null>(null);
|
||||
|
||||
// 分组标签页状态
|
||||
const [activeTab, setActiveTab] = useState<'regular' | 'adult'>('regular');
|
||||
|
||||
// 获取默认聚合设置:只读取用户本地设置,默认为 true
|
||||
const getDefaultAggregate = () => {
|
||||
@@ -45,11 +55,11 @@ function SearchPageClient() {
|
||||
return getDefaultAggregate() ? 'agg' : 'all';
|
||||
});
|
||||
|
||||
// 聚合后的结果(按标题和年份分组)
|
||||
const aggregatedResults = useMemo(() => {
|
||||
// 聚合函数
|
||||
const aggregateResults = (results: SearchResult[]) => {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
searchResults.forEach((item) => {
|
||||
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
||||
results.forEach((item) => {
|
||||
// 使用 title + year + type 作为键
|
||||
const key = `${item.title.replaceAll(' ', '')}-${
|
||||
item.year || 'unknown'
|
||||
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
|
||||
@@ -73,23 +83,21 @@ function SearchPageClient() {
|
||||
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 排在后面
|
||||
return 1;
|
||||
} else if (bYear === 'unknown') {
|
||||
return -1; // b 排在后面
|
||||
return -1;
|
||||
} else {
|
||||
// 都是数字年份,按数字大小排序(大的在前面)
|
||||
return aYear > bYear ? -1 : 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [searchResults]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 无搜索参数时聚焦搜索框
|
||||
@@ -161,39 +169,54 @@ function SearchPageClient() {
|
||||
const fetchSearchResults = async (query: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 获取用户认证信息
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
|
||||
// 构建请求头
|
||||
const headers: HeadersInit = {};
|
||||
if (authInfo?.username) {
|
||||
headers['Authorization'] = `Bearer ${authInfo.username}`;
|
||||
}
|
||||
|
||||
// 简化的搜索请求 - 成人内容过滤现在在API层面自动处理
|
||||
// 添加时间戳参数避免缓存问题
|
||||
const timestamp = Date.now();
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}&t=${timestamp}`,
|
||||
{
|
||||
headers: {
|
||||
...headers,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
}
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
setSearchResults(
|
||||
data.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;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 处理新的搜索结果格式
|
||||
if (data.regular_results || data.adult_results) {
|
||||
// 处理分组结果
|
||||
setGroupedResults({
|
||||
regular: data.regular_results || [],
|
||||
adult: data.adult_results || []
|
||||
});
|
||||
setSearchResults([...(data.regular_results || []), ...(data.adult_results || [])]);
|
||||
} else if (data.grouped) {
|
||||
// 兼容旧的分组格式
|
||||
setGroupedResults({
|
||||
regular: data.regular || [],
|
||||
adult: data.adult || []
|
||||
});
|
||||
setSearchResults([...(data.regular || []), ...(data.adult || [])]);
|
||||
} else {
|
||||
// 兼容旧的普通结果格式
|
||||
setGroupedResults(null);
|
||||
setSearchResults(data.results || []);
|
||||
}
|
||||
|
||||
setShowResults(true);
|
||||
} catch (error) {
|
||||
setGroupedResults(null);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -284,50 +307,100 @@ function SearchPageClient() {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 如果有分组结果且有成人内容,显示分组标签 */}
|
||||
{groupedResults && groupedResults.adult.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('regular')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'regular'
|
||||
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
常规结果 ({groupedResults.regular.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('adult')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'adult'
|
||||
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
成人内容 ({groupedResults.adult.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === 'adult' && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-600 dark:text-red-400 text-center">
|
||||
⚠️ 以下内容可能包含成人资源,请确保您已年满18周岁
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={`search-results-${viewMode}`}
|
||||
key={`search-results-${viewMode}-${activeTab}`}
|
||||
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'
|
||||
>
|
||||
{(() => {
|
||||
// 确定要显示的结果
|
||||
let displayResults = searchResults;
|
||||
if (groupedResults && groupedResults.adult.length > 0) {
|
||||
displayResults = activeTab === 'adult'
|
||||
? groupedResults.adult
|
||||
: groupedResults.regular;
|
||||
}
|
||||
|
||||
// 聚合显示模式
|
||||
if (viewMode === 'agg') {
|
||||
const aggregated = aggregateResults(displayResults);
|
||||
return aggregated.map(([mapKey, group]: [string, SearchResult[]]) => (
|
||||
<div key={`agg-${mapKey}`} 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?.toString()}
|
||||
from='search'
|
||||
items={group}
|
||||
query={
|
||||
searchQuery.trim() !== item.title
|
||||
searchQuery.trim() !== group[0].title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
year={item.year}
|
||||
from='search'
|
||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
}
|
||||
|
||||
// 列表显示模式
|
||||
return displayResults.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?.toString()}
|
||||
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'>
|
||||
未找到相关结果
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft, Settings, User } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import AdultContentFilter from '@/components/AdultContentFilter';
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
const router = useRouter();
|
||||
const [authInfo, setAuthInfo] = useState<{ userName: string } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const auth = getAuthInfoFromBrowserCookie();
|
||||
if (!auth || !auth.username) {
|
||||
// 如果用户未登录,重定向到登录页面
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
setAuthInfo({ userName: auth.username });
|
||||
setIsLoading(false);
|
||||
}, [router]);
|
||||
|
||||
const handleFilterUpdate = (_enabled: boolean) => {
|
||||
// 可以在这里添加一些全局状态更新或通知逻辑
|
||||
// console.log('成人内容过滤状态已更新:', enabled);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<Settings className="w-8 h-8 mr-3 text-blue-600" />
|
||||
用户设置
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
管理您的个人偏好设置和隐私选项
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 px-4 py-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<User className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{authInfo.userName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设置区域 */}
|
||||
<div className="space-y-6">
|
||||
{/* 内容过滤设置 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
内容过滤
|
||||
</h2>
|
||||
<AdultContentFilter
|
||||
userName={authInfo.userName}
|
||||
onUpdate={handleFilterUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 其他设置部分预留 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
其他设置
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
更多设置选项即将推出...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>设置会自动保存并在所有设备间同步</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { Shield, ShieldOff } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AdultContentFilterProps {
|
||||
userName: string;
|
||||
onUpdate?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const AdultContentFilter: React.FC<AdultContentFilterProps> = ({
|
||||
userName,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [isEnabled, setIsEnabled] = useState(true); // 默认开启过滤
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 获取用户设置
|
||||
useEffect(() => {
|
||||
const fetchUserSettings = async () => {
|
||||
if (!userName) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userName}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsEnabled(data.settings.filter_adult_content);
|
||||
} else {
|
||||
setError('获取用户设置失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络连接失败');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch user settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserSettings();
|
||||
}, [userName]);
|
||||
|
||||
// 更新用户设置
|
||||
const handleToggle = async () => {
|
||||
if (!userName || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${userName}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
filter_adult_content: !isEnabled,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newState = !isEnabled;
|
||||
setIsEnabled(newState);
|
||||
|
||||
// 强制刷新用户设置缓存 - 向搜索API发送一个空请求来刷新设置
|
||||
try {
|
||||
await fetch('/api/search?q=_cache_refresh_', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userName}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 忽略刷新缓存的错误
|
||||
}
|
||||
|
||||
onUpdate?.(newState);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || '更新设置失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络连接失败');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to update user settings:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
{isEnabled ? (
|
||||
<Shield className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<ShieldOff className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
成人内容过滤
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{isEnabled
|
||||
? '已开启过滤,将自动隐藏所有标记为"成人"的资源站及其内容'
|
||||
: '已关闭过滤,成人内容将在搜索结果中单独分组显示'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || !userName}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${isEnabled
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${isEnabled ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="w-5 h-5">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
安全提示
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
为了确保良好的使用体验和遵守相关法规,建议保持成人内容过滤开启。如需访问相关内容,请确保您已年满18周岁并承担相应法律责任。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdultContentFilter;
|
||||
@@ -234,7 +234,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
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'>
|
||||
<div className='flex mb-0 -mx-6 flex-shrink-0'>
|
||||
{totalEpisodes > 1 && (
|
||||
<div
|
||||
onClick={() => setActiveTab('episodes')}
|
||||
@@ -267,7 +267,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{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 items-center gap-4 mb-2 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) => {
|
||||
@@ -320,8 +320,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 集数网格 */}
|
||||
<div className='grid grid-cols-[repeat(auto-fill,minmax(48px,1fr))] justify-center gap-2 overflow-y-auto pb-4'>
|
||||
{/* 集数网格 - 优化为10行×5列布局 */}
|
||||
<div className='grid grid-cols-5 gap-3 pb-6 px-2'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
@@ -338,7 +338,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
e.stopPropagation();
|
||||
handleEpisodeClick(episodeNumber);
|
||||
}}
|
||||
className={`w-full h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 cursor-pointer
|
||||
className={`w-full h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all duration-200 cursor-pointer
|
||||
${
|
||||
isActive
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// 临时内联认证函数,避免导入问题
|
||||
function getAuthInfoFromBrowserCookie(): {
|
||||
password?: string;
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
} | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookies = document.cookie.split(';');
|
||||
const authCookie = cookies.find(cookie => cookie.trim().startsWith('auth='));
|
||||
|
||||
if (!authCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cookieValue = authCookie.split('=')[1];
|
||||
const decoded = decodeURIComponent(cookieValue);
|
||||
const authData = JSON.parse(decoded);
|
||||
return authData;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse auth cookie:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
username: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
filter_adult_content: boolean;
|
||||
can_disable_filter: boolean;
|
||||
managed_by_admin: boolean;
|
||||
last_filter_change?: string;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 获取当前用户信息
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (authInfo?.username) {
|
||||
setCurrentUser(authInfo.username);
|
||||
loadUsers();
|
||||
} else {
|
||||
setError('未登录或权限不足');
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
throw new Error('未获取到用户认证信息');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${encodeURIComponent(authInfo.username)}`,
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '获取用户列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
console.error('加载用户列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserSettings = async (username: string, action: string, settings?: any) => {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (!authInfo?.username) {
|
||||
throw new Error('未获取到用户认证信息');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${encodeURIComponent(authInfo.username)}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
username,
|
||||
settings
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '操作失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
alert(data.message || '操作成功');
|
||||
|
||||
// 重新加载用户列表
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '未知错误';
|
||||
alert(`操作失败: ${errorMsg}`);
|
||||
console.error('用户管理操作失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceFilter = (username: string) => {
|
||||
if (confirm(`确定要强制开启用户 ${username} 的成人内容过滤功能吗?`)) {
|
||||
updateUserSettings(username, 'force_filter');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllowDisable = (username: string) => {
|
||||
if (confirm(`确定要允许用户 ${username} 自己管理过滤设置吗?`)) {
|
||||
updateUserSettings(username, 'allow_disable');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
|
||||
<div className="text-red-600 dark:text-red-400">{error}</div>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
className="mt-2 text-sm text-red-600 dark:text-red-400 underline hover:no-underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||
用户管理
|
||||
</h2>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
总计 {users.length} 个用户
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="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="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-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<tr key={user.username} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.username}
|
||||
</div>
|
||||
{user.username === currentUser && (
|
||||
<span className="ml-2 px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
|
||||
当前用户
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.role === 'owner'
|
||||
? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{user.role === 'owner' ? '站长' : '用户'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
user.filter_adult_content
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{user.filter_adult_content ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{user.managed_by_admin ? (
|
||||
<span className="text-orange-600 dark:text-orange-400">管理员控制</span>
|
||||
) : user.can_disable_filter ? (
|
||||
<span className="text-green-600 dark:text-green-400">用户自主</span>
|
||||
) : (
|
||||
<span className="text-gray-600 dark:text-gray-400">受限制</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{user.role !== 'owner' && user.username !== currentUser && (
|
||||
<div className="flex space-x-2">
|
||||
{!user.filter_adult_content || !user.managed_by_admin ? (
|
||||
<button
|
||||
onClick={() => handleForceFilter(user.username)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
强制过滤
|
||||
</button>
|
||||
) : null}
|
||||
{user.managed_by_admin || !user.can_disable_filter ? (
|
||||
<button
|
||||
onClick={() => handleAllowDisable(user.username)}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
允许自主
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
暂无用户数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
说明
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-600 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>强制过滤</strong>:开启用户的成人内容过滤,用户无法自己关闭</li>
|
||||
<li>• <strong>允许自主</strong>:允许用户自己管理成人内容过滤设置</li>
|
||||
<li>• 站长账户默认具有所有权限,无法被其他用户管理</li>
|
||||
<li>• 管理员控制的用户无法在用户设置中关闭成人内容过滤</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
|
||||
import { Filter, KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -208,12 +208,18 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设置点击
|
||||
const handleSettings = () => {
|
||||
setIsOpen(false);
|
||||
setIsSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSettings = () => {
|
||||
// 处理内容过滤设置
|
||||
const handleContentFilter = () => {
|
||||
setIsOpen(false);
|
||||
// 跳转到内容过滤设置页面
|
||||
router.push('/settings');
|
||||
}; const handleCloseSettings = () => {
|
||||
setIsSettingsOpen(false);
|
||||
};
|
||||
|
||||
@@ -360,7 +366,16 @@ export const UserMenu: React.FC = () => {
|
||||
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>
|
||||
<span className='font-medium'>本地设置</span>
|
||||
</button>
|
||||
|
||||
{/* 内容过滤按钮 */}
|
||||
<button
|
||||
onClick={handleContentFilter}
|
||||
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'
|
||||
>
|
||||
<Filter className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>内容过滤</span>
|
||||
</button>
|
||||
|
||||
{/* 管理面板按钮 */}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AdminConfig {
|
||||
detail?: string;
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
is_adult?: boolean; // 新增:是否为成人内容资源站
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
+124
-12
@@ -1,8 +1,7 @@
|
||||
/* 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 { getStorage } from './db';
|
||||
import runtimeConfig from './runtime';
|
||||
|
||||
export interface ApiSite {
|
||||
@@ -101,6 +100,7 @@ async function initConfig() {
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
is_adult: (site as any).is_adult || false, // 确保 is_adult 字段被正确处理
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -110,6 +110,12 @@ async function initConfig() {
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
} else {
|
||||
// 更新现有源的 is_adult 字段
|
||||
const siteConfig = fileConfig.api_site[source.key];
|
||||
if (siteConfig) {
|
||||
source.is_adult = (siteConfig as any).is_adult || false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,6 +178,7 @@ async function initConfig() {
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
is_adult: (site as any).is_adult || false, // 确保 is_adult 字段被正确处理
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -218,19 +225,24 @@ async function initConfig() {
|
||||
|
||||
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) {
|
||||
// 合并一些环境变量配置
|
||||
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||
try {
|
||||
const storage = getStorage();
|
||||
let adminConfig: AdminConfig | null = null;
|
||||
|
||||
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||
adminConfig = await (storage as any).getAdminConfig();
|
||||
}
|
||||
|
||||
if (adminConfig) {
|
||||
// 合并一些环境变量配置
|
||||
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||
adminConfig.SiteConfig.Announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
@@ -254,6 +266,7 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
detail: site.detail,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
is_adult: (site as any).is_adult || false, // 确保处理 is_adult 字段
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -263,6 +276,12 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
adminConfig.SourceConfig.forEach((source) => {
|
||||
if (!apiSiteKeys.has(source.key)) {
|
||||
source.from = 'custom';
|
||||
} else {
|
||||
// 更新现有源的 is_adult 字段
|
||||
const siteConfig = fileConfig.api_site[source.key];
|
||||
if (siteConfig) {
|
||||
source.is_adult = (siteConfig as any).is_adult || false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -292,6 +311,11 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
await initConfig();
|
||||
}
|
||||
return cachedConfig;
|
||||
} catch (error) {
|
||||
// 如果数据库访问失败,回退到默认配置
|
||||
await initConfig();
|
||||
return cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
@@ -378,9 +402,97 @@ export async function getCacheTime(): Promise<number> {
|
||||
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
|
||||
}
|
||||
|
||||
export async function getAvailableApiSites(): Promise<ApiSite[]> {
|
||||
export async function getAvailableApiSites(filterAdult = false): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
|
||||
|
||||
// 防御性检查:确保 SourceConfig 存在且为数组
|
||||
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
|
||||
console.warn('SourceConfig is missing or not an array, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 防御性处理:为每个源确保 is_adult 字段存在
|
||||
let sites = config.SourceConfig
|
||||
.filter((s) => !s.disabled)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
is_adult: s.is_adult === true // 严格检查,只有明确为 true 的才是成人内容
|
||||
}));
|
||||
|
||||
// 如果需要过滤成人内容,则排除标记为成人内容的资源站
|
||||
if (filterAdult) {
|
||||
sites = sites.filter((s) => !s.is_adult);
|
||||
}
|
||||
|
||||
return sites.map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
api: s.api,
|
||||
detail: s.detail,
|
||||
}));
|
||||
}
|
||||
|
||||
// 根据用户设置动态获取可用资源站(你的想法实现)
|
||||
export async function getFilteredApiSites(userName?: string): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
|
||||
// 防御性检查:确保 SourceConfig 存在且为数组
|
||||
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
|
||||
console.warn('SourceConfig is missing or not an array, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 默认过滤成人内容
|
||||
let shouldFilterAdult = true;
|
||||
|
||||
// 如果提供了用户名,获取用户设置
|
||||
if (userName) {
|
||||
try {
|
||||
const storage = getStorage();
|
||||
const userSettings = await storage.getUserSettings(userName);
|
||||
shouldFilterAdult = userSettings?.filter_adult_content !== false; // 默认为 true
|
||||
} catch (error) {
|
||||
// 获取用户设置失败时,默认过滤成人内容
|
||||
console.warn('Failed to get user settings, using default filter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 防御性处理:为每个源确保 is_adult 字段存在
|
||||
let sites = config.SourceConfig
|
||||
.filter((s) => !s.disabled)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
is_adult: s.is_adult === true // 严格检查,只有明确为 true 的才是成人内容
|
||||
}));
|
||||
|
||||
// 根据用户设置动态过滤成人内容源
|
||||
if (shouldFilterAdult) {
|
||||
sites = sites.filter((s) => !s.is_adult);
|
||||
}
|
||||
|
||||
return sites.map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
api: s.api,
|
||||
detail: s.detail,
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取成人内容资源站
|
||||
export async function getAdultApiSites(): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
|
||||
// 防御性检查:确保 SourceConfig 存在且为数组
|
||||
if (!config.SourceConfig || !Array.isArray(config.SourceConfig)) {
|
||||
console.warn('SourceConfig is missing or not an array, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 防御性处理:严格检查成人内容标记
|
||||
const adultSites = config.SourceConfig
|
||||
.filter((s) => !s.disabled && s.is_adult === true); // 只有明确为 true 的才被认为是成人内容
|
||||
|
||||
return adultSites.map((s) => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
api: s.api,
|
||||
|
||||
+89
-9
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -39,7 +39,21 @@ interface D1ExecResult {
|
||||
|
||||
// 获取全局D1数据库实例
|
||||
function getD1Database(): D1Database {
|
||||
return (process.env as any).DB as D1Database;
|
||||
// 在 Cloudflare Pages 环境中,DB 通过全局绑定可用
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
// 尝试直接访问全局 DB
|
||||
const globalDB = (globalThis as any).DB;
|
||||
if (globalDB) {
|
||||
return globalDB as D1Database;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 process.env(用于本地开发)
|
||||
if (process.env.DB) {
|
||||
return (process.env as any).DB as D1Database;
|
||||
}
|
||||
|
||||
throw new Error('D1 database not available');
|
||||
}
|
||||
|
||||
export class D1Storage implements IStorage {
|
||||
@@ -428,14 +442,20 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT username FROM users ORDER BY created_at ASC')
|
||||
.all<{ username: string }>();
|
||||
.prepare('SELECT username, created_at FROM users ORDER BY created_at ASC')
|
||||
.all<{ username: string; created_at: string }>();
|
||||
|
||||
return result.results.map((row) => row.username);
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
|
||||
return result.results.map((row) => ({
|
||||
username: row.username,
|
||||
role: row.username === ownerUsername ? 'owner' : 'user',
|
||||
created_at: row.created_at
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to get all users:', err);
|
||||
throw err;
|
||||
@@ -447,7 +467,8 @@ export class D1Storage implements IStorage {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const result = await db
|
||||
.prepare('SELECT config FROM admin_config WHERE id = 1')
|
||||
.prepare('SELECT config_value as config FROM admin_configs WHERE config_key = ? LIMIT 1')
|
||||
.bind('main_config')
|
||||
.first<{ config: string }>();
|
||||
|
||||
if (!result) return null;
|
||||
@@ -464,9 +485,9 @@ export class D1Storage implements IStorage {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT OR REPLACE INTO admin_config (id, config) VALUES (1, ?)'
|
||||
'INSERT OR REPLACE INTO admin_configs (config_key, config_value, description) VALUES (?, ?, ?)'
|
||||
)
|
||||
.bind(JSON.stringify(config))
|
||||
.bind('main_config', JSON.stringify(config), '主要管理员配置')
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set admin config:', err);
|
||||
@@ -573,4 +594,63 @@ export class D1Storage implements IStorage {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 用户设置 ----------
|
||||
async getUserSettings(userName: string): Promise<UserSettings | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const row = await db
|
||||
.prepare('SELECT settings FROM user_settings WHERE username = ?')
|
||||
.bind(userName)
|
||||
.first();
|
||||
|
||||
if (row && row.settings) {
|
||||
return JSON.parse(row.settings as string) as UserSettings;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('Failed to get user settings:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setUserSettings(
|
||||
userName: string,
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
await db
|
||||
.prepare(`
|
||||
INSERT OR REPLACE INTO user_settings (username, settings, updated_time)
|
||||
VALUES (?, ?, ?)
|
||||
`)
|
||||
.bind(userName, JSON.stringify(settings), Date.now())
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('Failed to set user settings:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserSettings(
|
||||
userName: string,
|
||||
settings: Partial<UserSettings>
|
||||
): Promise<void> {
|
||||
const current = await this.getUserSettings(userName);
|
||||
const defaultSettings: UserSettings = {
|
||||
filter_adult_content: true,
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: false,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
const updated: UserSettings = {
|
||||
...defaultSettings,
|
||||
...current,
|
||||
...settings,
|
||||
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
|
||||
};
|
||||
await this.setUserSettings(userName, updated);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-13
@@ -20,19 +20,34 @@ const STORAGE_TYPE =
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
switch (STORAGE_TYPE) {
|
||||
case 'redis':
|
||||
return new RedisStorage();
|
||||
case 'kvrocks':
|
||||
return new KvrocksStorage();
|
||||
case 'upstash':
|
||||
return new UpstashRedisStorage();
|
||||
case 'd1':
|
||||
return new D1Storage();
|
||||
case 'localstorage':
|
||||
default:
|
||||
// 使用 LocalStorage 实现,适用于本地开发和简单部署
|
||||
return new LocalStorage();
|
||||
const storageType = STORAGE_TYPE;
|
||||
|
||||
try {
|
||||
switch (storageType) {
|
||||
case 'redis':
|
||||
return new RedisStorage();
|
||||
case 'kvrocks':
|
||||
return new KvrocksStorage();
|
||||
case 'upstash':
|
||||
return new UpstashRedisStorage();
|
||||
case 'd1':
|
||||
// 对于 d1,先检查是否可用
|
||||
if (typeof globalThis !== 'undefined' && (globalThis as any).DB) {
|
||||
return new D1Storage();
|
||||
} else if (process.env.DB) {
|
||||
return new D1Storage();
|
||||
} else {
|
||||
// D1 不可用,回退到 LocalStorage
|
||||
return new LocalStorage();
|
||||
}
|
||||
case 'localstorage':
|
||||
default:
|
||||
// 使用 LocalStorage 实现,适用于本地开发和简单部署
|
||||
return new LocalStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
// 创建存储失败,回退到 LocalStorage
|
||||
return new LocalStorage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-4
@@ -3,7 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -266,9 +266,31 @@ export class KvrocksStorage implements IStorage {
|
||||
});
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
const users = await withRetry(() => this.client.sMembers(this.userListKey()));
|
||||
return ensureStringArray(users);
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const usernames = await withRetry(() => this.client.sMembers(this.userListKey()));
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
|
||||
const users = await Promise.all(
|
||||
usernames.map(async (username) => {
|
||||
let created_at = '';
|
||||
try {
|
||||
const userData = await this.getUser(username);
|
||||
if (userData?.created_at) {
|
||||
created_at = new Date(userData.created_at).toISOString();
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略错误,使用空字符串
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
role: username === ownerUsername ? 'owner' : 'user',
|
||||
created_at
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async registerUser(userName: string, password: string): Promise<void> {
|
||||
@@ -337,6 +359,48 @@ export class KvrocksStorage implements IStorage {
|
||||
this.client.set(this.adminConfigKey(), JSON.stringify(config))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 用户设置 ----------
|
||||
private userSettingsKey(userName: string) {
|
||||
return `u:${userName}:settings`;
|
||||
}
|
||||
|
||||
async getUserSettings(userName: string): Promise<UserSettings | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.userSettingsKey(userName))
|
||||
);
|
||||
return val ? (JSON.parse(val) as UserSettings) : null;
|
||||
}
|
||||
|
||||
async setUserSettings(
|
||||
userName: string,
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userSettingsKey(userName), JSON.stringify(settings))
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserSettings(
|
||||
userName: string,
|
||||
settings: Partial<UserSettings>
|
||||
): Promise<void> {
|
||||
const current = await this.getUserSettings(userName);
|
||||
const defaultSettings: UserSettings = {
|
||||
filter_adult_content: true,
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: false,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
const updated: UserSettings = {
|
||||
...defaultSettings,
|
||||
...current,
|
||||
...settings,
|
||||
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
|
||||
};
|
||||
await this.setUserSettings(userName, updated);
|
||||
}
|
||||
}
|
||||
|
||||
// Kvrocks客户端单例
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
|
||||
|
||||
/**
|
||||
* LocalStorage 存储实现
|
||||
@@ -290,19 +290,89 @@ export class LocalStorage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 用户设置 ----------
|
||||
async getUserSettings(userName: string): Promise<UserSettings | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const storageKey = this.getStorageKey('settings', userName);
|
||||
const data = localStorage.getItem(storageKey);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
// 如果用户设置不存在,返回默认设置
|
||||
const defaultSettings: UserSettings = {
|
||||
filter_adult_content: true, // 默认开启成人内容过滤
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: true,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
|
||||
return defaultSettings;
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const storageKey = this.getStorageKey('settings', userName);
|
||||
localStorage.setItem(storageKey, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Error setting user settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const currentSettings = await this.getUserSettings(userName);
|
||||
const updatedSettings = { ...currentSettings, ...settings };
|
||||
await this.setUserSettings(userName, updatedSettings as UserSettings);
|
||||
} catch (error) {
|
||||
console.error('Error updating user settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 管理员功能 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const users: string[] = [];
|
||||
const users: User[] = [];
|
||||
const prefix = 'katelyatv_user_';
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const storageKey = localStorage.key(i);
|
||||
if (storageKey && storageKey.startsWith(prefix)) {
|
||||
const userName = storageKey.replace(prefix, '');
|
||||
users.push(userName);
|
||||
const username = storageKey.replace(prefix, '');
|
||||
|
||||
// 尝试获取用户创建时间
|
||||
let created_at = '';
|
||||
try {
|
||||
const userDataStr = localStorage.getItem(storageKey);
|
||||
if (userDataStr) {
|
||||
const userData = JSON.parse(userDataStr);
|
||||
if (userData.created_at) {
|
||||
created_at = new Date(userData.created_at).toISOString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
|
||||
users.push({
|
||||
username,
|
||||
role: username === ownerUsername ? 'owner' : 'user',
|
||||
created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +435,7 @@ export class LocalStorage implements IStorage {
|
||||
localStorage.removeItem(userKey);
|
||||
|
||||
// 删除用户相关的所有数据
|
||||
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig'];
|
||||
const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig', 'settings'];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const dataPrefix = this.getStorageKey(prefix, userName);
|
||||
|
||||
+74
-3
@@ -3,7 +3,7 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -223,6 +223,50 @@ export class RedisStorage implements IStorage {
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(favoriteKeys));
|
||||
}
|
||||
|
||||
// 删除用户设置
|
||||
await withRetry(() => this.client.del(this.userSettingsKey(userName)));
|
||||
}
|
||||
|
||||
// ---------- 用户设置 ----------
|
||||
private userSettingsKey(user: string) {
|
||||
return `u:${user}:settings`; // u:username:settings
|
||||
}
|
||||
|
||||
async getUserSettings(userName: string): Promise<UserSettings | null> {
|
||||
const data = await withRetry(() =>
|
||||
this.client.get(this.userSettingsKey(userName))
|
||||
);
|
||||
|
||||
if (data) {
|
||||
return JSON.parse(ensureString(data));
|
||||
}
|
||||
|
||||
// 如果用户设置不存在,返回默认设置
|
||||
const defaultSettings: UserSettings = {
|
||||
filter_adult_content: true, // 默认开启成人内容过滤
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: true,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
async setUserSettings(userName: string, settings: UserSettings): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(
|
||||
this.userSettingsKey(userName),
|
||||
JSON.stringify(settings)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void> {
|
||||
const currentSettings = await this.getUserSettings(userName);
|
||||
const updatedSettings = { ...currentSettings, ...settings };
|
||||
await this.setUserSettings(userName, updatedSettings as UserSettings);
|
||||
}
|
||||
|
||||
// ---------- 搜索历史 ----------
|
||||
@@ -258,14 +302,41 @@ export class RedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
|
||||
const usernames = keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
|
||||
// 获取用户创建时间并构造 User 对象
|
||||
const users = await Promise.all(
|
||||
usernames.map(async (username) => {
|
||||
// 尝试获取用户创建时间,如果没有则使用空字符串
|
||||
const createdAtKey = `u:${username}:created_at`;
|
||||
let created_at = '';
|
||||
try {
|
||||
const timestamp = await withRetry(() => this.client.get(createdAtKey));
|
||||
if (timestamp) {
|
||||
created_at = new Date(parseInt(timestamp)).toISOString();
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略错误,使用空字符串
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
role: username === ownerUsername ? 'owner' : 'user',
|
||||
created_at
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
|
||||
+45
-1
@@ -42,6 +42,13 @@ export interface Favorite {
|
||||
search_title: string; // 搜索时使用的标题
|
||||
}
|
||||
|
||||
// 用户数据结构
|
||||
export interface User {
|
||||
username: string;
|
||||
role?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
export interface IStorage {
|
||||
// 播放记录相关
|
||||
@@ -70,6 +77,11 @@ export interface IStorage {
|
||||
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
|
||||
deleteUser(userName: string): Promise<void>;
|
||||
|
||||
// 用户设置相关
|
||||
getUserSettings(userName: string): Promise<UserSettings | null>;
|
||||
setUserSettings(userName: string, settings: UserSettings): Promise<void>;
|
||||
updateUserSettings(userName: string, settings: Partial<UserSettings>): Promise<void>;
|
||||
|
||||
// 搜索历史相关
|
||||
getSearchHistory(userName: string): Promise<string[]>;
|
||||
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||
@@ -82,7 +94,7 @@ export interface IStorage {
|
||||
deleteSkipConfig(userName: string, key: string): Promise<void>;
|
||||
|
||||
// 用户列表
|
||||
getAllUsers(): Promise<string[]>;
|
||||
getAllUsers(): Promise<User[]>;
|
||||
|
||||
// 管理员配置相关
|
||||
getAdminConfig(): Promise<AdminConfig | null>;
|
||||
@@ -119,6 +131,38 @@ export interface DoubanResult {
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
// 资源站配置
|
||||
export interface ApiSite {
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
type?: number;
|
||||
playMode?: 'parse' | 'direct';
|
||||
is_adult?: boolean; // 新增:是否为成人内容资源站
|
||||
}
|
||||
|
||||
// 配置文件结构
|
||||
export interface Config {
|
||||
cache_time: number;
|
||||
api_site: { [key: string]: ApiSite };
|
||||
}
|
||||
|
||||
// 用户设置
|
||||
export interface UserSettings {
|
||||
filter_adult_content: boolean; // 是否过滤成人内容,默认为 true
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
language: string;
|
||||
auto_play: boolean;
|
||||
video_quality: string;
|
||||
[key: string]: string | boolean | number; // 允许其他设置
|
||||
}
|
||||
|
||||
// 搜索结果(支持成人内容分组)
|
||||
export interface GroupedSearchResults {
|
||||
regular_results: SearchResult[];
|
||||
adult_results?: SearchResult[];
|
||||
}
|
||||
|
||||
// Runtime配置类型
|
||||
export interface RuntimeConfig {
|
||||
STORAGE_TYPE?: string;
|
||||
|
||||
+72
-3
@@ -3,7 +3,7 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types';
|
||||
import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, User, UserSettings } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
@@ -244,14 +244,41 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
// ---------- 获取全部用户 ----------
|
||||
async getAllUsers(): Promise<string[]> {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||
return keys
|
||||
const ownerUsername = process.env.USERNAME || 'admin';
|
||||
|
||||
const usernames = keys
|
||||
.map((k) => {
|
||||
const match = k.match(/^u:(.+?):pwd$/);
|
||||
return match ? ensureString(match[1]) : undefined;
|
||||
})
|
||||
.filter((u): u is string => typeof u === 'string');
|
||||
|
||||
// 获取用户创建时间并构造 User 对象
|
||||
const users = await Promise.all(
|
||||
usernames.map(async (username) => {
|
||||
// 尝试获取用户创建时间,如果没有则使用空字符串
|
||||
const createdAtKey = `u:${username}:created_at`;
|
||||
let created_at = '';
|
||||
try {
|
||||
const timestamp = await withRetry(() => this.client.get(createdAtKey));
|
||||
if (timestamp && typeof timestamp === 'number') {
|
||||
created_at = new Date(timestamp).toISOString();
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略错误,使用空字符串
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
role: username === ownerUsername ? 'owner' : 'user',
|
||||
created_at
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// ---------- 管理员配置 ----------
|
||||
@@ -329,6 +356,48 @@ export class UpstashRedisStorage implements IStorage {
|
||||
await this.client.srem(this.skipConfigsKey(userName), key);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 用户设置 ----------
|
||||
private userSettingsKey(userName: string) {
|
||||
return `u:${userName}:settings`;
|
||||
}
|
||||
|
||||
async getUserSettings(userName: string): Promise<UserSettings | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.userSettingsKey(userName))
|
||||
);
|
||||
return val ? (val as UserSettings) : null;
|
||||
}
|
||||
|
||||
async setUserSettings(
|
||||
userName: string,
|
||||
settings: UserSettings
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userSettingsKey(userName), settings)
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserSettings(
|
||||
userName: string,
|
||||
settings: Partial<UserSettings>
|
||||
): Promise<void> {
|
||||
const current = await this.getUserSettings(userName);
|
||||
const defaultSettings: UserSettings = {
|
||||
filter_adult_content: true,
|
||||
theme: 'auto',
|
||||
language: 'zh-CN',
|
||||
auto_play: false,
|
||||
video_quality: 'auto'
|
||||
};
|
||||
const updated: UserSettings = {
|
||||
...defaultSettings,
|
||||
...current,
|
||||
...settings,
|
||||
filter_adult_content: settings.filter_adult_content ?? current?.filter_adult_content ?? true
|
||||
};
|
||||
await this.setUserSettings(userName, updated);
|
||||
}
|
||||
}
|
||||
|
||||
// 单例 Upstash Redis 客户端
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
const CURRENT_VERSION = '20250904151930';
|
||||
const CURRENT_VERSION = '20250904200125';
|
||||
|
||||
// 版本检查结果枚举
|
||||
export enum UpdateStatus {
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
if (!process.env.PASSWORD) {
|
||||
if (!process.env.AUTH_PASSWORD) {
|
||||
// 如果没有设置密码,重定向到警告页面
|
||||
const warningUrl = new URL('/warning', request.url);
|
||||
return NextResponse.redirect(warningUrl);
|
||||
@@ -29,7 +29,7 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
// localstorage模式:在middleware中完成验证
|
||||
if (storageType === 'localstorage') {
|
||||
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||
if (!authInfo.password || authInfo.password !== process.env.AUTH_PASSWORD) {
|
||||
return handleAuthFailure(request, pathname);
|
||||
}
|
||||
return NextResponse.next();
|
||||
@@ -46,7 +46,7 @@ export async function middleware(request: NextRequest) {
|
||||
const isValidSignature = await verifySignature(
|
||||
authInfo.username,
|
||||
authInfo.signature,
|
||||
process.env.PASSWORD || ''
|
||||
process.env.AUTH_PASSWORD || ''
|
||||
);
|
||||
|
||||
// 签名验证通过即可
|
||||
|
||||
+57
-42
@@ -1,53 +1,68 @@
|
||||
name = "katelyatv"
|
||||
compatibility_date = "2024-09-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
pages_build_output_dir = ".vercel/output/static"
|
||||
|
||||
# 默认 D1 数据库配置(用于命令行操作)
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "katelyatv-db"
|
||||
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
|
||||
|
||||
# 生产环境配置
|
||||
[env.production]
|
||||
name = "katelyatv"
|
||||
|
||||
[env.production.vars]
|
||||
# 存储类型配置
|
||||
NEXT_PUBLIC_STORAGE_TYPE = "d1"
|
||||
|
||||
# 站点配置
|
||||
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
|
||||
|
||||
# NextAuth 配置
|
||||
NEXTAUTH_URL = "https://your-domain.pages.dev"
|
||||
|
||||
# 图片代理配置
|
||||
IMAGE_PROXY_ENABLED = "true"
|
||||
|
||||
# 缓存配置
|
||||
CACHE_TTL = "3600"
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGIN = "*"
|
||||
|
||||
# Rate Limiting 配置
|
||||
RATE_LIMIT_MAX = "100"
|
||||
RATE_LIMIT_WINDOW = "60000"
|
||||
|
||||
# 健康检查配置
|
||||
HEALTH_CHECK_ENABLED = "true"
|
||||
HEALTH_CHECK_INTERVAL = "30"
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL = "info"
|
||||
LOG_FORMAT = "json"
|
||||
|
||||
# 生产环境标识
|
||||
NODE_ENV = "production"
|
||||
|
||||
# 生产环境 D1 数据库配置
|
||||
[[env.production.d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "katelyatv-db"
|
||||
database_id = "your-d1-database-id-here"
|
||||
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
|
||||
|
||||
[build]
|
||||
command = "pnpm pages:build"
|
||||
environment = { NODE_VERSION = "18" }
|
||||
# 生产环境变量
|
||||
[env.production.vars]
|
||||
NEXT_PUBLIC_STORAGE_TYPE = "d1"
|
||||
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
|
||||
NEXTAUTH_URL = "https://tv.katelya.eu.org"
|
||||
USERNAME = "katelya"
|
||||
AUTH_PASSWORD = "ab1433223cd@"
|
||||
IMAGE_PROXY_ENABLED = "true"
|
||||
CACHE_TTL = "3600"
|
||||
CORS_ORIGIN = "*"
|
||||
RATE_LIMIT_MAX = "100"
|
||||
RATE_LIMIT_WINDOW = "60000"
|
||||
HEALTH_CHECK_ENABLED = "true"
|
||||
HEALTH_CHECK_INTERVAL = "30"
|
||||
LOG_LEVEL = "info"
|
||||
LOG_FORMAT = "json"
|
||||
NODE_ENV = "production"
|
||||
|
||||
[[build.environment_variables]]
|
||||
name = "NPM_FLAGS"
|
||||
value = "--prefix=/opt/buildhome/.asdf/installs/nodejs/18.17.1/.npm"
|
||||
# 预览环境配置
|
||||
[env.preview]
|
||||
name = "katelyatv-preview"
|
||||
|
||||
# 预览环境 D1 数据库配置
|
||||
[[env.preview.d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "katelyatv-db"
|
||||
database_id = "6d580637-1f87-4ddf-8b4d-3d97254b4c33"
|
||||
|
||||
# 预览环境变量
|
||||
[env.preview.vars]
|
||||
NEXT_PUBLIC_STORAGE_TYPE = "d1"
|
||||
NEXT_PUBLIC_SITE_NAME = "KatelyaTV"
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION = "高性能影视播放平台"
|
||||
NEXTAUTH_URL = "https://katelyatv.pages.dev"
|
||||
USERNAME = "katelya"
|
||||
AUTH_PASSWORD = "ab1433223cd@"
|
||||
IMAGE_PROXY_ENABLED = "true"
|
||||
CACHE_TTL = "3600"
|
||||
CORS_ORIGIN = "*"
|
||||
RATE_LIMIT_MAX = "100"
|
||||
RATE_LIMIT_WINDOW = "60000"
|
||||
HEALTH_CHECK_ENABLED = "true"
|
||||
HEALTH_CHECK_INTERVAL = "30"
|
||||
LOG_LEVEL = "info"
|
||||
LOG_FORMAT = "json"
|
||||
NODE_ENV = "production"
|
||||
Reference in New Issue
Block a user