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*.local
.git
dist
node_modules
npm-debug.log*
pnpm-debug.log*

View File

@ -4,21 +4,44 @@ module.exports = {
es2021: true,
node: true,
},
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
plugins: [
'@typescript-eslint',
'react',
'react-hooks',
'simple-import-sort',
'unused-imports',
],
extends: [
'eslint:recommended',
'next',
'next/core-web-vitals',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
settings: {
react: {
version: 'detect',
},
},
rules: {
'no-unused-vars': 'off',
'no-console': 'warn',
'@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',
'no-empty': 'off',
'no-useless-escape': 'off',
'prefer-const': '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': [
'warn',
{ props: 'never', children: 'never' },

7
.gitignore vendored
View File

@ -8,12 +8,12 @@
# testing
/coverage
# next.js
/.next/
# static export
/out/
# production
/build
/dist
# misc
.DS_Store
@ -34,9 +34,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# next-sitemap
# generated sitemap
sitemap.xml
sitemap-*.xml

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

View File

@ -4,13 +4,14 @@
<img src="public/logo.png" alt="OrangeTV Logo" width="120">
</div>
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind&nbsp;CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Vite + React**、**Fastify**、**Tailwind&nbsp;CSS** 和 **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
<div align="center">
![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss)
![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript)
![Vite](https://img.shields.io/badge/Vite-React-646cff?logo=vite)
![Fastify](https://img.shields.io/badge/Fastify-API-000?logo=fastify)
![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)
![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 |
| UI & 样式 | [Tailwind&nbsp;CSS 3](https://tailwindcss.com/) |
| 语言 | TypeScript 4 |
| 前端框架 | [Vite](https://vite.dev/) · [React Router](https://reactrouter.com/) |
| 后端 | [Fastify](https://fastify.dev/) |
| 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/) |
| 代码质量 | ESLint · Prettier · Jest |
| 部署 | 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 的平台** 部署。
@ -77,11 +86,10 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
- VITE_STORAGE_TYPE=kvrocks
- KVROCKS_URL=redis://OrangeTV-kvrocks:6666
networks:
- OrangeTV-network
@ -112,11 +120,10 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=redis
- VITE_STORAGE_TYPE=redis
- REDIS_URL=redis://OrangeTV-redis:6379
networks:
- OrangeTV-network
@ -149,11 +156,10 @@ services:
restart: on-failure
ports:
- '3000:3000'
- '3001:3001'
environment:
- USERNAME=admin
- PASSWORD=orange
- NEXT_PUBLIC_STORAGE_TYPE=upstash
- VITE_STORAGE_TYPE=upstash
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
- UPSTASH_TOKEN=上面的 TOKEN
```
@ -218,37 +224,37 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
| VITE_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
| VITE_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
| VITE_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| VITE_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| VITE_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| VITE_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| VITE_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| VITE_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| VITE_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
VITE_DOUBAN_PROXY_TYPE 选项解释:
- direct: 由服务器直接请求豆瓣源站
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxyNEXT_PUBLIC_DOUBAN_PROXY 定义
- custom: 用户自定义 proxyVITE_DOUBAN_PROXY 定义
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
VITE_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
- direct由浏览器直接请求豆瓣分配的默认图片域名
- server由服务器代理请求豆瓣分配的默认图片域名
- img3由浏览器请求豆瓣官方的精品 cdn阿里云
- cmliussss-cdn-tencent由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali由浏览器请求豆瓣 CDN该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxyNEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
- custom: 用户自定义 proxyVITE_DOUBAN_IMAGE_PROXY 定义
## 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) — 由此启发,站在巨人的肩膀上。
- [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。
- [艾福森昵] - 感谢论坛佬友提供的短剧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.
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:

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
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
module.exports = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ['node_modules', '<rootDir>/'],
testEnvironment: 'jest-environment-jsdom',
/**
* Absolute imports and Module Path Aliases
*/
transform: {
'^.+\\.(ts|tsx)$': [
'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: {
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/public/$1',
'^.+\\.(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';
// Allow router mocks.
// eslint-disable-next-line no-undef
jest.mock('next/router', () => require('next-router-mock'));
require('@testing-library/jest-dom/extend-expect');

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

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

View File

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

View File

@ -3,17 +3,13 @@
"version": "0.1.0",
"private": true,
"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:complex": "pnpm gen:manifest && node dev-server.js",
"dev:ws": "node standalone-websocket.js",
"test:ws": "node test-websocket-connection.js",
"dev:complex": "pnpm dev",
"debug:api": "node debug-api.js",
"build": "pnpm gen:manifest && next build",
"start": "NODE_ENV=production node server.js",
"prod": "NODE_ENV=production node production.js",
"prod:final": "NODE_ENV=production node production-final.js",
"lint": "next lint",
"build": "pnpm gen:manifest && vite build && node scripts/build-server.js",
"start": "NODE_ENV=production node dist/server/index.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
"typecheck": "tsc --noEmit --incremental false",
@ -30,6 +26,9 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@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/styles": "3.0.5",
"@types/crypto-js": "^4.2.2",
@ -41,30 +40,34 @@
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
"fastify": "^5.8.5",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.10",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^15.5.18",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.4.0",
"react-image-crop": "^11.0.10",
"react-router-dom": "^7.15.1",
"redis": "^4.6.7",
"swiper": "^11.2.8",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "3.2.2",
"vidstack": "^0.6.15",
"vite-plugin-pwa": "^1.3.0",
"ws": "^8.18.3",
"zod": "^3.24.1"
},
"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/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/dom": "^10.4.1",
@ -72,28 +75,36 @@
"@testing-library/react": "^16.3.2",
"@types/bs58": "^5.0.0",
"@types/he": "^1.2.3",
"@types/jest": "27.5.2",
"@types/node": "24.0.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^6.0.2",
"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-config-next": "^15.5.18",
"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-unused-imports": "^2.0.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
"lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0",
"playwright": "^1.60.0",
"postcss": "^8.5.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^4.3.0",
"tsx": "^4.22.3",
"typescript": "^5.9.3",
"undici": "^8.3.0",
"vite": "^8.0.14",
"webpack-obfuscator": "^3.5.1"
},
"lint-staged": {
@ -105,5 +116,9 @@
"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');
});

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(
ports.map(async (port) => ({
port,
@ -136,16 +136,16 @@ function ensureRedis(config = getRedisConfig()) {
}
}
function startNextDev(config = getRedisConfig()) {
function startFastifyDev(config = getRedisConfig()) {
const redisUrl = buildRedisUrl(config.port);
const env = {
...process.env,
NEXT_PUBLIC_STORAGE_TYPE: 'redis',
VITE_STORAGE_TYPE: 'redis',
REDIS_URL: 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'], {
env,
@ -172,7 +172,7 @@ async function main() {
const config = getRedisConfig();
await assertDevPortsAvailable();
ensureRedis(config);
startNextDev(config);
startFastifyDev(config);
} catch (error) {
console.error(error.message);
process.exit(1);
@ -190,4 +190,5 @@ module.exports = {
getRedisConfig,
isPortAvailable,
resolveRedisAction,
startFastifyDev,
};

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/* eslint-disable */
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json
// 根据 VITE_SITE_NAME 动态生成 manifest.json
const fs = require('fs');
const path = require('path');
@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public');
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 模板
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";
@config "../../tailwind.config.ts";
@import "@heroui/styles";
@config "../../tailwind.config.ts";
:root {
color-scheme: light;
@ -257,7 +257,21 @@ body {
}
.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 {

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 React, {
useCallback,

View File

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

View File

@ -4,7 +4,7 @@
import { Clover, Film, Home, Star, Tv } from 'lucide-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';
interface MobileBottomNavProps {

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import {
Trash2,
} from 'lucide-react';
import {
Badge,
Button,
Card,
Chip,
@ -17,8 +16,8 @@ import {
ProgressBar,
Tooltip,
} from '@heroui/react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import Image from '@/client/AppImage';
import { useRouter } from '@/client/router';
import React, {
forwardRef,
memo,
@ -478,6 +477,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return configs[from] || configs.search;
}, [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 actions = [];
@ -835,14 +842,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
)}
{/* 年份徽章 */}
{config.showYear &&
actualYear &&
actualYear !== 'unknown' &&
actualYear.trim() !== '' && (
<Badge
size='sm'
variant='secondary'
className='absolute left-2 top-2'
{hasYearBadge && (
<div
className={`absolute left-2 top-2 ${coverBadgeClass}`}
style={
{
WebkitUserSelect: 'none',
@ -855,17 +857,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false;
}}
>
<Badge.Label>{actualYear}</Badge.Label>
</Badge>
{actualYear}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<Chip
size='md'
color='accent'
variant='primary'
className='absolute right-2 top-2'
<div
className={`absolute right-2 top-2 ${coverBadgeClass}`}
style={
{
WebkitUserSelect: 'none',
@ -878,15 +877,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false;
}}
>
<Chip.Label>{rate}</Chip.Label>
</Chip>
{rate}
</div>
)}
{actualEpisodes && actualEpisodes > 1 && (
<Chip
size='md'
variant='secondary'
className='absolute right-3 top-3 min-w-12 justify-center'
<div
className={`absolute right-2 ${config.showRating && rate ? 'top-10' : 'top-2'} ${coverBadgeClass}`}
style={
{
WebkitUserSelect: 'none',
@ -899,12 +896,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false;
}}
>
<Chip.Label>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</Chip.Label>
</Chip>
</div>
)}
{/* 豆瓣链接 */}
@ -920,7 +915,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 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={
{
WebkitUserSelect: 'none',
@ -980,10 +975,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
} as React.CSSProperties
}
>
<Badge
size='sm'
color='accent'
variant='secondary'
<div
className={coverBadgeClass}
style={
{
WebkitUserSelect: 'none',
@ -996,8 +989,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
return false;
}}
>
<Badge.Label>{sourceCount}</Badge.Label>
</Badge>
{sourceCount}
</div>
{/* 播放源详情悬浮框 */}
{(() => {

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useSearchParams } from '@/client/router';
import { Card, EmptyState, Spinner } from '@heroui/react';
import { Suspense } 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';
@ -6,7 +6,7 @@ import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Heart, Radio, Tv } from 'lucide-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 {

View File

@ -4,7 +4,7 @@
import { AlertCircle, CheckCircle, Shield } from 'lucide-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 { 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';
// Artplayer 和 Hls 以及弹幕插件将动态加载
import { Heart } from 'lucide-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 { getDefaultExport } from '@/client/module-interop';
import {
deleteFavorite,
deletePlayRecord,
@ -161,9 +162,9 @@ function PlayPageClient() {
if (mounted) {
setDynamicDeps({
Artplayer: ArtplayerModule.default,
Hls: HlsModule.default,
artplayerPluginDanmuku: DanmakuModule.default
Artplayer: getDefaultExport(ArtplayerModule),
Hls: getDefaultExport(HlsModule),
artplayerPluginDanmuku: getDefaultExport(DanmakuModule)
});
}
} catch (error) {
@ -2409,7 +2410,7 @@ function PlayPageClient() {
artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now();
let interval = 5000;
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
if (window.RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash') {
interval = 20000;
}
if (now - lastSaveTimeRef.current > interval) {

View File

@ -12,7 +12,7 @@ import {
Switch,
Tooltip,
} 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 {
@ -1092,7 +1092,7 @@ function SearchPageClient() {
{totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-muted'>
{completedSources}/{totalSources}
{completedSources}/{totalSources}
</span>
)}
{isLoading && useFluidSearch && (
@ -1238,19 +1238,17 @@ function SearchPageClient() {
>
<Chip.Label>{item}</Chip.Label>
</Chip>
{/* 删除按钮 */}
<Button
<button
type='button'
aria-label='删除搜索历史'
isIconOnly
size='sm'
variant='danger'
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
onPress={() => {
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'
onClick={(event) => {
event.stopPropagation();
deleteSearchHistory(item); // 事件监听会自动更新界面
}}
>
<X className='w-3 h-3' />
</Button>
<X className='h-2.5 w-2.5' strokeWidth={2.4} />
</button>
</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 */
import { NextRequest, NextResponse } from 'next/server';
import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
@ -15,10 +14,10 @@ interface BaseBody {
action?: Action;
}
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
export async function POST(request: AppRequest) {
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
return AppResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
@ -32,14 +31,14 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 基础校验
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
if (!username || !action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 获取配置与存储
@ -51,7 +50,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username
);
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;
};
if (!name || !type || !query) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 检查是否已存在相同的查询和类型组合
if (
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type
)
) {
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
return AppResponse.json({ error: '该分类已存在' }, { status: 400 });
}
adminConfig.CustomCategories.push({
name,
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
return AppResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
@ -96,7 +95,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = true;
break;
}
@ -106,7 +105,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
return AppResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
@ -114,7 +113,7 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type
);
if (!entry)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
entry.disabled = false;
break;
}
@ -124,7 +123,7 @@ export async function POST(request: NextRequest) {
type?: 'movie' | 'tv';
};
if (!query || !type)
return NextResponse.json(
return AppResponse.json(
{ error: '缺少 query 或 type 参数' },
{ status: 400 }
);
@ -132,10 +131,10 @@ export async function POST(request: NextRequest) {
(c) => c.query === query && c.type === type
);
if (idx === -1)
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
const entry = adminConfig.CustomCategories[idx];
if (entry.from === 'config') {
return NextResponse.json(
return AppResponse.json(
{ error: '该分类不可删除' },
{ status: 400 }
);
@ -146,7 +145,7 @@ export async function POST(request: NextRequest) {
case 'sort': {
const { order } = body as { order?: string[] };
if (!Array.isArray(order)) {
return NextResponse.json(
return AppResponse.json(
{ error: '排序列表格式错误' },
{ status: 400 }
);
@ -170,13 +169,13 @@ export async function POST(request: NextRequest) {
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
return AppResponse.json({ error: '未知操作' }, { status: 400 });
}
// 持久化到存储
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
return AppResponse.json(
{ ok: true },
{
headers: {
@ -186,7 +185,7 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
console.error('分类管理操作失败:', error);
return NextResponse.json(
return AppResponse.json(
{
error: '分类管理操作失败',
details: (error as Error).message,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
/* 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 { gunzip } from 'zlib';
@ -9,16 +9,15 @@ import { configSelfCheck, setCachedConfig } from '@/lib/config';
import { SimpleCrypto } from '@/lib/crypto';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
const gunzipAsync = promisify(gunzip);
export async function POST(req: NextRequest) {
export async function POST(req: AppRequest) {
try {
// 检查存储类型
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
return AppResponse.json(
{ error: '不支持本地存储进行数据迁移' },
{ status: 400 }
);
@ -27,12 +26,12 @@ export async function POST(req: NextRequest) {
// 验证身份和权限
const authInfo = getAuthInfoFromCookie(req);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未登录' }, { status: 401 });
return AppResponse.json({ error: '未登录' }, { status: 401 });
}
// 检查用户权限(只有站长可以导入数据)
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;
if (!file) {
return NextResponse.json({ error: '请选择备份文件' }, { status: 400 });
return AppResponse.json({ error: '请选择备份文件' }, { status: 400 });
}
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 {
decryptedData = SimpleCrypto.decrypt(encryptedData, password);
} 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 {
importData = JSON.parse(decompressedData);
} catch (error) {
return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 });
return AppResponse.json({ error: '备份文件格式错误' }, { status: 400 });
}
// 验证数据格式
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: '数据导入成功',
importedUsers: Object.keys(userData).length,
timestamp: importData.timestamp,
@ -136,7 +135,7 @@ export async function POST(req: NextRequest) {
} catch (error) {
console.error('数据导入失败:', error);
return NextResponse.json(
return AppResponse.json(
{ error: error instanceof Error ? error.message : '导入失败' },
{ status: 500 }
);

View File

@ -1,15 +1,14 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
export async function POST(request: AppRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username
);
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);
return NextResponse.json({
return AppResponse.json({
success: true,
message: '直播源刷新成功',
});
} catch (error) {
console.error('直播源刷新失败:', error);
return NextResponse.json(
return AppResponse.json(
{ error: error instanceof Error ? error.message : '刷新失败' },
{ status: 500 }
);

View File

@ -1,15 +1,14 @@
/* 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 { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
export async function POST(request: AppRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username
);
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;
if (!config) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
return AppResponse.json({ error: '配置不存在' }, { status: 404 });
}
// 确保 LiveConfig 存在
@ -41,7 +40,7 @@ export async function POST(request: NextRequest) {
case 'add':
// 检查是否已存在相同的 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 = {
@ -71,12 +70,12 @@ export async function POST(request: NextRequest) {
// 删除直播源
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
if (deleteIndex === -1) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
}
const liveSource = config.LiveConfig[deleteIndex];
if (liveSource.from === 'config') {
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
return AppResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
}
deleteCachedLiveChannels(key);
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
// 启用直播源
const enableSource = config.LiveConfig.find((l) => l.key === key);
if (!enableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
}
enableSource.disabled = false;
break;
@ -97,7 +96,7 @@ export async function POST(request: NextRequest) {
// 禁用直播源
const disableSource = config.LiveConfig.find((l) => l.key === key);
if (!disableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
}
disableSource.disabled = true;
break;
@ -106,12 +105,12 @@ export async function POST(request: NextRequest) {
// 编辑直播源
const editSource = config.LiveConfig.find((l) => l.key === key);
if (!editSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
}
// 配置文件中的直播源不允许编辑
if (editSource.from === 'config') {
return NextResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
return AppResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
}
// 更新字段(除了 key 和 from
@ -134,7 +133,7 @@ export async function POST(request: NextRequest) {
// 排序直播源
const { order } = body;
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;
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
return AppResponse.json({ error: '未知操作' }, { status: 400 });
}
// 保存配置
await db.saveAdminConfig(config);
return NextResponse.json({ success: true });
return AppResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
return AppResponse.json(
{ error: error instanceof Error ? error.message : '操作失败' },
{ status: 500 }
);

View File

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

View File

@ -1,17 +1,16 @@
/* 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 { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
export async function POST(request: AppRequest) {
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
return AppResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
@ -24,7 +23,7 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
@ -77,7 +76,7 @@ export async function POST(request: NextRequest) {
typeof CustomTheme.customCSS !== 'string'
))
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
}
const adminConfig = await getConfig();
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username
);
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);
return NextResponse.json(
return AppResponse.json(
{ ok: true },
{
headers: {
@ -122,7 +121,7 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
console.error('更新站点配置失败:', error);
return NextResponse.json(
return AppResponse.json(
{
error: '更新站点配置失败',
details: (error as Error).message,

View File

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

View File

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

View File

@ -1,12 +1,11 @@
/* 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 { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
// 支持的操作类型
const ACTIONS = [
@ -23,10 +22,10 @@ const ACTIONS = [
'batchUpdateUserGroups',
] as const;
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
export async function POST(request: AppRequest) {
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
return AppResponse.json(
{
error: '不支持本地存储进行管理员配置',
},
@ -39,7 +38,7 @@ export async function POST(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
@ -54,12 +53,12 @@ export async function POST(request: NextRequest) {
};
if (!action || !ACTIONS.includes(action)) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
}
// 用户组操作和批量操作不需要targetUsername
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
return AppResponse.json({ error: '缺少目标用户名' }, { status: 400 });
}
if (
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
action !== 'batchUpdateUserGroups' &&
username === targetUsername
) {
return NextResponse.json(
return AppResponse.json(
{ error: '无法对自己进行此操作' },
{ status: 400 }
);
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
(u) => u.username === username
);
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
return AppResponse.json({ error: '权限不足' }, { status: 401 });
}
operatorRole = 'admin';
}
@ -108,7 +107,7 @@ export async function POST(request: NextRequest) {
targetEntry.role === 'owner' &&
!['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) {
case 'add': {
if (targetEntry) {
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
return AppResponse.json({ error: '用户已存在' }, { status: 400 });
}
if (!targetPassword) {
return NextResponse.json(
return AppResponse.json(
{ error: '缺少目标用户密码' },
{ status: 400 }
);
@ -151,7 +150,7 @@ export async function POST(request: NextRequest) {
}
case 'ban': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
@ -159,7 +158,7 @@ export async function POST(request: NextRequest) {
if (isTargetAdmin) {
// 目标是管理员
if (operatorRole !== 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可封禁管理员' },
{ status: 401 }
);
@ -170,14 +169,14 @@ export async function POST(request: NextRequest) {
}
case 'unban': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (isTargetAdmin) {
if (operatorRole !== 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可操作管理员' },
{ status: 401 }
);
@ -188,19 +187,19 @@ export async function POST(request: NextRequest) {
}
case 'setAdmin': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role === 'admin') {
return NextResponse.json(
return AppResponse.json(
{ error: '该用户已是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可设置管理员' },
{ status: 401 }
);
@ -210,19 +209,19 @@ export async function POST(request: NextRequest) {
}
case 'cancelAdmin': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (targetEntry.role !== 'admin') {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不是管理员' },
{ status: 400 }
);
}
if (operatorRole !== 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可取消管理员' },
{ status: 401 }
);
@ -232,18 +231,18 @@ export async function POST(request: NextRequest) {
}
case 'changePassword': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
if (!targetPassword) {
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
return AppResponse.json({ error: '缺少新密码' }, { status: 400 });
}
// 权限检查:不允许修改站长密码
if (targetEntry.role === 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '无法修改站长密码' },
{ status: 401 }
);
@ -254,7 +253,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可修改其他管理员密码' },
{ status: 401 }
);
@ -265,7 +264,7 @@ export async function POST(request: NextRequest) {
}
case 'deleteUser': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
@ -273,14 +272,14 @@ export async function POST(request: NextRequest) {
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
if (username === targetUsername) {
return NextResponse.json(
return AppResponse.json(
{ error: '不能删除自己' },
{ status: 400 }
);
}
if (isTargetAdmin && operatorRole !== 'owner') {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可删除管理员' },
{ status: 401 }
);
@ -300,7 +299,7 @@ export async function POST(request: NextRequest) {
}
case 'updateUserApis': {
if (!targetEntry) {
return NextResponse.json(
return AppResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
@ -314,7 +313,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
return AppResponse.json(
{ error: '仅站长可配置其他管理员的采集源' },
{ status: 401 }
);
@ -346,7 +345,7 @@ export async function POST(request: NextRequest) {
case 'add': {
// 检查用户组是否已存在
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({
name: groupName,
@ -357,7 +356,7 @@ export async function POST(request: NextRequest) {
case 'edit': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
if (groupIndex === -1) {
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
return AppResponse.json({ error: '用户组不存在' }, { status: 404 });
}
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
break;
@ -365,7 +364,7 @@ export async function POST(request: NextRequest) {
case 'delete': {
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
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;
}
default:
return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 });
return AppResponse.json({ error: '未知的用户组操作' }, { status: 400 });
}
break;
}
case 'updateUserGroups': {
if (!targetEntry) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
}
const { userGroups } = body as { userGroups: string[] };
@ -408,7 +407,7 @@ export async function POST(request: NextRequest) {
operatorRole !== 'owner' &&
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[] };
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) {
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
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;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
return AppResponse.json({ error: '未知操作' }, { status: 400 });
}
// 将更新后的配置写入数据库
await db.saveAdminConfig(adminConfig);
return NextResponse.json(
return AppResponse.json(
{ ok: true },
{
headers: {
@ -470,7 +469,7 @@ export async function POST(request: NextRequest) {
);
} catch (error) {
console.error('用户管理操作失败:', error);
return NextResponse.json(
return AppResponse.json(
{
error: '用户管理操作失败',
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 { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取用户头像
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
@ -23,41 +22,41 @@ export async function GET(request: NextRequest) {
const avatar = await db.getUserAvatar(targetUser);
if (!avatar) {
return NextResponse.json({ avatar: null });
return AppResponse.json({ avatar: null });
}
return NextResponse.json({ avatar });
return AppResponse.json({ avatar });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { avatar, targetUser } = body;
if (!avatar) {
return NextResponse.json({ error: '头像数据不能为空' }, { status: 400 });
return AppResponse.json({ error: '头像数据不能为空' }, { status: 400 });
}
// 验证Base64格式
if (!avatar.startsWith('data:image/')) {
return NextResponse.json({ error: '无效的图片格式' }, { status: 400 });
return AppResponse.json({ error: '无效的图片格式' }, { status: 400 });
}
// 检查文件大小Base64编码后大约增加33%2MB的限制
const base64Data = avatar.split(',')[1];
const sizeInBytes = (base64Data.length * 3) / 4;
if (sizeInBytes > 2 * 1024 * 1024) {
return NextResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
return AppResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
}
const userToUpdate = targetUser || authInfo.username;
@ -68,24 +67,24 @@ export async function POST(request: NextRequest) {
authInfo.role === 'owner';
if (!canUpdate) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await db.setUserAvatar(userToUpdate, avatar);
return NextResponse.json({ success: true, message: '头像上传成功' });
return AppResponse.json({ success: true, message: '头像上传成功' });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
@ -97,14 +96,14 @@ export async function DELETE(request: NextRequest) {
authInfo.role === 'owner';
if (!canDelete) {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
}
await db.deleteUserAvatar(targetUser);
return NextResponse.json({ success: true, message: '头像删除成功' });
return AppResponse.json({ success: true, message: '头像删除成功' });
} catch (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*/
import { NextRequest, NextResponse } from 'next/server';
import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
export async function POST(request: AppRequest) {
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
// 不支持 localstorage 模式
if (storageType === 'localstorage') {
return NextResponse.json(
return AppResponse.json(
{
error: '不支持本地存储模式修改密码',
},
@ -27,19 +26,19 @@ export async function POST(request: NextRequest) {
// 获取认证信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 验证新密码
if (!newPassword || typeof newPassword !== 'string') {
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
return AppResponse.json({ error: '新密码不得为空' }, { status: 400 });
}
const username = authInfo.username;
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME
if (username === process.env.USERNAME) {
return NextResponse.json(
return AppResponse.json(
{ error: '站长不能通过此接口修改密码' },
{ status: 403 }
);
@ -48,10 +47,10 @@ export async function POST(request: NextRequest) {
// 修改密码
await db.changePassword(username, newPassword);
return NextResponse.json({ ok: true });
return AppResponse.json({ ok: true });
} catch (error) {
console.error('修改密码失败:', error);
return NextResponse.json(
return AppResponse.json(
{
error: '修改密码失败',
details: (error as Error).message,

View File

@ -1,34 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../../lib/db';
import { Conversation } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { db } from '@/lib/db';
import { Conversation } from '@/lib/types';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const conversations = await db.getConversations(authInfo.username);
return NextResponse.json(conversations);
return AppResponse.json(conversations);
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { participants, name, type } = await request.json();
if (!participants || !Array.isArray(participants) || participants.length === 0) {
return NextResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
return AppResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
}
// 确保当前用户在参与者列表中
@ -51,9 +51,9 @@ export async function POST(request: NextRequest) {
};
await db.createConversation(conversation);
return NextResponse.json(conversation, { status: 201 });
return AppResponse.json(conversation, { status: 201 });
} catch (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 { db } from '../../../../lib/db';
import { FriendRequest, Friend } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { db } from '@/lib/db';
import { FriendRequest, Friend } from '@/lib/types';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const friendRequests = await db.getFriendRequests(authInfo.username);
return NextResponse.json(friendRequests);
return AppResponse.json(friendRequests);
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { to_user, message } = await request.json();
if (!to_user) {
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 });
return AppResponse.json({ error: '目标用户不能为空' }, { status: 400 });
}
// 检查目标用户是否存在
const userExists = await db.checkUserExist(to_user);
if (!userExists) {
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
}
// 检查是否已经是好友
const friends = await db.getFriends(authInfo.username);
const isAlreadyFriend = friends.some(friend => friend.username === to_user);
if (isAlreadyFriend) {
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
return AppResponse.json({ error: '已经是好友' }, { status: 400 });
}
// 检查是否已经有pending的申请
@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
req => req.from_user === authInfo.username && req.status === 'pending'
);
if (hasPendingRequest) {
return NextResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
return AppResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
}
const friendRequest: FriendRequest = {
@ -64,24 +64,24 @@ export async function POST(request: NextRequest) {
};
await db.createFriendRequest(friendRequest);
return NextResponse.json(friendRequest, { status: 201 });
return AppResponse.json(friendRequest, { status: 201 });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { requestId, status } = await request.json();
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);
if (!friendRequest) {
return NextResponse.json({ error: '好友申请不存在' }, { status: 404 });
return AppResponse.json({ error: '好友申请不存在' }, { status: 404 });
}
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) {
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 { db } from '../../../../lib/db';
import { Friend } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { db } from '@/lib/db';
import { Friend } from '@/lib/types';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const friends = await db.getFriends(authInfo.username);
return NextResponse.json(friends);
return AppResponse.json(friends);
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { username, nickname } = await request.json();
if (!username) {
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
return AppResponse.json({ error: '用户名不能为空' }, { status: 400 });
}
// 检查用户是否存在
const userExists = await db.checkUserExist(username);
if (!userExists) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return AppResponse.json({ error: '用户不存在' }, { status: 404 });
}
// 检查是否已经是好友
const friends = await db.getFriends(authInfo.username);
const isAlreadyFriend = friends.some(friend => friend.username === username);
if (isAlreadyFriend) {
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
return AppResponse.json({ error: '已经是好友' }, { status: 400 });
}
const friend: Friend = {
@ -53,31 +53,31 @@ export async function POST(request: NextRequest) {
};
await db.addFriend(authInfo.username, friend);
return NextResponse.json(friend, { status: 201 });
return AppResponse.json(friend, { status: 201 });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const friendId = searchParams.get('friendId');
if (!friendId) {
return NextResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
return AppResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
}
await db.removeFriend(authInfo.username, friendId);
return NextResponse.json({ success: true });
return AppResponse.json({ success: true });
} catch (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 { db } from '../../../../lib/db';
import { ChatMessage } from '../../../../lib/types';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { db } from '@/lib/db';
import { ChatMessage } from '@/lib/types';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
console.log('未授权访问消息API:', authInfo);
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
if (!conversationId) {
console.log('缺少对话ID参数');
return NextResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
return AppResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
}
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
} catch (dbError) {
console.error('数据库查询对话失败:', dbError);
return NextResponse.json({
return AppResponse.json({
error: '数据库查询失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 });
@ -38,21 +38,21 @@ export async function GET(request: NextRequest) {
if (!conversation) {
console.log('对话不存在:', conversationId);
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
return AppResponse.json({ error: '对话不存在' }, { status: 404 });
}
if (!conversation.participants.includes(authInfo.username)) {
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
return NextResponse.json({ error: '无权限访问此对话' }, { status: 403 });
return AppResponse.json({ error: '无权限访问此对话' }, { status: 403 });
}
try {
const messages = await db.getMessages(conversationId, limit, offset);
console.log(`成功加载 ${messages.length} 条消息`);
return NextResponse.json(messages);
return AppResponse.json(messages);
} catch (dbError) {
console.error('数据库查询消息失败:', dbError);
return NextResponse.json({
return AppResponse.json({
error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
}, { status: 500 });
@ -60,30 +60,30 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error('加载消息API发生未知错误:', error);
return NextResponse.json({
return AppResponse.json({
error: '获取消息失败',
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
}, { status: 500 });
}
}
export async function POST(request: NextRequest) {
export async function POST(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const messageData = await request.json();
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);
if (!conversation || !conversation.participants.includes(authInfo.username)) {
return NextResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
return AppResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
}
const message: ChatMessage = {
@ -105,30 +105,30 @@ export async function POST(request: NextRequest) {
updated_at: Date.now(),
});
return NextResponse.json(message, { status: 201 });
return AppResponse.json(message, { status: 201 });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { messageId } = await request.json();
if (!messageId) {
return NextResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
return AppResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
}
await db.markMessageAsRead(messageId);
return NextResponse.json({ success: true });
return AppResponse.json({ success: true });
} catch (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 { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth';
// 从全局对象获取WebSocket实例相关方法
function getOnlineUsers(): string[] {
@ -16,17 +16,17 @@ function getOnlineUsers(): string[] {
}
// 获取在线用户列表
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const onlineUsers = getOnlineUsers();
return NextResponse.json({ onlineUsers });
return AppResponse.json({ onlineUsers });
} catch (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 { db } from '../../../../lib/db';
import { getAuthInfoFromCookie } from '../../../../lib/auth';
import { AppRequest, AppResponse } from '@/server/web';
import { db } from '@/lib/db';
import { getAuthInfoFromCookie } from '@/lib/auth';
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query || query.trim().length < 2) {
return NextResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
return AppResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
}
// 获取所有用户并进行模糊匹配
@ -31,9 +31,9 @@ export async function GET(request: NextRequest) {
added_at: 0,
}));
return NextResponse.json(userResults);
return AppResponse.json(userResults);
} catch (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 { getAuthInfoFromCookie } from '../../../../lib/auth';
import { WebSocketMessage } from '../../../../lib/types';
import { AppRequest, AppResponse } from '@/server/web';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { WebSocketMessage } from '@/lib/types';
// 从全局对象获取WebSocket实例相关方法
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
@ -17,11 +17,11 @@ function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boole
}
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用
export async function POST(request: NextRequest) {
export async function POST(request: AppRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
return AppResponse.json({ error: '未授权' }, { status: 401 });
}
const message: WebSocketMessage = await request.json();
@ -52,18 +52,18 @@ export async function POST(request: NextRequest) {
break;
default:
return NextResponse.json({ error: '不支持的消息类型' }, { status: 400 });
return AppResponse.json({ error: '不支持的消息类型' }, { status: 400 });
}
// 通过 WebSocket 发送消息
const sent = sendMessageToUsers(targetUsers, message);
return NextResponse.json({
return AppResponse.json({
success: true,
delivered: sent
});
} catch (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 */
import { NextRequest, NextResponse } from 'next/server';
import { AppRequest, AppResponse } from '@/server/web';
import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db';
@ -8,16 +8,15 @@ import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live';
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);
try {
console.log('Cron job triggered:', new Date().toISOString());
cronJob();
return NextResponse.json({
return AppResponse.json({
success: true,
message: 'Cron job executed successfully',
timestamp: new Date().toISOString(),
@ -25,7 +24,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error('Cron job failed:', error);
return NextResponse.json(
return AppResponse.json(
{
success: false,
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 { db } from '@/lib/db';
export const runtime = 'nodejs';
// 获取弹幕
export async function GET(request: NextRequest) {
export async function GET(request: AppRequest) {
try {
const { searchParams } = new URL(request.url);
const videoId = searchParams.get('videoId');
if (!videoId) {
return NextResponse.json({ error: '视频ID不能为空' }, { status: 400 });
return AppResponse.json({ error: '视频ID不能为空' }, { status: 400 });
}
const danmuList = await db.getDanmu(videoId);
@ -27,38 +26,38 @@ export async function GET(request: NextRequest) {
size: 25
}));
return NextResponse.json(formattedDanmu);
return AppResponse.json(formattedDanmu);
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { videoId, text, color, mode, time } = body;
if (!videoId || !text) {
return NextResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
return AppResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
}
// 验证弹幕内容长度
if (text.length > 100) {
return NextResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
return AppResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
}
// 过滤敏感内容(可以扩展)
const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词
const hasSensitiveWord = sensitiveWords.some(word => text.includes(word));
if (hasSensitiveWord) {
return NextResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
return AppResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
}
const danmuData = {
@ -71,24 +70,24 @@ export async function POST(request: NextRequest) {
await db.saveDanmu(videoId, authInfo.username, danmuData);
return NextResponse.json({ success: true, message: '弹幕发送成功' });
return AppResponse.json({ success: true, message: '弹幕发送成功' });
} catch (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 {
const authInfo = getAuthInfoFromCookie(request);
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') {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
@ -96,14 +95,14 @@ export async function DELETE(request: NextRequest) {
const danmuId = searchParams.get('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);
return NextResponse.json({ success: true, message: '弹幕删除成功' });
return AppResponse.json({ success: true, message: '弹幕删除成功' });
} catch (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 { getAvailableApiSites, getCacheTime } from '@/lib/config';
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);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
@ -17,11 +16,11 @@ export async function GET(request: NextRequest) {
const sourceCode = searchParams.get('source');
if (!id || !sourceCode) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
return AppResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
}
try {
@ -29,13 +28,13 @@ export async function GET(request: NextRequest) {
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
return AppResponse.json({ error: '无效的API来源' }, { status: 400 });
}
const result = await getDetailFromApi(apiSite, id);
const cacheTime = await getCacheTime();
return NextResponse.json(result, {
return AppResponse.json(result, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -44,7 +43,7 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
return NextResponse.json(
return AppResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);

View File

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

View File

@ -1,6 +1,6 @@
/* 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 { 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);
// 获取参数
@ -47,7 +46,7 @@ export async function GET(request: NextRequest) {
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
if (!kind) {
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
return AppResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
}
const selectedCategories = { 类型: category } as any;
@ -113,7 +112,7 @@ export async function GET(request: NextRequest) {
};
const cacheTime = await getCacheTime();
return NextResponse.json(response, {
return AppResponse.json(response, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
@ -122,7 +121,7 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
return NextResponse.json(
return AppResponse.json(
{ error: '获取豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);

View File

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

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