Compare commits

...

20 Commits
8.8.8 ... main

Author SHA1 Message Date
djteang 2194a3d6ad fixed:TypeError: Cannot read properties of null (reading 'large') 2025-09-25 11:08:27 +08:00
djteang 3fd6211697 fixed:更新文档 2025-09-25 10:15:30 +08:00
djteang 7357131005 fixed:TypeError: Cannot read properties of null (reading 'large') 2025-09-25 10:01:46 +08:00
djteang 49d1d3b8b8 fixed:自定义主题应用所有人 2025-09-25 00:07:07 +08:00
djteang 668146f414 fixed:自定义主题应用所有人 2025-09-24 22:12:34 +08:00
djteang b07b4ef36a fixed:自定义主题应用所有人 2025-09-23 16:37:49 +08:00
djteang 7707ba5414 fixed:自定义主题应用所有人 2025-09-23 09:20:31 +08:00
djteang 3457a7c565 fixed:自定义主题应用所有人 2025-09-22 17:20:02 +08:00
djteang c462e1c2b5 fixed:版本检查问题 2025-09-21 09:30:41 +08:00
djteang a5e9ce41f1 fixed:版本检查问题 2025-09-21 09:16:01 +08:00
djteang f3138e9681 fixed:镜像健康检查问题 2025-09-21 01:23:06 +08:00
djteang 167d328116 added:添加内置主题,支持用户自定义CSS
changed:优化搜索页面缓存机制
fixed:镜像健康检查问题,弹幕功能适配移动端
2025-09-21 01:19:44 +08:00
djteang c1f86270ec fixed:修复弹窗提示无法关闭问题 2025-09-16 10:17:10 +08:00
djteang 7b73c7c71d fixed:恢复版本检测功能 2025-09-16 09:06:24 +08:00
djteang 091ca9d2ff add:机器识别码设定开关;配置文件去重添加;视频源编辑;单个视频源进行有效性检测
update:聊天页面适配移动端
fixed:弹幕发送问题;播放页测速问题
2025-09-15 20:11:25 +08:00
djteang 156f2de526 add:新增聊天功能
add:新增打包成多架构镜像
fixed:修复500问题
2025-09-14 22:41:18 +08:00
djteang ded66a2b97 update:更新文档 2025-09-11 17:16:20 +08:00
djteang 2c6e99e241 fixed:修复短剧页面出现的各种问题 2025-09-11 16:26:48 +08:00
djteang ecaeaea49d update: 更新镜像名 2025-09-10 20:38:01 +08:00
djteang 65b46211ef fixed: 修复短剧API访问404的问题 2025-09-10 14:41:18 +08:00
73 changed files with 10087 additions and 1061 deletions

View File

@ -1,3 +1,57 @@
## [8.9.5] - 2025-09-21
### Added
- 添加内置主题支持用户自定义CSS
### Changed
- 优化搜索页面缓存机制
### Fixed
- 镜像健康检查问题
- 弹幕功能适配移动端
## [8.9.0] - 2025-09-15
### Added
- 机器识别码设定开关
- 配置文件去重添加
- 视频源编辑
- 单个视频源进行有效性检测
### Changed
- 聊天页面适配移动端
### Fixed
- 弹幕发送问题
- 播放页测速问题
## [8.8.9] - 2025-09-14
### Added
- 聊天,好友等功能
- 支持arm架构镜像
### Fixed
- 播放页面500问题
### Added
- 聊天,好友等功能
- 支持arm架构镜像
### Fixed
- 播放页面500问题
## [8.8.8] - 2025-09-12
### Added
- 新增短剧类目聚合
- 支持短剧类目搜索
- 弹幕功能
- 用户头像上传
- 设备识别码绑定用户
### Changed
- 美化界面
- 修改图标和标题
### Fixed
- 停用版本检查功能
## [100.0.0] - 2025-08-26 ## [100.0.0] - 2025-08-26
### Added ### Added

View File

@ -1,19 +1,42 @@
# ---- 第 1 阶段:安装依赖 ---- # 多架构构建 Dockerfile
FROM node:20-alpine AS deps # 使用 Docker Buildx 进行多架构构建:
# docker buildx build --platform linux/amd64,linux/arm64 -t your-image:tag --push .
# 或单一架构构建:
# docker buildx build --platform linux/amd64 -t your-image:tag --load .
# 启用 corepack 并激活 pnpmNode20 默认提供 corepack # 声明构建参数,用于多架构构建
ARG BUILDPLATFORM
ARG TARGETPLATFORM
# ---- 第 1 阶段:安装依赖 ----
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
# 启用 corepack 并激活 pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
# 仅复制依赖清单,提高构建缓存利用率 # 先复制所有文件
COPY package.json pnpm-lock.yaml ./ COPY . .
# 安装所有依赖(含 devDependencies后续会裁剪 # 然后检查文件
RUN echo "文件列表:" && ls -la && \
echo "检查 tsconfig.json:" && \
if [ -f "tsconfig.json" ]; then \
echo "tsconfig.json 存在"; \
else \
echo "tsconfig.json 不存在"; \
echo "查找所有文件:"; \
find . -type f -name "*tsconfig*"; \
exit 1; \
fi
# 安装所有依赖
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# ---- 第 2 阶段:构建项目 ---- # ---- 第 2 阶段:构建项目 ----
FROM node:20-alpine AS builder FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app WORKDIR /app
@ -22,7 +45,6 @@ COPY --from=deps /app/node_modules ./node_modules
# 复制全部源代码 # 复制全部源代码
COPY . . COPY . .
# 在构建阶段也显式设置 DOCKER_ENV
ENV DOCKER_ENV=true ENV DOCKER_ENV=true
# 生成生产构建 # 生成生产构建
@ -44,16 +66,78 @@ ENV DOCKER_ENV=true
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录 # 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js # 从构建器中复制启动脚本和WebSocket相关文件
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
COPY --from=builder --chown=nextjs:nodejs /app/websocket.js ./websocket.js
COPY --from=builder --chown=nextjs:nodejs /app/production.js ./production.js
COPY --from=builder --chown=nextjs:nodejs /app/production-final.js ./production-final.js
COPY --from=builder --chown=nextjs:nodejs /app/standalone-websocket.js ./standalone-websocket.js
# 从构建器中复制 public 和 .next/static 目录 # 从构建器中复制 public 和 .next/static 目录
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 从构建器中复制 package.json 和 package-lock.json用于安装额外依赖
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
# 复制 tsconfig.json 以确保路径解析正确
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
# 切换到非特权用户 # 安装必要的WebSocket依赖兼容多架构
USER root
RUN corepack enable && corepack prepare pnpm@latest --activate && \
# 使用 --no-optional 避免某些架构下的可选依赖问题
pnpm install --prod --no-optional ws && \
# 清理安装缓存减小镜像大小
pnpm store prune
# 创建健康检查脚本在切换用户之前以root权限创建
RUN echo '#!/usr/bin/env node\n\
const http = require("http");\n\
const options = {\n\
hostname: "localhost",\n\
port: 3000,\n\
path: "/api/health",\n\
method: "GET",\n\
timeout: 5000\n\
};\n\
\n\
const req = http.request(options, (res) => {\n\
if (res.statusCode === 200) {\n\
console.log("Health check passed");\n\
process.exit(0);\n\
} else {\n\
console.log(`Health check failed with status: ${res.statusCode}`);\n\
process.exit(1);\n\
}\n\
});\n\
\n\
req.on("error", (err) => {\n\
console.log(`Health check error: ${err.message}`);\n\
process.exit(1);\n\
});\n\
\n\
req.on("timeout", () => {\n\
console.log("Health check timeout");\n\
req.destroy();\n\
process.exit(1);\n\
});\n\
\n\
req.setTimeout(5000);\n\
req.end();' > /app/healthcheck.js && \
chmod +x /app/healthcheck.js && \
chown nextjs:nodejs /app/healthcheck.js
# 切回非特权用户
USER nextjs USER nextjs
EXPOSE 3000 # 暴露HTTP和WebSocket端口
EXPOSE 3000 3001
# 使用自定义启动脚本,先预加载配置再启动服务器 # 添加健康检查
CMD ["node", "start.js"] HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node /app/healthcheck.js
# 设置WebSocket端口环境变量
ENV WS_PORT=3001
# 使用最终的生产环境脚本分离WebSocket服务
CMD ["node", "production-final.js"]

View File

@ -26,7 +26,7 @@
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。 - ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性) - 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集 ### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
@ -72,11 +72,12 @@
```yml ```yml
services: services:
OrangeTV-core: OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0 image: ghcr.io/djteang/orangetv:latest
container_name: OrangeTV-core container_name: OrangeTV-core
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange
@ -106,11 +107,12 @@ volumes:
```yml ```yml
services: services:
OrangeTV-core: OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0 image: ghcr.io/djteang/orangetv:latest
container_name: OrangeTV-core container_name: OrangeTV-core
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange
@ -142,11 +144,12 @@ networks:
```yml ```yml
services: services:
OrangeTV-core: OrangeTV-core:
image: ghcr.io/djteang/orangetv:1.0 image: ghcr.io/djteang/orangetv:latest
container_name: OrangeTV-core container_name: OrangeTV-core
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange

View File

@ -1 +1 @@
8.8.8 8.9.5

View File

@ -5,7 +5,7 @@ const nextConfig = {
output: 'standalone', output: 'standalone',
eslint: { eslint: {
dirs: ['src'], dirs: ['src'],
ignoreDuringBuilds: process.env.DOCKER_ENV === 'true', ignoreDuringBuilds: true, // 始终在构建时忽略 ESLint 错误
}, },
reactStrictMode: false, reactStrictMode: false,
@ -59,6 +59,23 @@ const nextConfig = {
// Modify the file loader rule to ignore *.svg, since we have it handled now. // Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i; fileLoaderRule.exclude = /\.svg$/i;
// Add alias configuration to ensure proper path resolution in Docker builds
const path = require('path');
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, 'src'),
'~': path.resolve(__dirname, 'public'),
};
// Ensure proper file extension resolution
config.resolve.extensions = ['.ts', '.tsx', '.js', '.jsx', '.json'];
// Add TypeScript module resolution support
config.resolve.modules = [
path.resolve(__dirname, 'src'),
'node_modules'
];
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,
net: false, net: false,

46
nginx.conf Normal file
View File

@ -0,0 +1,46 @@
server {
listen 443 ssl;
server_name domain.com;
charset utf-8;
ssl_certificate /home/cert/tvcertificate.crt;
ssl_certificate_key /home/cert/tvprivate.pem;
location / {
proxy_pass http://ip:3003;
# 重要的代理头信息,让 Next.js 服务器知道原始请求的来源
proxy_set_header Host $host; # 原始主机名
proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议 (http/https)
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
}
location /ws-api {
proxy_pass http://ip:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 特定的超时设置(长连接)
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
# 禁用缓冲以减少延迟
proxy_buffering off;
}
}
server {
listen 80;
server_name domain.com;
return 301 https://domain.com$request_uri;
}

72
nginx.conf.example Normal file
View File

@ -0,0 +1,72 @@
# Nginx配置示例用于生产环境反向代理
# 将此文件放置在 /etc/nginx/sites-available/ 并创建符号链接到 sites-enabled/
upstream nextjs_app {
server localhost:3000;
}
upstream websocket_app {
server localhost:3001;
}
server {
listen 80;
server_name your-domain.com;
# 如果使用HTTPS取消下面的注释并配置SSL证书
# listen 443 ssl;
# ssl_certificate /path/to/ssl/cert.pem;
# ssl_certificate_key /path/to/ssl/key.pem;
# 增加请求体大小限制
client_max_body_size 100M;
# Next.js应用的主要路由
location / {
proxy_pass http://nextjs_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket专用路由
location /ws-api {
proxy_pass http://websocket_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket特定的超时设置
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
# 静态资源缓存
location /_next/static {
proxy_pass http://nextjs_app;
proxy_cache_valid 60m;
add_header Cache-Control "public, immutable";
}
location /public {
proxy_pass http://nextjs_app;
proxy_cache_valid 60m;
add_header Cache-Control "public, max-age=3600";
}
}

View File

@ -3,9 +3,15 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0", "dev": "pnpm gen:manifest && node simple-dev.js",
"dev:complex": "pnpm gen:manifest && node dev-server.js",
"dev:ws": "node standalone-websocket.js",
"test:ws": "node test-websocket-connection.js",
"debug:api": "node debug-api.js",
"build": "pnpm gen:manifest && next build", "build": "pnpm gen:manifest && next build",
"start": "next start", "start": "NODE_ENV=production node server.js",
"prod": "NODE_ENV=production node production.js",
"prod:final": "NODE_ENV=production node production-final.js",
"lint": "next lint", "lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format", "lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src", "lint:strict": "eslint --max-warnings=0 src",
@ -26,6 +32,7 @@
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/ws": "^8.18.1",
"@upstash/redis": "^1.25.0", "@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13", "@vidstack/react": "^1.12.13",
"artplayer": "^5.2.5", "artplayer": "^5.2.5",
@ -38,7 +45,7 @@
"hls.js": "^1.6.10", "hls.js": "^1.6.10",
"lucide-react": "^0.438.0", "lucide-react": "^0.438.0",
"media-icons": "^1.1.5", "media-icons": "^1.1.5",
"next": "^14.2.23", "next": "^14.2.30",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^18.2.0", "react": "^18.2.0",
@ -49,6 +56,7 @@
"swiper": "^11.2.8", "swiper": "^11.2.8",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15", "vidstack": "^0.6.15",
"ws": "^8.18.3",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -29,6 +29,9 @@ importers:
'@types/crypto-js': '@types/crypto-js':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@upstash/redis': '@upstash/redis':
specifier: ^1.25.0 specifier: ^1.25.0
version: 1.35.1 version: 1.35.1
@ -66,7 +69,7 @@ importers:
specifier: ^1.1.5 specifier: ^1.1.5
version: 1.1.5 version: 1.1.5
next: next:
specifier: ^14.2.23 specifier: ^14.2.30
version: 14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-pwa: next-pwa:
specifier: ^5.6.0 specifier: ^5.6.0
@ -98,6 +101,9 @@ importers:
vidstack: vidstack:
specifier: ^0.6.15 specifier: ^0.6.15
version: 0.6.15 version: 0.6.15
ws:
specifier: ^8.18.3
version: 8.18.3
zod: zod:
specifier: ^3.24.1 specifier: ^3.24.1
version: 3.25.67 version: 3.25.67
@ -1583,6 +1589,9 @@ packages:
'@types/validator@13.15.3': '@types/validator@13.15.3':
resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yargs-parser@21.0.3': '@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@ -5266,6 +5275,18 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@3.0.0: xml-name-validator@3.0.0:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
@ -7060,6 +7081,10 @@ snapshots:
'@types/validator@13.15.3': {} '@types/validator@13.15.3': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.0.3
'@types/yargs-parser@21.0.3': {} '@types/yargs-parser@21.0.3': {}
'@types/yargs@16.0.9': '@types/yargs@16.0.9':
@ -9880,7 +9905,7 @@ snapshots:
'@next/env': 14.2.30 '@next/env': 14.2.30
'@swc/helpers': 0.5.5 '@swc/helpers': 0.5.5
busboy: 1.6.0 busboy: 1.6.0
caniuse-lite: 1.0.30001723 caniuse-lite: 1.0.30001741
graceful-fs: 4.2.11 graceful-fs: 4.2.11
postcss: 8.4.31 postcss: 8.4.31
react: 18.3.1 react: 18.3.1
@ -11371,6 +11396,8 @@ snapshots:
ws@7.5.10: {} ws@7.5.10: {}
ws@8.18.3: {}
xml-name-validator@3.0.0: {} xml-name-validator@3.0.0: {}
xmlchars@2.2.0: {} xmlchars@2.2.0: {}

176
production-final.js Normal file
View File

@ -0,0 +1,176 @@
/**
* 最终的生产环境启动文件
* 分离Next.js和WebSocket服务器避免任何冲突
*/
process.env.NODE_ENV = 'production';
const path = require('path');
const http = require('http');
// 调用 generate-manifest.js 生成 manifest.json
function generateManifest() {
console.log('Generating manifest.json for Docker deployment...');
try {
const generateManifestScript = path.join(
__dirname,
'scripts',
'generate-manifest.js'
);
require(generateManifestScript);
} catch (error) {
console.error('❌ Error calling generate-manifest.js:', error);
throw error;
}
}
// 生成manifest
generateManifest();
// 启动独立的WebSocket服务器
const { createStandaloneWebSocketServer, getOnlineUsers, sendMessageToUsers } = require('./standalone-websocket');
const wsPort = process.env.WS_PORT || 3001;
const wss = createStandaloneWebSocketServer(wsPort);
// 将WebSocket函数存储到全局对象供API路由使用
global.getOnlineUsers = getOnlineUsers;
global.sendMessageToUsers = sendMessageToUsers;
// 启动Next.js standalone服务器
console.log('Starting Next.js production server...');
const nextServerPath = path.join(__dirname, 'server.js');
// 检查是否存在standalone server.js
const fs = require('fs');
if (fs.existsSync(nextServerPath)) {
// Docker环境使用standalone server
require(nextServerPath);
} else {
// 非Docker环境使用标准Next.js启动
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = process.env.PORT || 3000;
const app = next({
dev: false,
hostname,
port
});
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('处理请求时出错:', req.url, err);
res.statusCode = 500;
res.end('内部服务器错误');
}
});
server.listen(port, (err) => {
if (err) throw err;
console.log(`> Next.js服务已启动: http://${hostname}:${port}`);
setupServerTasks();
});
});
}
// 设置服务器启动后的任务
function setupServerTasks() {
const httpPort = process.env.PORT || 3000;
const hostname = process.env.HOSTNAME || 'localhost';
// 每1秒轮询一次直到请求成功
const TARGET_URL = `http://${hostname}:${httpPort}/login`;
const intervalId = setInterval(() => {
console.log(`Fetching ${TARGET_URL} ...`);
const req = http.get(TARGET_URL, (res) => {
// 当返回2xx状态码时认为成功然后停止轮询
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Server is up, stop polling.');
clearInterval(intervalId);
setTimeout(() => {
// 服务器启动后立即执行一次cron任务
executeCronJob();
}, 3000);
// 然后设置每小时执行一次cron任务
setInterval(() => {
executeCronJob();
}, 60 * 60 * 1000); // 每小时执行一次
// 显示服务状态
console.log('====================================');
console.log(`✅ Next.js服务运行在: http://${hostname}:${httpPort}`);
console.log(`✅ WebSocket服务运行在: ws://${hostname}:${wsPort}`);
console.log('====================================');
}
});
req.setTimeout(2000, () => {
req.destroy();
});
req.on('error', () => {
// 忽略连接错误,继续轮询
});
}, 1000);
}
// 执行cron任务的函数
function executeCronJob() {
const httpPort = process.env.PORT || 3000;
const hostname = process.env.HOSTNAME || 'localhost';
const cronUrl = `http://${hostname}:${httpPort}/api/cron`;
console.log(`Executing cron job: ${cronUrl}`);
const req = http.get(cronUrl, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Cron job executed successfully:', data);
} else {
console.error('Cron job failed:', res.statusCode, data);
}
});
});
req.on('error', (err) => {
console.error('Error executing cron job:', err);
});
req.setTimeout(30000, () => {
console.error('Cron job timeout');
req.destroy();
});
}
// 如果直接运行此文件,设置任务
if (require.main === module) {
// 延迟启动任务,等待服务器完全启动
setTimeout(() => {
setupServerTasks();
}, 5000);
}

178
production.js Normal file
View File

@ -0,0 +1,178 @@
/**
* 生产模式下的服务器入口
* 使用 NODE_ENV=production node production.js 来启动
*/
process.env.NODE_ENV = 'production';
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const path = require('path');
const http = require('http');
const { createWebSocketServer } = require('./websocket');
// 调用 generate-manifest.js 生成 manifest.json
function generateManifest() {
console.log('Generating manifest.json for Docker deployment...');
try {
const generateManifestScript = path.join(
__dirname,
'scripts',
'generate-manifest.js'
);
require(generateManifestScript);
} catch (error) {
console.error('❌ Error calling generate-manifest.js:', error);
throw error;
}
}
// 生成manifest
generateManifest();
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = process.env.PORT || 3000;
// 在生产模式下初始化 Next.js
const app = next({
dev: false,
hostname,
port
});
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
// 检查是否是WebSocket升级请求如果是则跳过Next.js处理
const upgrade = req.headers.upgrade;
if (upgrade && upgrade.toLowerCase() === 'websocket') {
// 不处理WebSocket升级请求让upgrade事件处理器处理
return;
}
// 使用Next.js处理所有非WebSocket请求
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('处理请求时出错:', req.url, err);
res.statusCode = 500;
res.end('内部服务器错误');
}
});
// 初始化 WebSocket 服务器
const wss = createWebSocketServer();
// 将 WebSocket 服务器实例存储到全局对象中,供 API 路由使用
global.wss = wss;
// 使用WeakSet来跟踪已处理的socket避免重复处理
const handledSockets = new WeakSet();
// 处理 WebSocket 升级请求
server.on('upgrade', (request, socket, head) => {
// 如果socket已经被处理过直接返回
if (handledSockets.has(socket)) {
return;
}
const pathname = parse(request.url).pathname;
if (pathname === '/ws') {
console.log('处理 WebSocket 升级请求:', pathname);
// 标记socket已被处理
handledSockets.add(socket);
// 处理WebSocket连接
try {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} catch (error) {
console.error('WebSocket升级错误:', error);
socket.destroy();
}
} else {
console.log('未知的升级请求路径:', pathname);
// 不销毁socket让它自然关闭
}
});
// 启动服务器
server.listen(port, (err) => {
if (err) throw err;
console.log(`> 服务已启动 (生产模式): http://${hostname}:${port}`);
console.log(`> WebSocket 服务已启动: ws://${hostname}:${port}/ws`);
// 设置服务器启动后的任务
setupServerTasks();
});
});
// 设置服务器启动后的任务
function setupServerTasks() {
// 每 1 秒轮询一次,直到请求成功
const TARGET_URL = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}/login`;
const intervalId = setInterval(() => {
console.log(`Fetching ${TARGET_URL} ...`);
const req = http.get(TARGET_URL, (res) => {
// 当返回 2xx 状态码时认为成功,然后停止轮询
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Server is up, stop polling.');
clearInterval(intervalId);
setTimeout(() => {
// 服务器启动后,立即执行一次 cron 任务
executeCronJob();
}, 3000);
// 然后设置每小时执行一次 cron 任务
setInterval(() => {
executeCronJob();
}, 60 * 60 * 1000); // 每小时执行一次
}
});
req.setTimeout(2000, () => {
req.destroy();
});
}, 1000);
}
// 执行 cron 任务的函数
function executeCronJob() {
const cronUrl = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}/api/cron`;
console.log(`Executing cron job: ${cronUrl}`);
const req = http.get(cronUrl, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
console.log('Cron job executed successfully:', data);
} else {
console.error('Cron job failed:', res.statusCode, data);
}
});
});
req.on('error', (err) => {
console.error('Error executing cron job:', err);
});
req.setTimeout(30000, () => {
console.error('Cron job timeout');
req.destroy();
});
}

70
server.js Normal file
View File

@ -0,0 +1,70 @@
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { createWebSocketServer } = require('./websocket');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = process.env.PORT || 3000;
// 当使用Next.js时需要预准备应用程序
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
// 使用Next.js处理所有请求
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
res.statusCode = 500;
res.end('internal server error');
}
});
// 初始化 WebSocket 服务器
const wss = createWebSocketServer(server);
// 将 WebSocket 服务器实例及相关方法存储到全局对象中,供 API 路由使用
global.wss = wss;
// 使用一个标志确保每个连接只被处理一次
const upgradedSockets = new WeakSet();
// 直接处理 WebSocket 升级请求
server.on('upgrade', (request, socket, head) => {
// 如果这个 socket 已经被处理过,就忽略它
if (upgradedSockets.has(socket)) {
return;
}
const pathname = parse(request.url).pathname;
if (pathname === '/ws') {
console.log('处理 WebSocket 升级请求:', pathname);
try {
// 标记这个 socket 已经被处理
upgradedSockets.add(socket);
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} catch (error) {
console.error('WebSocket 升级处理错误:', error);
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
}
} else {
console.log('非 WebSocket 升级请求:', pathname);
// Next.js 会自己处理这些请求,无需销毁 socket
}
});
server.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server ready on ws://${hostname}:${port}/ws`);
});
});

54
simple-dev.js Normal file
View File

@ -0,0 +1,54 @@
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const wsPort = 3001;
// 启动独立WebSocket服务器
console.log('🔌 启动 WebSocket 服务器...');
const { createStandaloneWebSocketServer } = require('./standalone-websocket');
createStandaloneWebSocketServer(wsPort);
// 启动Next.js
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
res.statusCode = 500;
res.end('internal server error');
}
});
server.listen(port, (err) => {
if (err) throw err;
console.log(`🌐 Next.js ready on http://${hostname}:${port}`);
console.log(`🔌 WebSocket ready on ws://${hostname}:${wsPort}/ws`);
console.log('\n✅ 开发环境已启动!按 Ctrl+C 停止服务器');
});
// 优雅关闭
const cleanup = () => {
console.log('\n🛑 正在关闭服务器...');
server.close(() => {
process.exit(0);
});
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
});

View File

