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
|
||||||
.env*.local
|
.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,
|
es2021: true,
|
||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
|
plugins: [
|
||||||
|
'@typescript-eslint',
|
||||||
|
'react',
|
||||||
|
'react-hooks',
|
||||||
|
'simple-import-sort',
|
||||||
|
'unused-imports',
|
||||||
|
],
|
||||||
extends: [
|
extends: [
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'next',
|
'plugin:react/recommended',
|
||||||
'next/core-web-vitals',
|
'plugin:react-hooks/recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'prettier',
|
'prettier',
|
||||||
],
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'no-console': 'warn',
|
'no-console': 'warn',
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
|
'@typescript-eslint/no-inferrable-types': 'off',
|
||||||
|
'@typescript-eslint/no-var-requires': 'off',
|
||||||
|
'no-case-declarations': 'off',
|
||||||
'react/no-unescaped-entities': 'off',
|
'react/no-unescaped-entities': 'off',
|
||||||
|
'no-empty': 'off',
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
'prefer-const': 'off',
|
||||||
|
|
||||||
'react/display-name': 'off',
|
'react/display-name': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react-hooks/immutability': 'off',
|
||||||
|
'react-hooks/purity': 'off',
|
||||||
|
'react-hooks/refs': 'off',
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
'react/jsx-curly-brace-presence': [
|
'react/jsx-curly-brace-presence': [
|
||||||
'warn',
|
'warn',
|
||||||
{ props: 'never', children: 'never' },
|
{ props: 'never', children: 'never' },
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# static export
|
||||||
/.next/
|
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -34,12 +34,11 @@ yarn-error.log*
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# next-sitemap
|
# generated sitemap
|
||||||
sitemap.xml
|
sitemap.xml
|
||||||
sitemap-*.xml
|
sitemap-*.xml
|
||||||
|
|
||||||
# generated files
|
# generated files
|
||||||
src/lib/runtime.ts
|
src/lib/runtime.ts
|
||||||
public/manifest.json
|
public/manifest.json
|
||||||
|
|
|
||||||
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 BUILDPLATFORM
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
ARG NODE_VERSION=24
|
||||||
|
ARG PNPM_VERSION=10.14.0
|
||||||
|
|
||||||
# ---- 第 1 阶段:安装依赖 ----
|
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS deps
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
|
|
||||||
|
|
||||||
# 启用 corepack 并激活 pnpm
|
ARG PNPM_VERSION
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 先复制所有文件
|
COPY package.json pnpm-lock.yaml ./
|
||||||
COPY . .
|
ENV HUSKY=0
|
||||||
|
|
||||||
# 然后检查文件
|
|
||||||
RUN echo "文件列表:" && ls -la && \
|
|
||||||
echo "检查 tsconfig.json:" && \
|
|
||||||
if [ -f "tsconfig.json" ]; then \
|
|
||||||
echo "tsconfig.json 存在"; \
|
|
||||||
else \
|
|
||||||
echo "tsconfig.json 不存在"; \
|
|
||||||
echo "查找所有文件:"; \
|
|
||||||
find . -type f -name "*tsconfig*"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# 安装所有依赖
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# ---- 第 2 阶段:构建项目 ----
|
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS builder
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
ARG PNPM_VERSION
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
# 复制全部源代码
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV DOCKER_ENV=true
|
ENV DOCKER_ENV=true
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
# 生成生产构建
|
FROM node:${NODE_VERSION}-alpine AS runner
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# ---- 第 3 阶段:生成运行时镜像 ----
|
ARG PNPM_VERSION
|
||||||
FROM node:20-alpine AS runner
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S orangetv -G nodejs
|
||||||
# 创建非 root 用户
|
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV DOCKER_ENV=true
|
ENV DOCKER_ENV=true
|
||||||
|
|
||||||
# 从构建器中复制 standalone 输出
|
COPY package.json pnpm-lock.yaml ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
RUN pnpm install --prod --frozen-lockfile --ignore-scripts && pnpm store prune
|
||||||
# 从构建器中复制 scripts 目录
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
|
||||||
# 从构建器中复制启动脚本和WebSocket相关文件
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/websocket.js ./websocket.js
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/production.js ./production.js
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/production-final.js ./production-final.js
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/standalone-websocket.js ./standalone-websocket.js
|
|
||||||
# 从构建器中复制 public 和 .next/static 目录
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
# 从构建器中复制 package.json 和 package-lock.json,用于安装额外依赖
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
# 复制 tsconfig.json 以确保路径解析正确
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
# 安装必要的WebSocket依赖(兼容多架构)
|
COPY --from=builder --chown=orangetv:nodejs /app/dist ./dist
|
||||||
USER root
|
COPY --from=builder --chown=orangetv:nodejs /app/public ./public
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate && \
|
|
||||||
# 使用 --no-optional 避免某些架构下的可选依赖问题
|
|
||||||
pnpm install --prod --no-optional ws && \
|
|
||||||
# 清理安装缓存减小镜像大小
|
|
||||||
pnpm store prune
|
|
||||||
|
|
||||||
# 创建健康检查脚本(在切换用户之前以root权限创建)
|
RUN cat > /app/healthcheck.js <<'EOF'
|
||||||
RUN echo '#!/usr/bin/env node\n\
|
const http = require('http');
|
||||||
const http = require("http");\n\
|
|
||||||
const options = {\n\
|
|
||||||
hostname: "localhost",\n\
|
|
||||||
port: 3000,\n\
|
|
||||||
path: "/api/health",\n\
|
|
||||||
method: "GET",\n\
|
|
||||||
timeout: 5000\n\
|
|
||||||
};\n\
|
|
||||||
\n\
|
|
||||||
const req = http.request(options, (res) => {\n\
|
|
||||||
if (res.statusCode === 200) {\n\
|
|
||||||
console.log("Health check passed");\n\
|
|
||||||
process.exit(0);\n\
|
|
||||||
} else {\n\
|
|
||||||
console.log(`Health check failed with status: ${res.statusCode}`);\n\
|
|
||||||
process.exit(1);\n\
|
|
||||||
}\n\
|
|
||||||
});\n\
|
|
||||||
\n\
|
|
||||||
req.on("error", (err) => {\n\
|
|
||||||
console.log(`Health check error: ${err.message}`);\n\
|
|
||||||
process.exit(1);\n\
|
|
||||||
});\n\
|
|
||||||
\n\
|
|
||||||
req.on("timeout", () => {\n\
|
|
||||||
console.log("Health check timeout");\n\
|
|
||||||
req.destroy();\n\
|
|
||||||
process.exit(1);\n\
|
|
||||||
});\n\
|
|
||||||
\n\
|
|
||||||
req.setTimeout(5000);\n\
|
|
||||||
req.end();' > /app/healthcheck.js && \
|
|
||||||
chmod +x /app/healthcheck.js && \
|
|
||||||
chown nextjs:nodejs /app/healthcheck.js
|
|
||||||
|
|
||||||
# 切回非特权用户
|
const req = http.request(
|
||||||
USER nextjs
|
{
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
path: '/api/health',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
process.exit(res.statusCode === 200 ? 0 : 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
# 暴露HTTP和WebSocket端口
|
req.on('error', () => process.exit(1));
|
||||||
EXPOSE 3000 3001
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
EOF
|
||||||
|
|
||||||
# 添加健康检查
|
RUN chown orangetv:nodejs /app/healthcheck.js
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
|
USER orangetv
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
CMD node /app/healthcheck.js
|
CMD node /app/healthcheck.js
|
||||||
|
|
||||||
# 设置WebSocket端口环境变量
|
CMD ["node", "dist/server/index.js"]
|
||||||
ENV WS_PORT=3001
|
|
||||||
|
|
||||||
# 使用最终的生产环境脚本,分离WebSocket服务
|
|
||||||
CMD ["node", "production-final.js"]
|
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -4,13 +4,14 @@
|
||||||
<img src="public/logo.png" alt="OrangeTV Logo" width="120">
|
<img src="public/logo.png" alt="OrangeTV Logo" width="120">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
|
> 🎬 **OrangeTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Vite + React**、**Fastify**、**Tailwind CSS** 和 **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@
|
||||||
## 🗺 目录
|
## 🗺 目录
|
||||||
|
|
||||||
- [技术栈](#技术栈)
|
- [技术栈](#技术栈)
|
||||||
|
- [本地开发环境](#本地开发环境)
|
||||||
- [部署](#部署)
|
- [部署](#部署)
|
||||||
- [配置文件](#配置文件)
|
- [配置文件](#配置文件)
|
||||||
- [自动更新](#自动更新)
|
- [自动更新](#自动更新)
|
||||||
|
|
@ -56,13 +58,20 @@
|
||||||
|
|
||||||
| 分类 | 主要依赖 |
|
| 分类 | 主要依赖 |
|
||||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
| 前端框架 | [Vite](https://vite.dev/) · [React Router](https://reactrouter.com/) |
|
||||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
| 后端 | [Fastify](https://fastify.dev/) |
|
||||||
| 语言 | TypeScript 4 |
|
| UI & 样式 | [Tailwind CSS 4](https://tailwindcss.com/) |
|
||||||
|
| 语言 | TypeScript 5 |
|
||||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||||
| 代码质量 | ESLint · Prettier · Jest |
|
| 代码质量 | ESLint · Prettier · Jest |
|
||||||
| 部署 | Docker |
|
| 部署 | Docker |
|
||||||
|
|
||||||
|
## 本地开发环境
|
||||||
|
|
||||||
|
- Node.js:`v24.14.1`,仓库通过 `.nvmrc` 固定,并只支持 Node 24.x。
|
||||||
|
- pnpm:`10.14.0`,通过 `packageManager`、`engines` 和 `.npmrc` 校验。
|
||||||
|
- 建议先运行 `nvm use`,再运行 `corepack enable && corepack prepare pnpm@10.14.0 --activate`,然后执行 `pnpm install`。
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
|
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
|
||||||
|
|
@ -77,11 +86,10 @@ services:
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
- '3001:3001'
|
|
||||||
environment:
|
environment:
|
||||||
- USERNAME=admin
|
- USERNAME=admin
|
||||||
- PASSWORD=orange
|
- PASSWORD=orange
|
||||||
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
- VITE_STORAGE_TYPE=kvrocks
|
||||||
- KVROCKS_URL=redis://OrangeTV-kvrocks:6666
|
- KVROCKS_URL=redis://OrangeTV-kvrocks:6666
|
||||||
networks:
|
networks:
|
||||||
- OrangeTV-network
|
- OrangeTV-network
|
||||||
|
|
@ -112,11 +120,10 @@ services:
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
- '3001:3001'
|
|
||||||
environment:
|
environment:
|
||||||
- USERNAME=admin
|
- USERNAME=admin
|
||||||
- PASSWORD=orange
|
- PASSWORD=orange
|
||||||
- NEXT_PUBLIC_STORAGE_TYPE=redis
|
- VITE_STORAGE_TYPE=redis
|
||||||
- REDIS_URL=redis://OrangeTV-redis:6379
|
- REDIS_URL=redis://OrangeTV-redis:6379
|
||||||
networks:
|
networks:
|
||||||
- OrangeTV-network
|
- OrangeTV-network
|
||||||
|
|
@ -149,11 +156,10 @@ services:
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
- '3001:3001'
|
|
||||||
environment:
|
environment:
|
||||||
- USERNAME=admin
|
- USERNAME=admin
|
||||||
- PASSWORD=orange
|
- PASSWORD=orange
|
||||||
- NEXT_PUBLIC_STORAGE_TYPE=upstash
|
- VITE_STORAGE_TYPE=upstash
|
||||||
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
|
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
|
||||||
- UPSTASH_TOKEN=上面的 TOKEN
|
- UPSTASH_TOKEN=上面的 TOKEN
|
||||||
```
|
```
|
||||||
|
|
@ -218,37 +224,37 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
|
| VITE_SITE_NAME | 站点名称 | 任意字符串 | OrangeTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
| VITE_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
| VITE_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
| VITE_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
| VITE_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
| VITE_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
| VITE_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
| VITE_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
| VITE_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
VITE_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
- direct: 由服务器直接请求豆瓣源站
|
- direct: 由服务器直接请求豆瓣源站
|
||||||
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
|
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
|
||||||
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
|
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
|
||||||
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
|
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
|
||||||
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义
|
- custom: 用户自定义 proxy,由 VITE_DOUBAN_PROXY 定义
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
VITE_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
- direct:由浏览器直接请求豆瓣分配的默认图片域名
|
- direct:由浏览器直接请求豆瓣分配的默认图片域名
|
||||||
- server:由服务器代理请求豆瓣分配的默认图片域名
|
- server:由服务器代理请求豆瓣分配的默认图片域名
|
||||||
- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云)
|
- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云)
|
||||||
- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
|
- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
|
||||||
- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
|
- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
|
||||||
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
|
- custom: 用户自定义 proxy,由 VITE_DOUBAN_IMAGE_PROXY 定义
|
||||||
|
|
||||||
## AndroidTV 使用
|
## AndroidTV 使用
|
||||||
|
|
||||||
|
|
@ -282,7 +288,7 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
|
- OrangeTV has moved to a pure Vite/Fastify runtime while preserving the original product flows.
|
||||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
||||||
- [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。
|
- [MoonTV](https://github.com/MoonTechLab/LunaTV) — 由此启发,第二次站在巨人的肩膀上。
|
||||||
- [艾福森昵] - 感谢论坛佬友提供的短剧API
|
- [艾福森昵] - 感谢论坛佬友提供的短剧API
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Chosen direction: `A2` cinematic lounge
|
||||||
|
|
||||||
OrangeTV will be redesigned around the `A2` direction: a sharper, warmer, projection-inspired interface that treats the app like a screening room rather than a generic streaming dashboard.
|
OrangeTV will be redesigned around the `A2` direction: a sharper, warmer, projection-inspired interface that treats the app like a screening room rather than a generic streaming dashboard.
|
||||||
|
|
||||||
The redesign will not introduce a new information architecture or a new framework. It will keep the current Next.js 14 + Tailwind CSS v3 stack, preserve existing product flows, and refactor the visual system through a new design token layer and updated component styling.
|
The redesign will not introduce a new information architecture or a new framework. It will keep the current React + Tailwind CSS stack, preserve existing product flows, and refactor the visual system through a new design token layer and updated component styling.
|
||||||
|
|
||||||
The visual reset is defined by these decisions:
|
The visual reset is defined by these decisions:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
module.exports = {
|
||||||
const nextJest = require('next/jest');
|
|
||||||
|
|
||||||
const createJestConfig = nextJest({
|
|
||||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
|
||||||
dir: './',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add any custom config to be passed to Jest
|
|
||||||
const customJestConfig = {
|
|
||||||
// Add more setup options before each test is run
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||||
|
|
||||||
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
|
|
||||||
moduleDirectories: ['node_modules', '<rootDir>/'],
|
moduleDirectories: ['node_modules', '<rootDir>/'],
|
||||||
|
|
||||||
testEnvironment: 'jest-environment-jsdom',
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
transform: {
|
||||||
/**
|
'^.+\\.(ts|tsx)$': [
|
||||||
* Absolute imports and Module Path Aliases
|
'babel-jest',
|
||||||
*/
|
{
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||||
|
['@babel/preset-react', { runtime: 'automatic' }],
|
||||||
|
'@babel/preset-typescript',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/dist/'],
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
|
'^@heroui/react$': '<rootDir>/src/__mocks__/heroui-react.tsx',
|
||||||
'^@/(.*)$': '<rootDir>/src/$1',
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
'^~/(.*)$': '<rootDir>/public/$1',
|
'^~/(.*)$': '<rootDir>/public/$1',
|
||||||
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
|
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
|
||||||
},
|
},
|
||||||
modulePathIgnorePatterns: ['<rootDir>/.next/'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
|
||||||
module.exports = createJestConfig(customJestConfig);
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1 @@
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
require('@testing-library/jest-dom/extend-expect');
|
||||||
|
|
||||||
// Allow router mocks.
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
jest.mock('next/router', () => require('next-router-mock'));
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
48
nginx.conf
48
nginx.conf
|
|
@ -1,46 +1,26 @@
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name domain.com;
|
server_name domain.com;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
ssl_certificate /home/cert/tvcertificate.crt;
|
|
||||||
ssl_certificate_key /home/cert/tvprivate.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://ip:3003;
|
|
||||||
|
|
||||||
# 重要的代理头信息,让 Next.js 服务器知道原始请求的来源
|
ssl_certificate /home/cert/tvcertificate.crt;
|
||||||
proxy_set_header Host $host; # 原始主机名
|
ssl_certificate_key /home/cert/tvprivate.pem;
|
||||||
proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链
|
location / {
|
||||||
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议 (http/https)
|
proxy_pass http://ip:3000;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Server $host;
|
|
||||||
}
|
|
||||||
location /ws-api {
|
|
||||||
proxy_pass http://ip:3001;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
# WebSocket 特定的超时设置(长连接)
|
proxy_set_header X-Forwarded-Server $host;
|
||||||
proxy_connect_timeout 7d;
|
|
||||||
proxy_send_timeout 7d;
|
|
||||||
proxy_read_timeout 7d;
|
|
||||||
|
|
||||||
# 禁用缓冲以减少延迟
|
|
||||||
proxy_buffering off;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_name domain.com;
|
||||||
|
|
||||||
server_name domain.com;
|
return 301 https://domain.com$request_uri;
|
||||||
|
}
|
||||||
return 301 https://domain.com$request_uri;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,27 @@
|
||||||
# Nginx配置示例,用于生产环境反向代理
|
# Nginx production reverse proxy example.
|
||||||
# 将此文件放置在 /etc/nginx/sites-available/ 并创建符号链接到 sites-enabled/
|
|
||||||
|
|
||||||
upstream nextjs_app {
|
upstream orangetv_app {
|
||||||
server localhost:3000;
|
server localhost:3000;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream websocket_app {
|
|
||||||
server localhost:3001;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name your-domain.com;
|
server_name your-domain.com;
|
||||||
|
|
||||||
# 如果使用HTTPS,取消下面的注释并配置SSL证书
|
# If using HTTPS, uncomment and configure certificates.
|
||||||
# listen 443 ssl;
|
# listen 443 ssl;
|
||||||
# ssl_certificate /path/to/ssl/cert.pem;
|
# ssl_certificate /path/to/ssl/cert.pem;
|
||||||
# ssl_certificate_key /path/to/ssl/key.pem;
|
# ssl_certificate_key /path/to/ssl/key.pem;
|
||||||
|
|
||||||
# 增加请求体大小限制
|
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
# Next.js应用的主要路由
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://nextjs_app;
|
proxy_pass http://orangetv_app;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket专用路由
|
|
||||||
location /ws-api {
|
|
||||||
proxy_pass http://websocket_app;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
# WebSocket特定的超时设置
|
|
||||||
proxy_connect_timeout 7d;
|
|
||||||
proxy_send_timeout 7d;
|
|
||||||
proxy_read_timeout 7d;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 静态资源缓存
|
|
||||||
location /_next/static {
|
|
||||||
proxy_pass http://nextjs_app;
|
|
||||||
proxy_cache_valid 60m;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
location /public {
|
|
||||||
proxy_pass http://nextjs_app;
|
|
||||||
proxy_cache_valid 60m;
|
|
||||||
add_header Cache-Control "public, max-age=3600";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
47
package.json
47
package.json
|
|
@ -3,17 +3,13 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm gen:manifest && node simple-dev.js",
|
"dev": "pnpm gen:manifest && tsx watch src/server/index.ts",
|
||||||
"dev:redis": "node scripts/dev-with-redis.js",
|
"dev:redis": "node scripts/dev-with-redis.js",
|
||||||
"dev:complex": "pnpm gen:manifest && node dev-server.js",
|
"dev:complex": "pnpm dev",
|
||||||
"dev:ws": "node standalone-websocket.js",
|
|
||||||
"test:ws": "node test-websocket-connection.js",
|
|
||||||
"debug:api": "node debug-api.js",
|
"debug:api": "node debug-api.js",
|
||||||
"build": "pnpm gen:manifest && next build",
|
"build": "pnpm gen:manifest && vite build && node scripts/build-server.js",
|
||||||
"start": "NODE_ENV=production node server.js",
|
"start": "NODE_ENV=production node dist/server/index.js",
|
||||||
"prod": "NODE_ENV=production node production.js",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"prod:final": "NODE_ENV=production node production-final.js",
|
|
||||||
"lint": "next lint",
|
|
||||||
"lint:fix": "eslint src --fix && pnpm format",
|
"lint:fix": "eslint src --fix && pnpm format",
|
||||||
"lint:strict": "eslint --max-warnings=0 src",
|
"lint:strict": "eslint --max-warnings=0 src",
|
||||||
"typecheck": "tsc --noEmit --incremental false",
|
"typecheck": "tsc --noEmit --incremental false",
|
||||||
|
|
@ -30,6 +26,9 @@
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/middie": "^9.3.2",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@heroui/react": "3.0.5",
|
"@heroui/react": "3.0.5",
|
||||||
"@heroui/styles": "3.0.5",
|
"@heroui/styles": "3.0.5",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
|
@ -41,30 +40,34 @@
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
"framer-motion": "^12.18.1",
|
"framer-motion": "^12.18.1",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"hls.js": "^1.6.10",
|
"hls.js": "^1.6.10",
|
||||||
"lucide-react": "^0.438.0",
|
"lucide-react": "^0.438.0",
|
||||||
"media-icons": "^1.1.5",
|
"media-icons": "^1.1.5",
|
||||||
"next": "^15.5.18",
|
|
||||||
"next-pwa": "^5.6.0",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
|
"react-router-dom": "^7.15.1",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"swiper": "^11.2.8",
|
"swiper": "^11.2.8",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "3.2.2",
|
"tailwind-variants": "3.2.2",
|
||||||
"vidstack": "^0.6.15",
|
"vidstack": "^0.6.15",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.7",
|
||||||
|
"@babel/preset-env": "^7.29.7",
|
||||||
|
"@babel/preset-react": "^7.29.7",
|
||||||
|
"@babel/preset-typescript": "^7.29.7",
|
||||||
"@commitlint/cli": "^16.3.0",
|
"@commitlint/cli": "^16.3.0",
|
||||||
"@commitlint/config-conventional": "^16.2.4",
|
"@commitlint/config-conventional": "^16.2.4",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
|
@ -72,28 +75,36 @@
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/bs58": "^5.0.0",
|
"@types/bs58": "^5.0.0",
|
||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
|
"@types/jest": "27.5.2",
|
||||||
"@types/node": "24.0.3",
|
"@types/node": "24.0.3",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/testing-library__jest-dom": "^5.14.9",
|
"@types/testing-library__jest-dom": "^5.14.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"babel-jest": "27.5.1",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^15.5.18",
|
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||||
"eslint-plugin-unused-imports": "^2.0.0",
|
"eslint-plugin-unused-imports": "^2.0.0",
|
||||||
"husky": "^7.0.4",
|
"husky": "^7.0.4",
|
||||||
"jest": "^27.5.1",
|
"jest": "^27.5.1",
|
||||||
"lint-staged": "^12.5.0",
|
"lint-staged": "^12.5.0",
|
||||||
"next-router-mock": "^0.9.0",
|
|
||||||
"playwright": "^1.60.0",
|
"playwright": "^1.60.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"undici": "^8.3.0",
|
||||||
|
"vite": "^8.0.14",
|
||||||
"webpack-obfuscator": "^3.5.1"
|
"webpack-obfuscator": "^3.5.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|
@ -105,5 +116,9 @@
|
||||||
"prettier -w"
|
"prettier -w"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.14.0"
|
"packageManager": "pnpm@10.14.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24 <25",
|
||||||
|
"pnpm": "10.14.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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');
|
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(
|
const checks = await Promise.all(
|
||||||
ports.map(async (port) => ({
|
ports.map(async (port) => ({
|
||||||
port,
|
port,
|
||||||
|
|
@ -136,16 +136,16 @@ function ensureRedis(config = getRedisConfig()) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNextDev(config = getRedisConfig()) {
|
function startFastifyDev(config = getRedisConfig()) {
|
||||||
const redisUrl = buildRedisUrl(config.port);
|
const redisUrl = buildRedisUrl(config.port);
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
NEXT_PUBLIC_STORAGE_TYPE: 'redis',
|
VITE_STORAGE_TYPE: 'redis',
|
||||||
REDIS_URL: redisUrl,
|
REDIS_URL: redisUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Using Redis storage: ${redisUrl}`);
|
console.log(`Using Redis storage: ${redisUrl}`);
|
||||||
console.log('Starting OrangeTV dev server...');
|
console.log('Starting OrangeTV Fastify/Vite dev server...');
|
||||||
|
|
||||||
const child = spawn('pnpm', ['dev'], {
|
const child = spawn('pnpm', ['dev'], {
|
||||||
env,
|
env,
|
||||||
|
|
@ -172,7 +172,7 @@ async function main() {
|
||||||
const config = getRedisConfig();
|
const config = getRedisConfig();
|
||||||
await assertDevPortsAvailable();
|
await assertDevPortsAvailable();
|
||||||
ensureRedis(config);
|
ensureRedis(config);
|
||||||
startNextDev(config);
|
startFastifyDev(config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
@ -190,4 +190,5 @@ module.exports = {
|
||||||
getRedisConfig,
|
getRedisConfig,
|
||||||
isPortAvailable,
|
isPortAvailable,
|
||||||
resolveRedisAction,
|
resolveRedisAction,
|
||||||
|
startFastifyDev,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json
|
// 根据 VITE_SITE_NAME 动态生成 manifest.json
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public');
|
||||||
const manifestPath = path.join(publicDir, 'manifest.json');
|
const manifestPath = path.join(publicDir, 'manifest.json');
|
||||||
|
|
||||||
// 从环境变量获取站点名称
|
// 从环境变量获取站点名称
|
||||||
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV';
|
const siteName = process.env.VITE_SITE_NAME || 'OrangeTV';
|
||||||
|
|
||||||
// manifest.json 模板
|
// manifest.json 模板
|
||||||
const manifestTemplate = {
|
const manifestTemplate = {
|
||||||
|
|
|
||||||
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";
|
@import "tailwindcss";
|
||||||
@config "../../tailwind.config.ts";
|
|
||||||
@import "@heroui/styles";
|
@import "@heroui/styles";
|
||||||
|
@config "../../tailwind.config.ts";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
@ -257,7 +257,21 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-input {
|
.theme-input {
|
||||||
@apply rounded-xl border border-border bg-field px-3 py-2 text-foreground placeholder:text-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20;
|
border: 1px solid rgb(var(--color-border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgb(var(--color-surface));
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input::placeholder {
|
||||||
|
color: rgb(var(--color-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input:focus {
|
||||||
|
border-color: rgb(var(--color-accent));
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--color-accent) / 0.2);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-card-hover {
|
.video-card-hover {
|
||||||
|
|
@ -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 { Button, Chip, Spinner } from '@heroui/react';
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Radio } from 'lucide-react';
|
import { Radio } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from '@/client/AppImage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Chip } from '@heroui/react';
|
import { Card, Chip } from '@heroui/react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { Clover, Film, Home, Star, Tv } from 'lucide-react';
|
import { Clover, Film, Home, Star, Tv } from 'lucide-react';
|
||||||
import { Button, Card, ScrollShadow } from '@heroui/react';
|
import { Button, Card, ScrollShadow } from '@heroui/react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from '@/client/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface MobileBottomNavProps {
|
interface MobileBottomNavProps {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ import {
|
||||||
Star,
|
Star,
|
||||||
Tv,
|
Tv,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from '@/client/AppImage';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from '@/client/router';
|
||||||
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
|
import { Button, Card, Link as HeroLink, Separator, Tooltip } from '@heroui/react';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1 @@
|
||||||
'use client';
|
export { ThemeProvider, useTheme } from '@/client/theme-provider';
|
||||||
|
|
||||||
import type { ThemeProviderProps } from 'next-themes';
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|
||||||
return (
|
|
||||||
<NextThemesProvider
|
|
||||||
attribute='class'
|
|
||||||
defaultTheme='system'
|
|
||||||
enableSystem
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NextThemesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
import { Moon, Sun } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from '@/client/router';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from '@/client/theme-provider';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { AppIconButton } from './ui/HeroPrimitives';
|
import { AppIconButton } from './ui/HeroPrimitives';
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import {
|
||||||
User,
|
User,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from '@/client/AppImage';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from '@/client/router';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import ReactCrop, { Crop, PercentCrop, PixelCrop } from 'react-image-crop';
|
import ReactCrop, { Crop, PercentCrop, PixelCrop } from 'react-image-crop';
|
||||||
import 'react-image-crop/dist/ReactCrop.css';
|
import 'react-image-crop/dist/ReactCrop.css';
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Chip,
|
Chip,
|
||||||
|
|
@ -17,8 +16,8 @@ import {
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import Image from 'next/image';
|
import Image from '@/client/AppImage';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from '@/client/router';
|
||||||
import React, {
|
import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
memo,
|
memo,
|
||||||
|
|
@ -478,6 +477,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
return configs[from] || configs.search;
|
return configs[from] || configs.search;
|
||||||
}, [from, isAggregate, douban_id, rate]);
|
}, [from, isAggregate, douban_id, rate]);
|
||||||
|
|
||||||
|
const coverBadgeClass =
|
||||||
|
'pointer-events-none inline-flex h-7 min-w-11 max-w-[calc(100%-1rem)] items-center justify-center rounded-full border border-white/20 bg-black/70 px-2.5 text-[12px] font-semibold leading-none tracking-normal text-white shadow-[0_6px_18px_rgba(0,0,0,0.35)] backdrop-blur-md';
|
||||||
|
const hasYearBadge =
|
||||||
|
config.showYear &&
|
||||||
|
actualYear &&
|
||||||
|
actualYear !== 'unknown' &&
|
||||||
|
actualYear.trim() !== '';
|
||||||
|
|
||||||
// 移动端操作菜单配置
|
// 移动端操作菜单配置
|
||||||
const mobileActions = useMemo(() => {
|
const mobileActions = useMemo(() => {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
@ -835,14 +842,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 年份徽章 */}
|
{/* 年份徽章 */}
|
||||||
{config.showYear &&
|
{hasYearBadge && (
|
||||||
actualYear &&
|
<div
|
||||||
actualYear !== 'unknown' &&
|
className={`absolute left-2 top-2 ${coverBadgeClass}`}
|
||||||
actualYear.trim() !== '' && (
|
|
||||||
<Badge
|
|
||||||
size='sm'
|
|
||||||
variant='secondary'
|
|
||||||
className='absolute left-2 top-2'
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|
@ -855,17 +857,14 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Badge.Label>{actualYear}</Badge.Label>
|
{actualYear}
|
||||||
</Badge>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 徽章 */}
|
{/* 徽章 */}
|
||||||
{config.showRating && rate && (
|
{config.showRating && rate && (
|
||||||
<Chip
|
<div
|
||||||
size='md'
|
className={`absolute right-2 top-2 ${coverBadgeClass}`}
|
||||||
color='accent'
|
|
||||||
variant='primary'
|
|
||||||
className='absolute right-2 top-2'
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|
@ -878,15 +877,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip.Label>{rate}</Chip.Label>
|
{rate}
|
||||||
</Chip>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actualEpisodes && actualEpisodes > 1 && (
|
{actualEpisodes && actualEpisodes > 1 && (
|
||||||
<Chip
|
<div
|
||||||
size='md'
|
className={`absolute right-2 ${config.showRating && rate ? 'top-10' : 'top-2'} ${coverBadgeClass}`}
|
||||||
variant='secondary'
|
|
||||||
className='absolute right-3 top-3 min-w-12 justify-center'
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|
@ -899,12 +896,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip.Label>
|
{currentEpisode
|
||||||
{currentEpisode
|
? `${currentEpisode}/${actualEpisodes}`
|
||||||
? `${currentEpisode}/${actualEpisodes}`
|
: actualEpisodes}
|
||||||
: actualEpisodes}
|
</div>
|
||||||
</Chip.Label>
|
|
||||||
</Chip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 豆瓣链接 */}
|
{/* 豆瓣链接 */}
|
||||||
|
|
@ -920,7 +915,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0'
|
className={`absolute left-2 ${hasYearBadge ? 'top-11' : 'top-2'} opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|
@ -980,10 +975,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge
|
<div
|
||||||
size='sm'
|
className={coverBadgeClass}
|
||||||
color='accent'
|
|
||||||
variant='secondary'
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|
@ -996,8 +989,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Badge.Label>{sourceCount}</Badge.Label>
|
{sourceCount}
|
||||||
</Badge>
|
</div>
|
||||||
|
|
||||||
{/* 播放源详情悬浮框 */}
|
{/* 播放源详情悬浮框 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,15 @@ import MobileBottomNav from '../MobileBottomNav';
|
||||||
import Sidebar from '../Sidebar';
|
import Sidebar from '../Sidebar';
|
||||||
import { ThemeToggle } from '../ThemeToggle';
|
import { ThemeToggle } from '../ThemeToggle';
|
||||||
|
|
||||||
const push = jest.fn();
|
const mockPush = jest.fn();
|
||||||
|
|
||||||
jest.mock('next/navigation', () => ({
|
jest.mock('@/client/router', () => ({
|
||||||
usePathname: () => '/',
|
usePathname: () => '/',
|
||||||
useRouter: () => ({ push }),
|
useRouter: () => ({ push: mockPush }),
|
||||||
useSearchParams: () => new URLSearchParams(),
|
useSearchParams: () => new URLSearchParams(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('next-themes', () => ({
|
jest.mock('@/client/theme-provider', () => ({
|
||||||
useTheme: () => ({
|
useTheme: () => ({
|
||||||
resolvedTheme: 'dark',
|
resolvedTheme: 'dark',
|
||||||
setTheme: jest.fn(),
|
setTheme: jest.fn(),
|
||||||
|
|
@ -25,7 +25,7 @@ jest.mock('../ChatModal', () => ({
|
||||||
|
|
||||||
describe('hidden front-end options', () => {
|
describe('hidden front-end options', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
push.mockClear();
|
mockPush.mockClear();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextRequest } from 'next/server';
|
import type { AppRequest } from '@/server/web';
|
||||||
|
|
||||||
// 从cookie获取认证信息 (服务端使用)
|
// 从cookie获取认证信息 (服务端使用)
|
||||||
export function getAuthInfoFromCookie(request: NextRequest): {
|
export function getAuthInfoFromCookie(request: AppRequest): {
|
||||||
password?: string;
|
password?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
|
|
|
||||||
|
|
@ -225,25 +225,25 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||||
ConfigFile: configFile,
|
ConfigFile: configFile,
|
||||||
ConfigSubscribtion: subConfig,
|
ConfigSubscribtion: subConfig,
|
||||||
SiteConfig: {
|
SiteConfig: {
|
||||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV',
|
SiteName: process.env.VITE_SITE_NAME || 'OrangeTV',
|
||||||
Announcement:
|
Announcement:
|
||||||
process.env.ANNOUNCEMENT ||
|
process.env.ANNOUNCEMENT ||
|
||||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
SearchDownstreamMaxPage:
|
SearchDownstreamMaxPage:
|
||||||
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
Number(process.env.VITE_SEARCH_MAX_PAGE) || 5,
|
||||||
SiteInterfaceCacheTime: cfgFile.cache_time || 7200,
|
SiteInterfaceCacheTime: cfgFile.cache_time || 7200,
|
||||||
DoubanProxyType:
|
DoubanProxyType:
|
||||||
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
process.env.VITE_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
DoubanProxy: process.env.VITE_DOUBAN_PROXY || '',
|
||||||
DoubanImageProxyType:
|
DoubanImageProxyType:
|
||||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
process.env.VITE_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
DoubanImageProxy: process.env.VITE_DOUBAN_IMAGE_PROXY || '',
|
||||||
DisableYellowFilter:
|
DisableYellowFilter:
|
||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
process.env.VITE_DISABLE_YELLOW_FILTER === 'true',
|
||||||
FluidSearch:
|
FluidSearch:
|
||||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
process.env.VITE_FLUID_SEARCH !== 'false',
|
||||||
RequireDeviceCode:
|
RequireDeviceCode:
|
||||||
process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
process.env.VITE_REQUIRE_DEVICE_CODE !== 'false',
|
||||||
},
|
},
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: [],
|
Users: [],
|
||||||
|
|
@ -360,23 +360,23 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||||
// 确保 SiteConfig 及其属性存在
|
// 确保 SiteConfig 及其属性存在
|
||||||
if (!adminConfig.SiteConfig) {
|
if (!adminConfig.SiteConfig) {
|
||||||
adminConfig.SiteConfig = {
|
adminConfig.SiteConfig = {
|
||||||
SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'OrangeTV',
|
SiteName: process.env.VITE_SITE_NAME || 'OrangeTV',
|
||||||
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
SearchDownstreamMaxPage: Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
SearchDownstreamMaxPage: Number(process.env.VITE_SEARCH_MAX_PAGE) || 5,
|
||||||
SiteInterfaceCacheTime: 7200,
|
SiteInterfaceCacheTime: 7200,
|
||||||
DoubanProxyType: process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
DoubanProxyType: process.env.VITE_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
DoubanProxy: process.env.VITE_DOUBAN_PROXY || '',
|
||||||
DoubanImageProxyType: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
DoubanImageProxyType: process.env.VITE_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
|
||||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
DoubanImageProxy: process.env.VITE_DOUBAN_IMAGE_PROXY || '',
|
||||||
DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
DisableYellowFilter: process.env.VITE_DISABLE_YELLOW_FILTER === 'true',
|
||||||
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
FluidSearch: process.env.VITE_FLUID_SEARCH !== 'false',
|
||||||
RequireDeviceCode: process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false',
|
RequireDeviceCode: process.env.VITE_REQUIRE_DEVICE_CODE !== 'false',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 RequireDeviceCode 属性存在
|
// 确保 RequireDeviceCode 属性存在
|
||||||
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
|
if (adminConfig.SiteConfig.RequireDeviceCode === undefined) {
|
||||||
adminConfig.SiteConfig.RequireDeviceCode = process.env.NEXT_PUBLIC_REQUIRE_DEVICE_CODE !== 'false';
|
adminConfig.SiteConfig.RequireDeviceCode = process.env.VITE_REQUIRE_DEVICE_CODE !== 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 ThemeConfig 存在
|
// 确保 ThemeConfig 存在
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { UpstashRedisStorage } from './upstash.db';
|
||||||
|
|
||||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
(process.env.VITE_STORAGE_TYPE as
|
||||||
| 'localstorage'
|
| 'localstorage'
|
||||||
| 'redis'
|
| 'redis'
|
||||||
| 'upstash'
|
| 'upstash'
|
||||||
|
|
|
||||||
|
|
@ -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 { Alert, Avatar, Button, Card, Checkbox, Chip, Input, Label, Skeleton, Switch, Table, TextArea, TextField } from '@heroui/react';
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { AdminConfig, AdminConfigResult } from '../../lib/admin.types';
|
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
|
||||||
import DataMigration from '@/components/DataMigration';
|
import DataMigration from '@/components/DataMigration';
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from '@/client/router';
|
||||||
import { Card, EmptyState, Spinner } from '@heroui/react';
|
import { Card, EmptyState, Spinner } from '@heroui/react';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, react/no-unknown-property */
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ import Artplayer from 'artplayer';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { Heart, Radio, Tv } from 'lucide-react';
|
import { Heart, Radio, Tv } from 'lucide-react';
|
||||||
import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react';
|
import { Alert, Button, Card, Chip, EmptyState, ProgressBar, ScrollShadow, Spinner, Tabs } from '@heroui/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from '@/client/router';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
|
import { AlertCircle, CheckCircle, Shield } from 'lucide-react';
|
||||||
import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react';
|
import { Alert, Checkbox, Form, Input, Label, Link, TextField } from '@heroui/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from '@/client/router';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { CURRENT_VERSION } from '@/lib/version';
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, react/no-unknown-property */
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
// Artplayer 和 Hls 以及弹幕插件将动态加载
|
// Artplayer 和 Hls 以及弹幕插件将动态加载
|
||||||
import { Heart } from 'lucide-react';
|
import { Heart } from 'lucide-react';
|
||||||
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
|
import { Alert, Button, Card, Chip, ProgressBar, Spinner } from '@heroui/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from '@/client/router';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getDefaultExport } from '@/client/module-interop';
|
||||||
import {
|
import {
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
deletePlayRecord,
|
deletePlayRecord,
|
||||||
|
|
@ -161,9 +162,9 @@ function PlayPageClient() {
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setDynamicDeps({
|
setDynamicDeps({
|
||||||
Artplayer: ArtplayerModule.default,
|
Artplayer: getDefaultExport(ArtplayerModule),
|
||||||
Hls: HlsModule.default,
|
Hls: getDefaultExport(HlsModule),
|
||||||
artplayerPluginDanmuku: DanmakuModule.default
|
artplayerPluginDanmuku: getDefaultExport(DanmakuModule)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2409,7 +2410,7 @@ function PlayPageClient() {
|
||||||
artPlayerRef.current.on('video:timeupdate', () => {
|
artPlayerRef.current.on('video:timeupdate', () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let interval = 5000;
|
let interval = 5000;
|
||||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
|
if (window.RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash') {
|
||||||
interval = 20000;
|
interval = 20000;
|
||||||
}
|
}
|
||||||
if (now - lastSaveTimeRef.current > interval) {
|
if (now - lastSaveTimeRef.current > interval) {
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@heroui/react';
|
} from '@heroui/react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from '@/client/router';
|
||||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -1092,7 +1092,7 @@ function SearchPageClient() {
|
||||||
搜索结果
|
搜索结果
|
||||||
{totalSources > 0 && useFluidSearch && (
|
{totalSources > 0 && useFluidSearch && (
|
||||||
<span className='ml-2 text-sm font-normal text-muted'>
|
<span className='ml-2 text-sm font-normal text-muted'>
|
||||||
{completedSources}/{totalSources}
|
来源 {completedSources}/{totalSources}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isLoading && useFluidSearch && (
|
{isLoading && useFluidSearch && (
|
||||||
|
|
@ -1238,19 +1238,17 @@ function SearchPageClient() {
|
||||||
>
|
>
|
||||||
<Chip.Label>{item}</Chip.Label>
|
<Chip.Label>{item}</Chip.Label>
|
||||||
</Chip>
|
</Chip>
|
||||||
{/* 删除按钮 */}
|
<button
|
||||||
<Button
|
type='button'
|
||||||
aria-label='删除搜索历史'
|
aria-label='删除搜索历史'
|
||||||
isIconOnly
|
className='absolute -right-1 -top-1 inline-flex h-4 w-4 items-center justify-center rounded-full border border-border/80 bg-surface text-muted opacity-0 shadow-sm transition hover:bg-danger hover:text-white focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/40 group-hover:opacity-100'
|
||||||
size='sm'
|
onClick={(event) => {
|
||||||
variant='danger'
|
event.stopPropagation();
|
||||||
className='absolute -right-2 -top-2 opacity-0 group-hover:opacity-100'
|
|
||||||
onPress={() => {
|
|
||||||
deleteSearchHistory(item); // 事件监听会自动更新界面
|
deleteSearchHistory(item); // 事件监听会自动更新界面
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className='w-3 h-3' />
|
<X className='h-2.5 w-2.5' strokeWidth={2.4} />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
||||||
|
|
@ -15,10 +14,10 @@ interface BaseBody {
|
||||||
action?: Action;
|
action?: Action;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -32,14 +31,14 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 基础校验
|
// 基础校验
|
||||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
||||||
if (!username || !action || !ACTIONS.includes(action)) {
|
if (!username || !action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置与存储
|
// 获取配置与存储
|
||||||
|
|
@ -51,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||||
query?: string;
|
query?: string;
|
||||||
};
|
};
|
||||||
if (!name || !type || !query) {
|
if (!name || !type || !query) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
// 检查是否已存在相同的查询和类型组合
|
// 检查是否已存在相同的查询和类型组合
|
||||||
if (
|
if (
|
||||||
|
|
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||||
(c) => c.query === query && c.type === type
|
(c) => c.query === query && c.type === type
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
|
return AppResponse.json({ error: '该分类已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
adminConfig.CustomCategories.push({
|
adminConfig.CustomCategories.push({
|
||||||
name,
|
name,
|
||||||
|
|
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
|
||||||
type?: 'movie' | 'tv';
|
type?: 'movie' | 'tv';
|
||||||
};
|
};
|
||||||
if (!query || !type)
|
if (!query || !type)
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少 query 或 type 参数' },
|
{ error: '缺少 query 或 type 参数' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -96,7 +95,7 @@ export async function POST(request: NextRequest) {
|
||||||
(c) => c.query === query && c.type === type
|
(c) => c.query === query && c.type === type
|
||||||
);
|
);
|
||||||
if (!entry)
|
if (!entry)
|
||||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||||
entry.disabled = true;
|
entry.disabled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||||
type?: 'movie' | 'tv';
|
type?: 'movie' | 'tv';
|
||||||
};
|
};
|
||||||
if (!query || !type)
|
if (!query || !type)
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少 query 或 type 参数' },
|
{ error: '缺少 query 或 type 参数' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -114,7 +113,7 @@ export async function POST(request: NextRequest) {
|
||||||
(c) => c.query === query && c.type === type
|
(c) => c.query === query && c.type === type
|
||||||
);
|
);
|
||||||
if (!entry)
|
if (!entry)
|
||||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||||
entry.disabled = false;
|
entry.disabled = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +123,7 @@ export async function POST(request: NextRequest) {
|
||||||
type?: 'movie' | 'tv';
|
type?: 'movie' | 'tv';
|
||||||
};
|
};
|
||||||
if (!query || !type)
|
if (!query || !type)
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少 query 或 type 参数' },
|
{ error: '缺少 query 或 type 参数' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -132,10 +131,10 @@ export async function POST(request: NextRequest) {
|
||||||
(c) => c.query === query && c.type === type
|
(c) => c.query === query && c.type === type
|
||||||
);
|
);
|
||||||
if (idx === -1)
|
if (idx === -1)
|
||||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
return AppResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||||
const entry = adminConfig.CustomCategories[idx];
|
const entry = adminConfig.CustomCategories[idx];
|
||||||
if (entry.from === 'config') {
|
if (entry.from === 'config') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '该分类不可删除' },
|
{ error: '该分类不可删除' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -146,7 +145,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'sort': {
|
case 'sort': {
|
||||||
const { order } = body as { order?: string[] };
|
const { order } = body as { order?: string[] };
|
||||||
if (!Array.isArray(order)) {
|
if (!Array.isArray(order)) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '排序列表格式错误' },
|
{ error: '排序列表格式错误' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -170,13 +169,13 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return AppResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化到存储
|
// 持久化到存储
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ ok: true },
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -186,7 +185,7 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('分类管理操作失败:', error);
|
console.error('分类管理操作失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '分类管理操作失败',
|
error: '分类管理操作失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { AdminConfigResult } from '@/lib/admin.types';
|
import { AdminConfigResult } from '@/lib/admin.types';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -55,7 +54,7 @@ export async function GET(request: NextRequest) {
|
||||||
Config: config,
|
Config: config,
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
return AppResponse.json(result, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||||
},
|
},
|
||||||
|
|
@ -77,7 +76,7 @@ export async function GET(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig);
|
console.log('返回公开配置给', userRole, ',包含主题配置:', !!publicConfig.ThemeConfig);
|
||||||
return NextResponse.json(result, {
|
return AppResponse.json(result, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟
|
'Cache-Control': 'public, max-age=60', // 公开配置可以缓存1分钟
|
||||||
},
|
},
|
||||||
|
|
@ -85,7 +84,7 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取配置失败:', error);
|
console.error('获取配置失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '获取配置失败',
|
error: '获取配置失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig, refineConfig } from '@/lib/config';
|
import { getConfig, refineConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
|
|
@ -31,7 +30,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// 仅站长可以修改配置文件
|
// 仅站长可以修改配置文件
|
||||||
if (username !== process.env.USERNAME) {
|
if (username !== process.env.USERNAME) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '权限不足,只有站长可以修改配置文件' },
|
{ error: '权限不足,只有站长可以修改配置文件' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -42,7 +41,7 @@ export async function POST(request: NextRequest) {
|
||||||
const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body;
|
const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body;
|
||||||
|
|
||||||
if (!configFile || typeof configFile !== 'string') {
|
if (!configFile || typeof configFile !== 'string') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '配置文件内容不能为空' },
|
{ error: '配置文件内容不能为空' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -52,7 +51,7 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(configFile);
|
JSON.parse(configFile);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '配置文件格式错误,请检查 JSON 语法' },
|
{ error: '配置文件格式错误,请检查 JSON 语法' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -79,13 +78,13 @@ export async function POST(request: NextRequest) {
|
||||||
adminConfig = refineConfig(adminConfig);
|
adminConfig = refineConfig(adminConfig);
|
||||||
// 更新配置文件
|
// 更新配置文件
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '配置文件更新成功',
|
message: '配置文件更新成功',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新配置文件失败:', error);
|
console.error('更新配置文件失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '更新配置文件失败',
|
error: '更新配置文件失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
// 权限检查:仅站长可以拉取配置订阅
|
// 权限检查:仅站长可以拉取配置订阅
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '权限不足,只有站长可以拉取配置订阅' },
|
{ error: '权限不足,只有站长可以拉取配置订阅' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -24,14 +23,14 @@ export async function POST(request: NextRequest) {
|
||||||
const { url } = await request.json();
|
const { url } = await request.json();
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接 fetch URL 获取配置内容
|
// 直接 fetch URL 获取配置内容
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: `请求失败: ${response.status} ${response.statusText}` },
|
{ error: `请求失败: ${response.status} ${response.statusText}` },
|
||||||
{ status: response.status }
|
{ status: response.status }
|
||||||
);
|
);
|
||||||
|
|
@ -50,7 +49,7 @@ export async function POST(request: NextRequest) {
|
||||||
throw decodeError;
|
throw decodeError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
configContent: decodedContent,
|
configContent: decodedContent,
|
||||||
message: '配置拉取成功'
|
message: '配置拉取成功'
|
||||||
|
|
@ -58,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('拉取配置失败:', error);
|
console.error('拉取配置失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '拉取配置失败' },
|
{ error: '拉取配置失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { gzip } from 'zlib';
|
import { gzip } from 'zlib';
|
||||||
|
|
||||||
|
|
@ -9,16 +9,15 @@ import { SimpleCrypto } from '@/lib/crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { CURRENT_VERSION } from '@/lib/version';
|
import { CURRENT_VERSION } from '@/lib/version';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
const gzipAsync = promisify(gzip);
|
const gzipAsync = promisify(gzip);
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: AppRequest) {
|
||||||
try {
|
try {
|
||||||
// 检查存储类型
|
// 检查存储类型
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '不支持本地存储进行数据迁移' },
|
{ error: '不支持本地存储进行数据迁移' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -27,23 +26,23 @@ export async function POST(req: NextRequest) {
|
||||||
// 验证身份和权限
|
// 验证身份和权限
|
||||||
const authInfo = getAuthInfoFromCookie(req);
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return AppResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户权限(只有站长可以导出数据)
|
// 检查用户权限(只有站长可以导出数据)
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await db.getAdminConfig();
|
const config = await db.getAdminConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
|
return AppResponse.json({ error: '无法获取配置' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析请求体获取密码
|
// 解析请求体获取密码
|
||||||
const { password } = await req.json();
|
const { password } = await req.json();
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 });
|
return AppResponse.json({ error: '请提供加密密码' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集所有数据
|
// 收集所有数据
|
||||||
|
|
@ -100,7 +99,7 @@ export async function POST(req: NextRequest) {
|
||||||
const filename = `OrangeTV-backup-${timestamp}.dat`;
|
const filename = `OrangeTV-backup-${timestamp}.dat`;
|
||||||
|
|
||||||
// 返回加密的数据作为文件下载
|
// 返回加密的数据作为文件下载
|
||||||
return new NextResponse(encryptedData, {
|
return new AppResponse(encryptedData, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
|
|
@ -111,7 +110,7 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据导出失败:', error);
|
console.error('数据导出失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '导出失败' },
|
{ error: error instanceof Error ? error.message : '导出失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { gunzip } from 'zlib';
|
import { gunzip } from 'zlib';
|
||||||
|
|
||||||
|
|
@ -9,16 +9,15 @@ import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||||
import { SimpleCrypto } from '@/lib/crypto';
|
import { SimpleCrypto } from '@/lib/crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
const gunzipAsync = promisify(gunzip);
|
const gunzipAsync = promisify(gunzip);
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: AppRequest) {
|
||||||
try {
|
try {
|
||||||
// 检查存储类型
|
// 检查存储类型
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '不支持本地存储进行数据迁移' },
|
{ error: '不支持本地存储进行数据迁移' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -27,12 +26,12 @@ export async function POST(req: NextRequest) {
|
||||||
// 验证身份和权限
|
// 验证身份和权限
|
||||||
const authInfo = getAuthInfoFromCookie(req);
|
const authInfo = getAuthInfoFromCookie(req);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
return AppResponse.json({ error: '未登录' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户权限(只有站长可以导入数据)
|
// 检查用户权限(只有站长可以导入数据)
|
||||||
if (authInfo.username !== process.env.USERNAME) {
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析表单数据
|
// 解析表单数据
|
||||||
|
|
@ -41,11 +40,11 @@ export async function POST(req: NextRequest) {
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: '请选择备份文件' }, { status: 400 });
|
return AppResponse.json({ error: '请选择备份文件' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
return NextResponse.json({ error: '请提供解密密码' }, { status: 400 });
|
return AppResponse.json({ error: '请提供解密密码' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容
|
||||||
|
|
@ -56,7 +55,7 @@ export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
decryptedData = SimpleCrypto.decrypt(encryptedData, password);
|
decryptedData = SimpleCrypto.decrypt(encryptedData, password);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 });
|
return AppResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解压缩数据
|
// 解压缩数据
|
||||||
|
|
@ -69,12 +68,12 @@ export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
importData = JSON.parse(decompressedData);
|
importData = JSON.parse(decompressedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '备份文件格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证数据格式
|
// 验证数据格式
|
||||||
if (!importData.data || !importData.data.adminConfig || !importData.data.userData) {
|
if (!importData.data || !importData.data.adminConfig || !importData.data.userData) {
|
||||||
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 });
|
return AppResponse.json({ error: '备份文件格式无效' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始导入数据 - 先清空现有数据
|
// 开始导入数据 - 先清空现有数据
|
||||||
|
|
@ -127,7 +126,7 @@ export async function POST(req: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
message: '数据导入成功',
|
message: '数据导入成功',
|
||||||
importedUsers: Object.keys(userData).length,
|
importedUsers: Object.keys(userData).length,
|
||||||
timestamp: importData.timestamp,
|
timestamp: importData.timestamp,
|
||||||
|
|
@ -136,7 +135,7 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据导入失败:', error);
|
console.error('数据导入失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '导入失败' },
|
{ error: error instanceof Error ? error.message : '导入失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { refreshLiveChannels } from '@/lib/live';
|
import { refreshLiveChannels } from '@/lib/live';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
// 权限检查
|
// 权限检查
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!user || user.role !== 'admin' || user.banned) {
|
if (!user || user.role !== 'admin' || user.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,13 +42,13 @@ export async function POST(request: NextRequest) {
|
||||||
// 保存配置
|
// 保存配置
|
||||||
await db.saveAdminConfig(config);
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '直播源刷新成功',
|
message: '直播源刷新成功',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('直播源刷新失败:', error);
|
console.error('直播源刷新失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '刷新失败' },
|
{ error: error instanceof Error ? error.message : '刷新失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
/* eslint-disable no-console,no-case-declarations */
|
/* eslint-disable no-console,no-case-declarations */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
|
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
// 权限检查
|
// 权限检查
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
@ -21,7 +20,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!user || user.role !== 'admin' || user.banned) {
|
if (!user || user.role !== 'admin' || user.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +28,7 @@ export async function POST(request: NextRequest) {
|
||||||
const { action, key, name, url, ua, epg } = body;
|
const { action, key, name, url, ua, epg } = body;
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
return AppResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 LiveConfig 存在
|
// 确保 LiveConfig 存在
|
||||||
|
|
@ -41,7 +40,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'add':
|
case 'add':
|
||||||
// 检查是否已存在相同的 key
|
// 检查是否已存在相同的 key
|
||||||
if (config.LiveConfig.some((l) => l.key === key)) {
|
if (config.LiveConfig.some((l) => l.key === key)) {
|
||||||
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
|
return AppResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveInfo = {
|
const liveInfo = {
|
||||||
|
|
@ -71,12 +70,12 @@ export async function POST(request: NextRequest) {
|
||||||
// 删除直播源
|
// 删除直播源
|
||||||
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
|
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
|
||||||
if (deleteIndex === -1) {
|
if (deleteIndex === -1) {
|
||||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveSource = config.LiveConfig[deleteIndex];
|
const liveSource = config.LiveConfig[deleteIndex];
|
||||||
if (liveSource.from === 'config') {
|
if (liveSource.from === 'config') {
|
||||||
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
|
return AppResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCachedLiveChannels(key);
|
deleteCachedLiveChannels(key);
|
||||||
|
|
@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
|
||||||
// 启用直播源
|
// 启用直播源
|
||||||
const enableSource = config.LiveConfig.find((l) => l.key === key);
|
const enableSource = config.LiveConfig.find((l) => l.key === key);
|
||||||
if (!enableSource) {
|
if (!enableSource) {
|
||||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
enableSource.disabled = false;
|
enableSource.disabled = false;
|
||||||
break;
|
break;
|
||||||
|
|
@ -97,7 +96,7 @@ export async function POST(request: NextRequest) {
|
||||||
// 禁用直播源
|
// 禁用直播源
|
||||||
const disableSource = config.LiveConfig.find((l) => l.key === key);
|
const disableSource = config.LiveConfig.find((l) => l.key === key);
|
||||||
if (!disableSource) {
|
if (!disableSource) {
|
||||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
disableSource.disabled = true;
|
disableSource.disabled = true;
|
||||||
break;
|
break;
|
||||||
|
|
@ -106,12 +105,12 @@ export async function POST(request: NextRequest) {
|
||||||
// 编辑直播源
|
// 编辑直播源
|
||||||
const editSource = config.LiveConfig.find((l) => l.key === key);
|
const editSource = config.LiveConfig.find((l) => l.key === key);
|
||||||
if (!editSource) {
|
if (!editSource) {
|
||||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置文件中的直播源不允许编辑
|
// 配置文件中的直播源不允许编辑
|
||||||
if (editSource.from === 'config') {
|
if (editSource.from === 'config') {
|
||||||
return NextResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
|
return AppResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新字段(除了 key 和 from)
|
// 更新字段(除了 key 和 from)
|
||||||
|
|
@ -134,7 +133,7 @@ export async function POST(request: NextRequest) {
|
||||||
// 排序直播源
|
// 排序直播源
|
||||||
const { order } = body;
|
const { order } = body;
|
||||||
if (!Array.isArray(order)) {
|
if (!Array.isArray(order)) {
|
||||||
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '排序数据格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新的排序后的数组
|
// 创建新的排序后的数组
|
||||||
|
|
@ -157,15 +156,15 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return AppResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
await db.saveAdminConfig(config);
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return AppResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { resetConfig } from '@/lib/config';
|
import { resetConfig } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -20,18 +19,18 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
if (username !== process.env.USERNAME) {
|
if (username !== process.env.USERNAME) {
|
||||||
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
|
return AppResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resetConfig();
|
await resetConfig();
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ ok: true },
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -40,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '重置管理员配置失败',
|
error: '重置管理员配置失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -24,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
|
|
@ -77,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||||
typeof CustomTheme.customCSS !== 'string'
|
typeof CustomTheme.customCSS !== 'string'
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminConfig = await getConfig();
|
const adminConfig = await getConfig();
|
||||||
|
|
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!user || user.role !== 'admin' || user.banned) {
|
if (!user || user.role !== 'admin' || user.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +111,7 @@ export async function POST(request: NextRequest) {
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ ok: true },
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -122,7 +121,7 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新站点配置失败:', error);
|
console.error('更新站点配置失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '更新站点配置失败',
|
error: '更新站点配置失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'edit' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||||
|
|
@ -15,10 +14,10 @@ interface BaseBody {
|
||||||
action?: Action;
|
action?: Action;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -32,14 +31,14 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 基础校验
|
// 基础校验
|
||||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'edit', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||||
if (!username || !action || !ACTIONS.includes(action)) {
|
if (!username || !action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置与存储
|
// 获取配置与存储
|
||||||
|
|
@ -51,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,10 +63,10 @@ export async function POST(request: NextRequest) {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
if (!key || !name || !api) {
|
if (!key || !name || !api) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
||||||
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
return AppResponse.json({ error: '该源已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
adminConfig.SourceConfig.push({
|
adminConfig.SourceConfig.push({
|
||||||
key,
|
key,
|
||||||
|
|
@ -82,20 +81,20 @@ export async function POST(request: NextRequest) {
|
||||||
case 'disable': {
|
case 'disable': {
|
||||||
const { key } = body as { key?: string };
|
const { key } = body as { key?: string };
|
||||||
if (!key)
|
if (!key)
|
||||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
if (!entry)
|
if (!entry)
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
entry.disabled = true;
|
entry.disabled = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'enable': {
|
case 'enable': {
|
||||||
const { key } = body as { key?: string };
|
const { key } = body as { key?: string };
|
||||||
if (!key)
|
if (!key)
|
||||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
if (!entry)
|
if (!entry)
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
entry.disabled = false;
|
entry.disabled = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -107,11 +106,11 @@ export async function POST(request: NextRequest) {
|
||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
if (!key || !name || !api) {
|
if (!key || !name || !api) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
// 更新字段(除了 key 和 from)
|
// 更新字段(除了 key 和 from)
|
||||||
entry.name = name;
|
entry.name = name;
|
||||||
|
|
@ -122,13 +121,13 @@ export async function POST(request: NextRequest) {
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
const { key } = body as { key?: string };
|
const { key } = body as { key?: string };
|
||||||
if (!key)
|
if (!key)
|
||||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
||||||
if (idx === -1)
|
if (idx === -1)
|
||||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
return AppResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
const entry = adminConfig.SourceConfig[idx];
|
const entry = adminConfig.SourceConfig[idx];
|
||||||
if (entry.from === 'config') {
|
if (entry.from === 'config') {
|
||||||
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
|
return AppResponse.json({ error: '该源不可删除' }, { status: 400 });
|
||||||
}
|
}
|
||||||
adminConfig.SourceConfig.splice(idx, 1);
|
adminConfig.SourceConfig.splice(idx, 1);
|
||||||
|
|
||||||
|
|
@ -153,7 +152,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'batch_disable': {
|
case 'batch_disable': {
|
||||||
const { keys } = body as { keys?: string[] };
|
const { keys } = body as { keys?: string[] };
|
||||||
if (!Array.isArray(keys) || keys.length === 0) {
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
keys.forEach(key => {
|
keys.forEach(key => {
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
|
|
@ -166,7 +165,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'batch_enable': {
|
case 'batch_enable': {
|
||||||
const { keys } = body as { keys?: string[] };
|
const { keys } = body as { keys?: string[] };
|
||||||
if (!Array.isArray(keys) || keys.length === 0) {
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
keys.forEach(key => {
|
keys.forEach(key => {
|
||||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
|
|
@ -179,7 +178,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'batch_delete': {
|
case 'batch_delete': {
|
||||||
const { keys } = body as { keys?: string[] };
|
const { keys } = body as { keys?: string[] };
|
||||||
if (!Array.isArray(keys) || keys.length === 0) {
|
if (!Array.isArray(keys) || keys.length === 0) {
|
||||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
return AppResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
// 过滤掉 from=config 的源,但不报错
|
// 过滤掉 from=config 的源,但不报错
|
||||||
const keysToDelete = keys.filter(key => {
|
const keysToDelete = keys.filter(key => {
|
||||||
|
|
@ -218,7 +217,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'sort': {
|
case 'sort': {
|
||||||
const { order } = body as { order?: string[] };
|
const { order } = body as { order?: string[] };
|
||||||
if (!Array.isArray(order)) {
|
if (!Array.isArray(order)) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '排序列表格式错误' },
|
{ error: '排序列表格式错误' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -240,13 +239,13 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return AppResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化到存储
|
// 持久化到存储
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ ok: true },
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -256,7 +255,7 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('视频源管理操作失败:', error);
|
console.error('视频源管理操作失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '视频源管理操作失败',
|
error: '视频源管理操作失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { API_CONFIG } from '@/lib/config';
|
import { API_CONFIG } from '@/lib/config';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { AppResponse } from '@/server/web';
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AdminConfig } from '@/lib/admin.types';
|
import { AdminConfig } from '@/lib/admin.types';
|
||||||
import { headers, cookies } from 'next/headers';
|
import { headers, cookies } from '@/server/web';
|
||||||
import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
|
import { getConfig, setCachedConfig, clearCachedConfig } from '@/lib/config';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// 创建一个模拟的NextRequest对象来使用getAuthInfoFromCookie
|
// 创建一个模拟的AppRequest对象来使用getAuthInfoFromCookie
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const authCookie = cookieStore.get('auth');
|
const authCookie = cookieStore.get('auth');
|
||||||
|
|
||||||
if (!authCookie) {
|
if (!authCookie) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let authData;
|
let authData;
|
||||||
|
|
@ -20,19 +20,19 @@ export async function GET() {
|
||||||
const decoded = decodeURIComponent(authCookie.value);
|
const decoded = decodeURIComponent(authCookie.value);
|
||||||
authData = JSON.parse(decoded);
|
authData = JSON.parse(decoded);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
|
return AppResponse.json({ error: '认证信息无效' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const themeConfig = config.ThemeConfig;
|
const themeConfig = config.ThemeConfig;
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: themeConfig,
|
data: themeConfig,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取主题配置失败:', error);
|
console.error('获取主题配置失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '获取主题配置失败' },
|
{ error: '获取主题配置失败' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -46,7 +46,7 @@ export async function POST(request: Request) {
|
||||||
const authCookie = cookieStore.get('auth');
|
const authCookie = cookieStore.get('auth');
|
||||||
|
|
||||||
if (!authCookie) {
|
if (!authCookie) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let authData;
|
let authData;
|
||||||
|
|
@ -54,12 +54,12 @@ export async function POST(request: Request) {
|
||||||
const decoded = decodeURIComponent(authCookie.value);
|
const decoded = decodeURIComponent(authCookie.value);
|
||||||
authData = JSON.parse(decoded);
|
authData = JSON.parse(decoded);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: '认证信息无效' }, { status: 401 });
|
return AppResponse.json({ error: '认证信息无效' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为管理员
|
// 检查是否为管理员
|
||||||
if (authData.role !== 'admin' && authData.role !== 'owner') {
|
if (authData.role !== 'admin' && authData.role !== 'owner') {
|
||||||
return NextResponse.json({ error: '权限不足,仅管理员可设置全局主题' }, { status: 403 });
|
return AppResponse.json({ error: '权限不足,仅管理员可设置全局主题' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
@ -68,7 +68,7 @@ export async function POST(request: Request) {
|
||||||
// 验证主题名称
|
// 验证主题名称
|
||||||
const validThemes = ['default', 'minimal', 'warm', 'fresh'];
|
const validThemes = ['default', 'minimal', 'warm', 'fresh'];
|
||||||
if (!validThemes.includes(defaultTheme)) {
|
if (!validThemes.includes(defaultTheme)) {
|
||||||
return NextResponse.json({ error: '无效的主题名称' }, { status: 400 });
|
return AppResponse.json({ error: '无效的主题名称' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前配置
|
// 获取当前配置
|
||||||
|
|
@ -86,7 +86,7 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
console.log('=== 保存主题配置 ===');
|
console.log('=== 保存主题配置 ===');
|
||||||
console.log('请求参数:', { defaultTheme, customCSS, allowUserCustomization });
|
console.log('请求参数:', { defaultTheme, customCSS, allowUserCustomization });
|
||||||
console.log('当前存储类型:', process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage');
|
console.log('当前存储类型:', process.env.VITE_STORAGE_TYPE || 'localstorage');
|
||||||
console.log('待保存配置:', updatedConfig.ThemeConfig);
|
console.log('待保存配置:', updatedConfig.ThemeConfig);
|
||||||
console.log('完整配置对象:', JSON.stringify(updatedConfig, null, 2));
|
console.log('完整配置对象:', JSON.stringify(updatedConfig, null, 2));
|
||||||
|
|
||||||
|
|
@ -101,14 +101,14 @@ export async function POST(request: Request) {
|
||||||
const cachedConfig = await getConfig();
|
const cachedConfig = await getConfig();
|
||||||
console.log('保存后验证缓存中的配置:', cachedConfig.ThemeConfig);
|
console.log('保存后验证缓存中的配置:', cachedConfig.ThemeConfig);
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '主题配置已更新',
|
message: '主题配置已更新',
|
||||||
data: updatedConfig.ThemeConfig,
|
data: updatedConfig.ThemeConfig,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新主题配置失败:', error);
|
console.error('更新主题配置失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '更新主题配置失败', details: error instanceof Error ? error.message : '未知错误' },
|
{ error: '更新主题配置失败', details: error instanceof Error ? error.message : '未知错误' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
const ACTIONS = [
|
const ACTIONS = [
|
||||||
|
|
@ -23,10 +22,10 @@ const ACTIONS = [
|
||||||
'batchUpdateUserGroups',
|
'batchUpdateUserGroups',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储进行管理员配置',
|
error: '不支持本地存储进行管理员配置',
|
||||||
},
|
},
|
||||||
|
|
@ -39,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
|
|
@ -54,12 +53,12 @@ export async function POST(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!action || !ACTIONS.includes(action)) {
|
if (!action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return AppResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户组操作和批量操作不需要targetUsername
|
// 用户组操作和批量操作不需要targetUsername
|
||||||
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
|
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
|
||||||
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
return AppResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -71,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||||
action !== 'batchUpdateUserGroups' &&
|
action !== 'batchUpdateUserGroups' &&
|
||||||
username === targetUsername
|
username === targetUsername
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '无法对自己进行此操作' },
|
{ error: '无法对自己进行此操作' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -89,7 +88,7 @@ export async function POST(request: NextRequest) {
|
||||||
(u) => u.username === username
|
(u) => u.username === username
|
||||||
);
|
);
|
||||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
return AppResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
}
|
}
|
||||||
operatorRole = 'admin';
|
operatorRole = 'admin';
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +107,7 @@ export async function POST(request: NextRequest) {
|
||||||
targetEntry.role === 'owner' &&
|
targetEntry.role === 'owner' &&
|
||||||
!['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
|
!['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
|
return AppResponse.json({ error: '无法操作站长' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限校验逻辑
|
// 权限校验逻辑
|
||||||
|
|
@ -118,10 +117,10 @@ export async function POST(request: NextRequest) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
if (targetEntry) {
|
if (targetEntry) {
|
||||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
return AppResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!targetPassword) {
|
if (!targetPassword) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少目标用户密码' },
|
{ error: '缺少目标用户密码' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -151,7 +150,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'ban': {
|
case 'ban': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
|
|
@ -159,7 +158,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (isTargetAdmin) {
|
if (isTargetAdmin) {
|
||||||
// 目标是管理员
|
// 目标是管理员
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可封禁管理员' },
|
{ error: '仅站长可封禁管理员' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -170,14 +169,14 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'unban': {
|
case 'unban': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isTargetAdmin) {
|
if (isTargetAdmin) {
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可操作管理员' },
|
{ error: '仅站长可操作管理员' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -188,19 +187,19 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'setAdmin': {
|
case 'setAdmin': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (targetEntry.role === 'admin') {
|
if (targetEntry.role === 'admin') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '该用户已是管理员' },
|
{ error: '该用户已是管理员' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可设置管理员' },
|
{ error: '仅站长可设置管理员' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -210,19 +209,19 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'cancelAdmin': {
|
case 'cancelAdmin': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (targetEntry.role !== 'admin') {
|
if (targetEntry.role !== 'admin') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不是管理员' },
|
{ error: '目标用户不是管理员' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (operatorRole !== 'owner') {
|
if (operatorRole !== 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可取消管理员' },
|
{ error: '仅站长可取消管理员' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -232,18 +231,18 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'changePassword': {
|
case 'changePassword': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!targetPassword) {
|
if (!targetPassword) {
|
||||||
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
|
return AppResponse.json({ error: '缺少新密码' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限检查:不允许修改站长密码
|
// 权限检查:不允许修改站长密码
|
||||||
if (targetEntry.role === 'owner') {
|
if (targetEntry.role === 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '无法修改站长密码' },
|
{ error: '无法修改站长密码' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -254,7 +253,7 @@ export async function POST(request: NextRequest) {
|
||||||
operatorRole !== 'owner' &&
|
operatorRole !== 'owner' &&
|
||||||
username !== targetUsername
|
username !== targetUsername
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可修改其他管理员密码' },
|
{ error: '仅站长可修改其他管理员密码' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -265,7 +264,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'deleteUser': {
|
case 'deleteUser': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
|
|
@ -273,14 +272,14 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
|
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
|
||||||
if (username === targetUsername) {
|
if (username === targetUsername) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '不能删除自己' },
|
{ error: '不能删除自己' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTargetAdmin && operatorRole !== 'owner') {
|
if (isTargetAdmin && operatorRole !== 'owner') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可删除管理员' },
|
{ error: '仅站长可删除管理员' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -300,7 +299,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
case 'updateUserApis': {
|
case 'updateUserApis': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '目标用户不存在' },
|
{ error: '目标用户不存在' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
|
|
@ -314,7 +313,7 @@ export async function POST(request: NextRequest) {
|
||||||
operatorRole !== 'owner' &&
|
operatorRole !== 'owner' &&
|
||||||
username !== targetUsername
|
username !== targetUsername
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '仅站长可配置其他管理员的采集源' },
|
{ error: '仅站长可配置其他管理员的采集源' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
|
|
@ -346,7 +345,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
// 检查用户组是否已存在
|
// 检查用户组是否已存在
|
||||||
if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
|
if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
|
||||||
return NextResponse.json({ error: '用户组已存在' }, { status: 400 });
|
return AppResponse.json({ error: '用户组已存在' }, { status: 400 });
|
||||||
}
|
}
|
||||||
adminConfig.UserConfig.Tags.push({
|
adminConfig.UserConfig.Tags.push({
|
||||||
name: groupName,
|
name: groupName,
|
||||||
|
|
@ -357,7 +356,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'edit': {
|
case 'edit': {
|
||||||
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
||||||
if (groupIndex === -1) {
|
if (groupIndex === -1) {
|
||||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
return AppResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
|
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
|
||||||
break;
|
break;
|
||||||
|
|
@ -365,7 +364,7 @@ export async function POST(request: NextRequest) {
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
||||||
if (groupIndex === -1) {
|
if (groupIndex === -1) {
|
||||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
return AppResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找使用该用户组的所有用户
|
// 查找使用该用户组的所有用户
|
||||||
|
|
@ -391,13 +390,13 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 });
|
return AppResponse.json({ error: '未知的用户组操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'updateUserGroups': {
|
case 'updateUserGroups': {
|
||||||
if (!targetEntry) {
|
if (!targetEntry) {
|
||||||
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { userGroups } = body as { userGroups: string[] };
|
const { userGroups } = body as { userGroups: string[] };
|
||||||
|
|
@ -408,7 +407,7 @@ export async function POST(request: NextRequest) {
|
||||||
operatorRole !== 'owner' &&
|
operatorRole !== 'owner' &&
|
||||||
username !== targetUsername
|
username !== targetUsername
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
|
return AppResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户的用户组
|
// 更新用户的用户组
|
||||||
|
|
@ -425,7 +424,7 @@ export async function POST(request: NextRequest) {
|
||||||
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
|
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
|
||||||
|
|
||||||
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
|
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
|
||||||
return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 });
|
return AppResponse.json({ error: '缺少用户名列表' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||||
|
|
@ -433,7 +432,7 @@ export async function POST(request: NextRequest) {
|
||||||
for (const targetUsername of usernames) {
|
for (const targetUsername of usernames) {
|
||||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||||
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
return AppResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -454,13 +453,13 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return AppResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将更新后的配置写入数据库
|
// 将更新后的配置写入数据库
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ ok: true },
|
{ ok: true },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -470,7 +469,7 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('用户管理操作失败:', error);
|
console.error('用户管理操作失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '用户管理操作失败',
|
error: '用户管理操作失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 获取用户头像
|
// 获取用户头像
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -23,41 +22,41 @@ export async function GET(request: NextRequest) {
|
||||||
const avatar = await db.getUserAvatar(targetUser);
|
const avatar = await db.getUserAvatar(targetUser);
|
||||||
|
|
||||||
if (!avatar) {
|
if (!avatar) {
|
||||||
return NextResponse.json({ avatar: null });
|
return AppResponse.json({ avatar: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ avatar });
|
return AppResponse.json({ avatar });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取头像失败:', error);
|
console.error('获取头像失败:', error);
|
||||||
return NextResponse.json({ error: '获取头像失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取头像失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传用户头像
|
// 上传用户头像
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { avatar, targetUser } = body;
|
const { avatar, targetUser } = body;
|
||||||
|
|
||||||
if (!avatar) {
|
if (!avatar) {
|
||||||
return NextResponse.json({ error: '头像数据不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '头像数据不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证Base64格式
|
// 验证Base64格式
|
||||||
if (!avatar.startsWith('data:image/')) {
|
if (!avatar.startsWith('data:image/')) {
|
||||||
return NextResponse.json({ error: '无效的图片格式' }, { status: 400 });
|
return AppResponse.json({ error: '无效的图片格式' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查文件大小(Base64编码后大约增加33%,2MB的限制)
|
// 检查文件大小(Base64编码后大约增加33%,2MB的限制)
|
||||||
const base64Data = avatar.split(',')[1];
|
const base64Data = avatar.split(',')[1];
|
||||||
const sizeInBytes = (base64Data.length * 3) / 4;
|
const sizeInBytes = (base64Data.length * 3) / 4;
|
||||||
if (sizeInBytes > 2 * 1024 * 1024) {
|
if (sizeInBytes > 2 * 1024 * 1024) {
|
||||||
return NextResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
|
return AppResponse.json({ error: '图片大小不能超过2MB' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToUpdate = targetUser || authInfo.username;
|
const userToUpdate = targetUser || authInfo.username;
|
||||||
|
|
@ -68,24 +67,24 @@ export async function POST(request: NextRequest) {
|
||||||
authInfo.role === 'owner';
|
authInfo.role === 'owner';
|
||||||
|
|
||||||
if (!canUpdate) {
|
if (!canUpdate) {
|
||||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.setUserAvatar(userToUpdate, avatar);
|
await db.setUserAvatar(userToUpdate, avatar);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '头像上传成功' });
|
return AppResponse.json({ success: true, message: '头像上传成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传头像失败:', error);
|
console.error('上传头像失败:', error);
|
||||||
return NextResponse.json({ error: '上传头像失败' }, { status: 500 });
|
return AppResponse.json({ error: '上传头像失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除用户头像
|
// 删除用户头像
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -97,14 +96,14 @@ export async function DELETE(request: NextRequest) {
|
||||||
authInfo.role === 'owner';
|
authInfo.role === 'owner';
|
||||||
|
|
||||||
if (!canDelete) {
|
if (!canDelete) {
|
||||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.deleteUserAvatar(targetUser);
|
await db.deleteUserAvatar(targetUser);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '头像删除成功' });
|
return AppResponse.json({ success: true, message: '头像删除成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除头像失败:', error);
|
console.error('删除头像失败:', error);
|
||||||
return NextResponse.json({ error: '删除头像失败' }, { status: 500 });
|
return AppResponse.json({ error: '删除头像失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
/* eslint-disable no-console*/
|
/* eslint-disable no-console*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.VITE_STORAGE_TYPE || 'localstorage';
|
||||||
|
|
||||||
// 不支持 localstorage 模式
|
// 不支持 localstorage 模式
|
||||||
if (storageType === 'localstorage') {
|
if (storageType === 'localstorage') {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '不支持本地存储模式修改密码',
|
error: '不支持本地存储模式修改密码',
|
||||||
},
|
},
|
||||||
|
|
@ -27,19 +26,19 @@ export async function POST(request: NextRequest) {
|
||||||
// 获取认证信息
|
// 获取认证信息
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证新密码
|
// 验证新密码
|
||||||
if (!newPassword || typeof newPassword !== 'string') {
|
if (!newPassword || typeof newPassword !== 'string') {
|
||||||
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
|
return AppResponse.json({ error: '新密码不得为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME)
|
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME)
|
||||||
if (username === process.env.USERNAME) {
|
if (username === process.env.USERNAME) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '站长不能通过此接口修改密码' },
|
{ error: '站长不能通过此接口修改密码' },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
|
|
@ -48,10 +47,10 @@ export async function POST(request: NextRequest) {
|
||||||
// 修改密码
|
// 修改密码
|
||||||
await db.changePassword(username, newPassword);
|
await db.changePassword(username, newPassword);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return AppResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('修改密码失败:', error);
|
console.error('修改密码失败:', error);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '修改密码失败',
|
error: '修改密码失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { db } from '../../../../lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { Conversation } from '../../../../lib/types';
|
import { Conversation } from '@/lib/types';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversations = await db.getConversations(authInfo.username);
|
const conversations = await db.getConversations(authInfo.username);
|
||||||
return NextResponse.json(conversations);
|
return AppResponse.json(conversations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading conversations:', error);
|
console.error('Error loading conversations:', error);
|
||||||
return NextResponse.json({ error: '获取对话列表失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取对话列表失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { participants, name, type } = await request.json();
|
const { participants, name, type } = await request.json();
|
||||||
|
|
||||||
if (!participants || !Array.isArray(participants) || participants.length === 0) {
|
if (!participants || !Array.isArray(participants) || participants.length === 0) {
|
||||||
return NextResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保当前用户在参与者列表中
|
// 确保当前用户在参与者列表中
|
||||||
|
|
@ -51,9 +51,9 @@ export async function POST(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.createConversation(conversation);
|
await db.createConversation(conversation);
|
||||||
return NextResponse.json(conversation, { status: 201 });
|
return AppResponse.json(conversation, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating conversation:', error);
|
console.error('Error creating conversation:', error);
|
||||||
return NextResponse.json({ error: '创建对话失败' }, { status: 500 });
|
return AppResponse.json({ error: '创建对话失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,47 +1,47 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { db } from '../../../../lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { FriendRequest, Friend } from '../../../../lib/types';
|
import { FriendRequest, Friend } from '@/lib/types';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendRequests = await db.getFriendRequests(authInfo.username);
|
const friendRequests = await db.getFriendRequests(authInfo.username);
|
||||||
return NextResponse.json(friendRequests);
|
return AppResponse.json(friendRequests);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading friend requests:', error);
|
console.error('Error loading friend requests:', error);
|
||||||
return NextResponse.json({ error: '获取好友申请失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取好友申请失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { to_user, message } = await request.json();
|
const { to_user, message } = await request.json();
|
||||||
|
|
||||||
if (!to_user) {
|
if (!to_user) {
|
||||||
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '目标用户不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标用户是否存在
|
// 检查目标用户是否存在
|
||||||
const userExists = await db.checkUserExist(to_user);
|
const userExists = await db.checkUserExist(to_user);
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
return AppResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经是好友
|
// 检查是否已经是好友
|
||||||
const friends = await db.getFriends(authInfo.username);
|
const friends = await db.getFriends(authInfo.username);
|
||||||
const isAlreadyFriend = friends.some(friend => friend.username === to_user);
|
const isAlreadyFriend = friends.some(friend => friend.username === to_user);
|
||||||
if (isAlreadyFriend) {
|
if (isAlreadyFriend) {
|
||||||
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
|
return AppResponse.json({ error: '已经是好友' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经有pending的申请
|
// 检查是否已经有pending的申请
|
||||||
|
|
@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
|
||||||
req => req.from_user === authInfo.username && req.status === 'pending'
|
req => req.from_user === authInfo.username && req.status === 'pending'
|
||||||
);
|
);
|
||||||
if (hasPendingRequest) {
|
if (hasPendingRequest) {
|
||||||
return NextResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
|
return AppResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendRequest: FriendRequest = {
|
const friendRequest: FriendRequest = {
|
||||||
|
|
@ -64,24 +64,24 @@ export async function POST(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.createFriendRequest(friendRequest);
|
await db.createFriendRequest(friendRequest);
|
||||||
return NextResponse.json(friendRequest, { status: 201 });
|
return AppResponse.json(friendRequest, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating friend request:', error);
|
console.error('Error creating friend request:', error);
|
||||||
return NextResponse.json({ error: '发送好友申请失败' }, { status: 500 });
|
return AppResponse.json({ error: '发送好友申请失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { requestId, status } = await request.json();
|
const { requestId, status } = await request.json();
|
||||||
|
|
||||||
if (!requestId || !status || !['accepted', 'rejected'].includes(status)) {
|
if (!requestId || !status || !['accepted', 'rejected'].includes(status)) {
|
||||||
return NextResponse.json({ error: '请求参数无效' }, { status: 400 });
|
return AppResponse.json({ error: '请求参数无效' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取申请信息
|
// 获取申请信息
|
||||||
|
|
@ -89,11 +89,11 @@ export async function PUT(request: NextRequest) {
|
||||||
const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username);
|
const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username);
|
||||||
|
|
||||||
if (!friendRequest) {
|
if (!friendRequest) {
|
||||||
return NextResponse.json({ error: '好友申请不存在' }, { status: 404 });
|
return AppResponse.json({ error: '好友申请不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (friendRequest.status !== 'pending') {
|
if (friendRequest.status !== 'pending') {
|
||||||
return NextResponse.json({ error: '申请已处理' }, { status: 400 });
|
return AppResponse.json({ error: '申请已处理' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新申请状态
|
// 更新申请状态
|
||||||
|
|
@ -122,9 +122,9 @@ export async function PUT(request: NextRequest) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return AppResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling friend request:', error);
|
console.error('Error handling friend request:', error);
|
||||||
return NextResponse.json({ error: '处理好友申请失败' }, { status: 500 });
|
return AppResponse.json({ error: '处理好友申请失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,47 +1,47 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { db } from '../../../../lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { Friend } from '../../../../lib/types';
|
import { Friend } from '@/lib/types';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const friends = await db.getFriends(authInfo.username);
|
const friends = await db.getFriends(authInfo.username);
|
||||||
return NextResponse.json(friends);
|
return AppResponse.json(friends);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading friends:', error);
|
console.error('Error loading friends:', error);
|
||||||
return NextResponse.json({ error: '获取好友列表失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取好友列表失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, nickname } = await request.json();
|
const { username, nickname } = await request.json();
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户是否存在
|
// 检查用户是否存在
|
||||||
const userExists = await db.checkUserExist(username);
|
const userExists = await db.checkUserExist(username);
|
||||||
if (!userExists) {
|
if (!userExists) {
|
||||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
return AppResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经是好友
|
// 检查是否已经是好友
|
||||||
const friends = await db.getFriends(authInfo.username);
|
const friends = await db.getFriends(authInfo.username);
|
||||||
const isAlreadyFriend = friends.some(friend => friend.username === username);
|
const isAlreadyFriend = friends.some(friend => friend.username === username);
|
||||||
if (isAlreadyFriend) {
|
if (isAlreadyFriend) {
|
||||||
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
|
return AppResponse.json({ error: '已经是好友' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const friend: Friend = {
|
const friend: Friend = {
|
||||||
|
|
@ -53,31 +53,31 @@ export async function POST(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.addFriend(authInfo.username, friend);
|
await db.addFriend(authInfo.username, friend);
|
||||||
return NextResponse.json(friend, { status: 201 });
|
return AppResponse.json(friend, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding friend:', error);
|
console.error('Error adding friend:', error);
|
||||||
return NextResponse.json({ error: '添加好友失败' }, { status: 500 });
|
return AppResponse.json({ error: '添加好友失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const friendId = searchParams.get('friendId');
|
const friendId = searchParams.get('friendId');
|
||||||
|
|
||||||
if (!friendId) {
|
if (!friendId) {
|
||||||
return NextResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.removeFriend(authInfo.username, friendId);
|
await db.removeFriend(authInfo.username, friendId);
|
||||||
return NextResponse.json({ success: true });
|
return AppResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error removing friend:', error);
|
console.error('Error removing friend:', error);
|
||||||
return NextResponse.json({ error: '删除好友失败' }, { status: 500 });
|
return AppResponse.json({ error: '删除好友失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { db } from '../../../../lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ChatMessage } from '../../../../lib/types';
|
import { ChatMessage } from '@/lib/types';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
console.log('未授权访问消息API:', authInfo);
|
console.log('未授权访问消息API:', authInfo);
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -18,7 +18,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
console.log('缺少对话ID参数');
|
console.log('缺少对话ID参数');
|
||||||
return NextResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
|
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
|
||||||
|
|
@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
|
||||||
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
|
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('数据库查询对话失败:', dbError);
|
console.error('数据库查询对话失败:', dbError);
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
error: '数据库查询失败',
|
error: '数据库查询失败',
|
||||||
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
|
|
@ -38,21 +38,21 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
console.log('对话不存在:', conversationId);
|
console.log('对话不存在:', conversationId);
|
||||||
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
return AppResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!conversation.participants.includes(authInfo.username)) {
|
if (!conversation.participants.includes(authInfo.username)) {
|
||||||
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
|
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
|
||||||
return NextResponse.json({ error: '无权限访问此对话' }, { status: 403 });
|
return AppResponse.json({ error: '无权限访问此对话' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await db.getMessages(conversationId, limit, offset);
|
const messages = await db.getMessages(conversationId, limit, offset);
|
||||||
console.log(`成功加载 ${messages.length} 条消息`);
|
console.log(`成功加载 ${messages.length} 条消息`);
|
||||||
return NextResponse.json(messages);
|
return AppResponse.json(messages);
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('数据库查询消息失败:', dbError);
|
console.error('数据库查询消息失败:', dbError);
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
error: '获取消息失败',
|
error: '获取消息失败',
|
||||||
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
|
|
@ -60,30 +60,30 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载消息API发生未知错误:', error);
|
console.error('加载消息API发生未知错误:', error);
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
error: '获取消息失败',
|
error: '获取消息失败',
|
||||||
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageData = await request.json();
|
const messageData = await request.json();
|
||||||
|
|
||||||
if (!messageData.conversation_id || !messageData.content) {
|
if (!messageData.conversation_id || !messageData.content) {
|
||||||
return NextResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证用户是否有权限发送消息到此对话
|
// 验证用户是否有权限发送消息到此对话
|
||||||
const conversation = await db.getConversation(messageData.conversation_id);
|
const conversation = await db.getConversation(messageData.conversation_id);
|
||||||
if (!conversation || !conversation.participants.includes(authInfo.username)) {
|
if (!conversation || !conversation.participants.includes(authInfo.username)) {
|
||||||
return NextResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
|
return AppResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
|
|
@ -105,30 +105,30 @@ export async function POST(request: NextRequest) {
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(message, { status: 201 });
|
return AppResponse.json(message, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error);
|
console.error('Error sending message:', error);
|
||||||
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
|
return AppResponse.json({ error: '发送消息失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messageId } = await request.json();
|
const { messageId } = await request.json();
|
||||||
|
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
return NextResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.markMessageAsRead(messageId);
|
await db.markMessageAsRead(messageId);
|
||||||
return NextResponse.json({ success: true });
|
return AppResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error marking message as read:', error);
|
console.error('Error marking message as read:', error);
|
||||||
return NextResponse.json({ error: '标记消息已读失败' }, { status: 500 });
|
return AppResponse.json({ error: '标记消息已读失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
// 从全局对象获取WebSocket实例相关方法
|
// 从全局对象获取WebSocket实例相关方法
|
||||||
function getOnlineUsers(): string[] {
|
function getOnlineUsers(): string[] {
|
||||||
|
|
@ -16,17 +16,17 @@ function getOnlineUsers(): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取在线用户列表
|
// 获取在线用户列表
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const onlineUsers = getOnlineUsers();
|
const onlineUsers = getOnlineUsers();
|
||||||
return NextResponse.json({ onlineUsers });
|
return AppResponse.json({ onlineUsers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取在线用户失败:', error);
|
console.error('获取在线用户失败:', error);
|
||||||
return NextResponse.json({ error: '获取在线用户失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取在线用户失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { db } from '../../../../lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = searchParams.get('q');
|
const query = searchParams.get('q');
|
||||||
|
|
||||||
if (!query || query.trim().length < 2) {
|
if (!query || query.trim().length < 2) {
|
||||||
return NextResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
|
return AppResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有用户并进行模糊匹配
|
// 获取所有用户并进行模糊匹配
|
||||||
|
|
@ -31,9 +31,9 @@ export async function GET(request: NextRequest) {
|
||||||
added_at: 0,
|
added_at: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(userResults);
|
return AppResponse.json(userResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching users:', error);
|
console.error('Error searching users:', error);
|
||||||
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
|
return AppResponse.json({ error: '搜索用户失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { WebSocketMessage } from '../../../../lib/types';
|
import { WebSocketMessage } from '@/lib/types';
|
||||||
|
|
||||||
// 从全局对象获取WebSocket实例相关方法
|
// 从全局对象获取WebSocket实例相关方法
|
||||||
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
|
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
|
||||||
|
|
@ -17,11 +17,11 @@ function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boole
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用
|
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
return AppResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: WebSocketMessage = await request.json();
|
const message: WebSocketMessage = await request.json();
|
||||||
|
|
@ -52,18 +52,18 @@ export async function POST(request: NextRequest) {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '不支持的消息类型' }, { status: 400 });
|
return AppResponse.json({ error: '不支持的消息类型' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通过 WebSocket 发送消息
|
// 通过 WebSocket 发送消息
|
||||||
const sent = sendMessageToUsers(targetUsers, message);
|
const sent = sendMessageToUsers(targetUsers, message);
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
delivered: sent
|
delivered: sent
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('通过 API 发送消息失败:', error);
|
console.error('通过 API 发送消息失败:', error);
|
||||||
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
|
return AppResponse.json({ error: '发送消息失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getConfig, refineConfig } from '@/lib/config';
|
import { getConfig, refineConfig } from '@/lib/config';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
@ -8,16 +8,15 @@ import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||||
import { refreshLiveChannels } from '@/lib/live';
|
import { refreshLiveChannels } from '@/lib/live';
|
||||||
import { SearchResult } from '@/lib/types';
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
console.log(request.url);
|
console.log(request.url);
|
||||||
try {
|
try {
|
||||||
console.log('Cron job triggered:', new Date().toISOString());
|
console.log('Cron job triggered:', new Date().toISOString());
|
||||||
|
|
||||||
cronJob();
|
cronJob();
|
||||||
|
|
||||||
return NextResponse.json({
|
return AppResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Cron job executed successfully',
|
message: 'Cron job executed successfully',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|
@ -25,7 +24,7 @@ export async function GET(request: NextRequest) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cron job failed:', error);
|
console.error('Cron job failed:', error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Cron job failed',
|
message: 'Cron job failed',
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
// 获取弹幕
|
// 获取弹幕
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const videoId = searchParams.get('videoId');
|
const videoId = searchParams.get('videoId');
|
||||||
|
|
||||||
if (!videoId) {
|
if (!videoId) {
|
||||||
return NextResponse.json({ error: '视频ID不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '视频ID不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const danmuList = await db.getDanmu(videoId);
|
const danmuList = await db.getDanmu(videoId);
|
||||||
|
|
@ -27,38 +26,38 @@ export async function GET(request: NextRequest) {
|
||||||
size: 25
|
size: 25
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(formattedDanmu);
|
return AppResponse.json(formattedDanmu);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取弹幕失败:', error);
|
console.error('获取弹幕失败:', error);
|
||||||
return NextResponse.json({ error: '获取弹幕失败' }, { status: 500 });
|
return AppResponse.json({ error: '获取弹幕失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送弹幕
|
// 发送弹幕
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { videoId, text, color, mode, time } = body;
|
const { videoId, text, color, mode, time } = body;
|
||||||
|
|
||||||
if (!videoId || !text) {
|
if (!videoId || !text) {
|
||||||
return NextResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '视频ID和弹幕内容不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证弹幕内容长度
|
// 验证弹幕内容长度
|
||||||
if (text.length > 100) {
|
if (text.length > 100) {
|
||||||
return NextResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
|
return AppResponse.json({ error: '弹幕内容不能超过100个字符' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤敏感内容(可以扩展)
|
// 过滤敏感内容(可以扩展)
|
||||||
const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词
|
const sensitiveWords = ['垃圾', '傻逼', '草泥马', '操你妈']; // 示例敏感词
|
||||||
const hasSensitiveWord = sensitiveWords.some(word => text.includes(word));
|
const hasSensitiveWord = sensitiveWords.some(word => text.includes(word));
|
||||||
if (hasSensitiveWord) {
|
if (hasSensitiveWord) {
|
||||||
return NextResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
|
return AppResponse.json({ error: '弹幕内容包含敏感词汇' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const danmuData = {
|
const danmuData = {
|
||||||
|
|
@ -71,24 +70,24 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
await db.saveDanmu(videoId, authInfo.username, danmuData);
|
await db.saveDanmu(videoId, authInfo.username, danmuData);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '弹幕发送成功' });
|
return AppResponse.json({ success: true, message: '弹幕发送成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送弹幕失败:', error);
|
console.error('发送弹幕失败:', error);
|
||||||
return NextResponse.json({ error: '发送弹幕失败' }, { status: 500 });
|
return AppResponse.json({ error: '发送弹幕失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除弹幕(管理员功能)
|
// 删除弹幕(管理员功能)
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: AppRequest) {
|
||||||
try {
|
try {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有管理员和站长可以删除弹幕
|
// 只有管理员和站长可以删除弹幕
|
||||||
if (authInfo.role !== 'admin' && authInfo.role !== 'owner') {
|
if (authInfo.role !== 'admin' && authInfo.role !== 'owner') {
|
||||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
return AppResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -96,14 +95,14 @@ export async function DELETE(request: NextRequest) {
|
||||||
const danmuId = searchParams.get('danmuId');
|
const danmuId = searchParams.get('danmuId');
|
||||||
|
|
||||||
if (!videoId || !danmuId) {
|
if (!videoId || !danmuId) {
|
||||||
return NextResponse.json({ error: '视频ID和弹幕ID不能为空' }, { status: 400 });
|
return AppResponse.json({ error: '视频ID和弹幕ID不能为空' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.deleteDanmu(videoId, danmuId);
|
await db.deleteDanmu(videoId, danmuId);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, message: '弹幕删除成功' });
|
return AppResponse.json({ success: true, message: '弹幕删除成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除弹幕失败:', error);
|
console.error('删除弹幕失败:', error);
|
||||||
return NextResponse.json({ error: '删除弹幕失败' }, { status: 500 });
|
return AppResponse.json({ error: '删除弹幕失败' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
import { getDetailFromApi } from '@/lib/downstream';
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return AppResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -17,11 +16,11 @@ export async function GET(request: NextRequest) {
|
||||||
const sourceCode = searchParams.get('source');
|
const sourceCode = searchParams.get('source');
|
||||||
|
|
||||||
if (!id || !sourceCode) {
|
if (!id || !sourceCode) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return AppResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[\w-]+$/.test(id)) {
|
if (!/^[\w-]+$/.test(id)) {
|
||||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
return AppResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -29,13 +28,13 @@ export async function GET(request: NextRequest) {
|
||||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||||
|
|
||||||
if (!apiSite) {
|
if (!apiSite) {
|
||||||
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
|
return AppResponse.json({ error: '无效的API来源' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getDetailFromApi(apiSite, id);
|
const result = await getDetailFromApi(apiSite, id);
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
return AppResponse.json(result, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|
@ -44,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: (error as Error).message },
|
{ error: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getCacheTime } from '@/lib/config';
|
import { getCacheTime } from '@/lib/config';
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
|
|
@ -20,7 +20,6 @@ interface DoubanCategoryApiResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -34,28 +33,28 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
// 验证参数
|
// 验证参数
|
||||||
if (!kind || !category || !type) {
|
if (!kind || !category || !type) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少必要参数: kind 或 category 或 type' },
|
{ error: '缺少必要参数: kind 或 category 或 type' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['tv', 'movie'].includes(kind)) {
|
if (!['tv', 'movie'].includes(kind)) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'kind 参数必须是 tv 或 movie' },
|
{ error: 'kind 参数必须是 tv 或 movie' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageLimit < 1 || pageLimit > 100) {
|
if (pageLimit < 1 || pageLimit > 100) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'pageSize 必须在 1-100 之间' },
|
{ error: 'pageSize 必须在 1-100 之间' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageStart < 0) {
|
if (pageStart < 0) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'pageStart 不能小于 0' },
|
{ error: 'pageStart 不能小于 0' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -83,7 +82,7 @@ export async function GET(request: Request) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(response, {
|
return AppResponse.json(response, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|
@ -92,7 +91,7 @@ export async function GET(request: Request) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { AppRequest, AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getCacheTime } from '@/lib/config';
|
import { getCacheTime } from '@/lib/config';
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
|
|
@ -23,9 +23,8 @@ interface DoubanRecommendApiResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: AppRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
// 获取参数
|
// 获取参数
|
||||||
|
|
@ -47,7 +46,7 @@ export async function GET(request: NextRequest) {
|
||||||
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
|
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
|
||||||
|
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
|
return AppResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCategories = { 类型: category } as any;
|
const selectedCategories = { 类型: category } as any;
|
||||||
|
|
@ -113,7 +112,7 @@ export async function GET(request: NextRequest) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(response, {
|
return AppResponse.json(response, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|
@ -122,7 +121,7 @@ export async function GET(request: NextRequest) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { AppResponse } from '@/server/web';
|
||||||
|
|
||||||
import { getCacheTime } from '@/lib/config';
|
import { getCacheTime } from '@/lib/config';
|
||||||
import { fetchDoubanData } from '@/lib/douban';
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
|
|
@ -13,7 +13,6 @@ interface DoubanApiResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -26,28 +25,28 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
// 验证参数
|
// 验证参数
|
||||||
if (!type || !tag) {
|
if (!type || !tag) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '缺少必要参数: type 或 tag' },
|
{ error: '缺少必要参数: type 或 tag' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['tv', 'movie'].includes(type)) {
|
if (!['tv', 'movie'].includes(type)) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'type 参数必须是 tv 或 movie' },
|
{ error: 'type 参数必须是 tv 或 movie' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageSize < 1 || pageSize > 100) {
|
if (pageSize < 1 || pageSize > 100) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'pageSize 必须在 1-100 之间' },
|
{ error: 'pageSize 必须在 1-100 之间' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageStart < 0) {
|
if (pageStart < 0) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: 'pageStart 不能小于 0' },
|
{ error: 'pageStart 不能小于 0' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
|
@ -79,7 +78,7 @@ export async function GET(request: Request) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(response, {
|
return AppResponse.json(response, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|
@ -88,7 +87,7 @@ export async function GET(request: Request) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|
@ -155,7 +154,7 @@ function handleTop250(pageStart: number) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
return NextResponse.json(apiResponse, {
|
return AppResponse.json(apiResponse, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|
@ -166,7 +165,7 @@ function handleTop250(pageStart: number) {
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
return NextResponse.json(
|
return AppResponse.json(
|
||||||
{
|
{
|
||||||
error: '获取豆瓣 Top250 数据失败',
|
error: '获取豆瓣 Top250 数据失败',
|
||||||
details: (error as Error).message,
|
details: (error as Error).message,
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue