mirror of https://github.com/djteang/OrangeTV.git
refactor: replace next with vite fastify runtime
This commit is contained in:
parent
6d1ada87b8
commit
7dc3db8baa
|
|
@ -1,2 +1,7 @@
|
|||
.env
|
||||
.env*.local
|
||||
.git
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
|
|
|||
29
.eslintrc.js
29
.eslintrc.js
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
163
Dockerfile
163
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
60
README.md
60
README.md
|
|
@ -4,13 +4,14 @@
|
|||
<img src="public/logo.png" alt="OrangeTV Logo" width="120">
|
||||
</div>
|
||||
|
||||
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Vite + React**、**Fastify**、**Tailwind CSS** 和 **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
## 🗺 目录
|
||||
|
||||
- [技术栈](#技术栈)
|
||||
- [本地开发环境](#本地开发环境)
|
||||
- [部署](#部署)
|
||||
- [配置文件](#配置文件)
|
||||
- [自动更新](#自动更新)
|
||||
|
|
@ -56,13 +58,20 @@
|
|||
|
||||
| 分类 | 主要依赖 |
|
||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||
| 语言 | TypeScript 4 |
|
||||
| 前端框架 | [Vite](https://vite.dev/) · [React Router](https://reactrouter.com/) |
|
||||
| 后端 | [Fastify](https://fastify.dev/) |
|
||||
| UI & 样式 | [Tailwind 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: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义
|
||||
- custom: 用户自定义 proxy,由 VITE_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: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
|
||||
- custom: 用户自定义 proxy,由 VITE_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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
44
nginx.conf
44
nginx.conf
|
|
@ -1,46 +1,26 @@
|
|||
server {
|
||||
listen 443 ssl;
|
||||
server_name domain.com;
|
||||
charset utf-8;
|
||||
listen 443 ssl;
|
||||
server_name domain.com;
|
||||
charset utf-8;
|
||||
|
||||
ssl_certificate /home/cert/tvcertificate.crt;
|
||||
ssl_certificate_key /home/cert/tvprivate.pem;
|
||||
ssl_certificate /home/cert/tvcertificate.crt;
|
||||
ssl_certificate_key /home/cert/tvprivate.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://ip:3003;
|
||||
|
||||
# 重要的代理头信息,让 Next.js 服务器知道原始请求的来源
|
||||
proxy_set_header Host $host; # 原始主机名
|
||||
proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议 (http/https)
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
}
|
||||
location /ws-api {
|
||||
proxy_pass http://ip:3001;
|
||||
location / {
|
||||
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;
|
||||
listen 80;
|
||||
server_name domain.com;
|
||||
|
||||
server_name domain.com;
|
||||
|
||||
return 301 https://domain.com$request_uri;
|
||||
return 301 https://domain.com$request_uri;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
47
package.json
47
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5370
pnpm-lock.yaml
5370
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
178
production.js
178
production.js
|
|
@ -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
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
70
server.js
70
server.js
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 />);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{currentEpisode
|
||||
? `${currentEpisode}/${actualEpisodes}`
|
||||
: actualEpisodes}
|
||||
</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>
|
||||
|
||||
{/* 播放源详情悬浮框 */}
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 存在
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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';
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import WarningClient from './warning-client';
|
||||
|
||||
export default function WarningPage() {
|
||||
return <WarningClient />;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import { getAuthCookie } from './auth';
|
||||
|
||||
export function getAuthContext(request: FastifyRequest) {
|
||||
return getAuthCookie(request);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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
Loading…
Reference in New Issue