@ -38,15 +38,16 @@ import {
Users, Users,
Video, Video,
} from 'lucide-react'; } from 'lucide-react';
import { GripVertical } from 'lucide-react'; import { GripVertical, Palette } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { AdminConfig, AdminConfigResult } from '../../lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import DataMigration from '@/components/DataMigration'; import DataMigration from '@/components/DataMigration';
import ThemeManager from '@/components/ThemeManager';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
// 统一按钮样式系统 // 统一按钮样式系统
@ -496,6 +497,7 @@ interface SiteConfig {
DoubanImageProxy: string; DoubanImageProxy: string;
DisableYellowFilter: boolean; DisableYellowFilter: boolean;
FluidSearch: boolean; FluidSearch: boolean;
RequireDeviceCode: boolean;
} }
// 视频源数据类型 // 视频源数据类型
@ -2295,6 +2297,7 @@ const VideoSourceConfig = ({
const { isLoading, withLoading } = useLoadingState(); const { isLoading, withLoading } = useLoadingState();
const [sources, setSources] = useState<DataSource[]>([]); const [sources, setSources] = useState<DataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [editingSource, setEditingSource] = useState<DataSource | null>(null);
const [orderChanged, setOrderChanged] = useState(false); const [orderChanged, setOrderChanged] = useState(false);
const [newSource, setNewSource] = useState<DataSource>({ const [newSource, setNewSource] = useState<DataSource>({
name: '', name: '',
@ -2340,6 +2343,32 @@ const VideoSourceConfig = ({
resultCount: number; resultCount: number;
}>>([]); }>>([]);
// 单个视频源验证状态
const [singleValidationResult, setSingleValidationResult] = useState<{
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
message: string;
details?: {
responseTime?: number;
resultCount?: number;
error?: string;
searchKeyword?: string;
};
}>({ status: null, message: '' });
const [isSingleValidating, setIsSingleValidating] = useState(false);
// 新增视频源验证状态
const [newSourceValidationResult, setNewSourceValidationResult] = useState<{
status: 'valid' | 'invalid' | 'no_results' | 'validating' | null;
message: string;
details?: {
responseTime?: number;
resultCount?: number;
error?: string;
searchKeyword?: string;
};
}>({ status: null, message: '' });
const [isNewSourceValidating, setIsNewSourceValidating] = useState(false);
// dnd-kit 传感器 // dnd-kit 传感器
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@ -2422,11 +2451,42 @@ const VideoSourceConfig = ({
from: 'custom', from: 'custom',
}); });
setShowAddForm(false); setShowAddForm(false);
// 清除检测结果
clearNewSourceValidation();
}).catch(() => { }).catch(() => {
console.error('操作失败', 'add', newSource); console.error('操作失败', 'add', newSource);
}); });
}; };
const handleEditSource = () => {
if (!editingSource || !editingSource.name || !editingSource.api) return;
withLoading('editSource', async () => {
await callSourceApi({
action: 'edit',
key: editingSource.key,
name: editingSource.name,
api: editingSource.api,
detail: editingSource.detail,
});
setEditingSource(null);
}).catch(() => {
console.error('操作失败', 'edit', editingSource);
});
};
const handleCancelEdit = () => {
setEditingSource(null);
// 清除单个源的检测结果
setSingleValidationResult({ status: null, message: '' });
setIsSingleValidating(false);
};
// 清除新增视频源检测结果
const clearNewSourceValidation = () => {
setNewSourceValidationResult({ status: null, message: '' });
setIsNewSourceValidating(false);
};
const handleDragEnd = (event: any) => { const handleDragEnd = (event: any) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
@ -2450,7 +2510,7 @@ const VideoSourceConfig = ({
// 有效性检测函数 // 有效性检测函数
const handleValidateSources = async () => { const handleValidateSources = async () => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' }); showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空', showConfirm: true });
return; return;
} }
@ -2524,7 +2584,7 @@ const VideoSourceConfig = ({
console.error('EventSource错误:', error); console.error('EventSource错误:', error);
eventSource.close(); eventSource.close();
setIsValidating(false); setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' }); showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试', showConfirm: true });
}; };
// 设置超时,防止长时间等待 // 设置超时,防止长时间等待
@ -2532,18 +2592,161 @@ const VideoSourceConfig = ({
if (eventSource.readyState === EventSource.OPEN) { if (eventSource.readyState === EventSource.OPEN) {
eventSource.close(); eventSource.close();
setIsValidating(false); setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' }); showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试', showConfirm: true });
} }
}, 60000); // 60秒超时 }, 60000); // 60秒超时
} catch (error) { } catch (error) {
setIsValidating(false); setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' }); showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误', showConfirm: true });
throw error; throw error;
} }
}); });
}; };
// 通用视频源有效性检测函数
const handleValidateSource = async (
api: string,
name: string,
isNewSource: boolean = false
) => {
if (!api.trim()) {
showAlert({ type: 'warning', title: 'API地址不能为空', message: '请输入有效的API地址', showConfirm: true });
return;
}
const validationKey = isNewSource ? 'validateNewSource' : 'validateSingleSource';
const setValidating = isNewSource ? setIsNewSourceValidating : setIsSingleValidating;
const setResult = isNewSource ? setNewSourceValidationResult : setSingleValidationResult;
await withLoading(validationKey, async () => {
setValidating(true);
setResult({ status: 'validating', message: '检测中...' });
const startTime = Date.now();
const testKeyword = '灵笼';
try {
// 构建检测 URL使用临时 API 地址
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(testKeyword)}&tempApi=${encodeURIComponent(api.trim())}&tempName=${encodeURIComponent(name)}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const responseTime = Date.now() - startTime;
switch (data.type) {
case 'start':
console.log(`开始检测视频源: ${name}`);
break;
case 'source_result':
case 'source_error':
if (data.source === 'temp') {
let message = '';
let details: any = {
responseTime,
searchKeyword: testKeyword
};
if (data.status === 'valid') {
message = '搜索正常';
details.resultCount = data.resultCount || 0;
} else if (data.status === 'no_results') {
message = '无法搜索到结果';
details.resultCount = 0;
} else {
message = '连接失败';
details.error = data.error || '未知错误';
}
setResult({
status: data.status,
message,
details
});
}
break;
case 'complete':
console.log(`检测完成: ${name}`);
eventSource.close();
setValidating(false);
break;
}
} catch (error) {
console.error('解析EventSource数据失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('EventSource错误:', error);
eventSource.close();
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: '连接错误,请重试',
details: {
responseTime,
error: '网络连接失败',
searchKeyword: testKeyword
}
});
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: '检测超时,请重试',
details: {
responseTime,
error: '请求超时30秒',
searchKeyword: testKeyword
}
});
}
}, 30000); // 30秒超时
} catch (error) {
setValidating(false);
const responseTime = Date.now() - startTime;
setResult({
status: 'invalid',
message: error instanceof Error ? error.message : '未知错误',
details: {
responseTime,
error: error instanceof Error ? error.message : '未知错误',
searchKeyword: testKeyword
}
});
}
});
};
// 单个视频源有效性检测函数
const handleValidateSingleSource = async () => {
if (!editingSource) {
showAlert({ type: 'warning', title: '没有可检测的视频源', message: '请确保正在编辑视频源', showConfirm: true });
return;
}
await handleValidateSource(editingSource.api, editingSource.name, false);
};
// 新增视频源有效性检测函数
const handleValidateNewSource = async () => {
if (!newSource.name.trim()) {
showAlert({ type: 'warning', title: '视频源名称不能为空', message: '请输入视频源名称', showConfirm: true });
return;
}
await handleValidateSource(newSource.api, newSource.name, true);
};
// 获取有效性状态显示 // 获取有效性状态显示
const getValidationStatus = (sourceKey: string) => { const getValidationStatus = (sourceKey: string) => {
const result = validationResults.find(r => r.key === sourceKey); const result = validationResults.find(r => r.key === sourceKey);
@ -2671,15 +2874,27 @@ const VideoSourceConfig = ({
> >
{!source.disabled ? '禁用' : '启用'} {!source.disabled ? '禁用' : '启用'}
</button> </button>
{source.from !== 'config' && ( <button
onClick={() => {
setEditingSource(source);
// 清除之前的检测结果
setSingleValidationResult({ status: null, message: '' });
setIsSingleValidating(false);
}}
disabled={isLoading(`editSource_${source.key}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`editSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
title='编辑此视频源'
>
</button>
<button <button
onClick={() => handleDelete(source.key)} onClick={() => handleDelete(source.key)}
disabled={isLoading(`deleteSource_${source.key}`)} disabled={isLoading(`deleteSource_${source.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`} className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
title='删除此视频源'
> >
</button> </button>
)}
</td> </td>
</tr> </tr>
); );
@ -2711,7 +2926,7 @@ const VideoSourceConfig = ({
// 批量操作 // 批量操作
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => { const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
if (selectedSources.size === 0) { if (selectedSources.size === 0) {
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' }); showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源', showConfirm: true });
return; return;
} }
@ -2746,7 +2961,7 @@ const VideoSourceConfig = ({
// 重置选择状态 // 重置选择状态
setSelectedSources(new Set()); setSelectedSources(new Set());
} catch (err) { } catch (err) {
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' }); showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败', showConfirm: true });
} }
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } }); setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
}, },
@ -2824,7 +3039,13 @@ const VideoSourceConfig = ({
)} )}
</button> </button>
<button <button
onClick={() => setShowAddForm(!showAddForm)} onClick={() => {
setShowAddForm(!showAddForm);
// 切换表单时清除检测结果
if (!showAddForm) {
clearNewSourceValidation();
}
}}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success} className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
> >
{showAddForm ? '取消' : '添加视频源'} {showAddForm ? '取消' : '添加视频源'}
@ -2873,11 +3094,62 @@ 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' className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/> />
</div> </div>
<div className='flex justify-end'>
{/* 新增视频源有效性检测结果显示 */}
{newSourceValidationResult.status && (
<div className='p-3 rounded-lg border'>
<div className='space-y-2'>
<div className='flex items-center space-x-2'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>:</span>
<span
className={`px-2 py-1 text-xs rounded-full ${newSourceValidationResult.status === 'valid'
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: newSourceValidationResult.status === 'validating'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: newSourceValidationResult.status === 'no_results'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{newSourceValidationResult.status === 'valid' && '✓ '}
{newSourceValidationResult.status === 'validating' && '⏳ '}
{newSourceValidationResult.status === 'no_results' && '⚠️ '}
{newSourceValidationResult.status === 'invalid' && '✗ '}
{newSourceValidationResult.message}
</span>
</div>
{newSourceValidationResult.details && (
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
{newSourceValidationResult.details.searchKeyword && (
<div>: {newSourceValidationResult.details.searchKeyword}</div>
)}
{newSourceValidationResult.details.responseTime && (
<div>: {newSourceValidationResult.details.responseTime}ms</div>
)}
{newSourceValidationResult.details.resultCount !== undefined && (
<div>: {newSourceValidationResult.details.resultCount}</div>
)}
{newSourceValidationResult.details.error && (
<div className='text-red-600 dark:text-red-400'>: {newSourceValidationResult.details.error}</div>
)}
</div>
)}
</div>
</div>
)}
<div className='flex justify-end space-x-2'>
<button
onClick={handleValidateNewSource}
disabled={!newSource.api || isNewSourceValidating || isLoading('validateNewSource')}
className={`px-4 py-2 ${!newSource.api || isNewSourceValidating || isLoading('validateNewSource') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isNewSourceValidating || isLoading('validateNewSource') ? '检测中...' : '有效性检测'}
</button>
<button <button
onClick={handleAddSource} onClick={handleAddSource}
disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')} disabled={!newSource.name || !newSource.key || !newSource.api || isLoading('addSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`} className={`px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
> >
{isLoading('addSource') ? '添加中...' : '添加'} {isLoading('addSource') ? '添加中...' : '添加'}
</button> </button>
@ -2885,6 +3157,140 @@ const VideoSourceConfig = ({
</div> </div>
)} )}
{/* 编辑视频源表单 */}
{editingSource && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='flex items-center justify-between'>
<h5 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
: {editingSource.name}
</h5>
<button
onClick={handleCancelEdit}
className='text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
>
</button>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='text'
value={editingSource.name}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, name: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
Key ()
</label>
<input
type='text'
value={editingSource.key}
disabled
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
API
</label>
<input
type='text'
value={editingSource.api}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, api: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1'>
Detail
</label>
<input
type='text'
value={editingSource.detail || ''}
onChange={(e) =>
setEditingSource((prev) => prev ? ({ ...prev, detail: e.target.value }) : null)
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
{/* 有效性检测结果显示 */}
{singleValidationResult.status && (
<div className='col-span-full mt-4 p-3 rounded-lg border'>
<div className='space-y-2'>
<div className='flex items-center space-x-2'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>:</span>
<span
className={`px-2 py-1 text-xs rounded-full ${singleValidationResult.status === 'valid'
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: singleValidationResult.status === 'validating'
? 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300'
: singleValidationResult.status === 'no_results'
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{singleValidationResult.status === 'valid' && '✓ '}
{singleValidationResult.status === 'validating' && '⏳ '}
{singleValidationResult.status === 'no_results' && '⚠️ '}
{singleValidationResult.status === 'invalid' && '✗ '}
{singleValidationResult.message}
</span>
</div>
{singleValidationResult.details && (
<div className='text-xs text-gray-600 dark:text-gray-400 space-y-1'>
{singleValidationResult.details.searchKeyword && (
<div>: {singleValidationResult.details.searchKeyword}</div>
)}
{singleValidationResult.details.responseTime && (
<div>: {singleValidationResult.details.responseTime}ms</div>
)}
{singleValidationResult.details.resultCount !== undefined && (
<div>: {singleValidationResult.details.resultCount}</div>
)}
{singleValidationResult.details.error && (
<div className='text-red-600 dark:text-red-400'>: {singleValidationResult.details.error}</div>
)}
</div>
)}
</div>
</div>
)}
</div>
<div className='flex justify-end space-x-2'>
<button
onClick={handleCancelEdit}
className={buttonStyles.secondary}
>
</button>
<button
onClick={handleValidateSingleSource}
disabled={!editingSource.api || isSingleValidating || isLoading('validateSingleSource')}
className={`${!editingSource.api || isSingleValidating || isLoading('validateSingleSource') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isSingleValidating || isLoading('validateSingleSource') ? '检测中...' : '有效性检测'}
</button>
<button
onClick={handleEditSource}
disabled={!editingSource.name || !editingSource.api || isLoading('editSource')}
className={`${!editingSource.name || !editingSource.api || isLoading('editSource') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('editSource') ? '保存中...' : '保存'}
</button>
</div>
</div>
)}
{/* 视频源表格 */} {/* 视频源表格 */}
@ -3657,6 +4063,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DoubanImageProxy: '', DoubanImageProxy: '',
DisableYellowFilter: false, DisableYellowFilter: false,
FluidSearch: true, FluidSearch: true,
RequireDeviceCode: true,
}); });
// 豆瓣数据源相关状态 // 豆瓣数据源相关状态
@ -3719,6 +4126,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '', DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false, DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
FluidSearch: config.SiteConfig.FluidSearch || true, FluidSearch: config.SiteConfig.FluidSearch || true,
RequireDeviceCode: config.SiteConfig.RequireDeviceCode !== undefined ? config.SiteConfig.RequireDeviceCode : true,
}); });
} }
}, [config]); }, [config]);
@ -4103,6 +4511,40 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
/> />
</div> </div>
{/* 启用设备码验证 */}
<div>
<div className='flex items-center justify-between'>
<label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
</label>
<button
type='button'
onClick={() =>
setSiteSettings((prev) => ({
...prev,
RequireDeviceCode: !prev.RequireDeviceCode,
}))
}
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 ${siteSettings.RequireDeviceCode
? buttonStyles.toggleOn
: buttonStyles.toggleOff
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full ${buttonStyles.toggleThumb} transition-transform ${siteSettings.RequireDeviceCode
? buttonStyles.toggleThumbOn
: buttonStyles.toggleThumbOff
}`}
/>
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 禁用黄色过滤器 */} {/* 禁用黄色过滤器 */}
<div> <div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
@ -4769,6 +5211,7 @@ function AdminPageClient() {
categoryConfig: false, categoryConfig: false,
configFile: false, configFile: false,
dataMigration: false, dataMigration: false,
themeManager: false,
}); });
// 机器码管理状态 // 机器码管理状态
@ -5006,6 +5449,21 @@ function AdminPageClient() {
<DataMigration onRefreshConfig={fetchConfig} /> <DataMigration onRefreshConfig={fetchConfig} />
</CollapsibleTab> </CollapsibleTab>
)} )}
{/* 主题定制标签 */}
<CollapsibleTab
title='主题定制'
icon={
<Palette
size={20}
className='text-gray-600 dark:text-gray-400'
/>
}
isExpanded={expandedTabs.themeManager}
onToggle={() => toggleTab('themeManager')}
>
<ThemeManager showAlert={showAlert} role={role} />
</CollapsibleTab>
</div> </div>
</div> </div>
</div> </div>

View File

