refactor: replace next with vite fastify runtime

This commit is contained in:
leowang 2026-06-04 11:14:19 +08:00
parent 6d1ada87b8
commit 7dc3db8baa
144 changed files with 5725 additions and 4481 deletions

View File

@ -1,2 +1,7 @@
.env .env
.env*.local .env*.local
.git
dist
node_modules
npm-debug.log*
pnpm-debug.log*

View File

@ -4,21 +4,44 @@ module.exports = {
es2021: true, es2021: true,
node: true, node: true,
}, },
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'], plugins: [
'@typescript-eslint',
'react',
'react-hooks',
'simple-import-sort',
'unused-imports',
],
extends: [ extends: [
'eslint:recommended', 'eslint:recommended',
'next', 'plugin:react/recommended',
'next/core-web-vitals', 'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'prettier', 'prettier',
], ],
settings: {
react: {
version: 'detect',
},
},
rules: { rules: {
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'no-console': 'warn', 'no-console': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-case-declarations': 'off',
'react/no-unescaped-entities': 'off', 'react/no-unescaped-entities': 'off',
'no-empty': 'off',
'no-useless-escape': 'off',
'prefer-const': 'off',
'react/display-name': 'off', 'react/display-name': 'off',
'react/prop-types': 'off',
'react-hooks/immutability': 'off',
'react-hooks/purity': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react/jsx-curly-brace-presence': [ 'react/jsx-curly-brace-presence': [
'warn', 'warn',
{ props: 'never', children: 'never' }, { props: 'never', children: 'never' },

9
.gitignore vendored
View File

@ -8,12 +8,12 @@
# testing # testing
/coverage /coverage
# next.js # static export
/.next/
/out/ /out/
# production # production
/build /build
/dist
# misc # misc
.DS_Store .DS_Store
@ -34,12 +34,11 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts
# next-sitemap # generated sitemap
sitemap.xml sitemap.xml
sitemap-*.xml sitemap-*.xml
# generated files # generated files
src/lib/runtime.ts src/lib/runtime.ts
public/manifest.json public/manifest.json

1
.npmrc
View File

@ -0,0 +1 @@
engine-strict=true

2
.nvmrc
View File

@ -1 +1 @@
v20.10.0 v24.14.1

View File

@ -1,143 +1,82 @@
# 多架构构建 Dockerfile
# 使用 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 .
# 声明构建参数,用于多架构构建
ARG BUILDPLATFORM ARG BUILDPLATFORM
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG NODE_VERSION=24
ARG PNPM_VERSION=10.14.0
# ---- 第 1 阶段:安装依赖 ---- FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS deps
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
# 启用 corepack 并激活 pnpm ARG PNPM_VERSION
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app WORKDIR /app
# 先复制所有文件 COPY package.json pnpm-lock.yaml ./
COPY . . ENV HUSKY=0
# 然后检查文件
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 阶段:构建项目 ---- FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate ARG PNPM_VERSION
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app WORKDIR /app
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
# 复制全部源代码
COPY . . COPY . .
ENV DOCKER_ENV=true ENV DOCKER_ENV=true
RUN pnpm build
# 生成生产构建 FROM node:${NODE_VERSION}-alpine AS runner
RUN pnpm run build
# ---- 第 3 阶段:生成运行时镜像 ---- ARG PNPM_VERSION
FROM node:20-alpine AS runner RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S orangetv -G nodejs
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV PORT=3000 ENV PORT=3000
ENV DOCKER_ENV=true ENV DOCKER_ENV=true
# 从构建器中复制 standalone 输出 COPY package.json pnpm-lock.yaml ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ RUN pnpm install --prod --frozen-lockfile --ignore-scripts && pnpm store prune
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制启动脚本和WebSocket相关文件
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 目录
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
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依赖兼容多架构 COPY --from=builder --chown=orangetv:nodejs /app/dist ./dist
USER root COPY --from=builder --chown=orangetv:nodejs /app/public ./public
RUN corepack enable && corepack prepare pnpm@latest --activate && \
# 使用 --no-optional 避免某些架构下的可选依赖问题
pnpm install --prod --no-optional ws && \
# 清理安装缓存减小镜像大小
pnpm store prune
# 创建健康检查脚本在切换用户之前以root权限创建 RUN cat > /app/healthcheck.js <<'EOF'
RUN echo '#!/usr/bin/env node\n\ const http = require('http');
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
# 切回非特权用户 const req = http.request(
USER nextjs {
hostname: 'localhost',
port: Number(process.env.PORT || 3000),
path: '/api/health',
method: 'GET',
timeout: 5000,
},
(res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
}
);
# 暴露HTTP和WebSocket端口 req.on('error', () => process.exit(1));
EXPOSE 3000 3001 req.on('timeout', () => {
req.destroy();
process.exit(1);
});
req.end();
EOF
# 添加健康检查 RUN chown orangetv:nodejs /app/healthcheck.js
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
USER orangetv
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD node /app/healthcheck.js CMD node /app/healthcheck.js
# 设置WebSocket端口环境变量 CMD ["node", "dist/server/index.js"]
ENV WS_PORT=3001
# 使用最终的生产环境脚本分离WebSocket服务
CMD ["node", "production-final.js"]

View File

@ -4,13 +4,14 @@
<img src="public/logo.png" alt="OrangeTV Logo" width="120"> <img src="public/logo.png" alt="OrangeTV Logo" width="120">
</div> </div>
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind&nbsp;CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。 > 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Vite + React**、**Fastify**、**Tailwind&nbsp;CSS** 和 **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
<div align="center"> <div align="center">
![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs) ![Vite](https://img.shields.io/badge/Vite-React-646cff?logo=vite)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss) ![Fastify](https://img.shields.io/badge/Fastify-API-000?logo=fastify)
![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript) ![TailwindCSS](https://img.shields.io/badge/TailwindCSS-4-38bdf8?logo=tailwindcss)
![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178c6?logo=typescript)
![License](https://img.shields.io/badge/License-MIT-green) ![License](https://img.shields.io/badge/License-MIT-green)
![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker) ![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker)
@ -42,6 +43,7 @@
## 🗺 目录 ## 🗺 目录
- [技术栈](#技术栈) - [技术栈](#技术栈)
- [本地开发环境](#本地开发环境)
- [部署](#部署) - [部署](#部署)
- [配置文件](#配置文件) - [配置文件](#配置文件)
- [自动更新](#自动更新) - [自动更新](#自动更新)
@ -56,13 +58,20 @@
| 分类 | 主要依赖 | | 分类 | 主要依赖 |
| --------- | ----------------------------------------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------------------------------------- |
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router | | 前端框架 | [Vite](https://vite.dev/) · [React Router](https://reactrouter.com/) |
| UI & 样式 | [Tailwind&nbsp;CSS 3](https://tailwindcss.com/) | | 后端 | [Fastify](https://fastify.dev/) |
| 语言 | TypeScript 4 | | UI & 样式 | [Tailwind&nbsp;CSS 4](https://tailwindcss.com/) |
| 语言 | TypeScript 5 |
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) | | 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
| 代码质量 | ESLint · Prettier · Jest | | 代码质量 | ESLint · Prettier · Jest |
| 部署 | Docker | | 部署 | Docker |
## 本地开发环境
- Node.js`v24.14.1`,仓库通过 `.nvmrc` 固定,并只支持 Node 24.x。
- pnpm`10.14.0`,通过 `packageManager`、`engines` 和 `.npmrc` 校验。
- 建议先运行 `nvm use`,再运行 `corepack enable && corepack prepare pnpm@10.14.0 --activate`,然后执行 `pnpm install`
## 部署 ## 部署
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。 本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
@ -77,11 +86,10 @@ services:
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks - VITE_STORAGE_TYPE=kvrocks
- KVROCKS_URL=redis://OrangeTV-kvrocks:6666 - KVROCKS_URL=redis://OrangeTV-kvrocks:6666
networks: networks:
- OrangeTV-network - OrangeTV-network
@ -112,11 +120,10 @@ services:
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=redis - VITE_STORAGE_TYPE=redis
- REDIS_URL=redis://OrangeTV-redis:6379 - REDIS_URL=redis://OrangeTV-redis:6379
networks: networks:
- OrangeTV-network - OrangeTV-network
@ -149,11 +156,10 @@ services:
restart: on-failure restart: on-failure
ports: ports:
- '3000:3000' - '3000:3000'
- '3001:3001'
environment: environment:
- USERNAME=admin - USERNAME=admin
- PASSWORD=orange - PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=upstash - VITE_STORAGE_TYPE=upstash
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT - UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
- UPSTASH_TOKEN=上面的 TOKEN - UPSTASH_TOKEN=上面的 TOKEN
``` ```
@ -218,37 +224,37 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | | USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | | PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
| SITE_BASE | 站点 url | 形如 https://example.com | 空 | | SITE_BASE | 站点 url | 形如 https://example.com | 空 |
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV | | VITE_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | | ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 | | VITE_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 | | KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 | | REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | | UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | | UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | | VITE_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | | VITE_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | | VITE_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | | VITE_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | | VITE_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | | VITE_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | | VITE_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: VITE_DOUBAN_PROXY_TYPE 选项解释:
- direct: 由服务器直接请求豆瓣源站 - direct: 由服务器直接请求豆瓣源站
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建 - cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 - cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 - cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxyNEXT_PUBLIC_DOUBAN_PROXY 定义 - custom: 用户自定义 proxyVITE_DOUBAN_PROXY 定义
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释: VITE_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
- direct由浏览器直接请求豆瓣分配的默认图片域名 - direct由浏览器直接请求豆瓣分配的默认图片域名
- server由服务器代理请求豆瓣分配的默认图片域名 - server由服务器代理请求豆瓣分配的默认图片域名
- img3由浏览器请求豆瓣官方的精品 cdn阿里云 - img3由浏览器请求豆瓣官方的精品 cdn阿里云
- cmliussss-cdn-tencent由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 - cmliussss-cdn-tencent由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 - cmliussss-cdn-ali由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxyNEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义 - custom: 用户自定义 proxyVITE_DOUBAN_IMAGE_PROXY 定义
## AndroidTV 使用 ## AndroidTV 使用
@ -282,7 +288,7 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
## 致谢 ## 致谢
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。 - OrangeTV has moved to a pure Vite/Fastify runtime while preserving the original product flows.
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。 - [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
- [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。 - [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。
- [艾福森昵] - 感谢论坛佬友提供的短剧API - [艾福森昵] - 感谢论坛佬友提供的短剧API

View File

@ -8,7 +8,7 @@ Chosen direction: `A2` cinematic lounge
OrangeTV will be redesigned around the `A2` direction: a sharper, warmer, projection-inspired interface that treats the app like a screening room rather than a generic streaming dashboard. OrangeTV will be redesigned around the `A2` direction: a sharper, warmer, projection-inspired interface that treats the app like a screening room rather than a generic streaming dashboard.
The redesign will not introduce a new information architecture or a new framework. It will keep the current Next.js 14 + Tailwind CSS v3 stack, preserve existing product flows, and refactor the visual system through a new design token layer and updated component styling. The redesign will not introduce a new information architecture or a new framework. It will keep the current React + Tailwind CSS stack, preserve existing product flows, and refactor the visual system through a new design token layer and updated component styling.
The visual reset is defined by these decisions: The visual reset is defined by these decisions:

View File

@ -0,0 +1,251 @@
# Vite/Fastify Refactor Handoff
Last updated: 2026-06-04
## Current State
The repo has been refactored from a Next.js compatibility migration into a Vite React + TypeScript SPA served by a Fastify runtime.
The intended architecture is now:
- Client: Vite, React 19, TypeScript, React Router, Tailwind CSS 4.
- Server: Fastify on port `3000`, serving both API routes and the SPA on the same origin.
- Dev: Fastify starts the app and mounts Vite middleware for client assets/HMR.
- Production: `pnpm build` writes `dist/client` and `dist/server`, then `pnpm start` runs `dist/server/index.js`.
- Toolchain: Node `v24.14.1` and pnpm `10.14.0`.
- Docker: official `node:24-alpine` image family with pnpm activated through Corepack.
The repo is still dirty and not committed. Treat this as a handoff snapshot before creating the first refactor commit.
## What Is Good
- Next.js runtime/build dependencies have been removed from production code.
- Client Next imports have been replaced with app-native React/Vite equivalents:
- Router behavior uses `react-router-dom` wrappers.
- Image usage no longer depends on `next/image`.
- Theme behavior no longer depends on `next-themes`.
- API routes have been moved to Fastify route modules under `src/server/routes`.
- The generated Next route adapter path has been removed from the runtime direction.
- Fastify handles SPA serving, API registration, auth guards, cookies, static assets, and runtime config.
- React Compiler is configured through the Vite React plugin path using `@vitejs/plugin-react`, `@rolldown/plugin-babel`, and `babel-plugin-react-compiler`.
- Node tooling is now pinned consistently:
- `.nvmrc` is `v24.14.1`.
- `package.json` enforces `node >=24 <25` and `pnpm 10.14.0`.
- `.npmrc` has `engine-strict=true`.
- Docker uses `node:24-alpine`.
- Server bundling targets `node24`.
- Search SSE completion was fixed:
- Shortdrama is included as an invoked async source.
- Completion reaches the expected source count.
- The header text clarifies source progress instead of implying visible card count.
- Aggregated search can still show fewer cards than source count by design.
- Search result cover badges and search-history pill close button were adjusted visually.
- The exact player URL that previously failed was manually confirmed working during the session:
- `http://localhost:3000/play?title=%E6%9C%A8%E4%B9%83%E4%BC%8A&year=2026&stype=movie&source=maotaizy&id=129378`
## Verified
These checks were run with Node `v24.14.1` and pnpm `10.14.0` unless noted.
```bash
node -v
# v24.14.1
pnpm -v
# 10.14.0
```
```bash
pnpm typecheck
# passed
```
```bash
pnpm lint
# passed with warnings
# 0 errors, 287 warnings
```
```bash
pnpm test --runInBand
# passed
# 13 test suites, 30 tests
```
Important: with pnpm `10.14.0`, use `pnpm test --runInBand`. The older-looking form `pnpm test -- --runInBand` forwarded `--` into Jest and caused Jest to treat `--runInBand` as a test pattern.
```bash
pnpm build
# passed
```
```bash
rg "v20|node20|Node 20|node:20|apk add --no-cache nodejs" . \
--glob '!node_modules/**' \
--glob '!dist/**'
# no matches
```
```bash
docker build -t orangetv-refactor-smoke:local .
# passed
```
Docker build details:
- Pulled and used `node:24-alpine`.
- Activated `pnpm@10.14.0` in Docker stages.
- Installed dependencies with the lockfile.
- Ran in-container `pnpm build`.
- Exported image as `orangetv-refactor-smoke:local`.
## Verified Earlier In The Refactor
These checks were done before the final Node 24 pin and are still useful context:
- API route parity check found 63 old app API routes and 63 new Fastify route paths.
- No missing route paths were identified in that comparison.
- No method mismatches were identified in that comparison.
- Static Next production-reference scans found no remaining production `next/*` imports.
- Local dev with Redis was used during debugging.
- Search route and player route issues were reproduced and fixed against the running dev server.
## Known Warnings
`pnpm lint` currently passes but reports 287 warnings. The warning categories are mostly:
- import sorting
- `no-console`
- explicit `any`
- React hook dependency warnings
- unused imports/vars
- non-null assertions
`pnpm test --runInBand` passes but Jest prints an open-handle warning after completion:
- `Jest did not exit one second after the test run has completed.`
`pnpm build` and Docker build pass but include expected build warnings:
- `/runtime-config.js` script in `index.html` cannot be bundled without `type="module"`.
- `tailwind.config.ts` is reparsed as ESM because `package.json` does not declare `"type": "module"`.
- `artplayer-plugin-danmuku` uses CommonJS `module` inside an ESM bundle.
- Some chunks are larger than 500 kB.
- `hls.js` and `artplayer` dynamic imports are ineffective because they are also imported statically elsewhere.
- `@rolldown/plugin-babel` dominates build plugin time.
- Browserslist data is stale.
These warnings existed as non-blocking issues during verification. They should not block the first refactor commit unless release standards require warning cleanup.
## Remaining Gaps
These are the main gaps to address before calling the refactor fully production-ready.
1. Docker runtime smoke was not completed.
- The image builds.
- The container has not yet been run and checked via `/api/health`, login, player page, and admin page.
2. Production runtime smoke is still limited.
- `pnpm build` passes.
- A fresh `pnpm start` smoke with HTTP checks was not run after the final Node 24 pin.
3. Chat/WebSocket parity needs focused review.
- HTTP chat routes exist in the Fastify route tree.
- Full WebSocket behavior should be compared against the old deployment expectations before claiming chat parity.
4. Lint warning debt remains large.
- Lint exits successfully, but the warning count is high.
- A follow-up cleanup pass should decide which warnings are acceptable and which should become errors.
5. Jest open-handle warning remains.
- Tests pass, but async cleanup should be investigated before tightening CI.
6. Bundle-size and import-splitting warnings remain.
- Player-related libraries are large and currently defeat some dynamic imports.
- This is not a correctness blocker, but it is relevant for performance.
7. Browser smoke should be repeated after the final commit candidate.
- Prior manual checks were useful, but a final pass should cover search, detail, play, favorites, play records, admin config, and theme save/load.
8. Git hygiene needs review before commit.
- The worktree includes broad refactor changes plus Node 24 pinning.
- `docs/superpowers/specs/2026-04-09-orangetv-a2-design.md` was already modified outside this handoff task and should be reviewed before staging.
## Important Commands
Local dev:
```bash
pnpm dev
```
Local dev with Redis:
```bash
pnpm dev:redis
```
Production build:
```bash
pnpm build
```
Production start:
```bash
pnpm start
```
Full local verification set:
```bash
node -v
pnpm -v
pnpm typecheck
pnpm lint
pnpm test --runInBand
pnpm build
docker build -t orangetv-refactor-smoke:local .
```
Static checks worth keeping:
```bash
rg "next/|NextRequest|NextResponse|next-env|next.config|eslint-config-next|next-themes|next-pwa" . \
--glob '!node_modules/**' \
--glob '!dist/**'
rg "v20|node20|Node 20|node:20|apk add --no-cache nodejs" . \
--glob '!node_modules/**' \
--glob '!dist/**'
```
## Suggested Next Steps
1. Run the Docker image and smoke it:
```bash
docker run --rm -p 3000:3000 \
-e USERNAME=admin \
-e PASSWORD=orange \
-e VITE_STORAGE_TYPE=redis \
-e REDIS_URL=redis://host.docker.internal:6379 \
orangetv-refactor-smoke:local
```
Then check:
- `GET http://localhost:3000/api/health`
- login as `admin` / `orange` if those env vars are used
- `/search?q=%E6%AD%8C%E6%89%8B2026`
- the known player URL
- `/admin`
2. Run a final browser smoke on `pnpm dev:redis`.
3. Review chat/WebSocket behavior explicitly.
4. Decide whether to clean warnings now or track them as follow-up issues.
5. Stage only the intended refactor files, then create the first refactor commit.

44
index.html Normal file
View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="description" content="影视聚合" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<script src="/runtime-config.js"></script>
<script>
(function () {
try {
const cachedTheme = localStorage.getItem('theme-cache');
if (!cachedTheme) return;
const themeConfig = JSON.parse(cachedTheme);
const html = document.documentElement;
html.removeAttribute('data-theme');
if (themeConfig.defaultTheme && themeConfig.defaultTheme !== 'default') {
html.setAttribute('data-theme', themeConfig.defaultTheme);
}
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;
}
} catch (error) {
localStorage.removeItem('theme-cache');
}
})();
</script>
<title>OrangeTV</title>
</head>
<body class="min-h-[100dvh] bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

View File

@ -1,32 +1,25 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires module.exports = {
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'], moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom', testEnvironment: 'jest-environment-jsdom',
transform: {
/** '^.+\\.(ts|tsx)$': [
* Absolute imports and Module Path Aliases 'babel-jest',
*/ {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
],
},
],
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/dist/'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
moduleNameMapper: { moduleNameMapper: {
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx', '^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1', '^~/(.*)$': '<rootDir>/public/$1',
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx', '^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
}, },
modulePathIgnorePatterns: ['<rootDir>/.next/'],
}; };
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

View File

@ -1,5 +1 @@
import '@testing-library/jest-dom/extend-expect'; require('@testing-library/jest-dom/extend-expect');
// Allow router mocks.
// eslint-disable-next-line no-undef
jest.mock('next/router', () => require('next-router-mock'));

View File

@ -1,91 +0,0 @@
/** @type {import('next').NextConfig} */
/* eslint-disable @typescript-eslint/no-var-requires */
const nextConfig = {
output: 'standalone',
eslint: {
dirs: ['src'],
ignoreDuringBuilds: true, // 始终在构建时忽略 ESLint 错误
},
reactStrictMode: false,
// Uncoment to add domain whitelist
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg')
);
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: { not: /\.(css|scss|sass)$/ },
resourceQuery: { not: /url/ }, // exclude if *.svg?url
loader: '@svgr/webpack',
options: {
dimensions: false,
titleProp: true,
},
}
);
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
// 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,
net: false,
tls: false,
crypto: false,
};
return config;
},
};
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
});
module.exports = withPWA(nextConfig);

View File

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

View File

@ -1,72 +1,27 @@
# Nginx配置示例用于生产环境反向代理 # Nginx production reverse proxy example.
# 将此文件放置在 /etc/nginx/sites-available/ 并创建符号链接到 sites-enabled/
upstream nextjs_app { upstream orangetv_app {
server localhost:3000; server localhost:3000;
} }
upstream websocket_app {
server localhost:3001;
}
server { server {
listen 80; listen 80;
server_name your-domain.com; server_name your-domain.com;
# 如果使用HTTPS取消下面的注释并配置SSL证书 # If using HTTPS, uncomment and configure certificates.
# listen 443 ssl; # listen 443 ssl;
# ssl_certificate /path/to/ssl/cert.pem; # ssl_certificate /path/to/ssl/cert.pem;
# ssl_certificate_key /path/to/ssl/key.pem; # ssl_certificate_key /path/to/ssl/key.pem;
# 增加请求体大小限制
client_max_body_size 100M; client_max_body_size 100M;
# Next.js应用的主要路由
location / { location / {
proxy_pass http://nextjs_app; proxy_pass http://orangetv_app;
proxy_http_version 1.1; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# 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,17 +3,13 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm gen:manifest && node simple-dev.js", "dev": "pnpm gen:manifest && tsx watch src/server/index.ts",
"dev:redis": "node scripts/dev-with-redis.js", "dev:redis": "node scripts/dev-with-redis.js",
"dev:complex": "pnpm gen:manifest && node dev-server.js", "dev:complex": "pnpm dev",
"dev:ws": "node standalone-websocket.js",
"test:ws": "node test-websocket-connection.js",
"debug:api": "node debug-api.js", "debug:api": "node debug-api.js",
"build": "pnpm gen:manifest && next build", "build": "pnpm gen:manifest && vite build && node scripts/build-server.js",
"start": "NODE_ENV=production node server.js", "start": "NODE_ENV=production node dist/server/index.js",
"prod": "NODE_ENV=production node production.js", "lint": "eslint src --ext .ts,.tsx",
"prod:final": "NODE_ENV=production node production-final.js",
"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",
"typecheck": "tsc --noEmit --incremental false", "typecheck": "tsc --noEmit --incremental false",
@ -30,6 +26,9 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/middie": "^9.3.2",
"@fastify/static": "^9.1.3",
"@heroui/react": "3.0.5", "@heroui/react": "3.0.5",
"@heroui/styles": "3.0.5", "@heroui/styles": "3.0.5",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@ -41,30 +40,34 @@
"bs58": "^6.0.0", "bs58": "^6.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"fastify": "^5.8.5",
"framer-motion": "^12.18.1", "framer-motion": "^12.18.1",
"he": "^1.2.0", "he": "^1.2.0",
"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": "^15.5.18",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-router-dom": "^7.15.1",
"redis": "^4.6.7", "redis": "^4.6.7",
"swiper": "^11.2.8", "swiper": "^11.2.8",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "3.2.2", "tailwind-variants": "3.2.2",
"vidstack": "^0.6.15", "vidstack": "^0.6.15",
"vite-plugin-pwa": "^1.3.0",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-react": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@commitlint/cli": "^16.3.0", "@commitlint/cli": "^16.3.0",
"@commitlint/config-conventional": "^16.2.4", "@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0", "@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
@ -72,28 +75,36 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/bs58": "^5.0.0", "@types/bs58": "^5.0.0",
"@types/he": "^1.2.3", "@types/he": "^1.2.3",
"@types/jest": "27.5.2",
"@types/node": "24.0.3", "@types/node": "24.0.3",
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-jest": "27.5.1",
"babel-plugin-react-compiler": "^1.0.0",
"esbuild": "^0.28.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "^15.5.18",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"husky": "^7.0.4", "husky": "^7.0.4",
"jest": "^27.5.1", "jest": "^27.5.1",
"lint-staged": "^12.5.0", "lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0",
"playwright": "^1.60.0", "playwright": "^1.60.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.0", "prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"tsx": "^4.22.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"undici": "^8.3.0",
"vite": "^8.0.14",
"webpack-obfuscator": "^3.5.1" "webpack-obfuscator": "^3.5.1"
}, },
"lint-staged": { "lint-staged": {
@ -105,5 +116,9 @@
"prettier -w" "prettier -w"
] ]
}, },
"packageManager": "pnpm@10.14.0" "packageManager": "pnpm@10.14.0",
"engines": {
"node": ">=24 <25",
"pnpm": "10.14.0"
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,176 +0,0 @@
/**
* 最终的生产环境启动文件
* 分离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);
}

View File

@ -1,178 +0,0 @@
/**
* 生产模式下的服务器入口
* 使用 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();
});
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -31,7 +31,7 @@ describe('dev-with-redis helpers', () => {
}); });
}); });
test('builds the Redis URL passed to the Next.js dev process', () => { test('builds the Redis URL passed to the local dev process', () => {
expect(buildRedisUrl('6380')).toBe('redis://localhost:6380'); expect(buildRedisUrl('6380')).toBe('redis://localhost:6380');
}); });

23
scripts/build-server.js Normal file
View File

@ -0,0 +1,23 @@
/* eslint-disable no-console */
const esbuild = require('esbuild');
async function main() {
await esbuild.build({
entryPoints: ['src/server/index.ts'],
outfile: 'dist/server/index.js',
bundle: true,
platform: 'node',
target: 'node24',
format: 'cjs',
sourcemap: true,
packages: 'external',
alias: {
'@': './src',
},
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -38,7 +38,7 @@ function isPortAvailable(port) {
}); });
} }
async function assertDevPortsAvailable(ports = [3000, 3001]) { async function assertDevPortsAvailable(ports = [3000]) {
const checks = await Promise.all( const checks = await Promise.all(
ports.map(async (port) => ({ ports.map(async (port) => ({
port, port,
@ -136,16 +136,16 @@ function ensureRedis(config = getRedisConfig()) {
} }
} }
function startNextDev(config = getRedisConfig()) { function startFastifyDev(config = getRedisConfig()) {
const redisUrl = buildRedisUrl(config.port); const redisUrl = buildRedisUrl(config.port);
const env = { const env = {
...process.env, ...process.env,
NEXT_PUBLIC_STORAGE_TYPE: 'redis', VITE_STORAGE_TYPE: 'redis',
REDIS_URL: redisUrl, REDIS_URL: redisUrl,
}; };
console.log(`Using Redis storage: ${redisUrl}`); console.log(`Using Redis storage: ${redisUrl}`);
console.log('Starting OrangeTV dev server...'); console.log('Starting OrangeTV Fastify/Vite dev server...');
const child = spawn('pnpm', ['dev'], { const child = spawn('pnpm', ['dev'], {
env, env,
@ -172,7 +172,7 @@ async function main() {
const config = getRedisConfig(); const config = getRedisConfig();
await assertDevPortsAvailable(); await assertDevPortsAvailable();
ensureRedis(config); ensureRedis(config);
startNextDev(config); startFastifyDev(config);
} catch (error) { } catch (error) {
console.error(error.message); console.error(error.message);
process.exit(1); process.exit(1);
@ -190,4 +190,5 @@ module.exports = {
getRedisConfig, getRedisConfig,
isPortAvailable, isPortAvailable,
resolveRedisAction, resolveRedisAction,
startFastifyDev,
}; };

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/* eslint-disable */ /* eslint-disable */
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json // 根据 VITE_SITE_NAME 动态生成 manifest.json
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public');
const manifestPath = path.join(publicDir, 'manifest.json'); const manifestPath = path.join(publicDir, 'manifest.json');
// 从环境变量获取站点名称 // 从环境变量获取站点名称
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV'; const siteName = process.env.VITE_SITE_NAME || 'OrangeTV';
// manifest.json 模板 // manifest.json 模板
const manifestTemplate = { const manifestTemplate = {

View File

@ -1,70 +0,0 @@
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`);
});
});

View File

@ -1,54 +0,0 @@
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

@ -1,15 +0,0 @@
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,19 +0,0 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAvailableApiSites } from '@/lib/config';
export const runtime = 'nodejs';
// OrionTV 兼容接口
export async function GET(request: NextRequest) {
console.log('request', request.url);
try {
const apiSites = await getAvailableApiSites();
return NextResponse.json(apiSites);
} catch (error) {
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
}
}

View File

@ -1,185 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Metadata, Viewport } from 'next';
import './globals.css';
import { getConfig } from '@/lib/config';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
import { ToastProvider } from '../components/Toast';
import GlobalThemeLoader from '../components/GlobalThemeLoader';
export const dynamic = 'force-dynamic';
// 动态生成 metadata支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const config = await getConfig();
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
if (storageType !== 'localstorage') {
siteName = config.SiteConfig.SiteName;
}
return {
title: siteName,
description: '影视聚合',
manifest: '/manifest.json',
};
}
export const viewport: Viewport = {
viewportFit: 'cover',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
let announcement =
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
let doubanImageProxyType =
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
let disableYellowFilter =
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let requireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
query: string;
}[];
if (storageType !== 'localstorage') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
doubanProxyType = config.SiteConfig.DoubanProxyType;
doubanProxy = config.SiteConfig.DoubanProxy;
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
customCategories = config.CustomCategories.filter(
(category) => !category.disabled
).map((category) => ({
name: category.name || '',
type: category.type,
query: category.query,
}));
fluidSearch = config.SiteConfig.FluidSearch;
requireDeviceCode = config.SiteConfig.RequireDeviceCode;
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
DOUBAN_PROXY_TYPE: doubanProxyType,
DOUBAN_PROXY: doubanProxy,
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
DOUBAN_IMAGE_PROXY: doubanImageProxy,
DISABLE_YELLOW_FILTER: disableYellowFilter,
CUSTOM_CATEGORIES: customCategories,
FLUID_SEARCH: fluidSearch,
REQUIRE_DEVICE_CODE: requireDeviceCode,
};
return (
<html lang='zh-CN' suppressHydrationWarning>
<head>
<meta
name='viewport'
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
/>
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
dangerouslySetInnerHTML={{
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
}}
/>
{/* 立即从缓存应用主题,避免闪烁 */}
{/* 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>
<body className='min-h-[100dvh] bg-background text-foreground antialiased'>
<ThemeProvider
attribute='class'
defaultTheme='light'
enableSystem
disableTransitionOnChange
>
<ToastProvider>
<SiteProvider siteName={siteName} announcement={announcement}>
<GlobalThemeLoader />
{children}
<GlobalErrorIndicator />
</SiteProvider>
</ToastProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -1,11 +0,0 @@
import { Metadata } from 'next';
import WarningClient from './warning-client';
export const metadata: Metadata = {
title: '安全警告 - OrangeTV',
description: '站点安全配置警告',
};
export default function WarningPage() {
return <WarningClient />;
}

39
src/client/AppImage.tsx Normal file
View File

@ -0,0 +1,39 @@
import type { ImgHTMLAttributes } from 'react';
type AppImageProps = ImgHTMLAttributes<HTMLImageElement> & {
fill?: boolean;
priority?: boolean;
quality?: number;
onLoadingComplete?: (image: HTMLImageElement) => void;
};
export default function AppImage({
fill,
priority: _priority,
quality: _quality,
onLoadingComplete,
onLoad,
style,
...props
}: AppImageProps) {
return (
<img
{...props}
onLoad={(event) => {
onLoad?.(event);
onLoadingComplete?.(event.currentTarget);
}}
style={{
...(fill
? {
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}
: null),
...style,
}}
/>
);
}

View File

@ -0,0 +1,10 @@
import { getDefaultExport } from '../module-interop';
describe('getDefaultExport', () => {
it('unwraps nested default exports produced by bundled CommonJS modules', () => {
function Artplayer() {}
const module = { default: { default: Artplayer } };
expect(getDefaultExport(module)).toBe(Artplayer);
});
});

View File

@ -1,6 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@config "../../tailwind.config.ts";
@import "@heroui/styles"; @import "@heroui/styles";
@config "../../tailwind.config.ts";
:root { :root {
color-scheme: light; color-scheme: light;
@ -257,7 +257,21 @@ body {
} }
.theme-input { .theme-input {
@apply rounded-xl border border-border bg-field px-3 py-2 text-foreground placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20; border: 1px solid rgb(var(--color-border));
border-radius: 0.75rem;
background: rgb(var(--color-surface));
color: rgb(var(--color-foreground));
padding: 0.5rem 0.75rem;
}
.theme-input::placeholder {
color: rgb(var(--color-muted));
}
.theme-input:focus {
border-color: rgb(var(--color-accent));
box-shadow: 0 0 0 2px rgb(var(--color-accent) / 0.2);
outline: none;
} }
.video-card-hover { .video-card-hover {

69
src/client/main.tsx Normal file
View File

@ -0,0 +1,69 @@
import '@/client/globals.css';
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import AdminPage from '@/pages/admin';
import DoubanPage from '@/pages/douban';
import HomePage from '@/pages/home';
import LivePage from '@/pages/live';
import LoginPage from '@/pages/login';
import PlayPage from '@/pages/play';
import SearchPage from '@/pages/search';
import ShortDramaPage from '@/pages/shortdrama';
import WarningPage from '@/pages/warning';
import { GlobalErrorIndicator } from '@/components/GlobalErrorIndicator';
import GlobalThemeLoader from '@/components/GlobalThemeLoader';
import { SiteProvider } from '@/components/SiteProvider';
import { ThemeProvider } from '@/components/ThemeProvider';
import { ToastProvider } from '@/components/Toast';
function Providers({ children }: { children: React.ReactNode }) {
const runtimeConfig = window.RUNTIME_CONFIG;
return (
<ThemeProvider
attribute='class'
defaultTheme='light'
enableSystem
disableTransitionOnChange
>
<ToastProvider>
<SiteProvider
siteName={runtimeConfig?.SITE_NAME || 'OrangeTV'}
announcement={runtimeConfig?.ANNOUNCEMENT}
>
<GlobalThemeLoader />
{children}
<GlobalErrorIndicator />
</SiteProvider>
</ToastProvider>
</ThemeProvider>
);
}
function App() {
return (
<BrowserRouter>
<Providers>
<Suspense fallback={null}>
<Routes>
<Route path='/' element={<HomePage />} />
<Route path='/search' element={<SearchPage />} />
<Route path='/play' element={<PlayPage />} />
<Route path='/live' element={<LivePage />} />
<Route path='/douban' element={<DoubanPage />} />
<Route path='/shortdrama' element={<ShortDramaPage />} />
<Route path='/admin' element={<AdminPage />} />
<Route path='/login' element={<LoginPage />} />
<Route path='/warning' element={<WarningPage />} />
<Route path='*' element={<HomePage />} />
</Routes>
</Suspense>
</Providers>
</BrowserRouter>
);
}
createRoot(document.getElementById('root')!).render(<App />);

View File

@ -0,0 +1,14 @@
export function getDefaultExport<T = unknown>(module: unknown): T {
let value = module as { default?: unknown };
while (
value &&
typeof value === 'object' &&
'default' in value &&
value.default
) {
value = value.default as { default?: unknown };
}
return value as T;
}

27
src/client/router.ts Normal file
View File

@ -0,0 +1,27 @@
import { useLocation, useNavigate, useSearchParams as useRRSearchParams } from 'react-router-dom';
export function useRouter() {
const navigate = useNavigate();
return {
push: (href: string) => navigate(href),
replace: (href: string) => navigate(href, { replace: true }),
back: () => window.history.back(),
forward: () => window.history.forward(),
refresh: () => window.location.reload(),
};
}
export function usePathname() {
return useLocation().pathname;
}
export function useSearchParams() {
const [searchParams] = useRRSearchParams();
return searchParams;
}
export function redirect(href: string): never {
window.location.href = href;
throw new Error(`Redirected to ${href}`);
}

19
src/client/runtime.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
export {};
declare global {
interface Window {
RUNTIME_CONFIG?: {
STORAGE_TYPE?: string;
SITE_NAME?: string;
ANNOUNCEMENT?: string;
DOUBAN_PROXY_TYPE?: string;
DOUBAN_PROXY?: string;
DOUBAN_IMAGE_PROXY_TYPE?: string;
DOUBAN_IMAGE_PROXY?: string;
DISABLE_YELLOW_FILTER?: boolean;
CUSTOM_CATEGORIES?: { name: string; type: 'movie' | 'tv'; query: string }[];
FLUID_SEARCH?: boolean;
REQUIRE_DEVICE_CODE?: boolean;
};
}
}

View File

@ -0,0 +1,84 @@
'use client';
import * as React from 'react';
type Theme = 'light' | 'dark' | 'system' | string;
type ThemeContextValue = {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
};
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
enableSystem?: boolean;
attribute?: 'class' | string;
disableTransitionOnChange?: boolean;
};
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = 'theme';
export function ThemeProvider({
children,
defaultTheme = 'system',
enableSystem = true,
attribute = 'class',
}: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => {
if (typeof window === 'undefined') return defaultTheme;
return localStorage.getItem(STORAGE_KEY) || defaultTheme;
});
const [systemTheme, setSystemTheme] = React.useState<'light' | 'dark'>(() =>
getSystemTheme()
);
const resolvedTheme =
theme === 'system' && enableSystem ? systemTheme : theme === 'dark' ? 'dark' : 'light';
React.useEffect(() => {
if (!enableSystem) return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setSystemTheme(media.matches ? 'dark' : 'light');
onChange();
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, [enableSystem]);
React.useEffect(() => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
if (attribute === 'class') {
root.classList.add(resolvedTheme);
} else {
root.setAttribute(attribute, resolvedTheme);
}
root.style.colorScheme = resolvedTheme;
}, [attribute, resolvedTheme]);
const setTheme = React.useCallback((nextTheme: Theme) => {
localStorage.setItem(STORAGE_KEY, nextTheme);
setThemeState(nextTheme);
}, []);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

View File

@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable react/no-unknown-property */
import { useRouter } from 'next/navigation'; import { useRouter } from '@/client/router';
import { Button, Chip, Spinner } from '@heroui/react'; import { Button, Chip, Spinner } from '@heroui/react';
import React, { import React, {
useCallback, useCallback,

View File

@ -1,5 +1,5 @@
import { Radio } from 'lucide-react'; import { Radio } from 'lucide-react';
import Image from 'next/image'; import Image from '@/client/AppImage';
import React from 'react'; import React from 'react';
import { Card, Chip } from '@heroui/react'; import { Card, Chip } from '@heroui/react';

View File

@ -4,7 +4,7 @@
import { Clover, Film, Home, Star, Tv } from 'lucide-react'; import { Clover, Film, Home, Star, Tv } from 'lucide-react';
import { Button, Card, ScrollShadow } from '@heroui/react'; import { Button, Card, ScrollShadow } from '@heroui/react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from '@/client/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
interface MobileBottomNavProps { interface MobileBottomNavProps {

View File

@ -12,8 +12,8 @@ import {
Star, Star,
Tv, Tv,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from '@/client/AppImage';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from '@/client/router';
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react'; import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
import { import {
createContext, createContext,

View File

@ -1,18 +1 @@
'use client'; export { ThemeProvider, useTheme } from '@/client/theme-provider';
import type { ThemeProviderProps } from 'next-themes';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import * as React from 'react';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute='class'
defaultTheme='system'
enableSystem
{...props}
>
{children}
</NextThemesProvider>
);
}

View File

@ -3,8 +3,8 @@
'use client'; 'use client';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from '@/client/router';
import { useTheme } from 'next-themes'; import { useTheme } from '@/client/theme-provider';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AppIconButton } from './ui/HeroPrimitives'; import { AppIconButton } from './ui/HeroPrimitives';

View File

@ -23,8 +23,8 @@ import {
User, User,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from '@/client/AppImage';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/client/router';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import ReactCrop, { Crop, PercentCrop, PixelCrop } from 'react-image-crop'; import ReactCrop, { Crop, PercentCrop, PixelCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css'; import 'react-image-crop/dist/ReactCrop.css';

View File

@ -9,7 +9,6 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Badge,
Button, Button,
Card, Card,
Chip, Chip,
@ -17,8 +16,8 @@ import {
ProgressBar, ProgressBar,
Tooltip, Tooltip,
} from '@heroui/react'; } from '@heroui/react';
import Image from 'next/image'; import Image from '@/client/AppImage';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/client/router';
import React, { import React, {
forwardRef, forwardRef,
memo, memo,
@ -478,6 +477,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return configs[from] || configs.search; return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate]); }, [from, isAggregate, douban_id, rate]);
const coverBadgeClass =
'pointer-events-none inline-flex h-7 min-w-11 max-w-[calc(100%-1rem)] items-center justify-center rounded-full border border-white/20 bg-black/70 px-2.5 text-[12px] font-semibold leading-none tracking-normal text-white shadow-[0_6px_18px_rgba(0,0,0,0.35)] backdrop-blur-md';
const hasYearBadge =
config.showYear &&
actualYear &&
actualYear !== 'unknown' &&
actualYear.trim() !== '';
// 移动端操作菜单配置 // 移动端操作菜单配置
const mobileActions = useMemo(() => { const mobileActions = useMemo(() => {
const actions = []; const actions = [];
@ -835,14 +842,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
)} )}
{/* 年份徽章 */} {/* 年份徽章 */}
{config.showYear && {hasYearBadge && (
actualYear && <div
actualYear !== 'unknown' && className={`absolute left-2 top-2 ${coverBadgeClass}`}
actualYear.trim() !== '' && (
<Badge
size='sm'
variant='secondary'
className='absolute left-2 top-2'
style={ style={
{ {
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -855,17 +857,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false; return false;
}} }}
> >
<Badge.Label>{actualYear}</Badge.Label> {actualYear}
</Badge> </div>
)} )}
{/* 徽章 */} {/* 徽章 */}
{config.showRating && rate && ( {config.showRating && rate && (
<Chip <div
size='md' className={`absolute right-2 top-2 ${coverBadgeClass}`}
color='accent'
variant='primary'
className='absolute right-2 top-2'
style={ style={
{ {
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -878,15 +877,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false; return false;
}} }}
> >
<Chip.Label>{rate}</Chip.Label> {rate}
</Chip> </div>
)} )}
{actualEpisodes && actualEpisodes > 1 && ( {actualEpisodes && actualEpisodes > 1 && (
<Chip <div
size='md' className={`absolute right-2 ${config.showRating && rate ? 'top-10' : 'top-2'} ${coverBadgeClass}`}
variant='secondary'
className='absolute right-3 top-3 min-w-12 justify-center'
style={ style={
{ {
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -899,12 +896,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false; return false;
}} }}
> >
<Chip.Label> {currentEpisode
{currentEpisode ? `${currentEpisode}/${actualEpisodes}`
? `${currentEpisode}/${actualEpisodes}` : actualEpisodes}
: actualEpisodes} </div>
</Chip.Label>
</Chip>
)} )}
{/* 豆瓣链接 */} {/* 豆瓣链接 */}
@ -920,7 +915,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' className={`absolute left-2 ${hasYearBadge ? 'top-11' : 'top-2'} opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0`}
style={ style={
{ {
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -980,10 +975,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
} as React.CSSProperties } as React.CSSProperties
} }
> >
<Badge <div
size='sm' className={coverBadgeClass}
color='accent'
variant='secondary'
style={ style={
{ {
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
@ -996,8 +989,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false; return false;
}} }}
> >
<Badge.Label>{sourceCount}</Badge.Label> {sourceCount}
</Badge> </div>
{/* 播放源详情悬浮框 */} {/* 播放源详情悬浮框 */}
{(() => { {(() => {

View File

@ -4,15 +4,15 @@ import MobileBottomNav from '../MobileBottomNav';
import Sidebar from '../Sidebar'; import Sidebar from '../Sidebar';
import { ThemeToggle } from '../ThemeToggle'; import { ThemeToggle } from '../ThemeToggle';
const push = jest.fn(); const mockPush = jest.fn();
jest.mock('next/navigation', () => ({ jest.mock('@/client/router', () => ({
usePathname: () => '/', usePathname: () => '/',
useRouter: () => ({ push }), useRouter: () => ({ push: mockPush }),
useSearchParams: () => new URLSearchParams(), useSearchParams: () => new URLSearchParams(),
})); }));
jest.mock('next-themes', () => ({ jest.mock('@/client/theme-provider', () => ({
useTheme: () => ({ useTheme: () => ({
resolvedTheme: 'dark', resolvedTheme: 'dark',
setTheme: jest.fn(), setTheme: jest.fn(),
@ -25,7 +25,7 @@ jest.mock('../ChatModal', () => ({
describe('hidden front-end options', () => { describe('hidden front-end options', () => {
beforeEach(() => { beforeEach(() => {
push.mockClear(); mockPush.mockClear();
localStorage.clear(); localStorage.clear();
}); });

View File

@ -1,7 +1,7 @@
import { NextRequest } from 'next/server'; import type { AppRequest } from '@/server/web';
// 从cookie获取认证信息 (服务端使用) // 从cookie获取认证信息 (服务端使用)
export function getAuthInfoFromCookie(request: NextRequest): { export function getAuthInfoFromCookie(request: AppRequest): {
password?: string; password?: string;
username?: string; username?: string;
signature?: string; signature?: string;

View File

@ -225,25 +225,25 @@ async function getInitConfig(configFile: string, subConfig: {
ConfigFile: configFile, ConfigFile: configFile,
ConfigSubscribtion: subConfig, ConfigSubscribtion: subConfig,
SiteConfig: { SiteConfig: {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV', SiteName: process.env.VITE_SITE_NAME || 'OrangeTV',
Announcement: Announcement:
process.env.ANNOUNCEMENT || process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage: SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, Number(process.env.VITE_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: cfgFile.cache_time || 7200, SiteInterfaceCacheTime: cfgFile.cache_time || 7200,
DoubanProxyType: DoubanProxyType:
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent', process.env.VITE_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '', DoubanProxy: process.env.VITE_DOUBAN_PROXY || '',
DoubanImageProxyType: DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent', process.env.VITE_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '', DoubanImageProxy: process.env.VITE_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter: DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', process.env.VITE_DISABLE_YELLOW_FILTER === 'true',
FluidSearch: FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', process.env.VITE_FLUID_SEARCH !== 'false',
RequireDeviceCode: RequireDeviceCode:
process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false', process.env.VITE_REQUIRE_DEVICE_CODE !== 'false',
}, },
UserConfig: { UserConfig: {
Users: [], Users: [],
@ -360,23 +360,23 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
// 确保 SiteConfig 及其属性存在 // 确保 SiteConfig 及其属性存在
if (!adminConfig.SiteConfig) { if (!adminConfig.SiteConfig) {
adminConfig.SiteConfig = { adminConfig.SiteConfig = {
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV', SiteName: process.env.VITE_SITE_NAME || 'OrangeTV',
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, SearchDownstreamMaxPage: Number(process.env.VITE_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: 7200, SiteInterfaceCacheTime: 7200,
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent', DoubanProxyType: process.env.VITE_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '', DoubanProxy: process.env.VITE_DOUBAN_PROXY || '',
DoubanImageProxyType: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent', DoubanImageProxyType: process.env.VITE_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '', DoubanImageProxy: process.env.VITE_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', DisableYellowFilter: process.env.VITE_DISABLE_YELLOW_FILTER === 'true',
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', FluidSearch: process.env.VITE_FLUID_SEARCH !== 'false',
RequireDeviceCode: process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false', RequireDeviceCode: process.env.VITE_REQUIRE_DEVICE_CODE !== 'false',
}; };
} }
// 确保 RequireDeviceCode 属性存在 // 确保 RequireDeviceCode 属性存在
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) { if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
adminConfig.SiteConfig.RequireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false'; adminConfig.SiteConfig.RequireDeviceCode = process.env.VITE_REQUIRE_DEVICE_CODE !== 'false';
} }
// 确保 ThemeConfig 存在 // 确保 ThemeConfig 存在

View File

@ -8,7 +8,7 @@ import { UpstashRedisStorage } from './upstash.db';
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage' // storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
const STORAGE_TYPE = const STORAGE_TYPE =
(process.env.NEXT_PUBLIC_STORAGE_TYPE as (process.env.VITE_STORAGE_TYPE as
| 'localstorage' | 'localstorage'
| 'redis' | 'redis'
| 'upstash' | 'upstash'

View File

@ -1,138 +0,0 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过不需要认证的路径
if (shouldSkipAuth(pathname)) {
return NextResponse.next();
}
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (!process.env.PASSWORD) {
// 如果没有设置密码,重定向到警告页面
const warningUrl = new URL('/warning', request.url);
return NextResponse.redirect(warningUrl);
}
// 从cookie获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
return handleAuthFailure(request, pathname);
}
// localstorage模式在middleware中完成验证
if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return handleAuthFailure(request, pathname);
}
return NextResponse.next();
}
// 其他模式:只验证签名
// 检查是否有用户名非localStorage模式下密码不存储在cookie中
if (!authInfo.username || !authInfo.signature) {
return handleAuthFailure(request, pathname);
}
// 验证签名(如果存在)
if (authInfo.signature) {
const isValidSignature = await verifySignature(
authInfo.username,
authInfo.signature,
process.env.PASSWORD || ''
);
// 签名验证通过即可
if (isValidSignature) {
return NextResponse.next();
}
}
// 签名验证失败或不存在签名
return handleAuthFailure(request, pathname);
}
// 验证签名
async function verifySignature(
data: string,
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
try {
// 导入密钥
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// 将十六进制字符串转换为Uint8Array
const signatureBuffer = new Uint8Array(
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
);
// 验证签名
return await crypto.subtle.verify(
'HMAC',
key,
signatureBuffer,
messageData
);
} catch (error) {
console.error('签名验证失败:', error);
return false;
}
}
// 处理认证失败的情况
function handleAuthFailure(
request: NextRequest,
pathname: string
): NextResponse {
// 如果是 API 路由,返回 401 状态码
if (pathname.startsWith('/api')) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 否则重定向到登录页面
const loginUrl = new URL('/login', request.url);
// 保留完整的URL包括查询参数
const fullUrl = `${pathname}${request.nextUrl.search}`;
loginUrl.searchParams.set('redirect', fullUrl);
return NextResponse.redirect(loginUrl);
}
// 判断是否需要跳过认证的路径
function shouldSkipAuth(pathname: string): boolean {
const skipPaths = [
'/_next',
'/favicon.ico',
'/robots.txt',
'/manifest.json',
'/icons/',
'/logo.png',
'/screenshot.png',
];
return skipPaths.some((path) => pathname.startsWith(path));
}
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
],
};

View File

@ -42,7 +42,7 @@ import { GripVertical, Palette } from 'lucide-react';
import { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react'; import { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';

View File

@ -2,7 +2,7 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from '@/client/router';
import { Card, EmptyState, Spinner } from '@heroui/react'; import { Card, EmptyState, Spinner } from '@heroui/react';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';

View File

@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, react/no-unknown-property */
'use client'; 'use client';
@ -6,7 +6,7 @@ import Artplayer from 'artplayer';
import Hls from 'hls.js'; import Hls from 'hls.js';
import { Heart, Radio, Tv } from 'lucide-react'; import { Heart, Radio, Tv } from 'lucide-react';
import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react'; import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from '@/client/router';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { import {

View File

@ -4,7 +4,7 @@
import { AlertCircle, CheckCircle, Shield } from 'lucide-react'; import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react'; import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from '@/client/router';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { CURRENT_VERSION } from '@/lib/version'; import { CURRENT_VERSION } from '@/lib/version';

View File

@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, react/no-unknown-property */
'use client'; 'use client';
// Artplayer 和 Hls 以及弹幕插件将动态加载 // Artplayer 和 Hls 以及弹幕插件将动态加载
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react'; import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from '@/client/router';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { getDefaultExport } from '@/client/module-interop';
import { import {
deleteFavorite, deleteFavorite,
deletePlayRecord, deletePlayRecord,
@ -161,9 +162,9 @@ function PlayPageClient() {
if (mounted) { if (mounted) {
setDynamicDeps({ setDynamicDeps({
Artplayer: ArtplayerModule.default, Artplayer: getDefaultExport(ArtplayerModule),
Hls: HlsModule.default, Hls: getDefaultExport(HlsModule),
artplayerPluginDanmuku: DanmakuModule.default artplayerPluginDanmuku: getDefaultExport(DanmakuModule)
}); });
} }
} catch (error) { } catch (error) {
@ -2409,7 +2410,7 @@ function PlayPageClient() {
artPlayerRef.current.on('video:timeupdate', () => { artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now(); const now = Date.now();
let interval = 5000; let interval = 5000;
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') { if (window.RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash') {
interval = 20000; interval = 20000;
} }
if (now - lastSaveTimeRef.current > interval) { if (now - lastSaveTimeRef.current > interval) {

View File

@ -12,7 +12,7 @@ import {
Switch, Switch,
Tooltip, Tooltip,
} from '@heroui/react'; } from '@heroui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from '@/client/router';
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react'; import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { import {
@ -1092,7 +1092,7 @@ function SearchPageClient() {
{totalSources > 0 && useFluidSearch && ( {totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-muted'> <span className='ml-2 text-sm font-normal text-muted'>
{completedSources}/{totalSources} {completedSources}/{totalSources}
</span> </span>
)} )}
{isLoading && useFluidSearch && ( {isLoading && useFluidSearch && (
@ -1238,19 +1238,17 @@ function SearchPageClient() {
> >
<Chip.Label>{item}</Chip.Label> <Chip.Label>{item}</Chip.Label>
</Chip> </Chip>
{/* 删除按钮 */} <button
<Button type='button'
aria-label='删除搜索历史' aria-label='删除搜索历史'
isIconOnly className='absolute -right-1 -top-1 inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/80 bg-surface text-muted opacity-0 shadow-sm transition hover:bg-danger hover:text-white focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/40 group-hover:opacity-100'
size='sm' onClick={(event) => {
variant='danger' event.stopPropagation();
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
onPress={() => {
deleteSearchHistory(item); // 事件监听会自动更新界面 deleteSearchHistory(item); // 事件监听会自动更新界面
}} }}
> >
<X className='w-3 h-3' /> <X className='h-2.5 w-2.5' strokeWidth={2.4} />
</Button> </button>
</div> </div>
))} ))}
</div> </div>

5
src/pages/warning.tsx Normal file
View File

@ -0,0 +1,5 @@
import WarningClient from './warning-client';
export default function WarningPage() {
return <WarningClient />;
}

View File

@ -0,0 +1,40 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadEnvFiles } from '../env';
describe('loadEnvFiles', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('loads local env defaults without overriding existing process env', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orangetv-env-'));
fs.writeFileSync(
path.join(tmp, '.env.local'),
[
'USERNAME=admin',
'PASSWORD=orangetv-local-dev',
'VITE_STORAGE_TYPE=localstorage',
].join('\n')
);
delete process.env.USERNAME;
delete process.env.PASSWORD;
process.env.VITE_STORAGE_TYPE = 'redis';
loadEnvFiles({ cwd: tmp, nodeEnv: 'development' });
expect(process.env.USERNAME).toBe('admin');
expect(process.env.PASSWORD).toBe('orangetv-local-dev');
expect(process.env.VITE_STORAGE_TYPE).toBe('redis');
});
});

View File

@ -0,0 +1,81 @@
/**
* @jest-environment node
*/
import type { createApp as createAppType } from '@/server/app';
describe('Fastify app shell', () => {
let createApp: typeof createAppType;
beforeAll(async () => {
const { Blob, File } = await import('node:buffer');
const { ReadableStream, TransformStream } = await import('node:stream/web');
const { MessageChannel, MessagePort } = await import('node:worker_threads');
Object.assign(globalThis, {
Blob,
DOMException: class DOMException extends Error {
constructor(message?: string, public name = 'DOMException') {
super(message);
}
},
File,
MessageChannel,
MessagePort,
ReadableStream,
TransformStream,
});
const { FormData, Headers, Request, Response } = await import('undici');
Object.assign(globalThis, { FormData, Headers, Request, Response });
const appModule = await import('@/server/app');
createApp = appModule.createApp;
});
beforeEach(() => {
process.env.PASSWORD = 'secret';
process.env.USERNAME = 'owner';
process.env.VITE_STORAGE_TYPE = 'redis';
process.env.REDIS_URL = 'redis://localhost:6379';
});
it('serves health and runtime config without authentication', async () => {
const app = await createApp({ dev: false, clientDist: null });
const health = await app.inject('/api/health');
expect(health.statusCode).toBe(200);
expect(health.json()).toMatchObject({
status: 'ok',
timestamp: expect.any(String),
});
const runtimeConfig = await app.inject('/runtime-config.js');
expect(runtimeConfig.statusCode).toBe(200);
expect(runtimeConfig.headers['content-type']).toContain('application/javascript');
expect(runtimeConfig.body).toContain('window.RUNTIME_CONFIG');
expect(runtimeConfig.body).toContain('"STORAGE_TYPE":"redis"');
await app.close();
});
it('redirects protected browser routes to login and serves the SPA when authenticated', async () => {
process.env.VITE_STORAGE_TYPE = 'localstorage';
const app = await createApp({ dev: false, clientDist: null });
const auth = encodeURIComponent(JSON.stringify({ password: 'secret', role: 'user' }));
const unauthenticated = await app.inject('/admin');
expect(unauthenticated.statusCode).toBe(302);
expect(unauthenticated.headers.location).toBe('/login?redirect=%2Fadmin');
const authenticated = await app.inject({
method: 'GET',
url: '/play?id=abc',
headers: {
cookie: `auth=${auth}`,
},
});
expect(authenticated.statusCode).toBe(200);
expect(authenticated.headers['content-type']).toContain('text/html');
expect(authenticated.body).toContain('<div id="root"></div>');
await app.close();
});
});

105
src/server/app.ts Normal file
View File

@ -0,0 +1,105 @@
import fs from 'node:fs';
import path from 'node:path';
import fastifyCookie from '@fastify/cookie';
import fastifyMiddie from '@fastify/middie';
import fastifyStatic from '@fastify/static';
import Fastify from 'fastify';
import type { ViteDevServer } from 'vite';
import { isAuthenticated, shouldSkipAuth } from './auth';
import { renderRuntimeConfigScript } from './runtime-config';
import { apiRoutes } from './routes';
import { registerWebRouteModule } from './web-route';
export interface CreateAppOptions {
dev?: boolean;
clientDist?: string | null;
}
export async function createApp(options: CreateAppOptions = {}) {
const app = Fastify({ logger: true });
const dev = options.dev ?? process.env.NODE_ENV !== 'production';
const clientDist =
options.clientDist === undefined
? path.resolve(process.cwd(), 'dist/client')
: options.clientDist;
let vite: ViteDevServer | null = null;
await app.register(fastifyCookie);
await app.register(fastifyMiddie);
app.addHook('onRequest', async (request, reply) => {
const pathname = new URL(request.url, 'http://localhost').pathname;
if (shouldSkipAuth(pathname)) return;
if (await isAuthenticated(request)) return;
if (pathname.startsWith('/api')) {
reply.code(401).send('Unauthorized');
return reply;
}
const redirect = `${pathname}${new URL(request.url, 'http://localhost').search}`;
reply.redirect(`/login?redirect=${encodeURIComponent(redirect)}`);
return reply;
});
app.get('/runtime-config.js', async (_request, reply) => {
reply.type('application/javascript; charset=utf-8');
return renderRuntimeConfigScript();
});
for (const route of apiRoutes) {
registerWebRouteModule(app, route.path, route.module);
}
if (dev) {
const { createServer } = await import('vite');
vite = await createServer({
server: { middlewareMode: true },
appType: 'spa',
});
app.use((request, response, next) => {
const requestUrl = request.url || '/';
if (
requestUrl.startsWith('/api/') ||
requestUrl.startsWith('/runtime-config.js')
) {
next();
return;
}
vite!.middlewares(request, response, next);
});
} else if (clientDist && fs.existsSync(clientDist)) {
await app.register(fastifyStatic, {
root: clientDist,
wildcard: false,
});
}
app.setNotFoundHandler(async (request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'Not Found' });
return;
}
reply.type('text/html; charset=utf-8');
if (dev && vite) {
const template = fs.readFileSync(path.resolve(process.cwd(), 'index.html'), 'utf-8');
return vite.transformIndexHtml(request.url, template);
}
const indexPath =
clientDist && fs.existsSync(path.join(clientDist, 'index.html'))
? path.join(clientDist, 'index.html')
: path.resolve(process.cwd(), 'index.html');
return fs.readFileSync(indexPath, 'utf-8');
});
app.addHook('onClose', async () => {
await vite?.close();
});
return app;
}

View File

@ -0,0 +1,7 @@
import type { FastifyRequest } from 'fastify';
import { getAuthCookie } from './auth';
export function getAuthContext(request: FastifyRequest) {
return getAuthCookie(request);
}

86
src/server/auth.ts Normal file
View File

@ -0,0 +1,86 @@
import type { FastifyRequest } from 'fastify';
type AuthInfo = {
password?: string;
username?: string;
signature?: string;
timestamp?: number;
role?: 'owner' | 'admin' | 'user';
};
const PUBLIC_PREFIXES = [
'/api/login',
'/api/logout',
'/api/cron',
'/api/server-config',
'/api/health',
'/runtime-config.js',
'/assets/',
'/icons/',
'/favicon.ico',
'/robots.txt',
'/manifest.json',
'/logo.png',
'/screenshot.png',
'/sw.js',
'/workbox-',
'/login',
'/warning',
];
export function shouldSkipAuth(pathname: string) {
return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
export function getAuthCookie(request: FastifyRequest): AuthInfo | null {
const authCookie = request.cookies?.auth;
if (!authCookie) return null;
try {
return JSON.parse(decodeURIComponent(authCookie));
} catch {
return null;
}
}
export async function isAuthenticated(request: FastifyRequest) {
if (!process.env.PASSWORD) return false;
const authInfo = getAuthCookie(request);
if (!authInfo) return false;
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return authInfo.password === process.env.PASSWORD;
}
if (!authInfo.username || !authInfo.signature) return false;
return verifySignature(
authInfo.username,
authInfo.signature,
process.env.PASSWORD
);
}
async function verifySignature(data: string, signature: string, secret: string) {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const messageData = encoder.encode(data);
try {
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signatureBuffer = new Uint8Array(
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
);
return crypto.subtle.verify('HMAC', key, signatureBuffer, messageData);
} catch {
return false;
}
}

60
src/server/env.ts Normal file
View File

@ -0,0 +1,60 @@
import fs from 'node:fs';
import path from 'node:path';
type LoadEnvOptions = {
cwd?: string;
nodeEnv?: string;
};
export function loadEnvFiles(options: LoadEnvOptions = {}) {
const cwd = options.cwd || process.cwd();
const nodeEnv = options.nodeEnv || process.env.NODE_ENV || 'development';
const files = [
`.env.${nodeEnv}.local`,
nodeEnv === 'test' ? null : '.env.local',
`.env.${nodeEnv}`,
'.env',
].filter(Boolean) as string[];
for (const file of files) {
loadEnvFile(path.join(cwd, file));
}
}
function loadEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const parsed = parseEnvLine(rawLine);
if (!parsed) continue;
const [key, value] = parsed;
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
function parseEnvLine(line: string): [string, string] | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return null;
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) return null;
return [match[1], parseEnvValue(match[2])];
}
function parseEnvValue(value: string) {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
const commentIndex = trimmed.indexOf(' #');
return commentIndex === -1 ? trimmed : trimmed.slice(0, commentIndex).trim();
}

11
src/server/http.ts Normal file
View File

@ -0,0 +1,11 @@
import { AppResponse } from './web';
export { AppResponse, type AppRequest } from './web';
export function json(data: unknown, init: ResponseInit = {}) {
return AppResponse.json(data, init);
}
export function error(message: string, status = 500) {
return AppResponse.json({ error: message }, { status });
}

19
src/server/index.ts Normal file
View File

@ -0,0 +1,19 @@
import { loadEnvFiles } from './env';
loadEnvFiles();
async function main() {
const { createApp } = await import('./app');
const app = await createApp({
dev: process.env.NODE_ENV !== 'production',
});
const port = Number(process.env.PORT || 3000);
const host = process.env.HOSTNAME || '0.0.0.0';
await app.listen({ port, host });
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

15
src/server/route-auth.ts Normal file
View File

@ -0,0 +1,15 @@
import { getAuthInfoFromCookie } from '@/lib/auth';
import type { AppRequest } from './web';
export function getRouteUsername(request: AppRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) return null;
return (
authInfo.username ||
process.env.USERNAME ||
process.env.ADMIN_USERNAME ||
'admin'
);
}

View File

@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型 // 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort'; type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
@ -15,10 +14,10 @@ interface BaseBody {
action?: Action; action?: Action;
} }
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -32,14 +31,14 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
// 基础校验 // 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort']; const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
if (!username || !action || !ACTIONS.includes(action)) { if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
// 获取配置与存储 // 获取配置与存储
@ -51,7 +50,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) { if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
} }
@ -63,7 +62,7 @@ export async function POST(request: NextRequest) {
query?: string; query?: string;
}; };
if (!name || !type || !query) { if (!name || !type || !query) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
} }
// 检查是否已存在相同的查询和类型组合 // 检查是否已存在相同的查询和类型组合
if ( if (
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type (c) => c.query === query && c.type === type
) )
) { ) {
return NextResponse.json({ error: '该分类已存在' }, { status: 400 }); return AppResponse.json({ error: '该分类已存在' }, { status: 400 });
} }
adminConfig.CustomCategories.push({ adminConfig.CustomCategories.push({
name, name,
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv'; type?: 'movie' | 'tv';
}; };
if (!query || !type) if (!query || !type)
return NextResponse.json( return AppResponse.json(
{ error: '缺少 query 或 type 参数' }, { error: '缺少 query 或 type 参数' },
{ status: 400 } { status: 400 }
); );
@ -96,7 +95,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type (c) => c.query === query && c.type === type
); );
if (!entry) if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 }); return AppResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = true; entry.disabled = true;
break; break;
} }
@ -106,7 +105,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv'; type?: 'movie' | 'tv';
}; };
if (!query || !type) if (!query || !type)
return NextResponse.json( return AppResponse.json(
{ error: '缺少 query 或 type 参数' }, { error: '缺少 query 或 type 参数' },
{ status: 400 } { status: 400 }
); );
@ -114,7 +113,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type (c) => c.query === query && c.type === type
); );
if (!entry) if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 }); return AppResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = false; entry.disabled = false;
break; break;
} }
@ -124,7 +123,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv'; type?: 'movie' | 'tv';
}; };
if (!query || !type) if (!query || !type)
return NextResponse.json( return AppResponse.json(
{ error: '缺少 query 或 type 参数' }, { error: '缺少 query 或 type 参数' },
{ status: 400 } { status: 400 }
); );
@ -132,10 +131,10 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type (c) => c.query === query && c.type === type
); );
if (idx === -1) if (idx === -1)
return NextResponse.json({ error: '分类不存在' }, { status: 404 }); return AppResponse.json({ error: '分类不存在' }, { status: 404 });
const entry = adminConfig.CustomCategories[idx]; const entry = adminConfig.CustomCategories[idx];
if (entry.from === 'config') { if (entry.from === 'config') {
return NextResponse.json( return AppResponse.json(
{ error: '该分类不可删除' }, { error: '该分类不可删除' },
{ status: 400 } { status: 400 }
); );
@ -146,7 +145,7 @@ export async function POST(request: NextRequest) {
case 'sort': { case 'sort': {
const { order } = body as { order?: string[] }; const { order } = body as { order?: string[] };
if (!Array.isArray(order)) { if (!Array.isArray(order)) {
return NextResponse.json( return AppResponse.json(
{ error: '排序列表格式错误' }, { error: '排序列表格式错误' },
{ status: 400 } { status: 400 }
); );
@ -170,13 +169,13 @@ export async function POST(request: NextRequest) {
break; break;
} }
default: default:
return NextResponse.json({ error: '未知操作' }, { status: 400 }); return AppResponse.json({ error: '未知操作' }, { status: 400 });
} }
// 持久化到存储 // 持久化到存储
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
return NextResponse.json( return AppResponse.json(
{ ok: true }, { ok: true },
{ {
headers: { headers: {
@ -186,7 +185,7 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error('分类管理操作失败:', error); console.error('分类管理操作失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '分类管理操作失败', error: '分类管理操作失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,17 +1,16 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { AdminConfigResult } from '@/lib/admin.types'; import { AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -55,7 +54,7 @@ export async function GET(request: NextRequest) {
Config: config, Config: config,
}; };
return NextResponse.json(result, { return AppResponse.json(result, {
headers: { headers: {
'Cache-Control': 'no-store', // 管理员配置不缓存 'Cache-Control': 'no-store', // 管理员配置不缓存
}, },
@ -77,7 +76,7 @@ export async function GET(request: NextRequest) {
}; };
console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig); console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig);
return NextResponse.json(result, { return AppResponse.json(result, {
headers: { headers: {
'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟 'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟
}, },
@ -85,7 +84,7 @@ export async function GET(request: NextRequest) {
} }
} catch (error) { } catch (error) {
console.error('获取配置失败:', error); console.error('获取配置失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '获取配置失败', error: '获取配置失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,17 +1,16 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig, refineConfig } from '@/lib/config'; import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
@ -31,7 +30,7 @@ export async function POST(request: NextRequest) {
// 仅站长可以修改配置文件 // 仅站长可以修改配置文件
if (username !== process.env.USERNAME) { if (username !== process.env.USERNAME) {
return NextResponse.json( return AppResponse.json(
{ error: '权限不足,只有站长可以修改配置文件' }, { error: '权限不足,只有站长可以修改配置文件' },
{ status: 401 } { status: 401 }
); );
@ -42,7 +41,7 @@ export async function POST(request: NextRequest) {
const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body; const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body;
if (!configFile || typeof configFile !== 'string') { if (!configFile || typeof configFile !== 'string') {
return NextResponse.json( return AppResponse.json(
{ error: '配置文件内容不能为空' }, { error: '配置文件内容不能为空' },
{ status: 400 } { status: 400 }
); );
@ -52,7 +51,7 @@ export async function POST(request: NextRequest) {
try { try {
JSON.parse(configFile); JSON.parse(configFile);
} catch (e) { } catch (e) {
return NextResponse.json( return AppResponse.json(
{ error: '配置文件格式错误,请检查 JSON 语法' }, { error: '配置文件格式错误,请检查 JSON 语法' },
{ status: 400 } { status: 400 }
); );
@ -79,13 +78,13 @@ export async function POST(request: NextRequest) {
adminConfig = refineConfig(adminConfig); adminConfig = refineConfig(adminConfig);
// 更新配置文件 // 更新配置文件
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
return NextResponse.json({ return AppResponse.json({
success: true, success: true,
message: '配置文件更新成功', message: '配置文件更新成功',
}); });
} catch (error) { } catch (error) {
console.error('更新配置文件失败:', error); console.error('更新配置文件失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '更新配置文件失败', error: '更新配置文件失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,21 +1,20 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
// 权限检查:仅站长可以拉取配置订阅 // 权限检查:仅站长可以拉取配置订阅
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
if (authInfo.username !== process.env.USERNAME) { if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json( return AppResponse.json(
{ error: '权限不足,只有站长可以拉取配置订阅' }, { error: '权限不足,只有站长可以拉取配置订阅' },
{ status: 401 } { status: 401 }
); );
@ -24,14 +23,14 @@ export async function POST(request: NextRequest) {
const { url } = await request.json(); const { url } = await request.json();
if (!url) { if (!url) {
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 }); return AppResponse.json({ error: '缺少URL参数' }, { status: 400 });
} }
// 直接 fetch URL 获取配置内容 // 直接 fetch URL 获取配置内容
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
return NextResponse.json( return AppResponse.json(
{ error: `请求失败: ${response.status} ${response.statusText}` }, { error: `请求失败: ${response.status} ${response.statusText}` },
{ status: response.status } { status: response.status }
); );
@ -50,7 +49,7 @@ export async function POST(request: NextRequest) {
throw decodeError; throw decodeError;
} }
return NextResponse.json({ return AppResponse.json({
success: true, success: true,
configContent: decodedContent, configContent: decodedContent,
message: '配置拉取成功' message: '配置拉取成功'
@ -58,7 +57,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('拉取配置失败:', error); console.error('拉取配置失败:', error);
return NextResponse.json( return AppResponse.json(
{ error: '拉取配置失败' }, { error: '拉取配置失败' },
{ status: 500 } { status: 500 }
); );

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { promisify } from 'util'; import { promisify } from 'util';
import { gzip } from 'zlib'; import { gzip } from 'zlib';
@ -9,16 +9,15 @@ import { SimpleCrypto } from '@/lib/crypto';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { CURRENT_VERSION } from '@/lib/version'; import { CURRENT_VERSION } from '@/lib/version';
export const runtime = 'nodejs';
const gzipAsync = promisify(gzip); const gzipAsync = promisify(gzip);
export async function POST(req: NextRequest) { export async function POST(req: AppRequest) {
try { try {
// 检查存储类型 // 检查存储类型
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ error: '不支持本地存储进行数据迁移' }, { error: '不支持本地存储进行数据迁移' },
{ status: 400 } { status: 400 }
); );
@ -27,23 +26,23 @@ export async function POST(req: NextRequest) {
// 验证身份和权限 // 验证身份和权限
const authInfo = getAuthInfoFromCookie(req); const authInfo = getAuthInfoFromCookie(req);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 }); return AppResponse.json({ error: '未登录' }, { status: 401 });
} }
// 检查用户权限(只有站长可以导出数据) // 检查用户权限(只有站长可以导出数据)
if (authInfo.username !== process.env.USERNAME) { if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 }); return AppResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
} }
const config = await db.getAdminConfig(); const config = await db.getAdminConfig();
if (!config) { if (!config) {
return NextResponse.json({ error: '无法获取配置' }, { status: 500 }); return AppResponse.json({ error: '无法获取配置' }, { status: 500 });
} }
// 解析请求体获取密码 // 解析请求体获取密码
const { password } = await req.json(); const { password } = await req.json();
if (!password || typeof password !== 'string') { if (!password || typeof password !== 'string') {
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 }); return AppResponse.json({ error: '请提供加密密码' }, { status: 400 });
} }
// 收集所有数据 // 收集所有数据
@ -100,7 +99,7 @@ export async function POST(req: NextRequest) {
const filename = `OrangeTV-backup-${timestamp}.dat`; const filename = `OrangeTV-backup-${timestamp}.dat`;
// 返回加密的数据作为文件下载 // 返回加密的数据作为文件下载
return new NextResponse(encryptedData, { return new AppResponse(encryptedData, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@ -111,7 +110,7 @@ export async function POST(req: NextRequest) {
} catch (error) { } catch (error) {
console.error('数据导出失败:', error); console.error('数据导出失败:', error);
return NextResponse.json( return AppResponse.json(
{ error: error instanceof Error ? error.message : '导出失败' }, { error: error instanceof Error ? error.message : '导出失败' },
{ status: 500 } { status: 500 }
); );

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { promisify } from 'util'; import { promisify } from 'util';
import { gunzip } from 'zlib'; import { gunzip } from 'zlib';
@ -9,16 +9,15 @@ import { configSelfCheck, setCachedConfig } from '@/lib/config';
import { SimpleCrypto } from '@/lib/crypto'; import { SimpleCrypto } from '@/lib/crypto';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
const gunzipAsync = promisify(gunzip); const gunzipAsync = promisify(gunzip);
export async function POST(req: NextRequest) { export async function POST(req: AppRequest) {
try { try {
// 检查存储类型 // 检查存储类型
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ error: '不支持本地存储进行数据迁移' }, { error: '不支持本地存储进行数据迁移' },
{ status: 400 } { status: 400 }
); );
@ -27,12 +26,12 @@ export async function POST(req: NextRequest) {
// 验证身份和权限 // 验证身份和权限
const authInfo = getAuthInfoFromCookie(req); const authInfo = getAuthInfoFromCookie(req);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 }); return AppResponse.json({ error: '未登录' }, { status: 401 });
} }
// 检查用户权限(只有站长可以导入数据) // 检查用户权限(只有站长可以导入数据)
if (authInfo.username !== process.env.USERNAME) { if (authInfo.username !== process.env.USERNAME) {
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 }); return AppResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
} }
// 解析表单数据 // 解析表单数据
@ -41,11 +40,11 @@ export async function POST(req: NextRequest) {
const password = formData.get('password') as string; const password = formData.get('password') as string;
if (!file) { if (!file) {
return NextResponse.json({ error: '请选择备份文件' }, { status: 400 }); return AppResponse.json({ error: '请选择备份文件' }, { status: 400 });
} }
if (!password) { if (!password) {
return NextResponse.json({ error: '请提供解密密码' }, { status: 400 }); return AppResponse.json({ error: '请提供解密密码' }, { status: 400 });
} }
// 读取文件内容 // 读取文件内容
@ -56,7 +55,7 @@ export async function POST(req: NextRequest) {
try { try {
decryptedData = SimpleCrypto.decrypt(encryptedData, password); decryptedData = SimpleCrypto.decrypt(encryptedData, password);
} catch (error) { } catch (error) {
return NextResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 }); return AppResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 });
} }
// 解压缩数据 // 解压缩数据
@ -69,12 +68,12 @@ export async function POST(req: NextRequest) {
try { try {
importData = JSON.parse(decompressedData); importData = JSON.parse(decompressedData);
} catch (error) { } catch (error) {
return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 }); return AppResponse.json({ error: '备份文件格式错误' }, { status: 400 });
} }
// 验证数据格式 // 验证数据格式
if (!importData.data || !importData.data.adminConfig || !importData.data.userData) { if (!importData.data || !importData.data.adminConfig || !importData.data.userData) {
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 }); return AppResponse.json({ error: '备份文件格式无效' }, { status: 400 });
} }
// 开始导入数据 - 先清空现有数据 // 开始导入数据 - 先清空现有数据
@ -127,7 +126,7 @@ export async function POST(req: NextRequest) {
} }
} }
return NextResponse.json({ return AppResponse.json({
message: '数据导入成功', message: '数据导入成功',
importedUsers: Object.keys(userData).length, importedUsers: Object.keys(userData).length,
timestamp: importData.timestamp, timestamp: importData.timestamp,
@ -136,7 +135,7 @@ export async function POST(req: NextRequest) {
} catch (error) { } catch (error) {
console.error('数据导入失败:', error); console.error('数据导入失败:', error);
return NextResponse.json( return AppResponse.json(
{ error: error instanceof Error ? error.message : '导入失败' }, { error: error instanceof Error ? error.message : '导入失败' },
{ status: 500 } { status: 500 }
); );

View File

@ -1,15 +1,14 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { refreshLiveChannels } from '@/lib/live'; import { refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
// 权限检查 // 权限检查
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!user || user.role !== 'admin' || user.banned) { if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
} }
@ -43,13 +42,13 @@ export async function POST(request: NextRequest) {
// 保存配置 // 保存配置
await db.saveAdminConfig(config); await db.saveAdminConfig(config);
return NextResponse.json({ return AppResponse.json({
success: true, success: true,
message: '直播源刷新成功', message: '直播源刷新成功',
}); });
} catch (error) { } catch (error) {
console.error('直播源刷新失败:', error); console.error('直播源刷新失败:', error);
return NextResponse.json( return AppResponse.json(
{ error: error instanceof Error ? error.message : '刷新失败' }, { error: error instanceof Error ? error.message : '刷新失败' },
{ status: 500 } { status: 500 }
); );

View File

@ -1,15 +1,14 @@
/* eslint-disable no-console,no-case-declarations */ /* eslint-disable no-console,no-case-declarations */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live'; import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
// 权限检查 // 权限检查
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!user || user.role !== 'admin' || user.banned) { if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
} }
@ -29,7 +28,7 @@ export async function POST(request: NextRequest) {
const { action, key, name, url, ua, epg } = body; const { action, key, name, url, ua, epg } = body;
if (!config) { if (!config) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 }); return AppResponse.json({ error: '配置不存在' }, { status: 404 });
} }
// 确保 LiveConfig 存在 // 确保 LiveConfig 存在
@ -41,7 +40,7 @@ export async function POST(request: NextRequest) {
case 'add': case 'add':
// 检查是否已存在相同的 key // 检查是否已存在相同的 key
if (config.LiveConfig.some((l) => l.key === key)) { if (config.LiveConfig.some((l) => l.key === key)) {
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 }); return AppResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
} }
const liveInfo = { const liveInfo = {
@ -71,12 +70,12 @@ export async function POST(request: NextRequest) {
// 删除直播源 // 删除直播源
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key); const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
if (deleteIndex === -1) { if (deleteIndex === -1) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 }); return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
} }
const liveSource = config.LiveConfig[deleteIndex]; const liveSource = config.LiveConfig[deleteIndex];
if (liveSource.from === 'config') { if (liveSource.from === 'config') {
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 }); return AppResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
} }
deleteCachedLiveChannels(key); deleteCachedLiveChannels(key);
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
// 启用直播源 // 启用直播源
const enableSource = config.LiveConfig.find((l) => l.key === key); const enableSource = config.LiveConfig.find((l) => l.key === key);
if (!enableSource) { if (!enableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 }); return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
} }
enableSource.disabled = false; enableSource.disabled = false;
break; break;
@ -97,7 +96,7 @@ export async function POST(request: NextRequest) {
// 禁用直播源 // 禁用直播源
const disableSource = config.LiveConfig.find((l) => l.key === key); const disableSource = config.LiveConfig.find((l) => l.key === key);
if (!disableSource) { if (!disableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 }); return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
} }
disableSource.disabled = true; disableSource.disabled = true;
break; break;
@ -106,12 +105,12 @@ export async function POST(request: NextRequest) {
// 编辑直播源 // 编辑直播源
const editSource = config.LiveConfig.find((l) => l.key === key); const editSource = config.LiveConfig.find((l) => l.key === key);
if (!editSource) { if (!editSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 }); return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
} }
// 配置文件中的直播源不允许编辑 // 配置文件中的直播源不允许编辑
if (editSource.from === 'config') { if (editSource.from === 'config') {
return NextResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 }); return AppResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
} }
// 更新字段(除了 key 和 from // 更新字段(除了 key 和 from
@ -134,7 +133,7 @@ export async function POST(request: NextRequest) {
// 排序直播源 // 排序直播源
const { order } = body; const { order } = body;
if (!Array.isArray(order)) { if (!Array.isArray(order)) {
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 }); return AppResponse.json({ error: '排序数据格式错误' }, { status: 400 });
} }
// 创建新的排序后的数组 // 创建新的排序后的数组
@ -157,15 +156,15 @@ export async function POST(request: NextRequest) {
break; break;
default: default:
return NextResponse.json({ error: '未知操作' }, { status: 400 }); return AppResponse.json({ error: '未知操作' }, { status: 400 });
} }
// 保存配置 // 保存配置
await db.saveAdminConfig(config); await db.saveAdminConfig(config);
return NextResponse.json({ success: true }); return AppResponse.json({ success: true });
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ error: error instanceof Error ? error.message : '操作失败' }, { error: error instanceof Error ? error.message : '操作失败' },
{ status: 500 } { status: 500 }
); );

View File

@ -1,16 +1,15 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { resetConfig } from '@/lib/config'; import { resetConfig } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -20,18 +19,18 @@ export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
if (username !== process.env.USERNAME) { if (username !== process.env.USERNAME) {
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 }); return AppResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
} }
try { try {
await resetConfig(); await resetConfig();
return NextResponse.json( return AppResponse.json(
{ ok: true }, { ok: true },
{ {
headers: { headers: {
@ -40,7 +39,7 @@ export async function GET(request: NextRequest) {
} }
); );
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ {
error: '重置管理员配置失败', error: '重置管理员配置失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,17 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -24,7 +23,7 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
@ -77,7 +76,7 @@ export async function POST(request: NextRequest) {
typeof CustomTheme.customCSS !== 'string' typeof CustomTheme.customCSS !== 'string'
)) ))
) { ) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
const adminConfig = await getConfig(); const adminConfig = await getConfig();
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!user || user.role !== 'admin' || user.banned) { if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
} }
@ -112,7 +111,7 @@ export async function POST(request: NextRequest) {
// 写入数据库 // 写入数据库
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
return NextResponse.json( return AppResponse.json(
{ ok: true }, { ok: true },
{ {
headers: { headers: {
@ -122,7 +121,7 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error('更新站点配置失败:', error); console.error('更新站点配置失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '更新站点配置失败', error: '更新站点配置失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型 // 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete'; type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
@ -15,10 +14,10 @@ interface BaseBody {
action?: Action; action?: Action;
} }
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -32,14 +31,14 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
// 基础校验 // 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', '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 AppResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
// 获取配置与存储 // 获取配置与存储
@ -51,7 +50,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) { if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
} }
@ -64,10 +63,10 @@ export async function POST(request: NextRequest) {
detail?: string; detail?: string;
}; };
if (!key || !name || !api) { if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
} }
if (adminConfig.SourceConfig.some((s) => s.key === key)) { if (adminConfig.SourceConfig.some((s) => s.key === key)) {
return NextResponse.json({ error: '该源已存在' }, { status: 400 }); return AppResponse.json({ error: '该源已存在' }, { status: 400 });
} }
adminConfig.SourceConfig.push({ adminConfig.SourceConfig.push({
key, key,
@ -82,20 +81,20 @@ export async function POST(request: NextRequest) {
case 'disable': { case 'disable': {
const { key } = body as { key?: string }; const { key } = body as { key?: string };
if (!key) if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry) if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 }); return AppResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = true; entry.disabled = true;
break; break;
} }
case 'enable': { case 'enable': {
const { key } = body as { key?: string }; const { key } = body as { key?: string };
if (!key) if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry) if (!entry)
return NextResponse.json({ error: '源不存在' }, { status: 404 }); return AppResponse.json({ error: '源不存在' }, { status: 404 });
entry.disabled = false; entry.disabled = false;
break; break;
} }
@ -107,11 +106,11 @@ export async function POST(request: NextRequest) {
detail?: string; detail?: string;
}; };
if (!key || !name || !api) { if (!key || !name || !api) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
} }
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
if (!entry) { if (!entry) {
return NextResponse.json({ error: '源不存在' }, { status: 404 }); return AppResponse.json({ error: '源不存在' }, { status: 404 });
} }
// 更新字段(除了 key 和 from // 更新字段(除了 key 和 from
entry.name = name; entry.name = name;
@ -122,13 +121,13 @@ export async function POST(request: NextRequest) {
case 'delete': { case 'delete': {
const { key } = body as { key?: string }; const { key } = body as { key?: string };
if (!key) if (!key)
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 }); return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key); const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
if (idx === -1) if (idx === -1)
return NextResponse.json({ error: '源不存在' }, { status: 404 }); return AppResponse.json({ error: '源不存在' }, { status: 404 });
const entry = adminConfig.SourceConfig[idx]; const entry = adminConfig.SourceConfig[idx];
if (entry.from === 'config') { if (entry.from === 'config') {
return NextResponse.json({ error: '该源不可删除' }, { status: 400 }); return AppResponse.json({ error: '该源不可删除' }, { status: 400 });
} }
adminConfig.SourceConfig.splice(idx, 1); adminConfig.SourceConfig.splice(idx, 1);
@ -153,7 +152,7 @@ export async function POST(request: NextRequest) {
case 'batch_disable': { case 'batch_disable': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
} }
keys.forEach(key => { keys.forEach(key => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
@ -166,7 +165,7 @@ export async function POST(request: NextRequest) {
case 'batch_enable': { case 'batch_enable': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
} }
keys.forEach(key => { keys.forEach(key => {
const entry = adminConfig.SourceConfig.find((s) => s.key === key); const entry = adminConfig.SourceConfig.find((s) => s.key === key);
@ -179,7 +178,7 @@ export async function POST(request: NextRequest) {
case 'batch_delete': { case 'batch_delete': {
const { keys } = body as { keys?: string[] }; const { keys } = body as { keys?: string[] };
if (!Array.isArray(keys) || keys.length === 0) { if (!Array.isArray(keys) || keys.length === 0) {
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 }); return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
} }
// 过滤掉 from=config 的源,但不报错 // 过滤掉 from=config 的源,但不报错
const keysToDelete = keys.filter(key => { const keysToDelete = keys.filter(key => {
@ -218,7 +217,7 @@ export async function POST(request: NextRequest) {
case 'sort': { case 'sort': {
const { order } = body as { order?: string[] }; const { order } = body as { order?: string[] };
if (!Array.isArray(order)) { if (!Array.isArray(order)) {
return NextResponse.json( return AppResponse.json(
{ error: '排序列表格式错误' }, { error: '排序列表格式错误' },
{ status: 400 } { status: 400 }
); );
@ -240,13 +239,13 @@ export async function POST(request: NextRequest) {
break; break;
} }
default: default:
return NextResponse.json({ error: '未知操作' }, { status: 400 }); return AppResponse.json({ error: '未知操作' }, { status: 400 });
} }
// 持久化到存储 // 持久化到存储
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
return NextResponse.json( return AppResponse.json(
{ ok: true }, { ok: true },
{ {
headers: { headers: {
@ -256,7 +255,7 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error('视频源管理操作失败:', error); console.error('视频源管理操作失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '视频源管理操作失败', error: '视频源管理操作失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,17 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { API_CONFIG } from '@/lib/config'; import { API_CONFIG } from '@/lib/config';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);

View File

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

View File

@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型 // 支持的操作类型
const ACTIONS = [ const ACTIONS = [
@ -23,10 +22,10 @@ const ACTIONS = [
'batchUpdateUserGroups', 'batchUpdateUserGroups',
] as const; ] as const;
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储进行管理员配置', error: '不支持本地存储进行管理员配置',
}, },
@ -39,7 +38,7 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const username = authInfo.username; const username = authInfo.username;
@ -54,12 +53,12 @@ export async function POST(request: NextRequest) {
}; };
if (!action || !ACTIONS.includes(action)) { if (!action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
} }
// 用户组操作和批量操作不需要targetUsername // 用户组操作和批量操作不需要targetUsername
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) { if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 }); return AppResponse.json({ error: '缺少目标用户名' }, { status: 400 });
} }
if ( if (
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
action !== 'batchUpdateUserGroups' && action !== 'batchUpdateUserGroups' &&
username === targetUsername username === targetUsername
) { ) {
return NextResponse.json( return AppResponse.json(
{ error: '无法对自己进行此操作' }, { error: '无法对自己进行此操作' },
{ status: 400 } { status: 400 }
); );
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username (u) => u.username === username
); );
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) { if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 }); return AppResponse.json({ error: '权限不足' }, { status: 401 });
} }
operatorRole = 'admin'; operatorRole = 'admin';
} }
@ -108,7 +107,7 @@ export async function POST(request: NextRequest) {
targetEntry.role === 'owner' && targetEntry.role === 'owner' &&
!['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action) !['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
) { ) {
return NextResponse.json({ error: '无法操作站长' }, { status: 400 }); return AppResponse.json({ error: '无法操作站长' }, { status: 400 });
} }
// 权限校验逻辑 // 权限校验逻辑
@ -118,10 +117,10 @@ export async function POST(request: NextRequest) {
switch (action) { switch (action) {
case 'add': { case 'add': {
if (targetEntry) { if (targetEntry) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 }); return AppResponse.json({ error: '用户已存在' }, { status: 400 });
} }
if (!targetPassword) { if (!targetPassword) {
return NextResponse.json( return AppResponse.json(
{ error: '缺少目标用户密码' }, { error: '缺少目标用户密码' },
{ status: 400 } { status: 400 }
); );
@ -151,7 +150,7 @@ export async function POST(request: NextRequest) {
} }
case 'ban': { case 'ban': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
@ -159,7 +158,7 @@ export async function POST(request: NextRequest) {
if (isTargetAdmin) { if (isTargetAdmin) {
// 目标是管理员 // 目标是管理员
if (operatorRole !== 'owner') { if (operatorRole !== 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可封禁管理员' }, { error: '仅站长可封禁管理员' },
{ status: 401 } { status: 401 }
); );
@ -170,14 +169,14 @@ export async function POST(request: NextRequest) {
} }
case 'unban': { case 'unban': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
} }
if (isTargetAdmin) { if (isTargetAdmin) {
if (operatorRole !== 'owner') { if (operatorRole !== 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可操作管理员' }, { error: '仅站长可操作管理员' },
{ status: 401 } { status: 401 }
); );
@ -188,19 +187,19 @@ export async function POST(request: NextRequest) {
} }
case 'setAdmin': { case 'setAdmin': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
} }
if (targetEntry.role === 'admin') { if (targetEntry.role === 'admin') {
return NextResponse.json( return AppResponse.json(
{ error: '该用户已是管理员' }, { error: '该用户已是管理员' },
{ status: 400 } { status: 400 }
); );
} }
if (operatorRole !== 'owner') { if (operatorRole !== 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可设置管理员' }, { error: '仅站长可设置管理员' },
{ status: 401 } { status: 401 }
); );
@ -210,19 +209,19 @@ export async function POST(request: NextRequest) {
} }
case 'cancelAdmin': { case 'cancelAdmin': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
} }
if (targetEntry.role !== 'admin') { if (targetEntry.role !== 'admin') {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不是管理员' }, { error: '目标用户不是管理员' },
{ status: 400 } { status: 400 }
); );
} }
if (operatorRole !== 'owner') { if (operatorRole !== 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可取消管理员' }, { error: '仅站长可取消管理员' },
{ status: 401 } { status: 401 }
); );
@ -232,18 +231,18 @@ export async function POST(request: NextRequest) {
} }
case 'changePassword': { case 'changePassword': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
} }
if (!targetPassword) { if (!targetPassword) {
return NextResponse.json({ error: '缺少新密码' }, { status: 400 }); return AppResponse.json({ error: '缺少新密码' }, { status: 400 });
} }
// 权限检查:不允许修改站长密码 // 权限检查:不允许修改站长密码
if (targetEntry.role === 'owner') { if (targetEntry.role === 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '无法修改站长密码' }, { error: '无法修改站长密码' },
{ status: 401 } { status: 401 }
); );
@ -254,7 +253,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' && operatorRole !== 'owner' &&
username !== targetUsername username !== targetUsername
) { ) {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可修改其他管理员密码' }, { error: '仅站长可修改其他管理员密码' },
{ status: 401 } { status: 401 }
); );
@ -265,7 +264,7 @@ export async function POST(request: NextRequest) {
} }
case 'deleteUser': { case 'deleteUser': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
@ -273,14 +272,14 @@ export async function POST(request: NextRequest) {
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户 // 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
if (username === targetUsername) { if (username === targetUsername) {
return NextResponse.json( return AppResponse.json(
{ error: '不能删除自己' }, { error: '不能删除自己' },
{ status: 400 } { status: 400 }
); );
} }
if (isTargetAdmin && operatorRole !== 'owner') { if (isTargetAdmin && operatorRole !== 'owner') {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可删除管理员' }, { error: '仅站长可删除管理员' },
{ status: 401 } { status: 401 }
); );
@ -300,7 +299,7 @@ export async function POST(request: NextRequest) {
} }
case 'updateUserApis': { case 'updateUserApis': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json( return AppResponse.json(
{ error: '目标用户不存在' }, { error: '目标用户不存在' },
{ status: 404 } { status: 404 }
); );
@ -314,7 +313,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' && operatorRole !== 'owner' &&
username !== targetUsername username !== targetUsername
) { ) {
return NextResponse.json( return AppResponse.json(
{ error: '仅站长可配置其他管理员的采集源' }, { error: '仅站长可配置其他管理员的采集源' },
{ status: 401 } { status: 401 }
); );
@ -346,7 +345,7 @@ export async function POST(request: NextRequest) {
case 'add': { case 'add': {
// 检查用户组是否已存在 // 检查用户组是否已存在
if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) { if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
return NextResponse.json({ error: '用户组已存在' }, { status: 400 }); return AppResponse.json({ error: '用户组已存在' }, { status: 400 });
} }
adminConfig.UserConfig.Tags.push({ adminConfig.UserConfig.Tags.push({
name: groupName, name: groupName,
@ -357,7 +356,7 @@ export async function POST(request: NextRequest) {
case 'edit': { case 'edit': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName); const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
if (groupIndex === -1) { if (groupIndex === -1) {
return NextResponse.json({ error: '用户组不存在' }, { status: 404 }); return AppResponse.json({ error: '用户组不存在' }, { status: 404 });
} }
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || []; adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
break; break;
@ -365,7 +364,7 @@ export async function POST(request: NextRequest) {
case 'delete': { case 'delete': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName); const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
if (groupIndex === -1) { if (groupIndex === -1) {
return NextResponse.json({ error: '用户组不存在' }, { status: 404 }); return AppResponse.json({ error: '用户组不存在' }, { status: 404 });
} }
// 查找使用该用户组的所有用户 // 查找使用该用户组的所有用户
@ -391,13 +390,13 @@ export async function POST(request: NextRequest) {
break; break;
} }
default: default:
return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 }); return AppResponse.json({ error: '未知的用户组操作' }, { status: 400 });
} }
break; break;
} }
case 'updateUserGroups': { case 'updateUserGroups': {
if (!targetEntry) { if (!targetEntry) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 }); return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
} }
const { userGroups } = body as { userGroups: string[] }; const { userGroups } = body as { userGroups: string[] };
@ -408,7 +407,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' && operatorRole !== 'owner' &&
username !== targetUsername username !== targetUsername
) { ) {
return NextResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 }); return AppResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
} }
// 更新用户的用户组 // 更新用户的用户组
@ -425,7 +424,7 @@ export async function POST(request: NextRequest) {
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] }; const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) { if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 }); return AppResponse.json({ error: '缺少用户名列表' }, { status: 400 });
} }
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户 // 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
@ -433,7 +432,7 @@ export async function POST(request: NextRequest) {
for (const targetUsername of usernames) { for (const targetUsername of usernames) {
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername); const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) { if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 }); return AppResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
} }
} }
} }
@ -454,13 +453,13 @@ export async function POST(request: NextRequest) {
break; break;
} }
default: default:
return NextResponse.json({ error: '未知操作' }, { status: 400 }); return AppResponse.json({ error: '未知操作' }, { status: 400 });
} }
// 将更新后的配置写入数据库 // 将更新后的配置写入数据库
await db.saveAdminConfig(adminConfig); await db.saveAdminConfig(adminConfig);
return NextResponse.json( return AppResponse.json(
{ ok: true }, { ok: true },
{ {
headers: { headers: {
@ -470,7 +469,7 @@ export async function POST(request: NextRequest) {
); );
} catch (error) { } catch (error) {
console.error('用户管理操作失败:', error); console.error('用户管理操作失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '用户管理操作失败', error: '用户管理操作失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,16 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取用户头像 // 获取用户头像
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -23,41 +22,41 @@ export async function GET(request: NextRequest) {
const avatar = await db.getUserAvatar(targetUser); const avatar = await db.getUserAvatar(targetUser);
if (!avatar) { if (!avatar) {
return NextResponse.json({ avatar: null }); return AppResponse.json({ avatar: null });
} }
return NextResponse.json({ avatar }); return AppResponse.json({ avatar });
} catch (error) { } catch (error) {
console.error('获取头像失败:', error); console.error('获取头像失败:', error);
return NextResponse.json({ error: '获取头像失败' }, { status: 500 }); return AppResponse.json({ error: '获取头像失败' }, { status: 500 });
} }
} }
// 上传用户头像 // 上传用户头像
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const body = await request.json(); const body = await request.json();
const { avatar, targetUser } = body; const { avatar, targetUser } = body;
if (!avatar) { if (!avatar) {
return NextResponse.json({ error: '头像数据不能为空' }, { status: 400 }); return AppResponse.json({ error: '头像数据不能为空' }, { status: 400 });
} }
// 验证Base64格式 // 验证Base64格式
if (!avatar.startsWith('data:image/')) { if (!avatar.startsWith('data:image/')) {
return NextResponse.json({ error: '无效的图片格式' }, { status: 400 }); return AppResponse.json({ error: '无效的图片格式' }, { status: 400 });
} }
// 检查文件大小Base64编码后大约增加33%2MB的限制 // 检查文件大小Base64编码后大约增加33%2MB的限制
const base64Data = avatar.split(',')[1]; const base64Data = avatar.split(',')[1];
const sizeInBytes = (base64Data.length * 3) / 4; const sizeInBytes = (base64Data.length * 3) / 4;
if (sizeInBytes > 2 * 1024 * 1024) { if (sizeInBytes > 2 * 1024 * 1024) {
return NextResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 }); return AppResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
} }
const userToUpdate = targetUser || authInfo.username; const userToUpdate = targetUser || authInfo.username;
@ -68,24 +67,24 @@ export async function POST(request: NextRequest) {
authInfo.role === 'owner'; authInfo.role === 'owner';
if (!canUpdate) { if (!canUpdate) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
} }
await db.setUserAvatar(userToUpdate, avatar); await db.setUserAvatar(userToUpdate, avatar);
return NextResponse.json({ success: true, message: '头像上传成功' }); return AppResponse.json({ success: true, message: '头像上传成功' });
} catch (error) { } catch (error) {
console.error('上传头像失败:', error); console.error('上传头像失败:', error);
return NextResponse.json({ error: '上传头像失败' }, { status: 500 }); return AppResponse.json({ error: '上传头像失败' }, { status: 500 });
} }
} }
// 删除用户头像 // 删除用户头像
export async function DELETE(request: NextRequest) { export async function DELETE(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -97,14 +96,14 @@ export async function DELETE(request: NextRequest) {
authInfo.role === 'owner'; authInfo.role === 'owner';
if (!canDelete) { if (!canDelete) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
} }
await db.deleteUserAvatar(targetUser); await db.deleteUserAvatar(targetUser);
return NextResponse.json({ success: true, message: '头像删除成功' }); return AppResponse.json({ success: true, message: '头像删除成功' });
} catch (error) { } catch (error) {
console.error('删除头像失败:', error); console.error('删除头像失败:', error);
return NextResponse.json({ error: '删除头像失败' }, { status: 500 }); return AppResponse.json({ error: '删除头像失败' }, { status: 500 });
} }
} }

View File

@ -1,18 +1,17 @@
/* eslint-disable no-console*/ /* eslint-disable no-console*/
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
// 不支持 localstorage 模式 // 不支持 localstorage 模式
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
return NextResponse.json( return AppResponse.json(
{ {
error: '不支持本地存储模式修改密码', error: '不支持本地存储模式修改密码',
}, },
@ -27,19 +26,19 @@ export async function POST(request: NextRequest) {
// 获取认证信息 // 获取认证信息
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// 验证新密码 // 验证新密码
if (!newPassword || typeof newPassword !== 'string') { if (!newPassword || typeof newPassword !== 'string') {
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 }); return AppResponse.json({ error: '新密码不得为空' }, { status: 400 });
} }
const username = authInfo.username; const username = authInfo.username;
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME // 不允许站长修改密码(站长用户名等于 process.env.USERNAME
if (username === process.env.USERNAME) { if (username === process.env.USERNAME) {
return NextResponse.json( return AppResponse.json(
{ error: '站长不能通过此接口修改密码' }, { error: '站长不能通过此接口修改密码' },
{ status: 403 } { status: 403 }
); );
@ -48,10 +47,10 @@ export async function POST(request: NextRequest) {
// 修改密码 // 修改密码
await db.changePassword(username, newPassword); await db.changePassword(username, newPassword);
return NextResponse.json({ ok: true }); return AppResponse.json({ ok: true });
} catch (error) { } catch (error) {
console.error('修改密码失败:', error); console.error('修改密码失败:', error);
return NextResponse.json( return AppResponse.json(
{ {
error: '修改密码失败', error: '修改密码失败',
details: (error as Error).message, details: (error as Error).message,

View File

@ -1,34 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { db } from '../../../../lib/db'; import { db } from '@/lib/db';
import { Conversation } from '../../../../lib/types'; import { Conversation } from '@/lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const conversations = await db.getConversations(authInfo.username); const conversations = await db.getConversations(authInfo.username);
return NextResponse.json(conversations); return AppResponse.json(conversations);
} catch (error) { } catch (error) {
console.error('Error loading conversations:', error); console.error('Error loading conversations:', error);
return NextResponse.json({ error: '获取对话列表失败' }, { status: 500 }); return AppResponse.json({ error: '获取对话列表失败' }, { status: 500 });
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { participants, name, type } = await request.json(); const { participants, name, type } = await request.json();
if (!participants || !Array.isArray(participants) || participants.length === 0) { if (!participants || !Array.isArray(participants) || participants.length === 0) {
return NextResponse.json({ error: '参与者列表不能为空' }, { status: 400 }); return AppResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
} }
// 确保当前用户在参与者列表中 // 确保当前用户在参与者列表中
@ -51,9 +51,9 @@ export async function POST(request: NextRequest) {
}; };
await db.createConversation(conversation); await db.createConversation(conversation);
return NextResponse.json(conversation, { status: 201 }); return AppResponse.json(conversation, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating conversation:', error); console.error('Error creating conversation:', error);
return NextResponse.json({ error: '创建对话失败' }, { status: 500 }); return AppResponse.json({ error: '创建对话失败' }, { status: 500 });
} }
} }

View File

@ -1,47 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { db } from '../../../../lib/db'; import { db } from '@/lib/db';
import { FriendRequest, Friend } from '../../../../lib/types'; import { FriendRequest, Friend } from '@/lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const friendRequests = await db.getFriendRequests(authInfo.username); const friendRequests = await db.getFriendRequests(authInfo.username);
return NextResponse.json(friendRequests); return AppResponse.json(friendRequests);
} catch (error) { } catch (error) {
console.error('Error loading friend requests:', error); console.error('Error loading friend requests:', error);
return NextResponse.json({ error: '获取好友申请失败' }, { status: 500 }); return AppResponse.json({ error: '获取好友申请失败' }, { status: 500 });
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { to_user, message } = await request.json(); const { to_user, message } = await request.json();
if (!to_user) { if (!to_user) {
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 }); return AppResponse.json({ error: '目标用户不能为空' }, { status: 400 });
} }
// 检查目标用户是否存在 // 检查目标用户是否存在
const userExists = await db.checkUserExist(to_user); const userExists = await db.checkUserExist(to_user);
if (!userExists) { if (!userExists) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 }); return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
} }
// 检查是否已经是好友 // 检查是否已经是好友
const friends = await db.getFriends(authInfo.username); const friends = await db.getFriends(authInfo.username);
const isAlreadyFriend = friends.some(friend => friend.username === to_user); const isAlreadyFriend = friends.some(friend => friend.username === to_user);
if (isAlreadyFriend) { if (isAlreadyFriend) {
return NextResponse.json({ error: '已经是好友' }, { status: 400 }); return AppResponse.json({ error: '已经是好友' }, { status: 400 });
} }
// 检查是否已经有pending的申请 // 检查是否已经有pending的申请
@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
req => req.from_user === authInfo.username && req.status === 'pending' req => req.from_user === authInfo.username && req.status === 'pending'
); );
if (hasPendingRequest) { if (hasPendingRequest) {
return NextResponse.json({ error: '已有待处理的好友申请' }, { status: 400 }); return AppResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
} }
const friendRequest: FriendRequest = { const friendRequest: FriendRequest = {
@ -64,24 +64,24 @@ export async function POST(request: NextRequest) {
}; };
await db.createFriendRequest(friendRequest); await db.createFriendRequest(friendRequest);
return NextResponse.json(friendRequest, { status: 201 }); return AppResponse.json(friendRequest, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error creating friend request:', error); console.error('Error creating friend request:', error);
return NextResponse.json({ error: '发送好友申请失败' }, { status: 500 }); return AppResponse.json({ error: '发送好友申请失败' }, { status: 500 });
} }
} }
export async function PUT(request: NextRequest) { export async function PUT(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { requestId, status } = await request.json(); const { requestId, status } = await request.json();
if (!requestId || !status || !['accepted', 'rejected'].includes(status)) { if (!requestId || !status || !['accepted', 'rejected'].includes(status)) {
return NextResponse.json({ error: '请求参数无效' }, { status: 400 }); return AppResponse.json({ error: '请求参数无效' }, { status: 400 });
} }
// 获取申请信息 // 获取申请信息
@ -89,11 +89,11 @@ export async function PUT(request: NextRequest) {
const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username); const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username);
if (!friendRequest) { if (!friendRequest) {
return NextResponse.json({ error: '好友申请不存在' }, { status: 404 }); return AppResponse.json({ error: '好友申请不存在' }, { status: 404 });
} }
if (friendRequest.status !== 'pending') { if (friendRequest.status !== 'pending') {
return NextResponse.json({ error: '申请已处理' }, { status: 400 }); return AppResponse.json({ error: '申请已处理' }, { status: 400 });
} }
// 更新申请状态 // 更新申请状态
@ -122,9 +122,9 @@ export async function PUT(request: NextRequest) {
]); ]);
} }
return NextResponse.json({ success: true }); return AppResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error handling friend request:', error); console.error('Error handling friend request:', error);
return NextResponse.json({ error: '处理好友申请失败' }, { status: 500 }); return AppResponse.json({ error: '处理好友申请失败' }, { status: 500 });
} }
} }

View File

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

View File

@ -1,14 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { db } from '../../../../lib/db'; import { db } from '@/lib/db';
import { ChatMessage } from '../../../../lib/types'; import { ChatMessage } from '@/lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
console.log('未授权访问消息API:', authInfo); console.log('未授权访问消息API:', authInfo);
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
if (!conversationId) { if (!conversationId) {
console.log('缺少对话ID参数'); console.log('缺少对话ID参数');
return NextResponse.json({ error: '对话 ID 不能为空' }, { status: 400 }); return AppResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
} }
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId); console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在'); console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
} catch (dbError) { } catch (dbError) {
console.error('数据库查询对话失败:', dbError); console.error('数据库查询对话失败:', dbError);
return NextResponse.json({ return AppResponse.json({
error: '数据库查询失败', error: '数据库查询失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 }); }, { status: 500 });
@ -38,21 +38,21 @@ export async function GET(request: NextRequest) {
if (!conversation) { if (!conversation) {
console.log('对话不存在:', conversationId); console.log('对话不存在:', conversationId);
return NextResponse.json({ error: '对话不存在' }, { status: 404 }); return AppResponse.json({ error: '对话不存在' }, { status: 404 });
} }
if (!conversation.participants.includes(authInfo.username)) { if (!conversation.participants.includes(authInfo.username)) {
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants); console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
return NextResponse.json({ error: '无权限访问此对话' }, { status: 403 }); return AppResponse.json({ error: '无权限访问此对话' }, { status: 403 });
} }
try { try {
const messages = await db.getMessages(conversationId, limit, offset); const messages = await db.getMessages(conversationId, limit, offset);
console.log(`成功加载 ${messages.length} 条消息`); console.log(`成功加载 ${messages.length} 条消息`);
return NextResponse.json(messages); return AppResponse.json(messages);
} catch (dbError) { } catch (dbError) {
console.error('数据库查询消息失败:', dbError); console.error('数据库查询消息失败:', dbError);
return NextResponse.json({ return AppResponse.json({
error: '获取消息失败', error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 }); }, { status: 500 });
@ -60,30 +60,30 @@ export async function GET(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('加载消息API发生未知错误:', error); console.error('加载消息API发生未知错误:', error);
return NextResponse.json({ return AppResponse.json({
error: '获取消息失败', error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
}, { status: 500 }); }, { status: 500 });
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const messageData = await request.json(); const messageData = await request.json();
if (!messageData.conversation_id || !messageData.content) { if (!messageData.conversation_id || !messageData.content) {
return NextResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 }); return AppResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 });
} }
// 验证用户是否有权限发送消息到此对话 // 验证用户是否有权限发送消息到此对话
const conversation = await db.getConversation(messageData.conversation_id); const conversation = await db.getConversation(messageData.conversation_id);
if (!conversation || !conversation.participants.includes(authInfo.username)) { if (!conversation || !conversation.participants.includes(authInfo.username)) {
return NextResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 }); return AppResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
} }
const message: ChatMessage = { const message: ChatMessage = {
@ -105,30 +105,30 @@ export async function POST(request: NextRequest) {
updated_at: Date.now(), updated_at: Date.now(),
}); });
return NextResponse.json(message, { status: 201 }); return AppResponse.json(message, { status: 201 });
} catch (error) { } catch (error) {
console.error('Error sending message:', error); console.error('Error sending message:', error);
return NextResponse.json({ error: '发送消息失败' }, { status: 500 }); return AppResponse.json({ error: '发送消息失败' }, { status: 500 });
} }
} }
export async function PUT(request: NextRequest) { export async function PUT(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { messageId } = await request.json(); const { messageId } = await request.json();
if (!messageId) { if (!messageId) {
return NextResponse.json({ error: '消息 ID 不能为空' }, { status: 400 }); return AppResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
} }
await db.markMessageAsRead(messageId); await db.markMessageAsRead(messageId);
return NextResponse.json({ success: true }); return AppResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Error marking message as read:', error); console.error('Error marking message as read:', error);
return NextResponse.json({ error: '标记消息已读失败' }, { status: 500 }); return AppResponse.json({ error: '标记消息已读失败' }, { status: 500 });
} }
} }

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
// 从全局对象获取WebSocket实例相关方法 // 从全局对象获取WebSocket实例相关方法
function getOnlineUsers(): string[] { function getOnlineUsers(): string[] {
@ -16,17 +16,17 @@ function getOnlineUsers(): string[] {
} }
// 获取在线用户列表 // 获取在线用户列表
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const onlineUsers = getOnlineUsers(); const onlineUsers = getOnlineUsers();
return NextResponse.json({ onlineUsers }); return AppResponse.json({ onlineUsers });
} catch (error) { } catch (error) {
console.error('获取在线用户失败:', error); console.error('获取在线用户失败:', error);
return NextResponse.json({ error: '获取在线用户失败' }, { status: 500 }); return AppResponse.json({ error: '获取在线用户失败' }, { status: 500 });
} }
} }

View File

@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { db } from '../../../../lib/db'; import { db } from '@/lib/db';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const query = searchParams.get('q'); const query = searchParams.get('q');
if (!query || query.trim().length < 2) { if (!query || query.trim().length < 2) {
return NextResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 }); return AppResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
} }
// 获取所有用户并进行模糊匹配 // 获取所有用户并进行模糊匹配
@ -31,9 +31,9 @@ export async function GET(request: NextRequest) {
added_at: 0, added_at: 0,
})); }));
return NextResponse.json(userResults); return AppResponse.json(userResults);
} catch (error) { } catch (error) {
console.error('Error searching users:', error); console.error('Error searching users:', error);
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 }); return AppResponse.json({ error: '搜索用户失败' }, { status: 500 });
} }
} }

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '../../../../lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { WebSocketMessage } from '../../../../lib/types'; import { WebSocketMessage } from '@/lib/types';
// 从全局对象获取WebSocket实例相关方法 // 从全局对象获取WebSocket实例相关方法
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean { function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
@ -17,11 +17,11 @@ function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boole
} }
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用 // 发送消息的备用 API 路由,在 WebSocket 不可用时使用
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return AppResponse.json({ error: '未授权' }, { status: 401 });
} }
const message: WebSocketMessage = await request.json(); const message: WebSocketMessage = await request.json();
@ -52,18 +52,18 @@ export async function POST(request: NextRequest) {
break; break;
default: default:
return NextResponse.json({ error: '不支持的消息类型' }, { status: 400 }); return AppResponse.json({ error: '不支持的消息类型' }, { status: 400 });
} }
// 通过 WebSocket 发送消息 // 通过 WebSocket 发送消息
const sent = sendMessageToUsers(targetUsers, message); const sent = sendMessageToUsers(targetUsers, message);
return NextResponse.json({ return AppResponse.json({
success: true, success: true,
delivered: sent delivered: sent
}); });
} catch (error) { } catch (error) {
console.error('通过 API 发送消息失败:', error); console.error('通过 API 发送消息失败:', error);
return NextResponse.json({ error: '发送消息失败' }, { status: 500 }); return AppResponse.json({ error: '发送消息失败' }, { status: 500 });
} }
} }

View File

@ -1,6 +1,6 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */ /* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getConfig, refineConfig } from '@/lib/config'; import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@ -8,16 +8,15 @@ import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live'; import { refreshLiveChannels } from '@/lib/live';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
console.log(request.url); console.log(request.url);
try { try {
console.log('Cron job triggered:', new Date().toISOString()); console.log('Cron job triggered:', new Date().toISOString());
cronJob(); cronJob();
return NextResponse.json({ return AppResponse.json({
success: true, success: true,
message: 'Cron job executed successfully', message: 'Cron job executed successfully',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@ -25,7 +24,7 @@ export async function GET(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Cron job failed:', error); console.error('Cron job failed:', error);
return NextResponse.json( return AppResponse.json(
{ {
success: false, success: false,
message: 'Cron job failed', message: 'Cron job failed',

View File

@ -1,18 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取弹幕 // 获取弹幕
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const videoId = searchParams.get('videoId'); const videoId = searchParams.get('videoId');
if (!videoId) { if (!videoId) {
return NextResponse.json({ error: '视频ID不能为空' }, { status: 400 }); return AppResponse.json({ error: '视频ID不能为空' }, { status: 400 });
} }
const danmuList = await db.getDanmu(videoId); const danmuList = await db.getDanmu(videoId);
@ -27,38 +26,38 @@ export async function GET(request: NextRequest) {
size: 25 size: 25
})); }));
return NextResponse.json(formattedDanmu); return AppResponse.json(formattedDanmu);
} catch (error) { } catch (error) {
console.error('获取弹幕失败:', error); console.error('获取弹幕失败:', error);
return NextResponse.json({ error: '获取弹幕失败' }, { status: 500 }); return AppResponse.json({ error: '获取弹幕失败' }, { status: 500 });
} }
} }
// 发送弹幕 // 发送弹幕
export async function POST(request: NextRequest) { export async function POST(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const body = await request.json(); const body = await request.json();
const { videoId, text, color, mode, time } = body; const { videoId, text, color, mode, time } = body;
if (!videoId || !text) { if (!videoId || !text) {
return NextResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 }); return AppResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
} }
// 验证弹幕内容长度 // 验证弹幕内容长度
if (text.length > 100) { if (text.length > 100) {
return NextResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 }); return AppResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
} }
// 过滤敏感内容(可以扩展) // 过滤敏感内容(可以扩展)
const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词 const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词
const hasSensitiveWord = sensitiveWords.some(word => text.includes(word)); const hasSensitiveWord = sensitiveWords.some(word => text.includes(word));
if (hasSensitiveWord) { if (hasSensitiveWord) {
return NextResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 }); return AppResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
} }
const danmuData = { const danmuData = {
@ -71,24 +70,24 @@ export async function POST(request: NextRequest) {
await db.saveDanmu(videoId, authInfo.username, danmuData); await db.saveDanmu(videoId, authInfo.username, danmuData);
return NextResponse.json({ success: true, message: '弹幕发送成功' }); return AppResponse.json({ success: true, message: '弹幕发送成功' });
} catch (error) { } catch (error) {
console.error('发送弹幕失败:', error); console.error('发送弹幕失败:', error);
return NextResponse.json({ error: '发送弹幕失败' }, { status: 500 }); return AppResponse.json({ error: '发送弹幕失败' }, { status: 500 });
} }
} }
// 删除弹幕(管理员功能) // 删除弹幕(管理员功能)
export async function DELETE(request: NextRequest) { export async function DELETE(request: AppRequest) {
try { try {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
// 只有管理员和站长可以删除弹幕 // 只有管理员和站长可以删除弹幕
if (authInfo.role !== 'admin' && authInfo.role !== 'owner') { if (authInfo.role !== 'admin' && authInfo.role !== 'owner') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -96,14 +95,14 @@ export async function DELETE(request: NextRequest) {
const danmuId = searchParams.get('danmuId'); const danmuId = searchParams.get('danmuId');
if (!videoId || !danmuId) { if (!videoId || !danmuId) {
return NextResponse.json({ error: '视频ID和弹幕ID不能为空' }, { status: 400 }); return AppResponse.json({ error: '视频ID和弹幕ID不能为空' }, { status: 400 });
} }
await db.deleteDanmu(videoId, danmuId); await db.deleteDanmu(videoId, danmuId);
return NextResponse.json({ success: true, message: '弹幕删除成功' }); return AppResponse.json({ success: true, message: '弹幕删除成功' });
} catch (error) { } catch (error) {
console.error('删除弹幕失败:', error); console.error('删除弹幕失败:', error);
return NextResponse.json({ error: '删除弹幕失败' }, { status: 500 }); return AppResponse.json({ error: '删除弹幕失败' }, { status: 500 });
} }
} }

View File

@ -1,15 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getCacheTime } from '@/lib/config'; import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream'; import { getDetailFromApi } from '@/lib/downstream';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -17,11 +16,11 @@ export async function GET(request: NextRequest) {
const sourceCode = searchParams.get('source'); const sourceCode = searchParams.get('source');
if (!id || !sourceCode) { if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
} }
if (!/^[\w-]+$/.test(id)) { if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 }); return AppResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
} }
try { try {
@ -29,13 +28,13 @@ export async function GET(request: NextRequest) {
const apiSite = apiSites.find((site) => site.key === sourceCode); const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) { if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 }); return AppResponse.json({ error: '无效的API来源' }, { status: 400 });
} }
const result = await getDetailFromApi(apiSite, id); const result = await getDetailFromApi(apiSite, id);
const cacheTime = await getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(result, { return AppResponse.json(result, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -44,7 +43,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ error: (error as Error).message }, { error: (error as Error).message },
{ status: 500 } { status: 500 }
); );

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { AppResponse } from '@/server/web';
import { getCacheTime } from '@/lib/config'; import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban'; import { fetchDoubanData } from '@/lib/douban';
@ -20,7 +20,6 @@ interface DoubanCategoryApiResponse {
}>; }>;
} }
export const runtime = 'nodejs';
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -34,28 +33,28 @@ export async function GET(request: Request) {
// 验证参数 // 验证参数
if (!kind || !category || !type) { if (!kind || !category || !type) {
return NextResponse.json( return AppResponse.json(
{ error: '缺少必要参数: kind 或 category 或 type' }, { error: '缺少必要参数: kind 或 category 或 type' },
{ status: 400 } { status: 400 }
); );
} }
if (!['tv', 'movie'].includes(kind)) { if (!['tv', 'movie'].includes(kind)) {
return NextResponse.json( return AppResponse.json(
{ error: 'kind 参数必须是 tv 或 movie' }, { error: 'kind 参数必须是 tv 或 movie' },
{ status: 400 } { status: 400 }
); );
} }
if (pageLimit < 1 || pageLimit > 100) { if (pageLimit < 1 || pageLimit > 100) {
return NextResponse.json( return AppResponse.json(
{ error: 'pageSize 必须在 1-100 之间' }, { error: 'pageSize 必须在 1-100 之间' },
{ status: 400 } { status: 400 }
); );
} }
if (pageStart < 0) { if (pageStart < 0) {
return NextResponse.json( return AppResponse.json(
{ error: 'pageStart 不能小于 0' }, { error: 'pageStart 不能小于 0' },
{ status: 400 } { status: 400 }
); );
@ -83,7 +82,7 @@ export async function GET(request: Request) {
}; };
const cacheTime = await getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(response, { return AppResponse.json(response, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -92,7 +91,7 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message }, { error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 } { status: 500 }
); );

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest, NextResponse } from 'next/server'; import { AppRequest, AppResponse } from '@/server/web';
import { getCacheTime } from '@/lib/config'; import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban'; import { fetchDoubanData } from '@/lib/douban';
@ -23,9 +23,8 @@ interface DoubanRecommendApiResponse {
}>; }>;
} }
export const runtime = 'nodejs';
export async function GET(request: NextRequest) { export async function GET(request: AppRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
// 获取参数 // 获取参数
@ -47,7 +46,7 @@ export async function GET(request: NextRequest) {
searchParams.get('label') === 'all' ? '' : searchParams.get('label'); searchParams.get('label') === 'all' ? '' : searchParams.get('label');
if (!kind) { if (!kind) {
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 }); return AppResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
} }
const selectedCategories = { 类型: category } as any; const selectedCategories = { 类型: category } as any;
@ -113,7 +112,7 @@ export async function GET(request: NextRequest) {
}; };
const cacheTime = await getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(response, { return AppResponse.json(response, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -122,7 +121,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message }, { error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 } { status: 500 }
); );

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { AppResponse } from '@/server/web';
import { getCacheTime } from '@/lib/config'; import { getCacheTime } from '@/lib/config';
import { fetchDoubanData } from '@/lib/douban'; import { fetchDoubanData } from '@/lib/douban';
@ -13,7 +13,6 @@ interface DoubanApiResponse {
}>; }>;
} }
export const runtime = 'nodejs';
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -26,28 +25,28 @@ export async function GET(request: Request) {
// 验证参数 // 验证参数
if (!type || !tag) { if (!type || !tag) {
return NextResponse.json( return AppResponse.json(
{ error: '缺少必要参数: type 或 tag' }, { error: '缺少必要参数: type 或 tag' },
{ status: 400 } { status: 400 }
); );
} }
if (!['tv', 'movie'].includes(type)) { if (!['tv', 'movie'].includes(type)) {
return NextResponse.json( return AppResponse.json(
{ error: 'type 参数必须是 tv 或 movie' }, { error: 'type 参数必须是 tv 或 movie' },
{ status: 400 } { status: 400 }
); );
} }
if (pageSize < 1 || pageSize > 100) { if (pageSize < 1 || pageSize > 100) {
return NextResponse.json( return AppResponse.json(
{ error: 'pageSize 必须在 1-100 之间' }, { error: 'pageSize 必须在 1-100 之间' },
{ status: 400 } { status: 400 }
); );
} }
if (pageStart < 0) { if (pageStart < 0) {
return NextResponse.json( return AppResponse.json(
{ error: 'pageStart 不能小于 0' }, { error: 'pageStart 不能小于 0' },
{ status: 400 } { status: 400 }
); );
@ -79,7 +78,7 @@ export async function GET(request: Request) {
}; };
const cacheTime = await getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(response, { return AppResponse.json(response, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -88,7 +87,7 @@ export async function GET(request: Request) {
}, },
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return AppResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message }, { error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 } { status: 500 }
); );
@ -155,7 +154,7 @@ function handleTop250(pageStart: number) {
}; };
const cacheTime = await getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(apiResponse, { return AppResponse.json(apiResponse, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -166,7 +165,7 @@ function handleTop250(pageStart: number) {
}) })
.catch((error) => { .catch((error) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
return NextResponse.json( return AppResponse.json(
{ {
error: '获取豆瓣 Top250 数据失败', error: '获取豆瓣 Top250 数据失败',
details: (error as Error).message, details: (error as Error).message,

Some files were not shown because too many files have changed in this diff Show More