@ -20,41 +20,74 @@ export async function GET(request: NextRequest) {
} }
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { const username = authInfo?.username;
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
try { try {
const config = await getConfig(); const config = await getConfig();
const result: AdminConfigResult = {
Role: 'owner', // 检查用户权限
Config: config, let userRole = 'guest'; // 未登录用户为 guest
}; let isAdmin = false;
if (username === process.env.USERNAME) { if (username === process.env.USERNAME) {
result.Role = 'owner'; userRole = 'owner';
} else { isAdmin = true;
} else if (username) {
const user = config.UserConfig.Users.find((u) => u.username === username); const user = config.UserConfig.Users.find((u) => u.username === username);
if (user && user.role === 'admin' && !user.banned) { if (user && user.role === 'admin' && !user.banned) {
result.Role = 'admin'; userRole = 'admin';
isAdmin = true;
} else if (user && !user.banned) {
userRole = 'user';
} else if (user && user.banned) {
userRole = 'banned';
} else { } else {
return NextResponse.json( // 认证了但用户不存在,可能是数据不同步
{ error: '你是管理员吗你就访问?' }, userRole = 'unknown';
{ status: 401 }
);
} }
} }
// 根据用户权限返回不同的配置信息
if (isAdmin) {
// 管理员返回完整配置
const result: AdminConfigResult = {
Role: userRole as 'admin' | 'owner',
Config: config,
};
return NextResponse.json(result, { return NextResponse.json(result, {
headers: { headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存 'Cache-Control': 'no-store', // 管理员配置不缓存
}, },
}); });
} else {
// 普通用户或未登录用户只返回公开配置
const publicConfig = {
ThemeConfig: config.ThemeConfig,
SiteConfig: {
SiteName: config.SiteConfig.SiteName,
Announcement: config.SiteConfig.Announcement,
// 其他公开的站点配置可以在这里添加
}
};
const result = {
Role: userRole,
Config: publicConfig,
};
console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig);
return NextResponse.json(result, {
headers: {
'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟
},
});
}
} catch (error) { } catch (error) {
console.error('获取管理员配置失败:', error); console.error('获取配置失败:', error);
return NextResponse.json( return NextResponse.json(
{ {
error: '获取管理员配置失败', error: '获取配置失败',
details: (error as Error).message, details: (error as Error).message,
}, },
{ status: 500 } { status: 500 }

View File

@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
DoubanImageProxy, DoubanImageProxy,
DisableYellowFilter, DisableYellowFilter,
FluidSearch, FluidSearch,
RequireDeviceCode,
CustomTheme,
} = body as { } = body as {
SiteName: string; SiteName: string;
Announcement: string; Announcement: string;
@ -50,6 +52,11 @@ export async function POST(request: NextRequest) {
DoubanImageProxy: string; DoubanImageProxy: string;
DisableYellowFilter: boolean; DisableYellowFilter: boolean;
FluidSearch: boolean; FluidSearch: boolean;
RequireDeviceCode: boolean;
CustomTheme?: {
selectedTheme: string;
customCSS: string;
};
}; };
// 参数校验 // 参数校验
@ -63,7 +70,12 @@ export async function POST(request: NextRequest) {
typeof DoubanImageProxyType !== 'string' || typeof DoubanImageProxyType !== 'string' ||
typeof DoubanImageProxy !== 'string' || typeof DoubanImageProxy !== 'string' ||
typeof DisableYellowFilter !== 'boolean' || typeof DisableYellowFilter !== 'boolean' ||
typeof FluidSearch !== 'boolean' typeof FluidSearch !== 'boolean' ||
typeof RequireDeviceCode !== 'boolean' ||
(CustomTheme && (
typeof CustomTheme.selectedTheme !== 'string' ||
typeof CustomTheme.customCSS !== 'string'
))
) { ) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
@ -93,6 +105,8 @@ export async function POST(request: NextRequest) {
DoubanImageProxy, DoubanImageProxy,
DisableYellowFilter, DisableYellowFilter,
FluidSearch, FluidSearch,
RequireDeviceCode,
CustomTheme,
}; };
// 写入数据库 // 写入数据库

View File

@ -9,7 +9,7 @@ import { db } from '@/lib/db';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
// 支持的操作类型 // 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete'; type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
interface BaseBody { interface BaseBody {
action?: Action; action?: Action;
@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
const username = authInfo.username; const username = authInfo.username;
// 基础校验 // 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete']; const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
if (!username || !action || !ACTIONS.includes(action)) { if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
@ -99,6 +99,26 @@ export async function POST(request: NextRequest) {
entry.disabled = false; entry.disabled = false;
break; break;
} }
case 'edit': {
const { key, name, api, detail } = body as {
key?: string;
name?: string;
api?: string;
detail?: string;
};
if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry) {
return NextResponse.json({ error: '源不存在' }, { status: 404 });
}
// 更新字段(除了 key 和 from
entry.name = name;
entry.api = api;
entry.detail = detail || '';
break;
}
case 'delete': { case 'delete': {
const { key } = body as { key?: string }; const { key } = body as { key?: string };
if (!key) if (!key)

View File

@ -16,6 +16,9 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const searchKeyword = searchParams.get('q'); const searchKeyword = searchParams.get('q');
const sourceKey = searchParams.get('source'); // 支持单个源验证
const tempApi = searchParams.get('tempApi'); // 临时 API 地址
const tempName = searchParams.get('tempName'); // 临时源名称
if (!searchKeyword) { if (!searchKeyword) {
return new Response( return new Response(
@ -30,7 +33,34 @@ export async function GET(request: NextRequest) {
} }
const config = await getConfig(); const config = await getConfig();
const apiSites = config.SourceConfig; let apiSites = config.SourceConfig;
// 如果提供了临时 API 地址,创建临时源进行验证
if (tempApi && tempName) {
apiSites = [{
key: 'temp',
name: tempName,
api: tempApi,
detail: '',
disabled: false,
from: 'custom' as const
}];
} else if (sourceKey) {
// 如果指定了特定源,只验证该源
const targetSite = apiSites.find(site => site.key === sourceKey);
if (!targetSite) {
return new Response(
JSON.stringify({ error: '指定的视频源不存在' }),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
apiSites = [targetSite];
}
// 共享状态 // 共享状态
let streamClosed = false; let streamClosed = false;
@ -94,6 +124,7 @@ export async function GET(request: NextRequest) {
// 检查结果是否有效 // 检查结果是否有效
let status: 'valid' | 'no_results' | 'invalid'; let status: 'valid' | 'no_results' | 'invalid';
let resultCount = 0;
if ( if (
data && data &&
data.list && data.list &&
@ -108,11 +139,14 @@ export async function GET(request: NextRequest) {
if (validResults.length > 0) { if (validResults.length > 0) {
status = 'valid'; status = 'valid';
resultCount = validResults.length;
} else { } else {
status = 'no_results'; status = 'no_results';
resultCount = 0;
} }
} else { } else {
status = 'no_results'; status = 'no_results';
resultCount = 0;
} }
// 发送该源的验证结果 // 发送该源的验证结果
@ -122,7 +156,8 @@ export async function GET(request: NextRequest) {
const sourceEvent = `data: ${JSON.stringify({ const sourceEvent = `data: ${JSON.stringify({
type: 'source_result', type: 'source_result',
source: site.key, source: site.key,
status status,
resultCount
})}\n\n`; })}\n\n`;
if (!safeEnqueue(encoder.encode(sourceEvent))) { if (!safeEnqueue(encoder.encode(sourceEvent))) {
@ -145,7 +180,9 @@ export async function GET(request: NextRequest) {
const errorEvent = `data: ${JSON.stringify({ const errorEvent = `data: ${JSON.stringify({
type: 'source_error', type: 'source_error',
source: site.key, source: site.key,
status: 'invalid' status: 'invalid',
error: error instanceof Error ? error.message : '未知错误',
resultCount: 0
})}\n\n`; })}\n\n`;
if (!safeEnqueue(encoder.encode(errorEvent))) { if (!safeEnqueue(encoder.encode(errorEvent))) {

View File

@ -0,0 +1,116 @@
import { NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
import { AdminConfig } from '@/lib/admin.types';
import { headers, cookies } from 'next/headers';
import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
export async function GET() {
try {
// 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie
const cookieStore = cookies();
const authCookie = cookieStore.get('auth');
if (!authCookie) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
let authData;
try {
const decoded = decodeURIComponent(authCookie.value);
authData = JSON.parse(decoded);
} catch (error) {
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
}
const config = await getConfig();
const themeConfig = config.ThemeConfig;
return NextResponse.json({
success: true,
data: themeConfig,
});
} catch (error) {
console.error('获取主题配置失败:', error);
return NextResponse.json(
{ error: '获取主题配置失败' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
// 获取认证信息
const cookieStore = cookies();
const authCookie = cookieStore.get('auth');
if (!authCookie) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
let authData;
try {
const decoded = decodeURIComponent(authCookie.value);
authData = JSON.parse(decoded);
} catch (error) {
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
}
// 检查是否为管理员
if (authData.role !== 'admin' && authData.role !== 'owner') {
return NextResponse.json({ error: '权限不足,仅管理员可设置全局主题' }, { status: 403 });
}
const body = await request.json();
const { defaultTheme, customCSS, allowUserCustomization } = body;
// 验证主题名称
const validThemes = ['default', 'minimal', 'warm', 'fresh'];
if (!validThemes.includes(defaultTheme)) {
return NextResponse.json({ error: '无效的主题名称' }, { status: 400 });
}
// 获取当前配置
const baseConfig = await getConfig();
// 更新主题配置
const updatedConfig: AdminConfig = {
...baseConfig,
ThemeConfig: {
defaultTheme: defaultTheme as 'default' | 'minimal' | 'warm' | 'fresh',
customCSS: customCSS || '',
allowUserCustomization: allowUserCustomization !== false,
},
};
console.log('=== 保存主题配置 ===');
console.log('请求参数:', { defaultTheme, customCSS, allowUserCustomization });
console.log('当前存储类型:', process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage');
console.log('待保存配置:', updatedConfig.ThemeConfig);
console.log('完整配置对象:', JSON.stringify(updatedConfig, null, 2));
await db.saveAdminConfig(updatedConfig);
console.log('主题配置保存成功');
// 直接更新缓存,确保缓存与数据库同步
await setCachedConfig(updatedConfig);
console.log('已更新配置缓存');
// 立即验证缓存中的配置
const cachedConfig = await getConfig();
console.log('保存后验证缓存中的配置:', cachedConfig.ThemeConfig);
return NextResponse.json({
success: true,
message: '主题配置已更新',
data: updatedConfig.ThemeConfig,
});
} catch (error) {
console.error('更新主题配置失败:', error);
return NextResponse.json(
{ error: '更新主题配置失败', details: error instanceof Error ? error.message : '未知错误' },
{ status: 500 }
);
}
}

View File

@ -16,14 +16,9 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const targetUser = searchParams.get('user') || authInfo.username; const targetUser = searchParams.get('user') || authInfo.username;
// 只允许获取自己的头像,管理员和站长可以获取任何用户的头像 // 在聊天系统中,用户应该能够查看其他用户的头像,这对聊天功能是必要的
const canAccess = targetUser === authInfo.username || // 只要是已认证用户,就可以查看任何用户的头像
authInfo.role === 'admin' || // 这对于聊天、好友功能等社交功能是必要的
authInfo.role === 'owner';
if (!canAccess) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
}
const avatar = await db.getUserAvatar(targetUser); const avatar = await db.getUserAvatar(targetUser);

View File

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { Conversation } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const conversations = await db.getConversations(authInfo.username);
return NextResponse.json(conversations);
} catch (error) {
console.error('Error loading conversations:', error);
return NextResponse.json({ error: '获取对话列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { participants, name, type } = await request.json();
if (!participants || !Array.isArray(participants) || participants.length === 0) {
return NextResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
}
// 确保当前用户在参与者列表中
if (!participants.includes(authInfo.username)) {
participants.push(authInfo.username);
}
// 根据参与者数量确定对话类型
const conversationType = type || (participants.length > 2 ? 'group' : 'private');
const isGroup = conversationType === 'group';
const conversation: Conversation = {
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: name || participants.filter(p => p !== authInfo.username).join(', '),
participants,
type: conversationType,
created_at: Date.now(),
updated_at: Date.now(),
is_group: isGroup,
};
await db.createConversation(conversation);
return NextResponse.json(conversation, { status: 201 });
} catch (error) {
console.error('Error creating conversation:', error);
return NextResponse.json({ error: '创建对话失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { FriendRequest, Friend } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const friendRequests = await db.getFriendRequests(authInfo.username);
return NextResponse.json(friendRequests);
} catch (error) {
console.error('Error loading friend requests:', error);
return NextResponse.json({ error: '获取好友申请失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { to_user, message } = await request.json();
if (!to_user) {
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 });
}
// 检查目标用户是否存在
const userExists = await db.checkUserExist(to_user);
if (!userExists) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
}
// 检查是否已经是好友
const friends = await db.getFriends(authInfo.username);
const isAlreadyFriend = friends.some(friend => friend.username === to_user);
if (isAlreadyFriend) {
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
}
// 检查是否已经有pending的申请
const existingRequests = await db.getFriendRequests(to_user);
const hasPendingRequest = existingRequests.some(
req => req.from_user === authInfo.username && req.status === 'pending'
);
if (hasPendingRequest) {
return NextResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
}
const friendRequest: FriendRequest = {
id: `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
from_user: authInfo.username,
to_user,
message: message || '请求添加您为好友',
status: 'pending',
created_at: Date.now(),
updated_at: Date.now(),
};
await db.createFriendRequest(friendRequest);
return NextResponse.json(friendRequest, { status: 201 });
} catch (error) {
console.error('Error creating friend request:', error);
return NextResponse.json({ error: '发送好友申请失败' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { requestId, status } = await request.json();
if (!requestId || !status || !['accepted', 'rejected'].includes(status)) {
return NextResponse.json({ error: '请求参数无效' }, { status: 400 });
}
// 获取申请信息
const allRequests = await db.getFriendRequests(authInfo.username);
const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username);
if (!friendRequest) {
return NextResponse.json({ error: '好友申请不存在' }, { status: 404 });
}
if (friendRequest.status !== 'pending') {
return NextResponse.json({ error: '申请已处理' }, { status: 400 });
}
// 更新申请状态
await db.updateFriendRequest(requestId, status);
// 如果接受申请,添加为好友
if (status === 'accepted') {
const friend1: Friend = {
id: `friend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
username: friendRequest.from_user,
status: 'offline',
added_at: Date.now(),
};
const friend2: Friend = {
id: `friend_${Date.now() + 1}_${Math.random().toString(36).substr(2, 9)}`,
username: authInfo.username,
status: 'offline',
added_at: Date.now(),
};
// 双向添加好友
await Promise.all([
db.addFriend(authInfo.username, friend1),
db.addFriend(friendRequest.from_user, friend2),
]);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error handling friend request:', error);
return NextResponse.json({ error: '处理好友申请失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { Friend } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const friends = await db.getFriends(authInfo.username);
return NextResponse.json(friends);
} catch (error) {
console.error('Error loading friends:', error);
return NextResponse.json({ error: '获取好友列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { username, nickname } = await request.json();
if (!username) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
// 检查用户是否存在
const userExists = await db.checkUserExist(username);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
// 检查是否已经是好友
const friends = await db.getFriends(authInfo.username);
const isAlreadyFriend = friends.some(friend => friend.username === username);
if (isAlreadyFriend) {
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
}
const friend: Friend = {
id: `friend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
username,
nickname,
status: 'offline',
added_at: Date.now(),
};
await db.addFriend(authInfo.username, friend);
return NextResponse.json(friend, { status: 201 });
} catch (error) {
console.error('Error adding friend:', error);
return NextResponse.json({ error: '添加好友失败' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const friendId = searchParams.get('friendId');
if (!friendId) {
return NextResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
}
await db.removeFriend(authInfo.username, friendId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing friend:', error);
return NextResponse.json({ error: '删除好友失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { ChatMessage } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
console.log('未授权访问消息API:', authInfo);
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const conversationId = searchParams.get('conversationId');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
if (!conversationId) {
console.log('缺少对话ID参数');
return NextResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
}
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
// 验证用户是否有权限访问此对话
let conversation;
try {
conversation = await db.getConversation(conversationId);
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
} catch (dbError) {
console.error('数据库查询对话失败:', dbError);
return NextResponse.json({
error: '数据库查询失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 });
}
if (!conversation) {
console.log('对话不存在:', conversationId);
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
}
if (!conversation.participants.includes(authInfo.username)) {
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
return NextResponse.json({ error: '无权限访问此对话' }, { status: 403 });
}
try {
const messages = await db.getMessages(conversationId, limit, offset);
console.log(`成功加载 ${messages.length} 条消息`);
return NextResponse.json(messages);
} catch (dbError) {
console.error('数据库查询消息失败:', dbError);
return NextResponse.json({
error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 });
}
} catch (error) {
console.error('加载消息API发生未知错误:', error);
return NextResponse.json({
error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const messageData = await request.json();
if (!messageData.conversation_id || !messageData.content) {
return NextResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 });
}
// 验证用户是否有权限发送消息到此对话
const conversation = await db.getConversation(messageData.conversation_id);
if (!conversation || !conversation.participants.includes(authInfo.username)) {
return NextResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
}
const message: ChatMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
conversation_id: messageData.conversation_id,
sender_id: authInfo.username,
sender_name: authInfo.username,
content: messageData.content,
message_type: messageData.message_type || 'text',
timestamp: Date.now(),
is_read: false,
};
await db.saveMessage(message);
// 更新对话的最后消息和更新时间
await db.updateConversation(messageData.conversation_id, {
last_message: message,
updated_at: Date.now(),
});
return NextResponse.json(message, { status: 201 });
} catch (error) {
console.error('Error sending message:', error);
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { messageId } = await request.json();
if (!messageId) {
return NextResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
}
await db.markMessageAsRead(messageId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error marking message as read:', error);
return NextResponse.json({ error: '标记消息已读失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
// 从全局对象获取WebSocket实例相关方法
function getOnlineUsers(): string[] {
try {
if ((global as any).wss) {
// 假设websocket.js中导出了getOnlineUsers方法并附加到了wss对象上
return require('../../../../../websocket').getOnlineUsers();
}
return [];
} catch (error) {
console.error('获取在线用户失败:', error);
return [];
}
}
// 获取在线用户列表
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const onlineUsers = getOnlineUsers();
return NextResponse.json({ onlineUsers });
} catch (error) {
console.error('获取在线用户失败:', error);
return NextResponse.json({ error: '获取在线用户失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query || query.trim().length < 2) {
return NextResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
}
// 获取所有用户并进行模糊匹配
const allUsers = await db.getAllUsers();
const matchedUsers = allUsers.filter(username =>
username.toLowerCase().includes(query.toLowerCase()) &&
username !== authInfo.username // 排除自己
);
// 转换为Friend格式返回
const userResults = matchedUsers.map(username => ({
id: username,
username,
status: 'offline' as const,
added_at: 0,
}));
return NextResponse.json(userResults);
} catch (error) {
console.error('Error searching users:', error);
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { WebSocketMessage } from '../../../../lib/types';
// 从全局对象获取WebSocket实例相关方法
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
try {
if ((global as any).wss) {
// 假设websocket.js中导出了sendMessageToUsers方法并附加到了wss对象上
return require('../../../../../websocket').sendMessageToUsers(userIds, message);
}
return false;
} catch (error) {
console.error('发送WebSocket消息失败:', error);
return false;
}
}
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用
export async function POST(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const message: WebSocketMessage = await request.json();
// 根据消息类型处理
let targetUsers: string[] = [];
switch (message.type) {
case 'message':
const { participants } = message.data;
if (participants && Array.isArray(participants)) {
targetUsers = participants;
}
break;
case 'friend_request':
const { to_user } = message.data;
if (to_user) {
targetUsers = [to_user];
}
break;
case 'friend_accepted':
const { from_user } = message.data;
if (from_user) {
targetUsers = [from_user];
}
break;
default:
return NextResponse.json({ error: '不支持的消息类型' }, { status: 400 });
}
// 通过 WebSocket 发送消息
const sent = sendMessageToUsers(targetUsers, message);
return NextResponse.json({
success: true,
delivered: sent
});
} catch (error) {
console.error('通过 API 发送消息失败:', error);
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
message: 'Next.js server is running'
});
}

View File

@ -1,248 +1,98 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server"; import { NextResponse } from 'next/server';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export async function GET(request: Request) { function buildCorsHeaders(contentType?: string, extra?: Record<string, string>) {
const headers = new Headers();
if (contentType) headers.set('Content-Type', contentType);
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
headers.set('Cache-Control', 'no-cache');
if (extra) {
Object.entries(extra).forEach(([k, v]) => headers.set(k, v));
}
return headers;
}
async function forwardRequest(url: string, method: 'GET' | 'HEAD', reqHeaders: Headers) {
const decodedUrl = decodeURIComponent(url);
// 透传范围请求和必要请求头
const fetchHeaders: Record<string, string> = {};
const range = reqHeaders.get('Range');
if (range) fetchHeaders['Range'] = range;
const accept = reqHeaders.get('Accept');
if (accept) fetchHeaders['Accept'] = accept;
// 统一 UA部分源如 quark drive需要浏览器 UA 才能返回
fetchHeaders['User-Agent'] =
reqHeaders.get('User-Agent') ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
const upstream = await fetch(decodedUrl, {
method,
headers: fetchHeaders,
redirect: 'follow',
cache: 'no-store',
});
return upstream;
}
export async function HEAD(request: Request) {
try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const url = searchParams.get('url'); const url = searchParams.get('url');
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
if (!url) { const upstream = await forwardRequest(url, 'HEAD', request.headers);
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); const headers = buildCorsHeaders(upstream.headers.get('Content-Type') || undefined, {
'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
'Content-Length': upstream.headers.get('Content-Length') || '',
'Content-Range': upstream.headers.get('Content-Range') || '',
});
const status = upstream.status === 206 ? 206 : upstream.status;
return new Response(null, { status, headers });
} catch (e) {
return NextResponse.json({ error: 'Proxy HEAD failed' }, { status: 500 });
}
} }
console.log('Proxy video request for URL:', url); export async function GET(request: Request) {
let response: Response | null = null;
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
try { try {
const decodedUrl = decodeURIComponent(url); const { searchParams } = new URL(request.url);
console.log('Decoded URL:', decodedUrl); const url = searchParams.get('url');
if (!url) return NextResponse.json({ error: 'Missing url' }, { status: 400 });
// 为短剧视频文件设置合适的请求头避免403错误 const upstream = await forwardRequest(url, 'GET', request.headers);
const headers: Record<string, string> = { if (!upstream.ok && upstream.status !== 206) {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', return NextResponse.json({ error: `Upstream error ${upstream.status}` }, { status: upstream.status });
'Accept': '*/*', }
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'identity', const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream';
'Cache-Control': 'no-cache', const extra: Record<string, string> = {
'Pragma': 'no-cache', 'Accept-Ranges': upstream.headers.get('Accept-Ranges') || 'bytes',
'Sec-Fetch-Dest': 'video',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site',
}; };
const contentLength = upstream.headers.get('Content-Length');
if (contentLength) extra['Content-Length'] = contentLength;
const contentRange = upstream.headers.get('Content-Range');
if (contentRange) extra['Content-Range'] = contentRange;
// 对于夸克网盘等,设置更精确的请求头 const headers = buildCorsHeaders(contentType, extra);
if (decodedUrl.includes('quark.cn') || decodedUrl.includes('drive.quark.cn')) { const status = upstream.status === 206 ? 206 : 200;
headers['Referer'] = 'https://pan.quark.cn/'; return new Response(upstream.body, { status, headers });
headers['Origin'] = 'https://pan.quark.cn';
// 移除可能导致问题的header
delete headers['Sec-Fetch-Dest'];
delete headers['Sec-Fetch-Mode'];
delete headers['Sec-Fetch-Site'];
} else if (decodedUrl.includes('dl-c-')) {
// 对于CDN链接使用更简单的请求头
headers['Referer'] = '';
delete headers['Origin'];
}
// 处理Range请求支持视频拖拽播放
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
headers['Range'] = rangeHeader;
}
response = await fetch(decodedUrl, {
headers,
// 添加超时设置
signal: AbortSignal.timeout(30000), // 30秒超时
});
if (!response.ok) {
console.error(`Failed to fetch video: ${response.status} ${response.statusText}`);
console.error('Request headers were:', JSON.stringify(headers, null, 2));
// 返回具有正确CORS头的错误响应
const errorHeaders = new Headers();
errorHeaders.set('Access-Control-Allow-Origin', '*');
errorHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
errorHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization');
return NextResponse.json({
error: `Failed to fetch video: ${response.status}`,
details: response.statusText,
url: decodedUrl
}, {
status: response.status >= 400 ? response.status : 500,
headers: errorHeaders
});
}
console.log(`Successfully fetched video: ${response.status} ${response.statusText}`);
const responseHeaders = new Headers();
// 设置内容类型
const contentType = response.headers.get('content-type');
if (contentType) {
responseHeaders.set('Content-Type', contentType);
} else {
// 默认为MP4
responseHeaders.set('Content-Type', 'video/mp4');
}
// 完整的CORS头设置
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With');
responseHeaders.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges, Content-Type');
responseHeaders.set('Access-Control-Allow-Credentials', 'false');
// 支持Range请求
responseHeaders.set('Accept-Ranges', 'bytes');
// 传递内容长度和Range响应头
const contentLength = response.headers.get('content-length');
if (contentLength) {
responseHeaders.set('Content-Length', contentLength);
}
const contentRange = response.headers.get('content-range');
if (contentRange) {
responseHeaders.set('Content-Range', contentRange);
}
// 缓存控制
responseHeaders.set('Cache-Control', 'public, max-age=3600, must-revalidate');
// 使用流式传输,支持大文件播放
const stream = new ReadableStream({
start(controller) {
if (!response?.body) {
controller.close();
return;
}
reader = response.body.getReader();
let isCancelled = false;
function pump() {
if (isCancelled || !reader) {
return;
}
reader.read().then(({ done, value }) => {
if (isCancelled) {
return;
}
if (done) {
controller.close();
cleanup();
return;
}
try {
controller.enqueue(value);
pump();
} catch (error) {
if (!isCancelled) {
console.error('Stream error:', error);
controller.error(error);
cleanup();
}
}
}).catch((error) => {
if (!isCancelled) {
console.error('Reader error:', error);
controller.error(error);
cleanup();
}
});
}
function cleanup() {
isCancelled = true;
if (reader && reader.releaseLock) {
try {
reader.releaseLock();
} catch (e) { } catch (e) {
// reader 可能已经被释放,忽略错误 console.error('Proxy video failed:', e);
} return NextResponse.json({ error: 'Proxy failed' }, { status: 500 });
reader = null;
} }
} }
pump(); export async function OPTIONS() {
}, return new Response(null, { status: 204, headers: buildCorsHeaders() });
cancel() {
// 当流被取消时,确保释放所有资源
if (reader && reader.releaseLock) {
try {
reader.releaseLock();
} catch (e) {
// reader 可能已经被释放,忽略错误
}
reader = null;
} }
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略取消时的错误
}
}
}
});
return new Response(stream, {
status: response.status,
headers: responseHeaders
});
} catch (error) {
console.error('Proxy video error:', error);
// 确保在错误情况下也释放资源
if (reader && typeof (reader as any)?.releaseLock === 'function') {
try {
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
} catch (e) {
// 忽略错误
}
}
if (response?.body) {
try {
response.body.cancel();
} catch (e) {
// 忽略错误
}
}
return NextResponse.json({
error: 'Failed to proxy video',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
}
}
// 支持OPTIONS请求用于CORS预检
export async function OPTIONS(_request: Request) {
console.log('CORS preflight request received');
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS, POST',
'Access-Control-Allow-Headers': 'Range, Content-Type, Accept, Origin, Authorization, X-Requested-With',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges, Content-Type',
'Access-Control-Allow-Credentials': 'false',
'Access-Control-Max-Age': '86400',
},
});
}

View File

@ -9,8 +9,6 @@ import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow'; import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 最新剧集API使用vod_id作为实际视频ID // 转换外部API数据格式到内部格式 - 最新剧集API使用vod_id作为实际视频ID
function transformExternalData(externalItem: any) { function transformExternalData(externalItem: any) {
return { return {

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 分类热搜API直接使用id作为视频ID // 转换外部API数据格式到内部格式 - 分类热搜API直接使用id作为视频ID
function transformExternalData(externalItem: any) { function transformExternalData(externalItem: any) {
return { return {

View File

@ -1,26 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');
if (!id) { if (!id) {
console.error('🚫 [短剧API] 缺少必需的ID参数');
return NextResponse.json( return NextResponse.json(
{ error: 'id parameter is required' }, { error: 'id parameter is required' },
{ status: 400 } { status: 400 }
); );
} }
console.log(`🎬 [短剧API] 开始请求短剧全集地址:`, {
requestId: id,
timestamp: new Date().toISOString(),
userAgent: request.headers.get('user-agent'),
referer: request.headers.get('referer')
});
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/all`); const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/all`);
apiUrl.searchParams.append('id', id); apiUrl.searchParams.append('id', id);
apiUrl.searchParams.append('proxy', 'true');
console.log(`🌐 [短剧API] 外部API调用详情:`, {
baseUrl: API_CONFIG.shortdrama.baseUrl,
fullUrl: apiUrl.toString(),
headers: API_CONFIG.shortdrama.headers,
timeout: '60秒'
});
const requestStartTime = performance.now();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒超时为获取全集地址提供充足时间 const timeoutId = setTimeout(() => {
console.error('⏰ [短剧API] 请求超时 - 60秒');
controller.abort();
}, 60000);
const response = await fetch(apiUrl.toString(), { const response = await fetch(apiUrl.toString(), {
method: 'GET', method: 'GET',
@ -29,42 +46,249 @@ export async function GET(request: NextRequest) {
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
const requestEndTime = performance.now();
const requestDuration = requestEndTime - requestStartTime;
console.log(`📡 [短剧API] 外部API响应状态:`, {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
requestDuration: `${requestDuration.toFixed(2)}ms`,
contentType: response.headers.get('content-type')
});
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed: ${response.status}`); console.error(`❌ [短剧API] 外部API请求失败:`, {
status: response.status,
statusText: response.statusText,
url: apiUrl.toString(),
requestDuration: `${requestDuration.toFixed(2)}ms`
});
throw new Error(`API request failed: ${response.status} - ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
console.log(`📦 [短剧API] 外部API响应数据分析:`, {
hasData: !!data,
dataKeys: data ? Object.keys(data) : [],
videoId: data?.videoId,
videoName: data?.videoName,
totalEpisodes: data?.totalEpisodes,
successfulCount: data?.successfulCount,
failedCount: data?.failedCount,
hasResults: !!data?.results,
resultsLength: data?.results?.length || 0,
resultsType: typeof data?.results,
isResultsArray: Array.isArray(data?.results),
hasCover: !!data?.cover,
hasDescription: !!data?.description
});
// 直接返回API响应数据播放页面会处理数据结构转换 // 分析results数组的详细结构
return NextResponse.json(data); if (data?.results && Array.isArray(data.results)) {
const successCount = data.results.filter((item: any) => item.status === 'success').length;
const failureCount = data.results.filter((item: any) => item.status !== 'success').length;
const withUrlCount = data.results.filter((item: any) => item.status === 'success' && item.parsedUrl).length;
console.log(`📋 [短剧API] Results数组详细分析:`, {
totalItems: data.results.length,
successItems: successCount,
failureItems: failureCount,
itemsWithUrl: withUrlCount,
sampleSuccessItems: data.results.filter((item: any) => item.status === 'success').slice(0, 3).map((item: any) => ({
index: item.index,
label: item.label,
status: item.status,
hasUrl: !!item.parsedUrl,
urlLength: item.parsedUrl ? item.parsedUrl.length : 0,
urlDomain: item.parsedUrl ? item.parsedUrl.match(/https?:\/\/([^\/]+)/)?.[1] : null
})),
sampleFailureItems: data.results.filter((item: any) => item.status !== 'success').slice(0, 3).map((item: any) => ({
index: item.index,
label: item.label,
status: item.status,
reason: item.reason
}))
});
} else {
console.error(`❌ [短剧API] Results数组无效:`, {
hasResults: !!data?.results,
resultsType: typeof data?.results,
isArray: Array.isArray(data?.results),
resultsValue: data?.results
});
}
// 验证返回的数据格式
if (!data || !data.results || !Array.isArray(data.results)) {
console.error('❌ [短剧API] 数据格式验证失败:', {
hasData: !!data,
hasResults: !!data?.results,
resultsType: typeof data?.results,
isResultsArray: Array.isArray(data?.results),
fullData: data
});
throw new Error('Invalid API response format - 外部API返回的数据格式不正确');
}
// 检查播放地址的有效性
console.log('🔍 [短剧API] 开始验证播放地址有效性...');
const validResults = data.results.filter((item: any) => {
const isValid = item.status === 'success' &&
item.parsedUrl &&
typeof item.parsedUrl === 'string' &&
item.parsedUrl.trim().length > 0;
if (!isValid) {
console.warn(`⚠️ [短剧API] 无效的播放源:`, {
index: item.index,
label: item.label,
status: item.status,
hasUrl: !!item.parsedUrl,
urlType: typeof item.parsedUrl,
urlLength: item.parsedUrl ? item.parsedUrl.length : 0,
reason: item.reason || '未知原因'
});
}
return isValid;
});
console.log(`✅ [短剧API] 播放源验证完成:`, {
totalSources: data.results.length,
validSources: validResults.length,
invalidSources: data.results.length - validResults.length,
validationRate: `${((validResults.length / data.results.length) * 100).toFixed(1)}%`
});
if (validResults.length === 0) {
console.error('❌ [短剧API] 没有找到任何有效的播放地址:', {
totalResults: data.results.length,
allResults: data.results.map((item: any) => ({
index: item.index,
label: item.label,
status: item.status,
hasUrl: !!item.parsedUrl,
urlType: typeof item.parsedUrl,
reason: item.reason
}))
});
throw new Error('No valid video sources found - 所有播放源都无效');
}
// 返回处理后的数据
const processedData = {
...data,
results: validResults,
totalEpisodes: validResults.length,
successfulCount: validResults.length,
originalTotalEpisodes: data.totalEpisodes,
originalSuccessfulCount: data.successfulCount,
filteredCount: data.results.length - validResults.length
};
console.log('🎯 [短剧API] 返回处理后的短剧数据:', {
videoId: processedData.videoId,
videoName: processedData.videoName,
originalTotal: processedData.originalTotalEpisodes,
filteredTotal: processedData.totalEpisodes,
originalSuccess: processedData.originalSuccessfulCount,
filteredSuccess: processedData.successfulCount,
filteredOut: processedData.filteredCount,
firstEpisode: {
index: processedData.results[0]?.index,
label: processedData.results[0]?.label,
urlPreview: processedData.results[0]?.parsedUrl?.substring(0, 100) + '...'
},
lastEpisode: {
index: processedData.results[processedData.results.length - 1]?.index,
label: processedData.results[processedData.results.length - 1]?.label,
urlPreview: processedData.results[processedData.results.length - 1]?.parsedUrl?.substring(0, 100) + '...'
}
});
return NextResponse.json(processedData);
} catch (error) { } catch (error) {
console.error('Short drama all parse API error:', error);
// 返回模拟的短剧数据作为备用
const { searchParams: errorSearchParams } = new URL(request.url); const { searchParams: errorSearchParams } = new URL(request.url);
const errorId = errorSearchParams.get('id'); const errorId = errorSearchParams.get('id');
console.error('💥 [短剧API] 发生错误:', {
errorType: error instanceof Error ? error.constructor.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
requestId: errorId,
timestamp: new Date().toISOString(),
isTimeoutError: error instanceof Error && error.name === 'AbortError',
isFetchError: error instanceof TypeError,
isNetworkError: error instanceof Error && error.message.includes('fetch')
});
// 分析错误类型
let errorCategory = '未知错误';
if (error instanceof Error) {
if (error.name === 'AbortError') {
errorCategory = '请求超时';
} else if (error.message.includes('fetch')) {
errorCategory = '网络连接错误';
} else if (error.message.includes('API request failed')) {
errorCategory = '外部API错误';
} else if (error.message.includes('Invalid API response format')) {
errorCategory = '数据格式错误';
} else if (error.message.includes('No valid video sources found')) {
errorCategory = '无有效播放源';
}
}
console.warn(`🔄 [短剧API] 错误类型: ${errorCategory},启用备用数据`);
const mockData = { const mockData = {
videoId: parseInt(errorId || '1') || 1, videoId: parseInt(errorId || '1') || 1,
videoName: '短剧播放示例', videoName: `短剧播放示例 (ID: ${errorId})`,
results: Array.from({ length: 10 }, (_, index) => ({ results: Array.from({ length: 8 }, (_, index) => ({
index: index, index: index,
label: `${index + 1}`, label: `${index + 1}`,
parsedUrl: `https://example.com/video${index + 1}.mp4`, // 使用一些测试视频地址,这些是公共测试资源
parsedUrl: `https://sample-videos.com/zip/10/mp4/SampleVideo_720x480_1mb.mp4?episode=${index + 1}`,
parseInfo: { parseInfo: {
headers: {}, headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://sample-videos.com'
},
type: 'mp4' type: 'mp4'
}, },
status: 'success', status: 'success',
reason: null reason: null
})), })),
totalEpisodes: 10, totalEpisodes: 8,
successfulCount: 10, successfulCount: 8,
failedCount: 0, failedCount: 0,
cover: 'https://via.placeholder.com/300x400', cover: 'https://via.placeholder.com/300x400?text=短剧示例',
description: '这是一个示例短剧,用于测试播放功能。' description: `这是一个短剧播放示例用于测试播放功能。原始ID: ${errorId},错误: ${errorCategory}`,
// 添加错误信息供调试使用
_debugInfo: {
errorCategory: errorCategory,
originalError: error instanceof Error ? error.message : String(error),
fallbackDataUsed: true,
timestamp: new Date().toISOString()
}
}; };
return NextResponse.json(mockData); console.log('🔧 [短剧API] 返回备用短剧数据:', {
videoName: mockData.videoName,
totalEpisodes: mockData.totalEpisodes,
errorCategory: errorCategory,
firstEpisodeUrl: mockData.results[0].parsedUrl,
hasFallbackData: true
});
return NextResponse.json(mockData, {
headers: {
'X-Fallback-Data': 'true',
'X-Error-Category': errorCategory,
'X-Original-Error': error instanceof Error ? error.message : String(error)
}
});
} }
} }

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -20,6 +17,7 @@ export async function GET(request: NextRequest) {
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/batch`); const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/batch`);
apiUrl.searchParams.append('id', id); apiUrl.searchParams.append('id', id);
if (episodes) apiUrl.searchParams.append('episodes', episodes); if (episodes) apiUrl.searchParams.append('episodes', episodes);
apiUrl.searchParams.append('proxy', 'true');
const response = await fetch(apiUrl.toString(), { const response = await fetch(apiUrl.toString(), {
method: 'GET', method: 'GET',

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -20,6 +17,7 @@ export async function GET(request: NextRequest) {
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/single`); const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/single`);
apiUrl.searchParams.append('id', id); apiUrl.searchParams.append('id', id);
if (episode) apiUrl.searchParams.append('episode', episode); if (episode) apiUrl.searchParams.append('episode', episode);
apiUrl.searchParams.append('proxy', 'true');
const response = await fetch(apiUrl.toString(), { const response = await fetch(apiUrl.toString(), {
method: 'GET', method: 'GET',

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
// 转换外部API数据格式到内部格式 - 推荐API通常和分类热搜格式相同 // 转换外部API数据格式到内部格式 - 推荐API通常和分类热搜格式相同
function transformExternalData(externalItem: any) { function transformExternalData(externalItem: any) {
return { return {

View File

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
// 强制动态渲染
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const name = searchParams.get('name'); const name = searchParams.get('name');

View File

@ -0,0 +1,30 @@
import { NextRequest } from 'next/server';
// 这个端点主要用于 WebSocket 升级,实际的 WebSocket 处理在自定义服务器中进行
export async function GET(request: NextRequest) {
// 如果运行在自定义服务器环境下WebSocket 连接应该已经被处理
// 这里主要是为了提供一个回退响应
const { searchParams } = new URL(request.url);
if (searchParams.get('upgrade') === 'websocket') {
return new Response('WebSocket upgrade should be handled by custom server', {
status: 426,
headers: {
'Upgrade': 'websocket',
'Connection': 'Upgrade',
},
});
}
return new Response('WebSocket endpoint', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
}

View File

@ -298,11 +298,12 @@ function DoubanPageClient() {
id: item.id?.toString() || '', id: item.id?.toString() || '',
title: item.name_cn || item.name, title: item.name_cn || item.name,
poster: poster:
item.images.large || item.images?.large ||
item.images.common || item.images?.common ||
item.images.medium || item.images?.medium ||
item.images.small || item.images?.small ||
item.images.grid, item.images?.grid ||
'', // 空字符串,让 VideoCard 组件处理图片加载失败
rate: item.rating?.score?.toFixed(1) || '', rate: item.rating?.score?.toFixed(1) || '',
year: item.air_date?.split('-')?.[0] || '', year: item.air_date?.split('-')?.[0] || '',
})), })),

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider'; import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider'; import { ThemeProvider } from '../components/ThemeProvider';
import { ToastProvider } from '../components/Toast'; import { ToastProvider } from '../components/Toast';
import GlobalThemeLoader from '../components/GlobalThemeLoader';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -55,6 +56,7 @@ export default async function RootLayout({
let disableYellowFilter = let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let requireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
let customCategories = [] as { let customCategories = [] as {
name: string; name: string;
type: 'movie' | 'tv'; type: 'movie' | 'tv';
@ -78,6 +80,7 @@ export default async function RootLayout({
query: category.query, query: category.query,
})); }));
fluidSearch = config.SiteConfig.FluidSearch; fluidSearch = config.SiteConfig.FluidSearch;
requireDeviceCode = config.SiteConfig.RequireDeviceCode;
} }
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取 // 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@ -90,6 +93,7 @@ export default async function RootLayout({
DISABLE_YELLOW_FILTER: disableYellowFilter, DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories, CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch, FLUID_SEARCH: fluidSearch,
REQUIRE_DEVICE_CODE: requireDeviceCode,
}; };
return ( return (
@ -107,6 +111,60 @@ export default async function RootLayout({
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`, __html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}} }}
/> />
{/* 立即从缓存应用主题,避免闪烁 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
// 从localStorage立即获取缓存的主题配置
const cachedTheme = localStorage.getItem('theme-cache');
if (cachedTheme) {
try {
const themeConfig = JSON.parse(cachedTheme);
console.log('应用缓存主题配置:', themeConfig);
// 立即应用缓存的主题,避免闪烁
const html = document.documentElement;
// 清除现有主题
html.removeAttribute('data-theme');
// 应用缓存的主题
if (themeConfig.defaultTheme && themeConfig.defaultTheme !== 'default') {
html.setAttribute('data-theme', themeConfig.defaultTheme);
}
// 应用缓存的自定义CSS
if (themeConfig.customCSS) {
let customStyleEl = document.getElementById('custom-theme-css');
if (!customStyleEl) {
customStyleEl = document.createElement('style');
customStyleEl.id = 'custom-theme-css';
document.head.appendChild(customStyleEl);
}
customStyleEl.textContent = themeConfig.customCSS;
}
console.log('缓存主题已应用:', themeConfig.defaultTheme);
} catch (parseError) {
console.warn('解析缓存主题配置失败:', parseError);
localStorage.removeItem('theme-cache'); // 清除无效缓存
}
} else {
console.log('未找到缓存主题等待API获取');
}
} catch (error) {
console.error('应用缓存主题失败:', error);
}
})();
`,
}}
/>
</head> </head>
<body <body
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`} className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
@ -119,6 +177,7 @@ export default async function RootLayout({
> >
<ToastProvider> <ToastProvider>
<SiteProvider siteName={siteName} announcement={announcement}> <SiteProvider siteName={siteName} announcement={announcement}>
<GlobalThemeLoader />
{children} {children}
<GlobalErrorIndicator /> <GlobalErrorIndicator />
</SiteProvider> </SiteProvider>

View File

@ -12,6 +12,7 @@ import MachineCode from '@/lib/machine-code';
import { useSite } from '@/components/SiteProvider'; import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle'; import { ThemeToggle } from '@/components/ThemeToggle';
import GlobalThemeLoader from '@/components/GlobalThemeLoader';
// 版本显示组件 // 版本显示组件
function VersionDisplay() { function VersionDisplay() {
@ -85,18 +86,23 @@ function LoginPageClient() {
const [machineCodeGenerated, setMachineCodeGenerated] = useState(false); const [machineCodeGenerated, setMachineCodeGenerated] = useState(false);
const [, setShowBindOption] = useState(false); const [, setShowBindOption] = useState(false);
const [bindMachineCode, setBindMachineCode] = useState(false); const [bindMachineCode, setBindMachineCode] = useState(false);
const [deviceCodeEnabled, setDeviceCodeEnabled] = useState(true); // 站点是否启用设备码功能
const { siteName } = useSite(); const { siteName } = useSite();
// 在客户端挂载后设置配置并生成机器码 // 在客户端挂载后设置配置并生成机器码
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE; const runtimeConfig = (window as any).RUNTIME_CONFIG;
setShouldAskUsername(storageType && storageType !== 'localstorage'); const storageType = runtimeConfig?.STORAGE_TYPE;
const requireDeviceCode = runtimeConfig?.REQUIRE_DEVICE_CODE;
// 生成机器码和设备信息 setShouldAskUsername(storageType && storageType !== 'localstorage');
setDeviceCodeEnabled(requireDeviceCode !== false); // 默认启用,除非明确设置为 false
// 只有在启用设备码功能时才生成机器码和设备信息
const generateMachineInfo = async () => { const generateMachineInfo = async () => {
if (MachineCode.isSupported()) { if (requireDeviceCode !== false && MachineCode.isSupported()) {
try { try {
const code = await MachineCode.generateMachineCode(); const code = await MachineCode.generateMachineCode();
const info = await MachineCode.getDeviceInfo(); const info = await MachineCode.getDeviceInfo();
@ -128,8 +134,8 @@ function LoginPageClient() {
...(shouldAskUsername ? { username } : {}), ...(shouldAskUsername ? { username } : {}),
}; };
// 如果需要机器码或用户选择绑定,则发送机器码 // 只有在启用设备码功能时才处理机器码逻辑
if ((requireMachineCode || bindMachineCode) && machineCode) { if (deviceCodeEnabled && (requireMachineCode || bindMachineCode) && machineCode) {
requestData.machineCode = machineCode; requestData.machineCode = machineCode;
} }
@ -142,8 +148,8 @@ function LoginPageClient() {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.ok) { if (res.ok) {
// 登录成功,如果用户选择绑定机器码,则绑定 // 登录成功,如果启用设备码功能且用户选择绑定机器码,则绑定
if (bindMachineCode && machineCode && shouldAskUsername) { if (deviceCodeEnabled && bindMachineCode && machineCode && shouldAskUsername) {
try { try {
await fetch('/api/machine-code', { await fetch('/api/machine-code', {
method: 'POST', method: 'POST',
@ -190,6 +196,7 @@ function LoginPageClient() {
return ( return (
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'> <div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
<GlobalThemeLoader />
<div className='absolute top-4 right-4'> <div className='absolute top-4 right-4'>
<ThemeToggle /> <ThemeToggle />
</div> </div>
@ -242,8 +249,8 @@ function LoginPageClient() {
</label> </label>
</div> </div>
{/* 机器码信息显示 */} {/* 机器码信息显示 - 只有在启用设备码功能时才显示 */}
{machineCodeGenerated && shouldAskUsername && ( {deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && (
<div className='space-y-4'> <div className='space-y-4'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'> <div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'> <div className='flex items-center space-x-2 mb-2'>
@ -262,6 +269,7 @@ function LoginPageClient() {
{/* 绑定选项 */} {/* 绑定选项 */}
{!requireMachineCode && ( {!requireMachineCode && (
<div className='space-y-2'>
<div className='flex items-center space-x-3'> <div className='flex items-center space-x-3'>
<input <input
id='bindMachineCode' id='bindMachineCode'
@ -274,6 +282,10 @@ function LoginPageClient() {
</label> </label>
</div> </div>
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
// 管理员可选择不绑定机器码直接登录
</p> */}
</div>
)} )}
</div> </div>
)} )}
@ -289,7 +301,7 @@ function LoginPageClient() {
!password || !password ||
loading || loading ||
(shouldAskUsername && !username) || (shouldAskUsername && !username) ||
(machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode) (deviceCodeEnabled && machineCodeGenerated && shouldAskUsername && !requireMachineCode && !bindMachineCode)
} }
className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50' className='inline-flex w-full justify-center rounded-lg bg-blue-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-blue-600 hover:to-blue-700 disabled:cursor-not-allowed disabled:opacity-50'
> >

View File

@ -372,20 +372,21 @@ function HomeClient() {
return todayAnimes.map((anime, index) => ( return todayAnimes.map((anime, index) => (
<div <div
key={`${anime.id}-${index}`} key={`${anime.id || 0}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
> >
<VideoCard <VideoCard
from='douban' from='douban'
title={anime.name_cn || anime.name} title={anime.name_cn || anime.name || '未知标题'}
poster={ poster={
anime.images.large || anime.images?.large ||
anime.images.common || anime.images?.common ||
anime.images.medium || anime.images?.medium ||
anime.images.small || anime.images?.small ||
anime.images.grid anime.images?.grid ||
'' // 空字符串,让 VideoCard 组件处理图片加载失败
} }
douban_id={anime.id} douban_id={anime.id || 0}
rate={anime.rating?.score?.toFixed(1) || ''} rate={anime.rating?.score?.toFixed(1) || ''}
year={anime.air_date?.split('-')?.[0] || ''} year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true} isBangumi={true}

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,229 @@ function SearchPageClient() {
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map()); const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map()); const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
// 执行搜索的通用函数
const performSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed) return;
// 更新搜索查询和状态
setSearchQuery(trimmed);
currentQueryRef.current = trimmed;
// 清理缓存标记,确保执行新搜索
sessionStorage.removeItem('fromPlayPage');
// 清空旧的搜索结果和状态
if (eventSourceRef.current) {
try { eventSourceRef.current.close(); } catch { }
eventSourceRef.current = null;
}
setSearchResults([]);
setTotalSources(0);
setCompletedSources(0);
pendingResultsRef.current = [];
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
// 清理聚合统计缓存和refs
groupStatsRef.current.clear();
groupRefs.current.clear();
setIsLoading(true);
setShowResults(true);
// 读取流式搜索设置
let currentFluidSearch = useFluidSearch;
if (typeof window !== 'undefined') {
const savedFluidSearch = localStorage.getItem('fluidSearch');
if (savedFluidSearch !== null) {
currentFluidSearch = JSON.parse(savedFluidSearch);
} else {
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
currentFluidSearch = defaultFluidSearch;
}
}
if (currentFluidSearch !== useFluidSearch) {
setUseFluidSearch(currentFluidSearch);
}
if (currentFluidSearch) {
// 流式搜索
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
eventSourceRef.current = es;
es.onmessage = (event) => {
if (!event.data) return;
try {
const payload = JSON.parse(event.data);
if (currentQueryRef.current !== trimmed || eventSourceRef.current !== es) {
console.warn('忽略过期的搜索响应:', payload.type, '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
return;
}
switch (payload.type) {
case 'start':
setTotalSources(payload.totalSources || 0);
setCompletedSources(0);
break;
case 'source_result': {
setCompletedSources((prev) => prev + 1);
if (Array.isArray(payload.results) && payload.results.length > 0) {
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
const incoming: SearchResult[] =
activeYearOrder === 'none'
? sortBatchForNoOrder(payload.results as SearchResult[])
: (payload.results as SearchResult[]);
pendingResultsRef.current.push(...incoming);
if (!flushTimerRef.current) {
flushTimerRef.current = window.setTimeout(() => {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
startTransition(() => {
setSearchResults((prev) => prev.concat(toAppend));
});
flushTimerRef.current = null;
}, 80);
}
}
break;
}
case 'source_error':
setCompletedSources((prev) => prev + 1);
break;
case 'complete':
setCompletedSources(payload.completedSources || totalSources);
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
startTransition(() => {
setSearchResults((prev) => {
const newResults = prev.concat(toAppend);
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(newResults));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: payload.completedSources || totalSources,
completedSources: payload.completedSources || totalSources,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
return newResults;
});
});
} else {
setTimeout(() => {
setSearchResults((prev) => {
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(prev));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: payload.completedSources || totalSources,
completedSources: payload.completedSources || totalSources,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
return prev;
});
}, 100);
}
setIsLoading(false);
try { es.close(); } catch { }
if (eventSourceRef.current === es) {
eventSourceRef.current = null;
}
break;
}
} catch { }
};
es.onerror = () => {
setIsLoading(false);
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
startTransition(() => {
setSearchResults((prev) => prev.concat(toAppend));
});
}
try { es.close(); } catch { }
if (eventSourceRef.current === es) {
eventSourceRef.current = null;
}
};
} else {
// 传统搜索
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
.then(response => response.json())
.then(data => {
if (currentQueryRef.current !== trimmed) {
console.warn('忽略过期的搜索响应 (传统):', '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
return;
}
if (data.results && Array.isArray(data.results)) {
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
const results: SearchResult[] =
activeYearOrder === 'none'
? sortBatchForNoOrder(data.results as SearchResult[])
: (data.results as SearchResult[]);
setSearchResults(results);
setTotalSources(1);
setCompletedSources(1);
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(results));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: 1,
completedSources: 1,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
}
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}
// 保存到搜索历史
addSearchHistory(trimmed);
// 更新URL但不触发重新渲染
const newUrl = `/search?q=${encodeURIComponent(trimmed)}`;
window.history.replaceState(null, '', newUrl);
};
const getGroupRef = (key: string) => { const getGroupRef = (key: string) => {
let ref = groupRefs.current.get(key); let ref = groupRefs.current.get(key);
if (!ref) { if (!ref) {
@ -136,6 +359,31 @@ function SearchPageClient() {
}); });
}; };
// 检查搜索结果与关键字的相关性
const isRelevantResult = (item: SearchResult, query: string) => {
if (!query.trim()) return true;
const searchTerms = query.trim().toLowerCase().split(/\s+/);
const title = (item.title || '').toLowerCase();
const typeName = (item.type_name || '').toLowerCase();
// 至少匹配一个搜索关键字
return searchTerms.some(term => {
// 标题包含关键字
if (title.includes(term)) return true;
// 类型名包含关键字
if (typeName.includes(term)) return true;
// 支持年份搜索
if (term.match(/^\d{4}$/) && item.year === term) return true;
// 支持模糊匹配(去除空格和标点符号后的匹配)
const cleanTitle = title.replace(/[\s\-_\.]/g, '');
const cleanTerm = term.replace(/[\s\-_\.]/g, '');
if (cleanTitle.includes(cleanTerm)) return true;
return false;
});
};
// 简化的年份排序unknown/空值始终在最后 // 简化的年份排序unknown/空值始终在最后
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => { const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
// 如果是无排序状态返回0保持原顺序 // 如果是无排序状态返回0保持原顺序
@ -209,13 +457,16 @@ function SearchPageClient() {
}); });
}, [aggregatedResults]); }, [aggregatedResults]);
// 构建筛选选项 // 构建筛选选项 - 只基于相关的搜索结果
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
const sourcesSet = new Map<string, string>(); const sourcesSet = new Map<string, string>();
const titlesSet = new Set<string>(); const titlesSet = new Set<string>();
const yearsSet = new Set<string>(); const yearsSet = new Set<string>();
searchResults.forEach((item) => { // 只考虑与搜索关键字相关的结果来构建过滤选项
const relevantResults = searchResults.filter(item => isRelevantResult(item, searchQuery));
relevantResults.forEach((item) => {
if (item.source && item.source_name) { if (item.source && item.source_name) {
sourcesSet.set(item.source, item.source_name); sourcesSet.set(item.source, item.source_name);
} }
@ -266,6 +517,9 @@ function SearchPageClient() {
const filteredAllResults = useMemo(() => { const filteredAllResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAll; const { source, title, year, yearOrder } = filterAll;
const filtered = searchResults.filter((item) => { const filtered = searchResults.filter((item) => {
// 首先检查相关性
if (!isRelevantResult(item, searchQuery)) return false;
// 然后应用其他过滤器
if (source !== 'all' && item.source !== source) return false; if (source !== 'all' && item.source !== source) return false;
if (title !== 'all' && item.title !== title) return false; if (title !== 'all' && item.title !== title) return false;
if (year !== 'all' && item.year !== year) return false; if (year !== 'all' && item.year !== year) return false;
@ -300,6 +554,10 @@ function SearchPageClient() {
const filteredAggResults = useMemo(() => { const filteredAggResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAgg as any; const { source, title, year, yearOrder } = filterAgg as any;
const filtered = aggregatedResults.filter(([_, group]) => { const filtered = aggregatedResults.filter(([_, group]) => {
// 检查聚合组中是否至少有一个结果与搜索关键字相关
const hasRelevantResult = group.some(item => isRelevantResult(item, searchQuery));
if (!hasRelevantResult) return false;
const gTitle = group[0]?.title ?? ''; const gTitle = group[0]?.title ?? '';
const gYear = group[0]?.year ?? 'unknown'; const gYear = group[0]?.year ?? 'unknown';
const hasSource = source === 'all' ? true : group.some((item) => item.source === source); const hasSource = source === 'all' ? true : group.some((item) => item.source === source);
@ -419,7 +677,61 @@ function SearchPageClient() {
if (query) { if (query) {
setSearchQuery(query); setSearchQuery(query);
// 新搜索:关闭旧连接并清空结果
// 检查是否从播放页返回,如果是则尝试使用缓存
const fromPlayPage = sessionStorage.getItem('fromPlayPage');
const cachedQuery = sessionStorage.getItem('cachedSearchQuery');
const cachedResults = sessionStorage.getItem('cachedSearchResults');
const cachedState = sessionStorage.getItem('cachedSearchState');
const cachedFilters = sessionStorage.getItem('cachedSearchFilters');
const cachedViewMode = sessionStorage.getItem('cachedViewMode');
if (fromPlayPage === 'true' && cachedQuery === query.trim() && cachedResults && cachedState) {
// 从播放页返回且有缓存,使用缓存的搜索结果
console.log('使用缓存的搜索结果');
try {
const results = JSON.parse(cachedResults);
const state = JSON.parse(cachedState);
// 恢复缓存的过滤器和视图状态
if (cachedFilters) {
const filters = JSON.parse(cachedFilters);
if (filters.filterAll) setFilterAll(filters.filterAll);
if (filters.filterAgg) setFilterAgg(filters.filterAgg);
}
if (cachedViewMode && ['agg', 'all'].includes(cachedViewMode)) {
setViewMode(cachedViewMode as 'agg' | 'all');
}
// 恢复搜索结果和状态
setSearchResults(results);
setTotalSources(state.totalSources || 0);
setCompletedSources(state.completedSources || 0);
setIsLoading(false);
setShowResults(true);
// 清理导航标记,避免影响后续搜索
sessionStorage.removeItem('fromPlayPage');
return; // 直接返回,不执行新搜索
} catch (error) {
console.error('恢复缓存的搜索结果失败:', error);
// 缓存损坏,清理缓存并继续正常搜索
sessionStorage.removeItem('cachedSearchQuery');
sessionStorage.removeItem('cachedSearchResults');
sessionStorage.removeItem('cachedSearchState');
sessionStorage.removeItem('cachedSearchFilters');
sessionStorage.removeItem('cachedViewMode');
sessionStorage.removeItem('fromPlayPage');
}
}
// 执行新搜索 - 使用performSearch函数不更新URL因为URL已经由路由处理了
const trimmed = query.trim();
// 清空旧的搜索结果和状态
if (eventSourceRef.current) { if (eventSourceRef.current) {
try { eventSourceRef.current.close(); } catch { } try { eventSourceRef.current.close(); } catch { }
eventSourceRef.current = null; eventSourceRef.current = null;
@ -427,18 +739,19 @@ function SearchPageClient() {
setSearchResults([]); setSearchResults([]);
setTotalSources(0); setTotalSources(0);
setCompletedSources(0); setCompletedSources(0);
// 清理缓冲
pendingResultsRef.current = []; pendingResultsRef.current = [];
if (flushTimerRef.current) { if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current); clearTimeout(flushTimerRef.current);
flushTimerRef.current = null; flushTimerRef.current = null;
} }
// 清理聚合统计缓存和refs
groupStatsRef.current.clear();
groupRefs.current.clear();
setIsLoading(true); setIsLoading(true);
setShowResults(true); setShowResults(true);
const trimmed = query.trim(); // 读取流式搜索设置
// 每次搜索时重新读取设置,确保使用最新的配置
let currentFluidSearch = useFluidSearch; let currentFluidSearch = useFluidSearch;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const savedFluidSearch = localStorage.getItem('fluidSearch'); const savedFluidSearch = localStorage.getItem('fluidSearch');
@ -450,13 +763,12 @@ function SearchPageClient() {
} }
} }
// 如果读取的配置与当前状态不同,更新状态
if (currentFluidSearch !== useFluidSearch) { if (currentFluidSearch !== useFluidSearch) {
setUseFluidSearch(currentFluidSearch); setUseFluidSearch(currentFluidSearch);
} }
if (currentFluidSearch) { if (currentFluidSearch) {
// 流式搜索:打开新的流式连接 // 流式搜索
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`); const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
eventSourceRef.current = es; eventSourceRef.current = es;
@ -464,7 +776,10 @@ function SearchPageClient() {
if (!event.data) return; if (!event.data) return;
try { try {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
if (currentQueryRef.current !== trimmed) return; if (currentQueryRef.current !== trimmed || eventSourceRef.current !== es) {
console.warn('忽略过期的搜索响应:', payload.type, '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
return;
}
switch (payload.type) { switch (payload.type) {
case 'start': case 'start':
setTotalSources(payload.totalSources || 0); setTotalSources(payload.totalSources || 0);
@ -473,7 +788,6 @@ function SearchPageClient() {
case 'source_result': { case 'source_result': {
setCompletedSources((prev) => prev + 1); setCompletedSources((prev) => prev + 1);
if (Array.isArray(payload.results) && payload.results.length > 0) { if (Array.isArray(payload.results) && payload.results.length > 0) {
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
const incoming: SearchResult[] = const incoming: SearchResult[] =
activeYearOrder === 'none' activeYearOrder === 'none'
@ -498,7 +812,6 @@ function SearchPageClient() {
break; break;
case 'complete': case 'complete':
setCompletedSources(payload.completedSources || totalSources); setCompletedSources(payload.completedSources || totalSources);
// 完成前确保将缓冲写入
if (pendingResultsRef.current.length > 0) { if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current; const toAppend = pendingResultsRef.current;
pendingResultsRef.current = []; pendingResultsRef.current = [];
@ -507,8 +820,47 @@ function SearchPageClient() {
flushTimerRef.current = null; flushTimerRef.current = null;
} }
startTransition(() => { startTransition(() => {
setSearchResults((prev) => prev.concat(toAppend)); setSearchResults((prev) => {
const newResults = prev.concat(toAppend);
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(newResults));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: payload.completedSources || totalSources,
completedSources: payload.completedSources || totalSources,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
return newResults;
}); });
});
} else {
setTimeout(() => {
setSearchResults((prev) => {
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(prev));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: payload.completedSources || totalSources,
completedSources: payload.completedSources || totalSources,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
return prev;
});
}, 100);
} }
setIsLoading(false); setIsLoading(false);
try { es.close(); } catch { } try { es.close(); } catch { }
@ -522,7 +874,6 @@ function SearchPageClient() {
es.onerror = () => { es.onerror = () => {
setIsLoading(false); setIsLoading(false);
// 错误时也清空缓冲
if (pendingResultsRef.current.length > 0) { if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current; const toAppend = pendingResultsRef.current;
pendingResultsRef.current = []; pendingResultsRef.current = [];
@ -540,11 +891,14 @@ function SearchPageClient() {
} }
}; };
} else { } else {
// 传统搜索:使用普通接口 // 传统搜索
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`) fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (currentQueryRef.current !== trimmed) return; if (currentQueryRef.current !== trimmed) {
console.warn('忽略过期的搜索响应 (传统):', '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
return;
}
if (data.results && Array.isArray(data.results)) { if (data.results && Array.isArray(data.results)) {
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
@ -556,6 +910,22 @@ function SearchPageClient() {
setSearchResults(results); setSearchResults(results);
setTotalSources(1); setTotalSources(1);
setCompletedSources(1); setCompletedSources(1);
try {
sessionStorage.setItem('cachedSearchQuery', trimmed);
sessionStorage.setItem('cachedSearchResults', JSON.stringify(results));
sessionStorage.setItem('cachedSearchState', JSON.stringify({
totalSources: 1,
completedSources: 1,
}));
sessionStorage.setItem('cachedSearchFilters', JSON.stringify({
filterAll,
filterAgg,
}));
sessionStorage.setItem('cachedViewMode', viewMode);
} catch (error) {
console.error('缓存搜索结果失败:', error);
}
} }
setIsLoading(false); setIsLoading(false);
}) })
@ -563,17 +933,17 @@ function SearchPageClient() {
setIsLoading(false); setIsLoading(false);
}); });
} }
setShowSuggestions(false);
// 保存到搜索历史 (事件监听会自动更新界面) setShowSuggestions(false);
addSearchHistory(query); // 保存到搜索历史
addSearchHistory(trimmed);
} else { } else {
setShowResults(false); setShowResults(false);
setShowSuggestions(false); setShowSuggestions(false);
} }
}, [searchParams]); }, [searchParams]);
// 组件卸载时,关闭可能存在的连接 // 组件卸载时,关闭可能存在的连接并清理所有状态
useEffect(() => { useEffect(() => {
return () => { return () => {
if (eventSourceRef.current) { if (eventSourceRef.current) {
@ -585,6 +955,11 @@ function SearchPageClient() {
flushTimerRef.current = null; flushTimerRef.current = null;
} }
pendingResultsRef.current = []; pendingResultsRef.current = [];
// 清理聚合统计缓存和refs防止状态泄露
groupStatsRef.current.clear();
groupRefs.current.clear();
// 重置当前查询引用
currentQueryRef.current = '';
}; };
}, []); }, []);
@ -613,26 +988,15 @@ function SearchPageClient() {
const trimmed = searchQuery.trim().replace(/\s+/g, ' '); const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return; if (!trimmed) return;
// 回显搜索框
setSearchQuery(trimmed);
setIsLoading(true);
setShowResults(true);
setShowSuggestions(false); setShowSuggestions(false);
// 直接调用搜索函数
router.push(`/search?q=${encodeURIComponent(trimmed)}`); performSearch(trimmed);
// 其余由 searchParams 变化的 effect 处理
}; };
const handleSuggestionSelect = (suggestion: string) => { const handleSuggestionSelect = (suggestion: string) => {
setSearchQuery(suggestion);
setShowSuggestions(false); setShowSuggestions(false);
// 直接调用搜索函数
// 自动执行搜索 performSearch(suggestion);
setIsLoading(true);
setShowResults(true);
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
// 其余由 searchParams 变化的 effect 处理
}; };
// 返回顶部功能 // 返回顶部功能
@ -695,13 +1059,9 @@ function SearchPageClient() {
const trimmed = searchQuery.trim().replace(/\s+/g, ' '); const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return; if (!trimmed) return;
// 回显搜索框
setSearchQuery(trimmed);
setIsLoading(true);
setShowResults(true);
setShowSuggestions(false); setShowSuggestions(false);
// 直接调用搜索函数
router.push(`/search?q=${encodeURIComponent(trimmed)}`); performSearch(trimmed);
}} }}
/> />
</div> </div>
@ -858,10 +1218,8 @@ function SearchPageClient() {
<div key={item} className='relative group'> <div key={item} className='relative group'>
<button <button
onClick={() => { onClick={() => {
setSearchQuery(item); // 直接调用搜索函数
router.push( performSearch(item.trim());
`/search?q=${encodeURIComponent(item.trim())}`
);
}} }}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300' className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
> >

1620
src/components/ChatModal.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error', type: 'error',
title: '错误', title: '错误',
message: '请输入加密密码', message: '请输入加密密码',
showConfirm: true
}); });
return; return;
} }
@ -231,6 +232,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error', type: 'error',
title: '导出失败', title: '导出失败',
message: error instanceof Error ? error.message : '导出过程中发生错误', message: error instanceof Error ? error.message : '导出过程中发生错误',
showConfirm: true,
}); });
} finally { } finally {
setIsExporting(false); setIsExporting(false);
@ -252,6 +254,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error', type: 'error',
title: '错误', title: '错误',
message: '请选择备份文件', message: '请选择备份文件',
showConfirm: true
}); });
return; return;
} }
@ -261,6 +264,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error', type: 'error',
title: '错误', title: '错误',
message: '请输入解密密码', message: '请输入解密密码',
showConfirm: true
}); });
return; return;
} }
@ -319,6 +323,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
type: 'error', type: 'error',
title: '导入失败', title: '导入失败',
message: error instanceof Error ? error.message : '导入过程中发生错误', message: error instanceof Error ? error.message : '导入过程中发生错误',
showConfirm: true
}); });
} finally { } finally {
setIsImporting(false); setIsImporting(false);

View File

@ -130,10 +130,20 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
const info = await getVideoResolutionFromM3u8(episodeUrl); const info = await getVideoResolutionFromM3u8(episodeUrl);
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info)); setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
} catch (error) { } catch (error) {
// 失败时保存错误状态 // 失败时保存错误状态,区分不同的错误类型
const errorMessage = error instanceof Error ? error.message : String(error);
const isNetworkRestricted = errorMessage.includes('Network access restricted') ||
errorMessage.includes('CORS') ||
errorMessage.includes('Forbidden');
// 只在开发环境下打印详细错误
if (process.env.NODE_ENV === 'development') {
console.warn(`Video info fetch failed for ${sourceKey}:`, errorMessage);
}
setVideoInfoMap((prev) => setVideoInfoMap((prev) =>
new Map(prev).set(sourceKey, { new Map(prev).set(sourceKey, {
quality: '错误', quality: isNetworkRestricted ? '受限' : '未知',
loadSpeed: '未知', loadSpeed: '未知',
pingTime: 0, pingTime: 0,
hasError: true, hasError: true,

View File

@ -0,0 +1,130 @@
'use client';
import { useEffect } from 'react';
// 全局主题加载器组件 - 从API同步最新配置确保缓存与服务端一致
const GlobalThemeLoader = () => {
useEffect(() => {
const syncThemeWithAPI = async () => {
try {
console.log('从API同步主题配置...');
const response = await fetch('/api/admin/config');
const result = await response.json();
if (result?.Config?.ThemeConfig) {
const themeConfig = result.Config.ThemeConfig;
const { defaultTheme, customCSS, allowUserCustomization } = themeConfig;
console.log('API返回主题配置:', {
defaultTheme,
customCSS,
allowUserCustomization
});
// 获取当前缓存的主题配置
const cachedTheme = getCachedTheme();
// 比较API配置与缓存配置
const configChanged = !cachedTheme ||
cachedTheme.defaultTheme !== defaultTheme ||
cachedTheme.customCSS !== customCSS;
if (configChanged) {
console.log('检测到主题配置变更,更新应用:', {
from: cachedTheme,
to: { defaultTheme, customCSS }
});
applyAndCacheTheme(defaultTheme, customCSS);
} else {
console.log('主题配置无变化,保持当前设置');
}
// 将配置存储到运行时配置中供ThemeManager使用
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = themeConfig;
}
} else {
console.log('无法获取主题配置,使用默认配置:', result);
// API失败时如果有缓存就保持没有缓存就用默认
const cachedTheme = getCachedTheme();
if (!cachedTheme) {
console.log('无缓存,应用默认主题');
applyAndCacheTheme('default', '');
} else {
console.log('保持缓存主题:', cachedTheme);
}
}
} catch (error) {
console.error('API同步失败:', error);
// 错误时如果有缓存就保持,没有缓存就用默认
const cachedTheme = getCachedTheme();
if (!cachedTheme) {
console.log('无缓存且请求失败,应用默认主题');
applyAndCacheTheme('default', '');
} else {
console.log('请求失败,保持缓存主题:', cachedTheme);
}
}
};
// 获取缓存的主题配置
const getCachedTheme = () => {
try {
const cached = localStorage.getItem('theme-cache');
return cached ? JSON.parse(cached) : null;
} catch (error) {
console.warn('读取主题缓存失败:', error);
localStorage.removeItem('theme-cache');
return null;
}
};
// 应用主题并缓存
const applyAndCacheTheme = (themeId: string, css: string = '') => {
applyTheme(themeId, css);
// 缓存主题配置
const themeConfig = { defaultTheme: themeId, customCSS: css };
try {
localStorage.setItem('theme-cache', JSON.stringify(themeConfig));
console.log('主题配置已缓存:', themeConfig);
} catch (error) {
console.warn('缓存主题配置失败:', error);
}
};
// 应用主题函数
const applyTheme = (themeId: string, css: string = '') => {
const html = document.documentElement;
// 移除所有主题class
html.removeAttribute('data-theme');
// 应用新主题
if (themeId !== 'default') {
html.setAttribute('data-theme', themeId);
}
// 应用自定义CSS
let customStyleEl = document.getElementById('custom-theme-css');
if (!customStyleEl) {
customStyleEl = document.createElement('style');
customStyleEl.id = 'custom-theme-css';
document.head.appendChild(customStyleEl);
}
customStyleEl.textContent = css;
};
// 延迟一点时间确保页面缓存主题已应用然后同步API配置
const timer = setTimeout(() => {
syncThemeWithAPI();
}, 100);
return () => clearTimeout(timer);
}, []);
return null; // 这是一个逻辑组件,不渲染任何内容
};
export default GlobalThemeLoader;

View File

@ -17,10 +17,10 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
<header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'> <header className='md:hidden fixed top-0 left-0 right-0 z-[999] w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
<div className='h-12 flex items-center justify-between px-4'> <div className='h-12 flex items-center justify-between px-4'>
{/* 左侧:搜索按钮、返回按钮和设置按钮 */} {/* 左侧:搜索按钮、返回按钮和设置按钮 */}
<div className='flex items-center gap-2'> <div className='flex items-center gap-1'>
<Link <Link
href='/search' href='/search'
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors' className='w-8 h-8 p-1.5 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
> >
<svg <svg
className='w-full h-full' className='w-full h-full'
@ -41,7 +41,7 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
</div> </div>
{/* 右侧按钮 */} {/* 右侧按钮 */}
<div className='flex items-center gap-2'> <div className='flex items-center gap-1'>
<ThemeToggle /> <ThemeToggle />
<UserMenu /> <UserMenu />
</div> </div>

View File

@ -0,0 +1,801 @@
'use client';
import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Palette, Eye, Check } from 'lucide-react';
// CSS模板配置
const cssTemplates = [
{
id: 'gradient-bg',
name: '渐变背景',
description: '为页面添加漂亮的渐变背景',
preview: 'body {\n background: linear-gradient(135deg, \n #667eea 0%, #764ba2 100%);\n}',
css: `/* 渐变背景主题 */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed;
}
/* 确保内容可读性 */
.admin-panel, .bg-theme-surface {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.9) !important;
}
.dark .admin-panel, .dark .bg-theme-surface {
background: rgba(0, 0, 0, 0.8) !important;
}`
},
{
id: 'image-bg',
name: '图片背景',
description: '使用自定义图片作为背景',
preview: 'body {\n background-image: url("图片链接");\n background-size: cover;\n}',
css: `/* 图片背景主题 */
body {
background-image: url("https://images.unsplash.com/photo-1519681393784-d120c3b3fd60?ixlib=rb-4.0.3");
background-size: cover;
background-position: center;
background-attachment: fixed;
}
/* 添加遮罩层确保可读性 */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: -1;
}
/* 调整内容区域透明度 */
.admin-panel, .bg-theme-surface {
backdrop-filter: blur(15px);
background: rgba(255, 255, 255, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .admin-panel, .dark .bg-theme-surface {
background: rgba(0, 0, 0, 0.85) !important;
border: 1px solid rgba(255, 255, 255, 0.1);
}`
},
{
id: 'sidebar-glow',
name: '发光侧边栏',
description: '为侧边栏添加发光效果',
preview: '.sidebar {\n box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);\n border-radius: 15px;\n}',
css: `/* 发光侧边栏效果 */
.sidebar, [data-sidebar] {
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
border-radius: 15px;
border: 1px solid rgba(14, 165, 233, 0.2);
backdrop-filter: blur(10px);
}
/* 侧边栏项目悬停效果 */
.sidebar a:hover, [data-sidebar] a:hover {
background: rgba(14, 165, 233, 0.1);
transform: translateX(5px);
transition: all 0.3s ease;
}
/* 活动项目发光 */
.sidebar [data-active="true"], [data-sidebar] [data-active="true"] {
background: rgba(14, 165, 233, 0.15);
box-shadow: inset 0 0 10px rgba(14, 165, 233, 0.2);
border-radius: 8px;
}`
},
{
id: 'card-animations',
name: '卡片动画',
description: '为视频卡片添加动画效果',
preview: '.video-card:hover {\n transform: scale(1.05);\n box-shadow: 0 10px 25px rgba(0,0,0,0.2);\n}',
css: `/* 卡片动画效果 */
.video-card, [data-video-card] {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px;
}
.video-card:hover, [data-video-card]:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
}
/* 图片悬停效果 */
.video-card img, [data-video-card] img {
transition: transform 0.3s ease;
border-radius: 8px;
}
.video-card:hover img, [data-video-card]:hover img {
transform: scale(1.05);
}
/* 按钮动画 */
.video-card button, [data-video-card] button {
transition: all 0.2s ease;
}
.video-card button:hover, [data-video-card] button:hover {
transform: scale(1.1);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}`
},
{
id: 'glass-theme',
name: '毛玻璃主题',
description: '现代毛玻璃风格界面',
preview: '.glass-effect {\n backdrop-filter: blur(20px);\n background: rgba(255, 255, 255, 0.1);\n}',
css: `/* 毛玻璃主题 */
body {
background: linear-gradient(45deg,
rgba(59, 130, 246, 0.1) 0%,
rgba(147, 51, 234, 0.1) 50%,
rgba(236, 72, 153, 0.1) 100%);
}
/* 所有面板使用毛玻璃效果 */
.admin-panel, .bg-theme-surface, [data-panel] {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.15) !important;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.dark .admin-panel, .dark .bg-theme-surface, .dark [data-panel] {
background: rgba(0, 0, 0, 0.3) !important;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 按钮毛玻璃效果 */
button {
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
button:hover {
backdrop-filter: blur(15px);
transform: translateY(-1px);
}`
},
{
id: 'neon-accents',
name: '霓虹强调',
description: '添加炫酷的霓虹发光效果',
preview: '.neon-glow {\n box-shadow: 0 0 20px currentColor;\n text-shadow: 0 0 10px currentColor;\n}',
css: `/* 霓虹发光主题 */
:root {
--neon-color: #00ff88;
--neon-glow: 0 0 20px var(--neon-color);
}
/* 主要标题霓虹效果 */
h1, h2, h3 {
text-shadow: 0 0 10px var(--neon-color);
color: var(--neon-color);
}
/* 按钮霓虹效果 */
button:hover, .btn-primary {
box-shadow: var(--neon-glow);
border: 1px solid var(--neon-color);
transition: all 0.3s ease;
}
/* 输入框聚焦霓虹效果 */
input:focus, textarea:focus {
box-shadow: var(--neon-glow);
border-color: var(--neon-color);
}
/* 卡片边框霓虹效果 */
.card-hover:hover {
box-shadow: var(--neon-glow);
border: 1px solid var(--neon-color);
}
/* 侧边栏活动项霓虹效果 */
[data-active="true"] {
box-shadow: inset var(--neon-glow);
background: rgba(0, 255, 136, 0.1);
}`
}
];
// 主题配置
const themes = [
{
id: 'default',
name: '默认主题',
description: '现代蓝色主题,清新优雅',
preview: {
bg: '#ffffff',
surface: '#f9fafb',
accent: '#0ea5e9',
text: '#111827',
border: '#e5e7eb'
}
},
{
id: 'minimal',
name: '极简主题',
description: '简约黑白,专注内容',
preview: {
bg: '#ffffff',
surface: '#fcfcfc',
accent: '#525252',
text: '#171717',
border: '#e5e5e5'
}
},
{
id: 'warm',
name: '暖色主题',
description: '温暖橙调,舒适护眼',
preview: {
bg: '#fffdf7',
surface: '#fefaf0',
accent: '#ea580c',
text: '#7c2d12',
border: '#fde68a'
}
},
{
id: 'fresh',
name: '清新主题',
description: '自然绿色,清新活力',
preview: {
bg: '#f7fdf9',
surface: '#f0fdf4',
accent: '#3fcc71',
text: '#14532d',
border: '#bbf7d0'
}
}
];
interface ThemeManagerProps {
showAlert: (config: any) => void;
role?: 'user' | 'admin' | 'owner' | null;
}
const ThemeManager = ({ showAlert, role }: ThemeManagerProps) => {
const [currentTheme, setCurrentTheme] = useState('default');
const [customCSS, setCustomCSS] = useState('');
const [previewMode, setPreviewMode] = useState(false);
const [showCustomEditor, setShowCustomEditor] = useState(false);
const [globalThemeConfig, setGlobalThemeConfig] = useState<{
defaultTheme: string;
customCSS: string;
allowUserCustomization: boolean;
} | null>(null);
const isAdmin = role === 'admin' || role === 'owner';
// 更新主题缓存的辅助函数
const updateThemeCache = (themeId: string, css: string) => {
try {
const themeConfig = {
defaultTheme: themeId,
customCSS: css
};
localStorage.setItem('theme-cache', JSON.stringify(themeConfig));
console.log('主题配置已缓存:', themeConfig);
} catch (error) {
console.warn('缓存主题配置失败:', error);
}
};
// 从API加载主题配置唯一数据源
const loadGlobalThemeConfig = async () => {
try {
console.log('从API获取主题配置...');
const response = await fetch('/api/admin/config');
const result = await response.json();
if (result?.Config?.ThemeConfig) {
const themeConfig = result.Config.ThemeConfig;
console.log('API返回的主题配置:', themeConfig);
setGlobalThemeConfig(themeConfig);
// 更新运行时配置,保持同步
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = themeConfig;
}
return themeConfig;
} else {
console.log('无法获取主题配置,可能未登录或权限不足:', result);
}
} catch (error) {
console.error('从API加载主题配置失败:', error);
}
return null;
};
// 保存全局主题配置
const saveGlobalThemeConfig = async (config: {
defaultTheme: string;
customCSS: string;
allowUserCustomization: boolean;
}) => {
try {
const response = await fetch('/api/admin/theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const result = await response.json();
if (result.success) {
setGlobalThemeConfig(result.data);
// 更新运行时配置,确保同步
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = result.data;
console.log('已更新运行时主题配置:', result.data);
}
// 立即应用新的主题配置,确保当前页面也能看到更改
applyTheme(result.data.defaultTheme, result.data.customCSS);
// 更新本地缓存
updateThemeCache(result.data.defaultTheme, result.data.customCSS);
console.log('已立即应用新主题配置:', result.data.defaultTheme);
showAlert({
type: 'success',
title: '全站主题配置已保存',
message: '所有用户将使用新的主题配置',
timer: 3000
});
return true;
} else {
throw new Error(result.error || '保存失败');
}
} catch (error) {
showAlert({
type: 'error',
title: '保存全局主题配置失败',
message: error instanceof Error ? error.message : '未知错误',
timer: 3000
});
return false;
}
};
// 从localStorage加载当前主题
useEffect(() => {
// 确保在客户端环境中执行
if (typeof window === 'undefined') return;
const initTheme = async () => {
// 加载全局配置
const globalConfig = await loadGlobalThemeConfig();
if (globalConfig) {
// 使用全局配置
setCurrentTheme(globalConfig.defaultTheme);
setCustomCSS(globalConfig.customCSS);
applyTheme(globalConfig.defaultTheme, globalConfig.customCSS);
} else {
// 如果没有全局配置,使用默认值
const defaultTheme = 'default';
const defaultCSS = '';
setCurrentTheme(defaultTheme);
setCustomCSS(defaultCSS);
applyTheme(defaultTheme, defaultCSS);
}
};
initTheme();
}, []);
// 应用主题
const applyTheme = (themeId: string, css: string = '') => {
const html = document.documentElement;
// 移除所有主题class
html.removeAttribute('data-theme');
// 应用新主题
if (themeId !== 'default') {
html.setAttribute('data-theme', themeId);
}
// 应用自定义CSS
let customStyleEl = document.getElementById('custom-theme-css');
if (!customStyleEl) {
customStyleEl = document.createElement('style');
customStyleEl.id = 'custom-theme-css';
document.head.appendChild(customStyleEl);
}
customStyleEl.textContent = css;
};
// 切换主题
const handleThemeChange = async (themeId: string) => {
setCurrentTheme(themeId);
applyTheme(themeId, customCSS);
if (isAdmin) {
// 保存到全局配置
const success = await saveGlobalThemeConfig({
defaultTheme: themeId,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
// 如果保存成功,立即更新本地全局配置状态
if (success) {
setGlobalThemeConfig({
defaultTheme: themeId,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
}
}
const theme = themes.find(t => t.id === themeId);
showAlert({
type: 'success',
title: '全站主题已设置',
message: `已切换到${theme?.name}`,
timer: 2000
});
};
// 预览主题
const handleThemePreview = (themeId: string) => {
if (!previewMode) {
setPreviewMode(true);
applyTheme(themeId, customCSS);
// 3秒后恢复原主题
setTimeout(() => {
setPreviewMode(false);
applyTheme(currentTheme, customCSS);
}, 3000);
}
};
// 应用自定义CSS
const handleCustomCSSApply = async () => {
try {
applyTheme(currentTheme, customCSS);
if (isAdmin) {
// 保存到全局配置
const success = await saveGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
// 如果保存成功,立即更新本地全局配置状态
if (success) {
setGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: customCSS,
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
}
} else {
showAlert({
type: 'warning',
title: '权限不足',
message: '仅管理员可以设置全站主题',
timer: 2000
});
}
} catch (error) {
showAlert({
type: 'error',
title: '样式应用失败',
message: 'CSS语法可能有误请检查后重试',
timer: 3000
});
}
};
// 重置自定义CSS
const handleCustomCSSReset = async () => {
setCustomCSS('');
applyTheme(currentTheme, '');
if (isAdmin) {
// 保存到全局配置
await saveGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
setGlobalThemeConfig({
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
});
// 更新运行时配置
const runtimeConfig = (window as any).RUNTIME_CONFIG;
if (runtimeConfig) {
runtimeConfig.THEME_CONFIG = {
defaultTheme: currentTheme,
customCSS: '',
allowUserCustomization: globalThemeConfig?.allowUserCustomization ?? true,
};
}
// 更新本地缓存
updateThemeCache(currentTheme, '');
}
showAlert({
type: 'success',
title: '全站自定义样式已重置',
timer: 2000
});
};
// 应用模板CSS
const handleApplyTemplate = (templateCSS: string, templateName: string) => {
setCustomCSS(templateCSS);
showAlert({
type: 'success',
title: '模板已复制',
message: `${templateName}模板已复制到编辑器`,
timer: 2000
});
};
return (
<div className="space-y-6">
{/* 管理员控制面板 */}
{isAdmin && globalThemeConfig && (
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
<div className="space-y-4">
<div className="p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
<div className="text-sm text-theme-text">
<strong></strong>
</div>
<div className="text-xs text-theme-text-secondary mt-1">
: {themes.find(t => t.id === globalThemeConfig.defaultTheme)?.name || globalThemeConfig.defaultTheme}
{globalThemeConfig.customCSS && ' | 包含自定义CSS'}
{!globalThemeConfig.allowUserCustomization && ' | 禁止用户自定义'}
</div>
</div>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-700">
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-200">
<span className="text-sm font-medium"> </span>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
</p>
</div>
</div>
</div>
)}
{/* 主题选择器 */}
<div>
<h3 className="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{themes.map((theme) => (
<div
key={theme.id}
className={`relative p-4 border-2 rounded-xl transition-all ${currentTheme === theme.id
? 'border-theme-accent bg-theme-accent/5'
: 'border-theme-border bg-theme-surface'
} ${isAdmin ? 'cursor-pointer hover:border-theme-accent/50' : 'cursor-not-allowed opacity-60'}`}
onClick={() => isAdmin && handleThemeChange(theme.id)}
>
{/* 主题预览 */}
<div className="flex items-center justify-between mb-3">
<div className="flex space-x-1">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.bg }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.surface }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: theme.preview.accent }} />
</div>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
if (isAdmin) handleThemePreview(theme.id);
}}
className={`p-1 transition-colors ${isAdmin ? 'text-theme-text-secondary hover:text-theme-accent' : 'text-theme-text-secondary opacity-50 cursor-not-allowed'}`}
title={isAdmin ? "预览主题" : "仅管理员可预览"}
disabled={previewMode || !isAdmin}
>
<Eye className="h-4 w-4" />
</button>
{currentTheme === theme.id && (
<Check className="h-4 w-4 text-theme-accent" />
)}
</div>
</div>
<h4 className="font-medium text-theme-text">{theme.name}</h4>
<p className="text-sm text-theme-text-secondary mt-1">{theme.description}</p>
</div>
))}
</div>
{previewMode && (
<div className="mt-4 p-3 bg-theme-info/10 border border-theme-info/20 rounded-lg">
<p className="text-sm text-theme-info">3...</p>
</div>
)}
</div>
{/* 自定义CSS编辑器 */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-theme-text flex items-center gap-2">
<Palette className="h-5 w-5" />
</h3>
{isAdmin ? (
<button
onClick={() => setShowCustomEditor(!showCustomEditor)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-surface border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors"
>
{showCustomEditor ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCustomEditor ? '收起编辑器' : '展开编辑器'}
</button>
) : (
<div className="text-sm text-theme-text-secondary">
</div>
)}
</div>
{!isAdmin && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-700 mb-4">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200">
<span className="text-sm font-medium"> </span>
</div>
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
</p>
</div>
)}
{isAdmin && showCustomEditor && (
<div className="space-y-4">
<div className="text-sm text-theme-text-secondary bg-theme-surface p-3 rounded-lg border border-theme-border">
<p className="mb-2">💡 <strong>使</strong></p>
<ul className="space-y-1 text-xs">
<li> 使CSS变量覆盖主题颜色<code className="bg-theme-bg px-1 rounded">--color-theme-accent: 255, 0, 0;</code></li>
<li> 使Tailwind类名<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
<li> <code className="bg-theme-bg px-1 rounded">{`.admin-panel { border-radius: 20px; }`}</code></li>
<li> 使</li>
</ul>
</div>
<div className="relative">
<textarea
value={customCSS}
onChange={(e) => setCustomCSS(e.target.value)}
placeholder="/* 在此输入您的自定义CSS */
:root {
--color-theme-accent: 255, 0, 0; /* 红色主题色 */
}
.admin-panel {
border-radius: 20px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
/* 使用Tailwind类名 */
.custom-button {
@apply bg-gradient-to-r from-purple-500 to-pink-500 text-white px-6 py-3 rounded-xl;
}"
className="w-full h-64 p-4 bg-theme-surface border border-theme-border rounded-lg text-sm font-mono text-theme-text placeholder-theme-text-secondary resize-none focus:outline-none focus:ring-2 focus:ring-theme-accent/50"
/>
</div>
<div className="flex gap-3">
<button
onClick={handleCustomCSSApply}
className="px-4 py-2 bg-theme-accent text-white rounded-lg hover:opacity-90 transition-opacity"
>
</button>
<button
onClick={handleCustomCSSReset}
className="px-4 py-2 bg-theme-surface border border-theme-border text-theme-text rounded-lg hover:bg-theme-accent/5 transition-colors"
>
</button>
</div>
</div>
)}
</div>
{/* CSS 模板库 */}
{isAdmin && (
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h4 className="font-medium text-theme-text mb-3 flex items-center gap-2">
<Palette className="h-4 w-4" />
🎨
</h4>
<p className="text-sm text-theme-text-secondary mb-4"></p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{cssTemplates.map((template) => (
<div key={template.id} className="p-3 border border-theme-border rounded-lg hover:bg-theme-accent/5 transition-colors group">
<div className="flex items-center justify-between mb-2">
<h5 className="text-sm font-medium text-theme-text">{template.name}</h5>
<button
onClick={() => handleApplyTemplate(template.css, template.name)}
className="text-xs px-2 py-1 bg-theme-accent text-white rounded hover:opacity-90 transition-opacity opacity-0 group-hover:opacity-100"
>
</button>
</div>
<p className="text-xs text-theme-text-secondary mb-2">{template.description}</p>
<div className="text-xs bg-theme-bg rounded p-2 max-h-16 overflow-y-auto">
<code className="whitespace-pre-wrap text-theme-text-secondary">{template.preview}</code>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-theme-accent/5 border border-theme-accent/20 rounded-lg">
<p className="text-xs text-theme-text-secondary">
<strong>💡 使</strong> "应用"CSS编辑器"应用样式"
</p>
</div>
</div>
)}
{/* 使用说明 */}
<div className="bg-theme-surface border border-theme-border rounded-lg p-4">
<h4 className="font-medium text-theme-text mb-2">📖 </h4>
<div className="text-sm text-theme-text-secondary space-y-2">
<p><strong></strong>{isAdmin ? '选择预设主题即可一键切换全站整体风格' : '由管理员设置的全站预设主题'}</p>
{isAdmin && <p><strong>CSS</strong>CSS变量或直接样式实现全站个性化定制</p>}
{isAdmin && <p><strong></strong>使</p>}
<p><strong></strong></p>
<ul className="text-xs space-y-1 ml-4 mt-1">
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-bg</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-surface</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-accent</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-text</code> - </li>
<li> <code className="bg-theme-bg px-1 rounded">--color-theme-border</code> - </li>
</ul>
{isAdmin && (
<>
<p><strong></strong></p>
<ul className="text-xs space-y-1 ml-4 mt-1">
<li> <code className="bg-theme-bg px-1 rounded">{`body { background: linear-gradient(...); }`}</code></li>
<li> 使Tailwind<code className="bg-theme-bg px-1 rounded">{`.my-class { @apply bg-red-500; }`}</code></li>
<li> </li>
</ul>
</>
)}
</div>
</div>
</div>
);
};
export default ThemeManager;

View File

@ -2,16 +2,45 @@
'use client'; 'use client';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun, MessageCircle } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { ChatModal } from './ChatModal';
import { useWebSocket } from '../hooks/useWebSocket';
import { WebSocketMessage } from '../lib/types';
export function ThemeToggle() { export function ThemeToggle() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [isChatModalOpen, setIsChatModalOpen] = useState(false);
const [messageCount, setMessageCount] = useState(0);
const [chatCount, setChatCount] = useState(0);
const [friendRequestCount, setFriendRequestCount] = useState(0);
const [isMobile, setIsMobile] = useState(false);
const { setTheme, resolvedTheme } = useTheme(); const { setTheme, resolvedTheme } = useTheme();
const pathname = usePathname(); const pathname = usePathname();
// 不再在ThemeToggle中创建独立的WebSocket连接
// 改为依赖ChatModal传递的消息计数
// 直接使用ChatModal传来的消息计数
const handleMessageCountFromModal = useCallback((totalCount: number) => {
console.log('📊 [ThemeToggle] 收到ChatModal传来的消息计数:', totalCount);
setMessageCount(totalCount);
}, []);
// 处理聊天消息计数重置(当用户查看对话时)
const handleChatCountReset = useCallback((resetCount: number) => {
console.log('💬 [ThemeToggle] 重置聊天计数:', resetCount);
// 这些回调函数现在主要用于同步状态实际计数由ChatModal管理
}, []);
// 处理好友请求计数重置(当用户查看好友请求时)
const handleFriendRequestCountReset = useCallback((resetCount: number) => {
console.log('👥 [ThemeToggle] 重置好友请求计数:', resetCount);
// 这些回调函数现在主要用于同步状态实际计数由ChatModal管理
}, []);
const setThemeColor = (theme?: string) => { const setThemeColor = (theme?: string) => {
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) { if (!meta) {
@ -26,6 +55,17 @@ export function ThemeToggle() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []); }, []);
// 监听主题变化和路由变化,确保主题色始终同步 // 监听主题变化和路由变化,确保主题色始终同步
@ -54,10 +94,32 @@ export function ThemeToggle() {
}); });
}; };
// 检查是否在登录页面
const isLoginPage = pathname === '/login';
return ( return (
<>
<div className={`flex items-center ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
{/* 聊天按钮 - 在登录页面不显示 */}
{!isLoginPage && (
<button
onClick={() => setIsChatModalOpen(true)}
className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative`}
aria-label='Open chat'
>
<MessageCircle className='w-full h-full' />
{messageCount > 0 && (
<span className={`absolute ${isMobile ? '-top-0.5 -right-0.5 w-4 h-4 text-xs' : '-top-1 -right-1 w-5 h-5 text-xs'} bg-red-500 text-white rounded-full flex items-center justify-center`}>
{messageCount > 99 ? '99+' : messageCount}
</span>
)}
</button>
)}
{/* 主题切换按钮 */}
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors' className={`${isMobile ? 'w-8 h-8 p-1.5' : 'w-10 h-10 p-2'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors`}
aria-label='Toggle theme' aria-label='Toggle theme'
> >
{resolvedTheme === 'dark' ? ( {resolvedTheme === 'dark' ? (
@ -66,5 +128,18 @@ export function ThemeToggle() {
<Moon className='w-full h-full' /> <Moon className='w-full h-full' />
)} )}
</button> </button>
</div>
{/* 聊天模态框 - 在登录页面不渲染 */}
{!isLoginPage && (
<ChatModal
isOpen={isChatModalOpen}
onClose={() => setIsChatModalOpen(false)}
onMessageCountChange={handleMessageCountFromModal}
onChatCountReset={handleChatCountReset}
onFriendRequestCountReset={handleFriendRequestCountReset}
/>
)}
</>
); );
} }

View File

@ -39,9 +39,21 @@ interface ToastProviderProps {
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => { export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [isMobile, setIsMobile] = useState(false);
React.useEffect(() => { React.useEffect(() => {
setMounted(true); setMounted(true);
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []); }, []);
const removeToast = useCallback((id: string) => { const removeToast = useCallback((id: string) => {
@ -86,16 +98,16 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
}; };
const getToastIcon = (type: ToastType) => { const getToastIcon = (type: ToastType) => {
const iconProps = { className: 'w-5 h-5 flex-shrink-0' }; const iconSize = isMobile ? 'w-4 h-4' : 'w-5 h-5';
switch (type) { switch (type) {
case 'success': case 'success':
return <CheckCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-green-500" />; return <CheckCircle className={`${iconSize} flex-shrink-0 text-green-500`} />;
case 'error': case 'error':
return <XCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-red-500" />; return <XCircle className={`${iconSize} flex-shrink-0 text-red-500`} />;
case 'warning': case 'warning':
return <AlertCircle {...iconProps} className="w-5 h-5 flex-shrink-0 text-yellow-500" />; return <AlertCircle className={`${iconSize} flex-shrink-0 text-yellow-500`} />;
case 'info': case 'info':
return <Info {...iconProps} className="w-5 h-5 flex-shrink-0 text-blue-500" />; return <Info className={`${iconSize} flex-shrink-0 text-blue-500`} />;
} }
}; };
@ -113,29 +125,36 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
}; };
const toastContainer = mounted && toasts.length > 0 && ( const toastContainer = mounted && toasts.length > 0 && (
<div className="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm w-full"> <div
className={`fixed ${isMobile ? 'space-y-1' : 'space-y-2'} ${isMobile
? 'top-14 left-3 right-3 max-w-none z-[2147483648]'
: 'top-4 right-4 max-w-sm w-full z-[9999]'
}`}
>
{toasts.map((toast) => ( {toasts.map((toast) => (
<div <div
key={toast.id} key={toast.id}
className={` className={`
flex items-start gap-3 p-4 rounded-lg border shadow-lg flex items-start gap-3 rounded-lg border shadow-lg
transform transition-all duration-300 ease-out transform transition-all duration-300 ease-out
animate-in slide-in-from-right-2 ${isMobile ? 'p-3 text-sm' : 'p-4'}
${isMobile ? 'animate-in slide-in-from-top-2' : 'animate-in slide-in-from-right-2'}
${getToastStyles(toast.type)} ${getToastStyles(toast.type)}
`} `}
> >
{getToastIcon(toast.type)} {getToastIcon(toast.type)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-sm font-medium">{toast.title}</h4> <h4 className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.title}</h4>
{toast.message && ( {toast.message && (
<p className="text-sm opacity-90 mt-1">{toast.message}</p> <p className={`opacity-90 mt-1 ${isMobile ? 'text-xs' : 'text-sm'}`}>{toast.message}</p>
)} )}
</div> </div>
<button <button
onClick={() => removeToast(toast.id)} onClick={() => removeToast(toast.id)}
className="flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity" className={`flex-shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity ${isMobile ? 'p-1' : ''
}`}
> >
<X className="w-4 h-4" /> <X className={isMobile ? 'w-3 h-3' : 'w-4 h-4'} />
</button> </button>
</div> </div>
))} ))}

View File

@ -48,6 +48,7 @@ export const UserMenu: React.FC = () => {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string>(''); const [avatarUrl, setAvatarUrl] = useState<string>('');
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 裁剪相关状态 // 裁剪相关状态
@ -137,6 +138,17 @@ export const UserMenu: React.FC = () => {
// 确保组件已挂载 // 确保组件已挂载
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
}, []); }, []);
// 获取认证信息、存储类型和头像 // 获取认证信息、存储类型和头像
@ -1319,7 +1331,7 @@ export const UserMenu: React.FC = () => {
<div className='relative'> <div className='relative'>
<button <button
onClick={handleMenuClick} onClick={handleMenuClick}
className='w-10 h-10 p-0.5 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors overflow-hidden' className={`${isMobile ? 'w-8 h-8 p-0.5' : 'w-10 h-10 p-0.5'} rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors overflow-hidden`}
aria-label='User Menu' aria-label='User Menu'
> >
{avatarUrl ? ( {avatarUrl ? (

View File

@ -81,16 +81,24 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
const fetchRemoteChangelog = async () => { const fetchRemoteChangelog = async () => {
try { try {
const response = await fetch( const response = await fetch(
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/CHANGELOG' 'https://raw.githubusercontent.com/djteang/OrangeTV/refs/heads/main/CHANGELOG'
); );
if (response.ok) { if (response.ok) {
const content = await response.text(); const content = await response.text();
const parsed = parseChangelog(content); const parsed = parseChangelog(content);
setRemoteChangelog(parsed); setRemoteChangelog(parsed);
// 检查是否有更新 // 检查是否有更新 - 基于日期而非版本号数字大小来确定最新版本
if (parsed.length > 0) { if (parsed.length > 0) {
const latest = parsed[0]; // 按日期排序,找到真正的最新版本
const sortedByDate = [...parsed].sort((a, b) => {
// 解析日期进行比较
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
});
const latest = sortedByDate[0];
setLatestVersion(latest.version); setLatestVersion(latest.version);
setIsHasUpdate( setIsHasUpdate(
compareVersions(latest.version) === UpdateStatus.HAS_UPDATE compareVersions(latest.version) === UpdateStatus.HAS_UPDATE
@ -363,7 +371,7 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
</div> </div>
</div> </div>
<a <a
href='https://github.com/MoonTechLab/LunaTV' href='https://github.com/djteang/OrangeTV'
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full' className='inline-flex items-center justify-center gap-2 px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-xs sm:text-sm rounded-lg transition-colors shadow-sm w-full'
@ -441,6 +449,12 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
); );
return !localVersions.includes(entry.version); return !localVersions.includes(entry.version);
}) })
.sort((a, b) => {
// 按日期排序,确保最新的版本在前面显示
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime(); // 降序排列,最新的在前
})
.map((entry, index) => ( .map((entry, index) => (
<div <div
key={index} key={index}

View File

@ -131,9 +131,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv') ? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv')
: type; : type;
// 获取收藏状态(搜索结果页面不检查) // 获取收藏状态(搜索结果、豆瓣和短剧页面不检查)
useEffect(() => { useEffect(() => {
if (from === 'douban' || from === 'search' || !actualSource || !actualId) return; if (from === 'douban' || from === 'search' || from === 'shortdrama' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => { const fetchFavoriteStatus = async () => {
try { try {
@ -164,7 +164,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
async (e: React.MouseEvent) => { async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (from === 'douban' || !actualSource || !actualId) return; if (from === 'douban' || from === 'shortdrama' || !actualSource || !actualId) return;
try { try {
// 确定当前收藏状态 // 确定当前收藏状态
@ -228,6 +228,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
); );
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
// 如果从搜索页面点击,设置标记以便返回时使用缓存
if (from === 'search' && typeof window !== 'undefined') {
sessionStorage.setItem('fromPlayPage', 'true');
}
if (origin === 'live' && actualSource && actualId) { if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面 // 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
@ -270,6 +275,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 新标签页播放处理函数 // 新标签页播放处理函数
const handlePlayInNewTab = useCallback(() => { const handlePlayInNewTab = useCallback(() => {
// 如果从搜索页面点击,设置标记以便返回时使用缓存
if (from === 'search' && typeof window !== 'undefined') {
sessionStorage.setItem('fromPlayPage', 'true');
}
if (origin === 'live' && actualSource && actualId) { if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面 // 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
@ -375,7 +385,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
showSourceName: true, showSourceName: true,
showProgress: false, showProgress: false,
showPlayButton: true, showPlayButton: true,
showHeart: true, showHeart: false, // 短剧不显示收藏功能
showCheckCircle: false, showCheckCircle: false,
showDoubanLink: false, showDoubanLink: false,
showRating: !!rate, showRating: !!rate,
@ -412,7 +422,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
// 收藏/取消收藏操作 // 收藏/取消收藏操作
if (config.showHeart && from !== 'douban' && actualSource && actualId) { if (config.showHeart && from !== 'douban' && from !== 'shortdrama' && actualSource && actualId) {
const currentFavorited = from === 'search' ? searchFavorited : favorited; const currentFavorited = from === 'search' ? searchFavorited : favorited;
if (from === 'search') { if (from === 'search') {
@ -680,7 +690,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
/> />
)} )}
{config.showHeart && from !== 'search' && ( {config.showHeart && from !== 'search' && from !== 'shortdrama' && (
<Heart <Heart
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
size={20} size={20}

20
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,20 @@
// 全局主题Hook - 已弃用,主题现在由 GlobalThemeLoader 统一管理
// 保留此文件是为了向后兼容性,但不再使用
export const useThemeInit = () => {
// 不再执行任何操作,主题由 GlobalThemeLoader 处理
console.warn('useThemeInit is deprecated. Theme is now managed by GlobalThemeLoader.');
};
export const useTheme = () => {
// 已弃用:主题现在由 GlobalThemeLoader 和 ThemeManager 统一管理
console.warn('useTheme is deprecated. Use ThemeManager component for theme management.');
return {
applyTheme: () => console.warn('applyTheme is deprecated'),
getCurrentTheme: () => 'default',
getCurrentCustomCSS: () => ''
};
};

333
src/hooks/useWebSocket.ts Normal file
View File

@ -0,0 +1,333 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { WebSocketMessage } from '../lib/types';
import { getAuthInfoFromBrowserCookie } from '../lib/auth';
// 全局连接计数器,用于调试
let globalConnectionCount = 0;
interface UseWebSocketOptions {
onMessage?: (message: WebSocketMessage) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Event) => void;
enabled?: boolean; // 是否启用WebSocket连接
}
export function useWebSocket(options: UseWebSocketOptions = {}) {
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5;
const isConnectingRef = useRef(false); // 添加连接状态标志,防止重复连接
const optionsRef = useRef(options); // 使用 ref 存储 options避免依赖项问题
// 为每个 useWebSocket 实例创建唯一标识符
const instanceIdRef = useRef<string>('');
if (!instanceIdRef.current) {
globalConnectionCount++;
instanceIdRef.current = `ws-${globalConnectionCount}-${Date.now()}`;
console.log(`🔌 创建 WebSocket 实例: ${instanceIdRef.current}`);
}
// 更新 options ref
useEffect(() => {
optionsRef.current = options;
}, [options]);
// 获取WebSocket URL
const getWebSocketUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const hostname = window.location.hostname;
// 在生产环境中WebSocket运行在不同的端口
// 可以通过环境变量或配置来设置
let wsPort = '3001'; // 默认WebSocket端口
// 如果在开发环境WebSocket运行在3001端口
if (process.env.NODE_ENV === 'development') {
return `${protocol}//${hostname}:3001/ws?_=${Date.now()}`;
}
// 生产环境使用独立的WebSocket端口
// 如果通过反向代理,可能需要特殊的路径
if (window.location.port && window.location.port !== '80' && window.location.port !== '443') {
// 本地测试环境
return `${protocol}//${hostname}:${wsPort}/ws?_=${Date.now()}`;
} else {
// 生产环境可能通过nginx反向代理
// 如果使用反向代理通常会将WebSocket映射到特定路径
// 例如: /ws -> localhost:3001
return `${protocol}//${hostname}/ws-api?_=${Date.now()}`;
}
};
// 连接WebSocket
const connect = useCallback(() => {
// 防止重复连接
if (wsRef.current?.readyState === WebSocket.OPEN || isConnectingRef.current) {
console.log('🚫 防止重复连接 - 当前状态:', {
readyState: wsRef.current?.readyState,
isConnecting: isConnectingRef.current,
timestamp: new Date().toISOString()
});
return;
}
// 清理之前的定时器
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current);
keepAliveIntervalRef.current = null;
}
// 关闭任何现有连接
if (wsRef.current) {
try {
wsRef.current.close();
} catch (e) {
// 忽略关闭错误
}
}
isConnectingRef.current = true;
setConnectionStatus('connecting');
const wsUrl = getWebSocketUrl();
try {
console.log(`🔄 [${instanceIdRef.current}] 正在连接 WebSocket:`, wsUrl);
wsRef.current = new WebSocket(wsUrl);
// 设置超时处理
const connectionTimeout = setTimeout(() => {
if (wsRef.current && wsRef.current.readyState !== WebSocket.OPEN) {
console.warn('WebSocket 连接超时,正在关闭...');
wsRef.current.close();
}
}, 10000); // 10秒超时
wsRef.current.onopen = () => {
clearTimeout(connectionTimeout);
isConnectingRef.current = false; // 重置连接标志
console.log(`✅ [${instanceIdRef.current}] WebSocket 连接成功:`, wsUrl);
setIsConnected(true);
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
// 发送用户连接消息
const authInfo = getAuthInfoFromBrowserCookie();
if (authInfo && authInfo.username) {
sendMessage({
type: 'user_connect',
data: { userId: authInfo.username },
timestamp: Date.now(),
});
console.log(`📤 [${instanceIdRef.current}] 已发送用户连接消息:`, authInfo.username);
}
// 清理之前的保持活动定时器(如果存在)
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current);
}
// 设置保持活动的定期消息
keepAliveIntervalRef.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
// console.log('已发送保持活动消息');
} else {
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current);
keepAliveIntervalRef.current = null;
}
}
}, 25000); // 每25秒发送一次
optionsRef.current.onConnect?.();
};
wsRef.current.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
console.log('收到 WebSocket 消息:', message);
optionsRef.current.onMessage?.(message);
} catch (error) {
console.error('解析 WebSocket 消息错误:', error);
}
};
wsRef.current.onclose = (event) => {
console.log(`❌ [${instanceIdRef.current}] WebSocket 断开连接:`, event.code, event.reason);
isConnectingRef.current = false; // 重置连接标志
setIsConnected(false);
setConnectionStatus('disconnected');
// 清理保持活动定时器
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current);
keepAliveIntervalRef.current = null;
}
// 关闭代码含义解释
let closeReason = '';
switch (event.code) {
case 1000:
closeReason = '正常关闭';
break;
case 1001:
closeReason = '离开页面';
break;
case 1002:
closeReason = '协议错误';
break;
case 1003:
closeReason = '不支持的数据类型';
break;
case 1005:
closeReason = '未提供关闭代码';
break;
case 1006:
closeReason = '异常关闭'; // 通常表示连接突然中断
break;
case 1007:
closeReason = '无效的数据';
break;
case 1008:
closeReason = '违反策略';
break;
case 1009:
closeReason = '消息过大';
break;
case 1010:
closeReason = '客户端要求扩展';
break;
case 1011:
closeReason = '服务器内部错误';
break;
case 1012:
closeReason = '服务重启';
break;
case 1013:
closeReason = '服务器临时问题';
break;
case 1015:
closeReason = 'TLS握手失败';
break;
default:
closeReason = '未知原因';
}
console.log(`WebSocket 关闭原因: ${closeReason}`);
optionsRef.current.onDisconnect?.();
// 自动重连(除非是正常关闭)
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
// 增加最小延迟时间,避免太频繁的重连
const baseDelay = 2000; // 最小2秒
const delay = Math.max(baseDelay, Math.min(Math.pow(2, reconnectAttemptsRef.current) * 1000, 30000)); // 指数退避最少2秒最多30秒
console.log(`准备重新连接,等待 ${delay / 1000} 秒... (尝试 ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`);
// 清除之前的重连定时器
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
console.log(`正在尝试重新连接... (尝试 ${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
connect();
}, delay);
}
};
wsRef.current.onerror = (error) => {
console.error('WebSocket 错误:', error);
isConnectingRef.current = false; // 重置连接标志
optionsRef.current.onError?.(error);
setConnectionStatus('disconnected');
};
} catch (error) {
console.error(`❌ [${instanceIdRef.current}] 创建 WebSocket 连接失败:`, error);
isConnectingRef.current = false; // 重置连接标志
setConnectionStatus('disconnected');
// 如果是在开发环境,给出更友好的错误提示
if (process.env.NODE_ENV === 'development') {
console.log('💡 开发环境WebSocket连接失败请检查');
console.log(' 1. WebSocket服务器是否已启动 (pnpm dev:ws)');
console.log(' 2. 端口3001是否被占用');
console.log(' 3. 防火墙是否阻止连接');
}
}
}, []); // 空依赖项数组,因为我们使用 optionsRef 避免了依赖问题
// 断开连接
const disconnect = () => {
console.log(`🔌 [${instanceIdRef.current}] 执行断开连接`);
// 重置连接状态标志
isConnectingRef.current = false;
// 清除所有计时器
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current);
keepAliveIntervalRef.current = null;
}
if (wsRef.current) {
wsRef.current.close(1000, 'User disconnected');
wsRef.current = null;
}
setIsConnected(false);
setConnectionStatus('disconnected');
};
// 发送消息
const sendMessage = (message: WebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
console.log('通过 WebSocket 发送消息:', message);
return true;
} else {
console.warn('WebSocket 未连接,无法发送消息:', message);
return false;
}
};
// 监听enabled状态变化动态连接或断开
useEffect(() => {
const enabled = options.enabled ?? true; // 默认启用
if (enabled) {
console.log(`🎯 [${instanceIdRef.current}] WebSocket 已启用,开始连接`);
connect();
} else {
console.log(`⏸️ [${instanceIdRef.current}] WebSocket 已禁用,断开现有连接`);
disconnect();
}
return () => {
console.log(`🧹 [${instanceIdRef.current}] WebSocket effect 清理,断开连接`);
disconnect();
};
}, [options.enabled, connect]); // 监听 enabled 状态变化
return {
isConnected,
connectionStatus,
sendMessage,
connect,
disconnect,
};
}

View File

@ -16,6 +16,16 @@ export interface AdminConfig {
DoubanImageProxy: string; DoubanImageProxy: string;
DisableYellowFilter: boolean; DisableYellowFilter: boolean;
FluidSearch: boolean; FluidSearch: boolean;
RequireDeviceCode: boolean;
CustomTheme?: {
selectedTheme: string;
customCSS: string;
};
};
ThemeConfig?: {
defaultTheme: 'default' | 'minimal' | 'warm' | 'fresh';
customCSS: string;
allowUserCustomization: boolean;
}; };
UserConfig: { UserConfig: {
Users: { Users: {

View File

@ -11,8 +11,54 @@ export interface ChangelogEntry {
export const changelog: ChangelogEntry[] = [ export const changelog: ChangelogEntry[] = [
{ {
version: "8.8.8", version: "8.9.5",
date: "2025-09-21",
added: [
"添加内置主题支持用户自定义CSS"
],
changed: [
"优化搜索页面缓存机制"
],
fixed: [
"镜像健康检查问题",
"弹幕功能适配移动端"
]
},
{
version: "8.9.0",
date: "2025-09-15", date: "2025-09-15",
added: [
"机器识别码设定开关",
"配置文件去重添加",
"视频源编辑",
"单个视频源进行有效性检测"
],
changed: [
"聊天页面适配移动端"
],
fixed: [
"弹幕发送问题",
"播放页测速问题",
"测速问题"
]
},
{
version: "8.8.9",
date: "2025-09-14",
added: [
"聊天,好友等功能",
"支持arm架构镜像"
],
changed: [
],
fixed: [
"播放页面500问题"
]
},
{
version: "8.8.8",
date: "2025-09-12",
added: [ added: [
"短剧类目聚合", "短剧类目聚合",
"支持短剧类目搜索", "支持短剧类目搜索",

View File

@ -80,15 +80,30 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
(adminConfig.SourceConfig || []).map((s) => [s.key, s]) (adminConfig.SourceConfig || []).map((s) => [s.key, s])
); );
// 用于跟踪已存在的API地址避免重复
const existingApiUrls = new Set(
Array.from(currentApiSites.values()).map(s => s.api.toLowerCase().trim())
);
apiSitesFromFile.forEach(([key, site]) => { apiSitesFromFile.forEach(([key, site]) => {
const existingSource = currentApiSites.get(key); const existingSource = currentApiSites.get(key);
const normalizedApiUrl = site.api.toLowerCase().trim();
if (existingSource) { if (existingSource) {
// 如果已存在,只覆盖 name、api、detail 和 from // 如果已存在,只覆盖 name、api、detail 和 from
existingSource.name = site.name; existingSource.name = site.name;
existingSource.api = site.api; existingSource.api = site.api;
existingSource.detail = site.detail; existingSource.detail = site.detail;
existingSource.from = 'config'; existingSource.from = 'config';
// 更新API地址记录
existingApiUrls.add(normalizedApiUrl);
} else { } else {
// 检查API地址是否已存在
if (existingApiUrls.has(normalizedApiUrl)) {
console.warn(`跳过重复的API地址: ${site.api} (key: ${key})`);
return; // 跳过重复的API地址
}
// 如果不存在,创建新条目 // 如果不存在,创建新条目
currentApiSites.set(key, { currentApiSites.set(key, {
key, key,
@ -98,6 +113,7 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
from: 'config', from: 'config',
disabled: false, disabled: false,
}); });
existingApiUrls.add(normalizedApiUrl);
} }
}); });
@ -226,6 +242,8 @@ async function getInitConfig(configFile: string, subConfig: {
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
FluidSearch: FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
RequireDeviceCode:
process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
}, },
UserConfig: { UserConfig: {
Users: [], Users: [],
@ -339,6 +357,37 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
adminConfig.LiveConfig = []; adminConfig.LiveConfig = [];
} }
// 确保 SiteConfig 及其属性存在
if (!adminConfig.SiteConfig) {
adminConfig.SiteConfig = {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV',
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: 7200,
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
RequireDeviceCode: process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
};
}
// 确保 RequireDeviceCode 属性存在
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
adminConfig.SiteConfig.RequireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
}
// 确保 ThemeConfig 存在
if (!adminConfig.ThemeConfig) {
adminConfig.ThemeConfig = {
defaultTheme: 'default',
customCSS: '',
allowUserCustomization: true,
};
}
// 站长变更自检 // 站长变更自检
const ownerUser = process.env.USERNAME; const ownerUser = process.env.USERNAME;
@ -477,3 +526,7 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
export async function setCachedConfig(config: AdminConfig) { export async function setCachedConfig(config: AdminConfig) {
cachedConfig = config; cachedConfig = config;
} }
export function clearCachedConfig() {
cachedConfig = undefined as any;
}

View File

@ -3,7 +3,7 @@
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import { KvrocksStorage } from './kvrocks.db'; import { KvrocksStorage } from './kvrocks.db';
import { RedisStorage } from './redis.db'; import { RedisStorage } from './redis.db';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
import { UpstashRedisStorage } from './upstash.db'; import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage' // storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
@ -15,6 +15,249 @@ const STORAGE_TYPE =
| 'kvrocks' | 'kvrocks'
| undefined) || 'localstorage'; | undefined) || 'localstorage';
// 简化的内存存储实现用于localstorage模式
class MemoryStorage implements IStorage {
private data: { [key: string]: any } = {};
// 聊天相关方法的基本实现
async saveMessage(message: ChatMessage): Promise<void> {
const key = `message:${message.id}`;
this.data[key] = message;
// 更新对话的消息列表
const messagesKey = `conversation_messages:${message.conversation_id}`;
if (!this.data[messagesKey]) {
this.data[messagesKey] = [];
}
this.data[messagesKey].push(message.id);
}
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
const messagesKey = `conversation_messages:${conversationId}`;
const messageIds = this.data[messagesKey] || [];
// 获取消息并按时间排序
const messages: ChatMessage[] = [];
for (const messageId of messageIds) {
const message = this.data[`message:${messageId}`];
if (message) {
messages.push(message);
}
}
messages.sort((a, b) => a.timestamp - b.timestamp);
return messages.slice(offset, offset + limit);
}
async markMessageAsRead(messageId: string): Promise<void> {
const key = `message:${messageId}`;
if (this.data[key]) {
this.data[key].is_read = true;
}
}
async getConversations(userName: string): Promise<Conversation[]> {
const userConversationsKey = `user_conversations:${userName}`;
const conversationIds = this.data[userConversationsKey] || [];
const conversations: Conversation[] = [];
for (const conversationId of conversationIds) {
const conversation = await this.getConversation(conversationId);
if (conversation) {
conversations.push(conversation);
}
}
return conversations.sort((a, b) => b.updated_at - a.updated_at);
}
async getConversation(conversationId: string): Promise<Conversation | null> {
const key = `conversation:${conversationId}`;
return this.data[key] || null;
}
async createConversation(conversation: Conversation): Promise<void> {
const key = `conversation:${conversation.id}`;
this.data[key] = conversation;
// 添加到每个参与者的对话列表
for (const participant of conversation.participants) {
const userConversationsKey = `user_conversations:${participant}`;
if (!this.data[userConversationsKey]) {
this.data[userConversationsKey] = [];
}
if (!this.data[userConversationsKey].includes(conversation.id)) {
this.data[userConversationsKey].push(conversation.id);
}
}
}
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
const key = `conversation:${conversationId}`;
if (this.data[key]) {
Object.assign(this.data[key], updates);
}
}
async deleteConversation(conversationId: string): Promise<void> {
const conversation = await this.getConversation(conversationId);
if (conversation) {
// 从每个参与者的对话列表中移除
for (const participant of conversation.participants) {
const userConversationsKey = `user_conversations:${participant}`;
if (this.data[userConversationsKey]) {
this.data[userConversationsKey] = this.data[userConversationsKey].filter(
(id: string) => id !== conversationId
);
}
}
// 删除对话本身
delete this.data[`conversation:${conversationId}`];
// 删除相关消息
const messagesKey = `conversation_messages:${conversationId}`;
const messageIds = this.data[messagesKey] || [];
for (const messageId of messageIds) {
delete this.data[`message:${messageId}`];
}
delete this.data[messagesKey];
}
}
// 好友相关方法的基本实现
async getFriends(userName: string): Promise<Friend[]> {
const key = `user_friends:${userName}`;
return this.data[key] || [];
}
async createFriend(friendship: { user1: string; user2: string; created_at: number }): Promise<void> {
// 双向添加好友关系
const user1FriendsKey = `user_friends:${friendship.user1}`;
const user2FriendsKey = `user_friends:${friendship.user2}`;
if (!this.data[user1FriendsKey]) this.data[user1FriendsKey] = [];
if (!this.data[user2FriendsKey]) this.data[user2FriendsKey] = [];
// 为user1添加user2作为好友
if (!this.data[user1FriendsKey].some((f: Friend) => f.username === friendship.user2)) {
this.data[user1FriendsKey].push({
id: `friend_${Date.now()}_1`,
username: friendship.user2,
nickname: friendship.user2,
status: 'offline' as const,
added_at: friendship.created_at
});
}
// 为user2添加user1作为好友
if (!this.data[user2FriendsKey].some((f: Friend) => f.username === friendship.user1)) {
this.data[user2FriendsKey].push({
id: `friend_${Date.now()}_2`,
username: friendship.user1,
nickname: friendship.user1,
status: 'offline' as const,
added_at: friendship.created_at
});
}
}
async deleteFriend(friendId: string): Promise<void> {
// 简化实现
}
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
const key = `user_friend_requests:${userName}`;
return this.data[key] || [];
}
async createFriendRequest(request: FriendRequest): Promise<void> {
const key = `user_friend_requests:${request.to_user}`;
if (!this.data[key]) {
this.data[key] = [];
}
this.data[key].push(request);
}
async updateFriendRequest(requestId: string, status: 'pending' | 'accepted' | 'rejected'): Promise<void> {
// 查找并更新好友请求
for (const key in this.data) {
if (key.startsWith('user_friend_requests:')) {
const requests = this.data[key];
const requestIndex = requests.findIndex((r: FriendRequest) => r.id === requestId);
if (requestIndex !== -1) {
requests[requestIndex].status = status;
requests[requestIndex].updated_at = Date.now();
break;
}
}
}
}
async deleteFriendRequest(requestId: string): Promise<void> {
// 查找并删除好友请求
for (const key in this.data) {
if (key.startsWith('user_friend_requests:')) {
const requests = this.data[key];
const requestIndex = requests.findIndex((r: FriendRequest) => r.id === requestId);
if (requestIndex !== -1) {
requests.splice(requestIndex, 1);
break;
}
}
}
}
// 搜索用户(基本实现)
async searchUsers(query: string): Promise<Friend[]> {
// 返回一些模拟用户用于测试
const mockUsers: Friend[] = [
{ id: 'user1', username: 'test1', nickname: 'Test User 1', status: 'offline' as const, added_at: Date.now() },
{ id: 'user2', username: 'test2', nickname: 'Test User 2', status: 'offline' as const, added_at: Date.now() },
{ id: 'user3', username: 'admin', nickname: 'Admin User', status: 'offline' as const, added_at: Date.now() },
];
return mockUsers.filter(user =>
user.username.toLowerCase().includes(query.toLowerCase()) ||
user.nickname?.toLowerCase().includes(query.toLowerCase())
);
}
// 其他必需的方法存根
async getPlayRecord(): Promise<PlayRecord | null> { return null; }
async setPlayRecord(): Promise<void> { }
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> { return {}; }
async deletePlayRecord(): Promise<void> { }
async getFavorite(): Promise<Favorite | null> { return null; }
async setFavorite(): Promise<void> { }
async getAllFavorites(): Promise<{ [key: string]: Favorite }> { return {}; }
async deleteFavorite(): Promise<void> { }
async registerUser(): Promise<void> { }
async verifyUser(): Promise<boolean> { return true; }
async checkUser(): Promise<boolean> { return true; }
async checkUserExist(): Promise<boolean> { return true; }
async changePassword(): Promise<void> { }
async deleteUser(): Promise<void> { }
async getSearchHistory(): Promise<string[]> { return []; }
async addSearchHistory(): Promise<void> { }
async deleteSearchHistory(): Promise<void> { }
async clearSearchHistory(): Promise<void> { }
async getSearchHistoryCount(): Promise<number> { return 0; }
async getSkipConfigs(): Promise<SkipConfig[]> { return []; }
async getSkipConfig(): Promise<SkipConfig | null> { return null; }
async setSkipConfig(): Promise<void> { }
async deleteSkipConfig(): Promise<void> { }
async getAdminConfig(): Promise<AdminConfig> { return {} as AdminConfig; }
async setAdminConfig(): Promise<void> { }
async getAllUsers(): Promise<string[]> { return []; }
async getAllSkipConfigs(): Promise<{ [key: string]: SkipConfig }> { return {}; }
async clearAllData(): Promise<void> { this.data = {}; }
async addFriend(): Promise<void> { }
async removeFriend(): Promise<void> { }
async updateFriend(): Promise<void> { }
async updateFriendStatus(): Promise<void> { }
}
// 创建存储实例 // 创建存储实例
function createStorage(): IStorage { function createStorage(): IStorage {
switch (STORAGE_TYPE) { switch (STORAGE_TYPE) {
@ -26,7 +269,8 @@ function createStorage(): IStorage {
return new KvrocksStorage(); return new KvrocksStorage();
case 'localstorage': case 'localstorage':
default: default:
return null as unknown as IStorage; console.log('使用内存存储模式(用于开发和测试)');
return new MemoryStorage();
} }
} }
@ -311,6 +555,120 @@ export class DbManager {
return null; return null;
} }
// ---------- 聊天功能 ----------
// 消息管理
async saveMessage(message: ChatMessage): Promise<void> {
if (typeof (this.storage as any).saveMessage === 'function') {
await (this.storage as any).saveMessage(message);
}
}
async getMessages(conversationId: string, limit?: number, offset?: number): Promise<ChatMessage[]> {
if (typeof (this.storage as any).getMessages === 'function') {
return (this.storage as any).getMessages(conversationId, limit, offset);
}
return [];
}
async markMessageAsRead(messageId: string): Promise<void> {
if (typeof (this.storage as any).markMessageAsRead === 'function') {
await (this.storage as any).markMessageAsRead(messageId);
}
}
// 对话管理
async getConversations(userName: string): Promise<Conversation[]> {
if (typeof (this.storage as any).getConversations === 'function') {
return (this.storage as any).getConversations(userName);
}
return [];
}
async getConversation(conversationId: string): Promise<Conversation | null> {
if (typeof (this.storage as any).getConversation === 'function') {
return (this.storage as any).getConversation(conversationId);
}
return null;
}
async createConversation(conversation: Conversation): Promise<void> {
if (typeof (this.storage as any).createConversation === 'function') {
await (this.storage as any).createConversation(conversation);
}
}
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
if (typeof (this.storage as any).updateConversation === 'function') {
await (this.storage as any).updateConversation(conversationId, updates);
}
}
async deleteConversation(conversationId: string): Promise<void> {
if (typeof (this.storage as any).deleteConversation === 'function') {
await (this.storage as any).deleteConversation(conversationId);
}
}
// 好友管理
async getFriends(userName: string): Promise<Friend[]> {
if (typeof (this.storage as any).getFriends === 'function') {
return (this.storage as any).getFriends(userName);
}
return [];
}
async addFriend(userName: string, friend: Friend): Promise<void> {
if (typeof (this.storage as any).addFriend === 'function') {
await (this.storage as any).addFriend(userName, friend);
}
}
async removeFriend(userName: string, friendId: string): Promise<void> {
if (typeof (this.storage as any).removeFriend === 'function') {
await (this.storage as any).removeFriend(userName, friendId);
}
}
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
if (typeof (this.storage as any).updateFriendStatus === 'function') {
await (this.storage as any).updateFriendStatus(friendId, status);
}
}
// 好友申请管理
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
if (typeof (this.storage as any).getFriendRequests === 'function') {
return (this.storage as any).getFriendRequests(userName);
}
return [];
}
async createFriendRequest(request: FriendRequest): Promise<void> {
if (typeof (this.storage as any).createFriendRequest === 'function') {
await (this.storage as any).createFriendRequest(request);
}
}
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
if (typeof (this.storage as any).updateFriendRequest === 'function') {
await (this.storage as any).updateFriendRequest(requestId, status);
}
}
async deleteFriendRequest(requestId: string): Promise<void> {
if (typeof (this.storage as any).deleteFriendRequest === 'function') {
await (this.storage as any).deleteFriendRequest(requestId);
}
}
// 用户搜索
async searchUsers(query: string): Promise<Friend[]> {
if (typeof (this.storage as any).searchUsers === 'function') {
return (this.storage as any).searchUsers(query);
}
return [];
}
// ---------- 数据清理 ---------- // ---------- 数据清理 ----------
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
if (typeof (this.storage as any).clearAllData === 'function') { if (typeof (this.storage as any).clearAllData === 'function') {

View File

@ -3,7 +3,7 @@
import { createClient, RedisClientType } from 'redis'; import { createClient, RedisClientType } from 'redis';
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
// 搜索历史最大条数 // 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20; const SEARCH_HISTORY_LIMIT = 20;
@ -599,6 +599,319 @@ export abstract class BaseRedisStorage implements IStorage {
return val ? ensureString(val) : null; return val ? ensureString(val) : null;
} }
// ---------- 聊天功能 ----------
// 私有键生成方法
private messageKey(messageId: string) {
return `msg:${messageId}`;
}
private conversationKey(conversationId: string) {
return `conv:${conversationId}`;
}
private conversationMessagesKey(conversationId: string) {
return `conv:${conversationId}:messages`;
}
private userConversationsKey(userName: string) {
return `u:${userName}:conversations`;
}
private userFriendsKey(userName: string) {
return `u:${userName}:friends`;
}
private userFriendRequestsKey(userName: string) {
return `u:${userName}:friend_requests`;
}
private friendKey(friendId: string) {
return `friend:${friendId}`;
}
private friendRequestKey(requestId: string) {
return `friend_req:${requestId}`;
}
// 消息管理
async saveMessage(message: ChatMessage): Promise<void> {
// 保存消息详情
await this.withRetry(() =>
this.client.set(this.messageKey(message.id), JSON.stringify(message))
);
// 将消息ID添加到对话的消息列表中按时间排序
await this.withRetry(() =>
this.client.zAdd(this.conversationMessagesKey(message.conversation_id), {
score: message.timestamp,
value: message.id
})
);
}
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
// 从有序集合中获取消息ID列表按时间倒序
const messageIds = await this.withRetry(() =>
this.client.zRange(this.conversationMessagesKey(conversationId), offset, offset + limit - 1, { REV: true })
);
const messages: ChatMessage[] = [];
for (const messageId of messageIds) {
const messageData = await this.withRetry(() => this.client.get(this.messageKey(messageId)));
if (messageData) {
try {
messages.push(JSON.parse(ensureString(messageData)));
} catch (error) {
console.error('Error parsing message:', error);
}
}
}
return messages.reverse(); // 返回正序消息
}
async markMessageAsRead(messageId: string): Promise<void> {
const messageData = await this.withRetry(() => this.client.get(this.messageKey(messageId)));
if (messageData) {
try {
const message = JSON.parse(ensureString(messageData));
message.is_read = true;
await this.withRetry(() =>
this.client.set(this.messageKey(messageId), JSON.stringify(message))
);
} catch (error) {
console.error('Error marking message as read:', error);
}
}
}
// 对话管理
async getConversations(userName: string): Promise<Conversation[]> {
const conversationIds = await this.withRetry(() =>
this.client.sMembers(this.userConversationsKey(userName))
);
const conversations: Conversation[] = [];
for (const conversationId of conversationIds) {
const conversation = await this.getConversation(conversationId);
if (conversation) {
conversations.push(conversation);
}
}
// 按最后更新时间排序
return conversations.sort((a, b) => b.updated_at - a.updated_at);
}
async getConversation(conversationId: string): Promise<Conversation | null> {
const conversationData = await this.withRetry(() =>
this.client.get(this.conversationKey(conversationId))
);
if (!conversationData) return null;
try {
return JSON.parse(ensureString(conversationData));
} catch (error) {
console.error('Error parsing conversation:', error);
return null;
}
}
async createConversation(conversation: Conversation): Promise<void> {
// 保存对话详情
await this.withRetry(() =>
this.client.set(this.conversationKey(conversation.id), JSON.stringify(conversation))
);
// 将对话ID添加到每个参与者的对话列表中
for (const participant of conversation.participants) {
await this.withRetry(() =>
this.client.sAdd(this.userConversationsKey(participant), conversation.id)
);
}
}
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
const conversation = await this.getConversation(conversationId);
if (conversation) {
Object.assign(conversation, updates);
await this.withRetry(() =>
this.client.set(this.conversationKey(conversationId), JSON.stringify(conversation))
);
}
}
async deleteConversation(conversationId: string): Promise<void> {
const conversation = await this.getConversation(conversationId);
if (conversation) {
// 从每个参与者的对话列表中移除
for (const participant of conversation.participants) {
await this.withRetry(() =>
this.client.sRem(this.userConversationsKey(participant), conversationId)
);
}
// 删除对话详情
await this.withRetry(() => this.client.del(this.conversationKey(conversationId)));
// 删除对话的消息列表
await this.withRetry(() => this.client.del(this.conversationMessagesKey(conversationId)));
}
}
// 好友管理
async getFriends(userName: string): Promise<Friend[]> {
const friendIds = await this.withRetry(() =>
this.client.sMembers(this.userFriendsKey(userName))
);
const friends: Friend[] = [];
for (const friendId of friendIds) {
const friendData = await this.withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
try {
friends.push(JSON.parse(ensureString(friendData)));
} catch (error) {
console.error('Error parsing friend:', error);
}
}
}
return friends.sort((a, b) => b.added_at - a.added_at);
}
async addFriend(userName: string, friend: Friend): Promise<void> {
// 保存好友详情
await this.withRetry(() =>
this.client.set(this.friendKey(friend.id), JSON.stringify(friend))
);
// 将好友ID添加到用户的好友列表中
await this.withRetry(() =>
this.client.sAdd(this.userFriendsKey(userName), friend.id)
);
}
async removeFriend(userName: string, friendId: string): Promise<void> {
// 从用户的好友列表中移除
await this.withRetry(() =>
this.client.sRem(this.userFriendsKey(userName), friendId)
);
// 删除好友详情
await this.withRetry(() => this.client.del(this.friendKey(friendId)));
}
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
const friendData = await this.withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
try {
const friend = JSON.parse(ensureString(friendData));
friend.status = status;
await this.withRetry(() =>
this.client.set(this.friendKey(friendId), JSON.stringify(friend))
);
} catch (error) {
console.error('Error updating friend status:', error);
}
}
}
// 好友申请管理
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
const requestIds = await this.withRetry(() =>
this.client.sMembers(this.userFriendRequestsKey(userName))
);
const requests: FriendRequest[] = [];
for (const requestId of requestIds) {
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = JSON.parse(ensureString(requestData));
// 只返回相关的申请(发送给该用户的或该用户发送的)
if (request.to_user === userName || request.from_user === userName) {
requests.push(request);
}
} catch (error) {
console.error('Error parsing friend request:', error);
}
}
}
return requests.sort((a, b) => b.created_at - a.created_at);
}
async createFriendRequest(request: FriendRequest): Promise<void> {
// 保存申请详情
await this.withRetry(() =>
this.client.set(this.friendRequestKey(request.id), JSON.stringify(request))
);
// 将申请ID添加到双方的申请列表中
await this.withRetry(() =>
this.client.sAdd(this.userFriendRequestsKey(request.from_user), request.id)
);
await this.withRetry(() =>
this.client.sAdd(this.userFriendRequestsKey(request.to_user), request.id)
);
}
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = JSON.parse(ensureString(requestData));
request.status = status;
request.updated_at = Date.now();
await this.withRetry(() =>
this.client.set(this.friendRequestKey(requestId), JSON.stringify(request))
);
} catch (error) {
console.error('Error updating friend request:', error);
}
}
}
async deleteFriendRequest(requestId: string): Promise<void> {
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = JSON.parse(ensureString(requestData));
// 从双方的申请列表中移除
await this.withRetry(() =>
this.client.sRem(this.userFriendRequestsKey(request.from_user), requestId)
);
await this.withRetry(() =>
this.client.sRem(this.userFriendRequestsKey(request.to_user), requestId)
);
} catch (error) {
console.error('Error deleting friend request:', error);
}
}
// 删除申请详情
await this.withRetry(() => this.client.del(this.friendRequestKey(requestId)));
}
// 用户搜索
async searchUsers(query: string): Promise<Friend[]> {
const allUsers = await this.getAllUsers();
const matchedUsers = allUsers.filter(username =>
username.toLowerCase().includes(query.toLowerCase())
);
// 转换为Friend格式返回
return matchedUsers.map(username => ({
id: username,
username,
status: 'offline' as const,
added_at: 0,
}));
}
// 清空所有数据 // 清空所有数据
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
try { try {

View File

@ -123,3 +123,54 @@ export interface SkipConfig {
intro_time: number; // 片头时间(秒) intro_time: number; // 片头时间(秒)
outro_time: number; // 片尾时间(秒) outro_time: number; // 片尾时间(秒)
} }
// 聊天消息数据结构
export interface ChatMessage {
id: string;
conversation_id: string;
sender_id: string;
sender_name: string;
content: string;
message_type: 'text' | 'image' | 'file';
timestamp: number;
is_read: boolean;
}
// 对话数据结构
export interface Conversation {
id: string;
name: string;
participants: string[];
type: 'private' | 'group';
created_at: number;
updated_at: number;
last_message?: ChatMessage;
is_group?: boolean;
}
// 好友数据结构
export interface Friend {
id: string;
username: string;
nickname?: string;
status: 'online' | 'offline';
added_at: number;
}
// 好友申请数据结构
export interface FriendRequest {
id: string;
from_user: string;
to_user: string;
message?: string;
status: 'pending' | 'accepted' | 'rejected';
created_at: number;
updated_at: number;
}
// WebSocket 消息类型
export interface WebSocketMessage {
type: 'message' | 'friend_request' | 'friend_accepted' | 'user_status' | 'online_users' | 'connection_confirmed' | 'user_connect' | 'ping' | 'pong';
data?: any;
timestamp: number;
}

View File

@ -3,7 +3,7 @@
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis';
import { AdminConfig } from './admin.types'; import { AdminConfig } from './admin.types';
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
// 搜索历史最大条数 // 搜索历史最大条数
const SEARCH_HISTORY_LIMIT = 20; const SEARCH_HISTORY_LIMIT = 20;
@ -72,7 +72,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() => const val = await withRetry(() =>
this.client.get(this.prKey(userName, key)) this.client.get(this.prKey(userName, key))
); );
return val ? (val as PlayRecord) : null; if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as PlayRecord;
} catch (error) {
console.error('解析播放记录失败:', error);
return null;
}
} }
async setPlayRecord( async setPlayRecord(
@ -80,7 +87,7 @@ export class UpstashRedisStorage implements IStorage {
key: string, key: string,
record: PlayRecord record: PlayRecord
): Promise<void> { ): Promise<void> {
await withRetry(() => this.client.set(this.prKey(userName, key), record)); await withRetry(() => this.client.set(this.prKey(userName, key), JSON.stringify(record)));
} }
async getAllPlayRecords( async getAllPlayRecords(
@ -94,9 +101,14 @@ export class UpstashRedisStorage implements IStorage {
for (const fullKey of keys) { for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey)); const value = await withRetry(() => this.client.get(fullKey));
if (value) { if (value) {
try {
// 截取 source+id 部分 // 截取 source+id 部分
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
result[keyPart] = value as PlayRecord; const record = typeof value === 'string' ? JSON.parse(value) : value as PlayRecord;
result[keyPart] = record;
} catch (error) {
console.error('解析播放记录失败:', error, 'key:', fullKey);
}
} }
} }
return result; return result;
@ -115,7 +127,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() => const val = await withRetry(() =>
this.client.get(this.favKey(userName, key)) this.client.get(this.favKey(userName, key))
); );
return val ? (val as Favorite) : null; if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as Favorite;
} catch (error) {
console.error('解析收藏失败:', error);
return null;
}
} }
async setFavorite( async setFavorite(
@ -124,7 +143,7 @@ export class UpstashRedisStorage implements IStorage {
favorite: Favorite favorite: Favorite
): Promise<void> { ): Promise<void> {
await withRetry(() => await withRetry(() =>
this.client.set(this.favKey(userName, key), favorite) this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
); );
} }
@ -137,8 +156,13 @@ export class UpstashRedisStorage implements IStorage {
for (const fullKey of keys) { for (const fullKey of keys) {
const value = await withRetry(() => this.client.get(fullKey)); const value = await withRetry(() => this.client.get(fullKey));
if (value) { if (value) {
try {
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
result[keyPart] = value as Favorite; const favorite = typeof value === 'string' ? JSON.parse(value) : value as Favorite;
result[keyPart] = favorite;
} catch (error) {
console.error('解析收藏失败:', error, 'key:', fullKey);
}
} }
} }
return result; return result;
@ -270,11 +294,24 @@ export class UpstashRedisStorage implements IStorage {
async getAdminConfig(): Promise<AdminConfig | null> { async getAdminConfig(): Promise<AdminConfig | null> {
const val = await withRetry(() => this.client.get(this.adminConfigKey())); const val = await withRetry(() => this.client.get(this.adminConfigKey()));
return val ? (val as AdminConfig) : null; if (!val) return null;
try {
// 尝试解析JSON字符串兼容BaseRedisStorage格式
if (typeof val === 'string') {
return JSON.parse(val) as AdminConfig;
}
// 如果已经是对象直接返回Upstash自动反序列化的情况
return val as AdminConfig;
} catch (error) {
console.error('解析管理员配置失败:', error);
return null;
}
} }
async setAdminConfig(config: AdminConfig): Promise<void> { async setAdminConfig(config: AdminConfig): Promise<void> {
await withRetry(() => this.client.set(this.adminConfigKey(), config)); // 统一使用JSON字符串格式存储与BaseRedisStorage保持一致
await withRetry(() => this.client.set(this.adminConfigKey(), JSON.stringify(config)));
} }
// ---------- 跳过片头片尾配置 ---------- // ---------- 跳过片头片尾配置 ----------
@ -290,7 +327,14 @@ export class UpstashRedisStorage implements IStorage {
const val = await withRetry(() => const val = await withRetry(() =>
this.client.get(this.skipConfigKey(userName, source, id)) this.client.get(this.skipConfigKey(userName, source, id))
); );
return val ? (val as SkipConfig) : null; if (!val) return null;
try {
return typeof val === 'string' ? JSON.parse(val) : val as SkipConfig;
} catch (error) {
console.error('解析跳过配置失败:', error);
return null;
}
} }
async setSkipConfig( async setSkipConfig(
@ -300,7 +344,7 @@ export class UpstashRedisStorage implements IStorage {
config: SkipConfig config: SkipConfig
): Promise<void> { ): Promise<void> {
await withRetry(() => await withRetry(() =>
this.client.set(this.skipConfigKey(userName, source, id), config) this.client.set(this.skipConfigKey(userName, source, id), JSON.stringify(config))
); );
} }
@ -332,11 +376,16 @@ export class UpstashRedisStorage implements IStorage {
keys.forEach((key, index) => { keys.forEach((key, index) => {
const value = values[index]; const value = values[index];
if (value) { if (value) {
try {
// 从key中提取source+id // 从key中提取source+id
const match = key.match(/^u:.+?:skip:(.+)$/); const match = key.match(/^u:.+?:skip:(.+)$/);
if (match) { if (match) {
const sourceAndId = match[1]; const sourceAndId = match[1];
configs[sourceAndId] = value as SkipConfig; const config = typeof value === 'string' ? JSON.parse(value) : value as SkipConfig;
configs[sourceAndId] = config;
}
} catch (error) {
console.error('解析跳过配置失败:', error, 'key:', key);
} }
} }
}); });
@ -373,7 +422,16 @@ export class UpstashRedisStorage implements IStorage {
async getDanmu(videoId: string): Promise<any[]> { async getDanmu(videoId: string): Promise<any[]> {
const val = await withRetry(() => this.client.lrange(this.danmuKey(videoId), 0, -1)); const val = await withRetry(() => this.client.lrange(this.danmuKey(videoId), 0, -1));
return val ? val.map(item => JSON.parse(ensureString(item))) : []; if (!val || !Array.isArray(val)) return [];
return val.map(item => {
try {
return typeof item === 'string' ? JSON.parse(item) : item;
} catch (error) {
console.error('解析弹幕数据失败:', error);
return null;
}
}).filter(item => item !== null);
} }
async saveDanmu(videoId: string, userName: string, danmu: { async saveDanmu(videoId: string, userName: string, danmu: {
@ -425,9 +483,11 @@ export class UpstashRedisStorage implements IStorage {
if (!val) return null; if (!val) return null;
try { try {
const data = JSON.parse(ensureString(val)); // 处理不同的序列化格式
const data = typeof val === 'string' ? JSON.parse(val) : val;
return data.machineCode || null; return data.machineCode || null;
} catch { } catch (error) {
console.error('解析用户机器码失败:', error);
return null; return null;
} }
} }
@ -439,7 +499,7 @@ export class UpstashRedisStorage implements IStorage {
bindTime: Date.now() bindTime: Date.now()
}; };
// 保存用户的机器码 // 保存用户的机器码 - 统一使用JSON序列化
await withRetry(() => await withRetry(() =>
this.client.set(this.machineCodeKey(userName), JSON.stringify(data)) this.client.set(this.machineCodeKey(userName), JSON.stringify(data))
); );
@ -481,10 +541,11 @@ export class UpstashRedisStorage implements IStorage {
if (val) { if (val) {
try { try {
const data = JSON.parse(ensureString(val)); // 处理不同的序列化格式
const data = typeof val === 'string' ? JSON.parse(val) : val;
result[userName] = data; result[userName] = data;
} catch { } catch (error) {
// 忽略解析错误 console.error('解析机器码用户数据失败:', error, 'key:', key);
} }
} }
} }
@ -500,6 +561,321 @@ export class UpstashRedisStorage implements IStorage {
return val ? ensureString(val) : null; return val ? ensureString(val) : null;
} }
// ---------- 聊天功能 ----------
// 私有键生成方法
private messageKey(messageId: string) {
return `msg:${messageId}`;
}
private conversationKey(conversationId: string) {
return `conv:${conversationId}`;
}
private conversationMessagesKey(conversationId: string) {
return `conv:${conversationId}:messages`;
}
private userConversationsKey(userName: string) {
return `u:${userName}:conversations`;
}
private userFriendsKey(userName: string) {
return `u:${userName}:friends`;
}
private userFriendRequestsKey(userName: string) {
return `u:${userName}:friend_requests`;
}
private friendKey(friendId: string) {
return `friend:${friendId}`;
}
private friendRequestKey(requestId: string) {
return `friend_req:${requestId}`;
}
// 消息管理
async saveMessage(message: ChatMessage): Promise<void> {
// 保存消息详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.messageKey(message.id), JSON.stringify(message))
);
// 将消息ID添加到对话的消息列表中按时间排序
await withRetry(() =>
this.client.zadd(this.conversationMessagesKey(message.conversation_id), {
score: message.timestamp,
member: message.id
})
);
}
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
// 从有序集合中获取消息ID列表按时间倒序
const messageIds = await withRetry(() =>
this.client.zrange(this.conversationMessagesKey(conversationId), offset, offset + limit - 1, { rev: true })
);
const messages: ChatMessage[] = [];
for (const messageId of messageIds) {
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId as string)));
if (messageData) {
try {
const message = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
messages.push(message as ChatMessage);
} catch (error) {
console.error('解析消息失败:', error);
}
}
}
return messages.reverse(); // 返回正序消息
}
async markMessageAsRead(messageId: string): Promise<void> {
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId)));
if (messageData) {
try {
const message = typeof messageData === 'string' ? JSON.parse(messageData) : messageData as ChatMessage;
message.is_read = true;
await withRetry(() =>
this.client.set(this.messageKey(messageId), JSON.stringify(message))
);
} catch (error) {
console.error('标记消息为已读失败:', error);
}
}
}
// 对话管理
async getConversations(userName: string): Promise<Conversation[]> {
const conversationIds = await withRetry(() =>
this.client.smembers(this.userConversationsKey(userName))
);
const conversations: Conversation[] = [];
for (const conversationId of conversationIds) {
const conversation = await this.getConversation(conversationId);
if (conversation) {
conversations.push(conversation);
}
}
// 按最后更新时间排序
return conversations.sort((a, b) => b.updated_at - a.updated_at);
}
async getConversation(conversationId: string): Promise<Conversation | null> {
const conversationData = await withRetry(() =>
this.client.get(this.conversationKey(conversationId))
);
if (!conversationData) return null;
try {
return typeof conversationData === 'string' ? JSON.parse(conversationData) : conversationData as Conversation;
} catch (error) {
console.error('解析对话失败:', error);
return null;
}
}
async createConversation(conversation: Conversation): Promise<void> {
// 保存对话详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.conversationKey(conversation.id), JSON.stringify(conversation))
);
// 将对话ID添加到每个参与者的对话列表中
for (const participant of conversation.participants) {
await withRetry(() =>
this.client.sadd(this.userConversationsKey(participant), conversation.id)
);
}
}
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
const conversation = await this.getConversation(conversationId);
if (conversation) {
Object.assign(conversation, updates);
await withRetry(() =>
this.client.set(this.conversationKey(conversationId), JSON.stringify(conversation))
);
}
}
async deleteConversation(conversationId: string): Promise<void> {
const conversation = await this.getConversation(conversationId);
if (conversation) {
// 从每个参与者的对话列表中移除
for (const participant of conversation.participants) {
await withRetry(() =>
this.client.srem(this.userConversationsKey(participant), conversationId)
);
}
// 删除对话详情
await withRetry(() => this.client.del(this.conversationKey(conversationId)));
// 删除对话的消息列表
await withRetry(() => this.client.del(this.conversationMessagesKey(conversationId)));
}
}
// 好友管理
async getFriends(userName: string): Promise<Friend[]> {
const friendIds = await withRetry(() =>
this.client.smembers(this.userFriendsKey(userName))
);
const friends: Friend[] = [];
for (const friendId of friendIds) {
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
try {
const friend = typeof friendData === 'string' ? JSON.parse(friendData) : friendData;
friends.push(friend as Friend);
} catch (error) {
console.error('解析好友数据失败:', error);
}
}
}
return friends.sort((a, b) => b.added_at - a.added_at);
}
async addFriend(userName: string, friend: Friend): Promise<void> {
// 保存好友详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.friendKey(friend.id), JSON.stringify(friend))
);
// 将好友ID添加到用户的好友列表中
await withRetry(() =>
this.client.sadd(this.userFriendsKey(userName), friend.id)
);
}
async removeFriend(userName: string, friendId: string): Promise<void> {
// 从用户的好友列表中移除
await withRetry(() =>
this.client.srem(this.userFriendsKey(userName), friendId)
);
// 删除好友详情
await withRetry(() => this.client.del(this.friendKey(friendId)));
}
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
if (friendData) {
try {
const friend = typeof friendData === 'string' ? JSON.parse(friendData) : friendData as Friend;
friend.status = status;
await withRetry(() =>
this.client.set(this.friendKey(friendId), JSON.stringify(friend))
);
} catch (error) {
console.error('更新好友状态失败:', error);
}
}
}
// 好友申请管理
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
const requestIds = await withRetry(() =>
this.client.smembers(this.userFriendRequestsKey(userName))
);
const requests: FriendRequest[] = [];
for (const requestId of requestIds) {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
// 只返回相关的申请(发送给该用户的或该用户发送的)
if (request.to_user === userName || request.from_user === userName) {
requests.push(request);
}
} catch (error) {
console.error('解析好友申请失败:', error);
}
}
}
return requests.sort((a, b) => b.created_at - a.created_at);
}
async createFriendRequest(request: FriendRequest): Promise<void> {
// 保存申请详情 - 使用JSON序列化
await withRetry(() =>
this.client.set(this.friendRequestKey(request.id), JSON.stringify(request))
);
// 将申请ID添加到双方的申请列表中
await withRetry(() =>
this.client.sadd(this.userFriendRequestsKey(request.from_user), request.id)
);
await withRetry(() =>
this.client.sadd(this.userFriendRequestsKey(request.to_user), request.id)
);
}
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
request.status = status;
request.updated_at = Date.now();
await withRetry(() =>
this.client.set(this.friendRequestKey(requestId), JSON.stringify(request))
);
} catch (error) {
console.error('更新好友申请失败:', error);
}
}
}
async deleteFriendRequest(requestId: string): Promise<void> {
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
if (requestData) {
try {
const request = typeof requestData === 'string' ? JSON.parse(requestData) : requestData as FriendRequest;
// 从双方的申请列表中移除
await withRetry(() =>
this.client.srem(this.userFriendRequestsKey(request.from_user), requestId)
);
await withRetry(() =>
this.client.srem(this.userFriendRequestsKey(request.to_user), requestId)
);
} catch (error) {
console.error('删除好友申请失败:', error);
}
}
// 删除申请详情
await withRetry(() => this.client.del(this.friendRequestKey(requestId)));
}
// 用户搜索
async searchUsers(query: string): Promise<Friend[]> {
const allUsers = await this.getAllUsers();
const matchedUsers = allUsers.filter(username =>
username.toLowerCase().includes(query.toLowerCase())
);
// 转换为Friend格式返回
return matchedUsers.map(username => ({
id: username,
username,
status: 'offline' as const,
added_at: 0,
}));
}
// 清空所有数据 // 清空所有数据
async clearAllData(): Promise<void> { async clearAllData(): Promise<void> {
try { try {

View File

@ -1,6 +1,6 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
const CURRENT_VERSION = '8.8.8'; const CURRENT_VERSION = '8.9.5';
// 导出当前版本号供其他地方使用 // 导出当前版本号供其他地方使用
export { CURRENT_VERSION }; export { CURRENT_VERSION };

View File

@ -2,7 +2,7 @@
'use client'; 'use client';
import { CURRENT_VERSION } from "@/lib/version"; import { CURRENT_VERSION } from '@/lib/version';
// 版本检查结果枚举 // 版本检查结果枚举
export enum UpdateStatus { export enum UpdateStatus {
@ -13,7 +13,7 @@ export enum UpdateStatus {
// 远程版本检查URL配置 // 远程版本检查URL配置
const VERSION_CHECK_URLS = [ const VERSION_CHECK_URLS = [
'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt', 'https://raw.githubusercontent.com/djteang/OrangeTV/refs/heads/main/VERSION.txt',
]; ];
/** /**
@ -89,13 +89,15 @@ async function fetchVersionFromUrl(url: string): Promise<string | null> {
*/ */
export function compareVersions(remoteVersion: string): UpdateStatus { export function compareVersions(remoteVersion: string): UpdateStatus {
// 如果版本号相同,无需更新 // 如果版本号相同,无需更新
if ('8.8.8' === CURRENT_VERSION) { if (remoteVersion === CURRENT_VERSION) {
return UpdateStatus.NO_UPDATE; return UpdateStatus.NO_UPDATE;
} }
try { try {
// 解析版本号为数字数组 [X, Y, Z] // 解析版本号为数字数组 [X, Y, Z]
const currentParts = (CURRENT_VERSION as string).split('.').map((part: string) => { const currentParts = (CURRENT_VERSION as string)
.split('.')
.map((part: string) => {
const num = parseInt(part, 10); const num = parseInt(part, 10);
if (isNaN(num) || num < 0) { if (isNaN(num) || num < 0) {
throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`); throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`);

View File

@ -115,4 +115,41 @@
.animated-underline:focus-visible { .animated-underline:focus-visible {
background-size: 0 2px, 100% 2px; background-size: 0 2px, 100% 2px;
} }
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
transition: all 0.2s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .custom-scrollbar {
scrollbar-color: #64748b transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #64748b;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #475569;
}
} }

237
standalone-websocket.js Normal file
View File

@ -0,0 +1,237 @@
/**
* 独立的WebSocket服务器
* 完全独立于Next.js避免任何冲突
*/
const WebSocket = require('ws');
// 存储已连接的用户
const connectedUsers = new Map();
// 创建独立的WebSocket服务器使用不同的端口
function createStandaloneWebSocketServer(port = 3001) {
const wss = new WebSocket.Server({
port: port,
perMessageDeflate: false,
clientTracking: true
});
console.log(`独立WebSocket服务器已启动在端口 ${port}`);
// 连接事件处理
wss.on('connection', (ws, req) => {
console.log('新的 WebSocket 连接');
let userId = null;
// 设置心跳检测
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
// 消息处理
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, message);
} catch (error) {
console.error('解析 WebSocket 消息错误:', error);
ws.send(JSON.stringify({
type: 'error',
data: { message: '消息格式无效' },
timestamp: Date.now()
}));
}
});
// 关闭连接处理
ws.on('close', () => {
if (userId) {
connectedUsers.delete(userId);
// 广播用户离线状态
broadcastUserStatus(userId, 'offline');
console.log(`用户 ${userId} 已断开连接`);
}
});
// 错误处理
ws.on('error', (error) => {
console.error(`WebSocket 错误 ${userId ? `(用户: ${userId})` : ''}:`, error);
});
// 消息处理函数
function handleMessage(ws, message) {
switch (message.type) {
case 'ping':
// 响应客户端的心跳检测
ws.send(JSON.stringify({
type: 'pong',
timestamp: Date.now()
}));
break;
case 'user_connect':
userId = message.data.userId;
connectedUsers.set(userId, ws);
console.log(`用户 ${userId} 已连接`);
// 确认连接成功
ws.send(JSON.stringify({
type: 'connection_confirmed',
data: { userId },
timestamp: Date.now()
}));
// 广播用户在线状态
broadcastUserStatus(userId, 'online');
// 发送在线用户列表给新连接的用户
ws.send(JSON.stringify({
type: 'online_users',
data: { users: Array.from(connectedUsers.keys()) },
timestamp: Date.now()
}));
break;
case 'message':
// 转发消息给对话参与者
if (message.data.participants && Array.isArray(message.data.participants)) {
message.data.participants.forEach(participantId => {
// 不发送给自己
if (participantId !== userId && connectedUsers.has(participantId)) {
const participantWs = connectedUsers.get(participantId);
if (participantWs && participantWs.readyState === WebSocket.OPEN) {
participantWs.send(JSON.stringify(message));
}
}
});
}
// 兼容旧版本的receiverId方式
else if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
const receiverWs = connectedUsers.get(message.data.receiverId);
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
receiverWs.send(JSON.stringify(message));
}
}
break;
case 'typing':
// 转发打字状态给目标用户
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
const receiverWs = connectedUsers.get(message.data.receiverId);
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
receiverWs.send(JSON.stringify(message));
}
}
break;
case 'friend_request':
// 转发好友申请给目标用户
const targetUser = message.data.to_user;
if (targetUser && connectedUsers.has(targetUser)) {
const targetWs = connectedUsers.get(targetUser);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify(message));
}
}
break;
case 'friend_accepted':
// 转发好友接受消息给申请发起人
const fromUser = message.data.from_user;
if (fromUser && connectedUsers.has(fromUser)) {
const fromUserWs = connectedUsers.get(fromUser);
if (fromUserWs && fromUserWs.readyState === WebSocket.OPEN) {
fromUserWs.send(JSON.stringify(message));
}
}
break;
}
}
});
// 心跳检测定时器
const heartbeatInterval = setInterval(() => {
let activeConnections = 0;
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log('检测到无响应的连接,正在终止...');
return ws.terminate();
}
ws.isAlive = false;
// 发送ping
try {
ws.ping(() => { });
activeConnections++;
} catch (error) {
console.error('发送ping失败:', error);
}
});
if (activeConnections > 0) {
console.log(`心跳检测: 活跃连接数: ${activeConnections}`);
}
}, 30000);
// 关闭服务器时清理定时器
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
return wss;
}
// 广播用户状态
function broadcastUserStatus(userId, status) {
const statusMessage = {
type: 'user_status',
data: { userId, status },
timestamp: Date.now()
};
connectedUsers.forEach((ws, connectedUserId) => {
if (connectedUserId !== userId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(statusMessage));
}
});
}
// 获取在线用户列表
function getOnlineUsers() {
return Array.from(connectedUsers.keys());
}
// 发送消息给特定用户
function sendMessageToUser(userId, message) {
const ws = connectedUsers.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
return true;
}
return false;
}
// 发送消息给多个用户
function sendMessageToUsers(userIds, message) {
let success = false;
userIds.forEach(userId => {
if (sendMessageToUser(userId, message)) {
success = true;
}
});
return success;
}
// 如果直接运行此文件启动WebSocket服务器
if (require.main === module) {
const port = process.env.WS_PORT || 3001;
createStandaloneWebSocketServer(port);
}
module.exports = {
createStandaloneWebSocketServer,
getOnlineUsers,
sendMessageToUser,
sendMessageToUsers,
broadcastUserStatus
};

View File

@ -20,18 +20,30 @@ const config: Config = {
}, },
colors: { colors: {
primary: { primary: {
50: '#f0f9ff', 50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: '#e0f2fe', 100: 'rgb(var(--color-primary-100) / <alpha-value>)',
200: '#bae6fd', 200: 'rgb(var(--color-primary-200) / <alpha-value>)',
300: '#7dd3fc', 300: 'rgb(var(--color-primary-300) / <alpha-value>)',
400: '#38bdf8', 400: 'rgb(var(--color-primary-400) / <alpha-value>)',
500: '#0ea5e9', 500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: '#0284c7', 600: 'rgb(var(--color-primary-600) / <alpha-value>)',
700: '#0369a1', 700: 'rgb(var(--color-primary-700) / <alpha-value>)',
800: '#075985', 800: 'rgb(var(--color-primary-800) / <alpha-value>)',
900: '#0c4a6e', 900: 'rgb(var(--color-primary-900) / <alpha-value>)',
}, },
dark: '#222222', dark: 'rgb(var(--color-dark) / <alpha-value>)',
// 主题颜色系统
'theme-bg': 'rgb(var(--color-theme-bg) / <alpha-value>)',
'theme-surface': 'rgb(var(--color-theme-surface) / <alpha-value>)',
'theme-accent': 'rgb(var(--color-theme-accent) / <alpha-value>)',
'theme-text': 'rgb(var(--color-theme-text) / <alpha-value>)',
'theme-text-secondary': 'rgb(var(--color-theme-text-secondary) / <alpha-value>)',
'theme-border': 'rgb(var(--color-theme-border) / <alpha-value>)',
// 扩展主题颜色
'theme-success': 'rgb(var(--color-theme-success) / <alpha-value>)',
'theme-warning': 'rgb(var(--color-theme-warning) / <alpha-value>)',
'theme-error': 'rgb(var(--color-theme-error) / <alpha-value>)',
'theme-info': 'rgb(var(--color-theme-info) / <alpha-value>)',
}, },
keyframes: { keyframes: {
flicker: { flicker: {

View File

@ -41,5 +41,10 @@
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
],
"moduleResolution": [
"node_modules",
".next",
"node"
] ]
} }

203
websocket.js Normal file
View File

@ -0,0 +1,203 @@
// WebSocket 服务器独立实现
const WebSocket = require('ws');
// 存储已连接的用户
const connectedUsers = new Map();
// 创建 WebSocket 服务器
function createWebSocketServer(server) {
const wss = new WebSocket.Server({
noServer: true // 使用 noServer 模式,手动处理升级请求
});
console.log('WebSocket 服务器已初始化');
// 连接事件处理
wss.on('connection', (ws, req) => {
console.log('新的 WebSocket 连接');
let userId = null;
// 设置心跳检测
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
// 消息处理
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, message);
} catch (error) {
console.error('解析 WebSocket 消息错误:', error);
ws.send(JSON.stringify({
type: 'error',
data: { message: '消息格式无效' },
timestamp: Date.now()
}));
}
});
// 关闭连接处理
ws.on('close', () => {
if (userId) {
connectedUsers.delete(userId);
// 广播用户离线状态
broadcastUserStatus(userId, 'offline');
console.log(`用户 ${userId} 已断开连接`);
}
});
// 错误处理
ws.on('error', (error) => {
console.error(`WebSocket 错误 ${userId ? `(用户: ${userId})` : ''}:`, error);
});
// 消息处理函数
function handleMessage(ws, message) {
switch (message.type) {
case 'ping':
// 响应客户端的心跳检测
ws.send(JSON.stringify({
type: 'pong',
timestamp: Date.now()
}));
break;
case 'user_connect':
userId = message.data.userId;
connectedUsers.set(userId, ws);
console.log(`用户 ${userId} 已连接`);
// 确认连接成功
ws.send(JSON.stringify({
type: 'connection_confirmed',
data: { userId },
timestamp: Date.now()
}));
// 广播用户在线状态
broadcastUserStatus(userId, 'online');
// 发送在线用户列表给新连接的用户
ws.send(JSON.stringify({
type: 'online_users',
data: { users: Array.from(connectedUsers.keys()) },
timestamp: Date.now()
}));
break;
case 'message':
// 转发消息给目标用户
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
const receiverWs = connectedUsers.get(message.data.receiverId);
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
receiverWs.send(JSON.stringify(message));
}
}
break;
case 'typing':
// 转发打字状态给目标用户
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
const receiverWs = connectedUsers.get(message.data.receiverId);
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
receiverWs.send(JSON.stringify(message));
}
}
break;
case 'friend_request':
case 'friend_accepted':
// 转发好友相关消息
if (message.data.targetUserId && connectedUsers.has(message.data.targetUserId)) {
const targetWs = connectedUsers.get(message.data.targetUserId);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify(message));
}
}
break;
}
}
});
// 心跳检测定时器
const heartbeatInterval = setInterval(() => {
let activeConnections = 0;
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log('检测到无响应的连接,正在终止...');
return ws.terminate();
}
ws.isAlive = false;
// 发送ping使用noop回调
try {
ws.ping(() => { });
activeConnections++;
} catch (error) {
console.error('发送ping失败:', error);
}
});
if (activeConnections > 0) {
console.log(`心跳检测: 活跃连接数: ${activeConnections}`);
}
}, 30000);
// 关闭服务器时清理定时器
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
return wss;
}
// 广播用户状态
function broadcastUserStatus(userId, status) {
const statusMessage = {
type: 'user_status',
data: { userId, status },
timestamp: Date.now()
};
connectedUsers.forEach((ws, connectedUserId) => {
if (connectedUserId !== userId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(statusMessage));
}
});
}
// 获取在线用户列表
function getOnlineUsers() {
return Array.from(connectedUsers.keys());
}
// 发送消息给特定用户
function sendMessageToUser(userId, message) {
const ws = connectedUsers.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
return true;
}
return false;
}
// 发送消息给多个用户
function sendMessageToUsers(userIds, message) {
let success = false;
userIds.forEach(userId => {
if (sendMessageToUser(userId, message)) {
success = true;
}
});
return success;
}
module.exports = {
createWebSocketServer,
getOnlineUsers,
sendMessageToUser,
sendMessageToUsers,
broadcastUserStatus
};