mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
docs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
dist
|
||||||
|
tmp
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL="postgresql://jboard:jboard123@localhost:5432/jboard"
|
||||||
|
|
||||||
|
# NextAuth
|
||||||
|
NEXTAUTH_SECRET="replace-with-a-long-random-secret"
|
||||||
|
NEXTAUTH_URL="https://your-domain.com"
|
||||||
|
|
||||||
|
# Must be at least 32 bytes, used for AES-256-GCM encryption
|
||||||
|
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
# Redis connection URL for rate limiting and caching
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx prisma generate
|
||||||
|
- run: npx tsc --noEmit
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run build
|
||||||
|
env:
|
||||||
|
NEXTAUTH_SECRET: ci-test-secret
|
||||||
|
NEXTAUTH_URL: http://localhost:3000
|
||||||
|
DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy
|
||||||
|
ENCRYPTION_KEY: ci-test-encryption-key-32-bytes!!
|
||||||
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
.impeccable.md
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# database/local artifacts
|
||||||
|
/backups/
|
||||||
|
*.sql
|
||||||
|
*.dump
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
mapi.php.json
|
||||||
|
|
||||||
|
# local agent build outputs
|
||||||
|
/agent/jboard-agent/jboard-agent
|
||||||
|
/agent/jboard-agent/jboard-agent-linux-*
|
||||||
|
/agent/jboard-agent/SHA256SUMS
|
||||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
|
# --- deps: install production + dev dependencies ---
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# --- builder: generate Prisma client & build Next.js ---
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- init: full environment for db push / seed ---
|
||||||
|
FROM base AS init
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && npx tsx prisma/seed.ts"]
|
||||||
|
|
||||||
|
# --- runner: minimal production image ---
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Next.js standalone output
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Prisma: schema + config + generated client + adapter
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
|
COPY --from=builder /app/node_modules/@prisma/adapter-pg ./node_modules/@prisma/adapter-pg
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 我在UNSW熬夜那些年
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
223
README.md
Normal file
223
README.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# J-Board
|
||||||
|
|
||||||
|
J-Board(也称 JB面板)是一个面向代理订阅售卖与流媒体共享的全栈应用。节点运行、入站和客户端配置由 3x-ui 面板维护;J-Board 负责售卖、订单、订阅、用户、支付、工单和探测展示;自带 Go 程序只做延迟与线路探测上报。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户浏览器
|
||||||
|
↓
|
||||||
|
Next.js App Router 面板
|
||||||
|
├─ PostgreSQL / Redis
|
||||||
|
├─ 3x-ui API:同步入站、开通/暂停/删除客户端
|
||||||
|
└─ Probe API:接收 jboard-agent 的延迟与路由上报
|
||||||
|
```
|
||||||
|
|
||||||
|
J-Board 只保存售卖和展示所需的节点、入站、客户端镜像数据,不下发 Xray/Hy2 配置,也不维护自建节点控制面。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
用户端:
|
||||||
|
|
||||||
|
- 注册、登录、Cloudflare Turnstile 人机验证
|
||||||
|
- 代理套餐与流媒体套餐购买、续费、增流量
|
||||||
|
- 线路体验:三网延迟、延迟历史、三网路由追踪详情
|
||||||
|
- 代理订阅查看、订阅链接下载、订阅访问重置
|
||||||
|
- 流媒体订阅查看与凭据展示
|
||||||
|
- 通知中心、工单售后、账号资料、邀请码
|
||||||
|
- 响应式移动端适配
|
||||||
|
|
||||||
|
管理端:
|
||||||
|
|
||||||
|
- 3x-ui 节点管理:保存面板地址、账号、密码,测试连接并同步入站
|
||||||
|
- 本地入站展示名称维护,套餐绑定同步后的入站线路
|
||||||
|
- 探测 Token 管理:仅用于 `/api/agent/latency` 与 `/api/agent/trace`
|
||||||
|
- 用户、订单、套餐、订阅、流媒体服务、支付配置
|
||||||
|
- 公告、工单、系统设置、审计日志、任务中心、备份恢复
|
||||||
|
- 流量视图:基于本地订阅与 3x-ui 同步结果展示客户端用量
|
||||||
|
|
||||||
|
节点侧:
|
||||||
|
|
||||||
|
- 3x-ui 负责入站、客户端、协议配置和节点运行
|
||||||
|
- J-Board 通过 3x-ui API 同步入站并执行客户端增删改
|
||||||
|
- `agent/jboard-agent` 只负责三网 TCP 延迟和三网路由追踪
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
节点接入:
|
||||||
|
|
||||||
|
1. 管理员在 3x-ui 中创建真实入站。
|
||||||
|
2. 管理员在 J-Board 添加节点并填写 3x-ui 面板地址、用户名、密码。
|
||||||
|
3. J-Board 登录 3x-ui,读取入站列表并写入 `NodeInbound`。
|
||||||
|
4. 管理员将套餐绑定到已同步入站。
|
||||||
|
5. 用户购买代理套餐后,J-Board 调用 3x-ui API 创建客户端,并保存 `NodeClient`。
|
||||||
|
6. 订阅暂停、恢复、删除、重置访问时,同步调用 3x-ui API 更新客户端。
|
||||||
|
|
||||||
|
支付开通:
|
||||||
|
|
||||||
|
1. 用户选择套餐、入站和流量规格并创建订单。
|
||||||
|
2. 支付平台回调后,`src/services/payment/process.ts` 标记订单已支付。
|
||||||
|
3. `src/services/provision.ts` 创建或更新 `UserSubscription`。
|
||||||
|
4. 代理订阅通过 `src/services/node-panel` 调用 3x-ui 创建或更新客户端。
|
||||||
|
5. 流媒体订阅分配 `StreamingSlot`。
|
||||||
|
|
||||||
|
探测上报:
|
||||||
|
|
||||||
|
1. 管理员为节点生成探测 Token。
|
||||||
|
2. 节点运行 `agent/jboard-agent`,配置 `SERVER_URL` 和 `AUTH_TOKEN`。
|
||||||
|
3. 探测程序定时调用 `POST /api/agent/latency` 和 `POST /api/agent/trace`。
|
||||||
|
4. J-Board 按 Token 匹配节点并更新延迟、历史和路由数据。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Next.js 16 App Router + React 19
|
||||||
|
- Prisma 7 + PostgreSQL 16
|
||||||
|
- NextAuth 4 Credentials + Cloudflare Turnstile
|
||||||
|
- Redis 7
|
||||||
|
- Tailwind CSS 4 + Base UI + Sonner + Recharts
|
||||||
|
- Go probe agent
|
||||||
|
- Docker / Docker Compose
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `src/app`:页面、布局、Route Handlers
|
||||||
|
- `src/actions`:Server Actions,负责写操作、权限校验、审计和缓存刷新
|
||||||
|
- `src/services`:领域服务与第三方适配
|
||||||
|
- `src/services/node-panel`:3x-ui 面板适配层
|
||||||
|
- `src/services/provision.ts`:支付成功后的订阅开通与 3x-ui 客户端同步
|
||||||
|
- `src/services/subscription.ts`:订阅内容生成
|
||||||
|
- `src/lib`:Prisma、鉴权、加密、Turnstile、通用工具
|
||||||
|
- `prisma/schema.prisma`:数据模型事实源
|
||||||
|
- `agent/jboard-agent`:延迟与线路探测程序
|
||||||
|
- `docs/API.md`:HTTP 接口与 Server Actions 参考
|
||||||
|
- `docs/openapi.yaml`:对外 HTTP 接口的 OpenAPI 描述
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
以 `.env.example` 为准,运行至少需要:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `NEXTAUTH_SECRET`
|
||||||
|
- `NEXTAUTH_URL`
|
||||||
|
- `ENCRYPTION_KEY`
|
||||||
|
- `REDIS_URL`
|
||||||
|
|
||||||
|
`ENCRYPTION_KEY` 必须至少 32 字节,用于 3x-ui 面板密码、探测 Token、流媒体凭据等敏感信息加密。Docker 部署时 `DATABASE_URL` 与 `REDIS_URL` 会由 `docker-compose.yml` 覆盖为容器内地址。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run db:push
|
||||||
|
npm run db:seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认管理员账号:
|
||||||
|
|
||||||
|
- 邮箱:`admin@jboard.local`
|
||||||
|
- 密码:`admin123`
|
||||||
|
|
||||||
|
常用检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库变更:
|
||||||
|
|
||||||
|
- 修改 `prisma/schema.prisma` 后运行 `npx prisma generate`
|
||||||
|
- 使用 `npm run db:push` 同步 schema 到数据库
|
||||||
|
- 不维护 Prisma migrations,不提交迁移脚本
|
||||||
|
- 删除字段或模型时同步清理引用、文档和导出逻辑
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
首次启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose --profile setup run --rm init sh -lc 'npx prisma db push --accept-data-loss'
|
||||||
|
docker compose exec app npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
更新部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose build init app
|
||||||
|
docker compose --profile setup run --rm init sh -lc 'npx prisma db push --accept-data-loss'
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
常用排障:
|
||||||
|
|
||||||
|
- 查看状态:`docker compose ps`
|
||||||
|
- 查看日志:`docker compose logs -f app`
|
||||||
|
- 页面仍是旧版本:确认已执行 `docker compose build init app` 和 `docker compose up -d app`
|
||||||
|
- Schema 没有生效:单独运行 `docker compose --profile setup run --rm init sh -lc 'npx prisma db push --accept-data-loss'`
|
||||||
|
|
||||||
|
## 节点与探测
|
||||||
|
|
||||||
|
1. 在 VPS 安装并配置 3x-ui,确认面板 API 可访问。
|
||||||
|
2. 管理后台添加 3x-ui 节点。
|
||||||
|
3. 保存后 J-Board 会登录 3x-ui 并同步入站。
|
||||||
|
4. 在代理套餐中绑定已同步入站。
|
||||||
|
5. 如需前台展示延迟/线路,点击“生成探测 Token”,复制弹窗里的一键安装命令到节点执行。
|
||||||
|
|
||||||
|
探测程序也可手动运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVER_URL=https://your-domain.com \
|
||||||
|
AUTH_TOKEN=后台生成的探测Token \
|
||||||
|
./jboard-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
可选变量:
|
||||||
|
|
||||||
|
- `LATENCY_INTERVAL`:默认 `5m`
|
||||||
|
- `TRACE_INTERVAL`:默认 `30m`
|
||||||
|
|
||||||
|
路由探测依赖 `nexttrace`。没有该命令时,延迟探测仍可运行,路由探测会记录错误日志。
|
||||||
|
|
||||||
|
## 备份与安全
|
||||||
|
|
||||||
|
下载 SQL 备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db pg_dump -U jboard jboard > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
后台也可通过 `/admin/backups` 导出或恢复数据库。恢复前务必先保存当前数据库备份。
|
||||||
|
|
||||||
|
安全建议:
|
||||||
|
|
||||||
|
- 不要提交 `.env`、探测 Token、3x-ui 密码、支付密钥
|
||||||
|
- 生产环境不要公开 PostgreSQL 和 Redis 端口
|
||||||
|
- 3x-ui 面板建议限制来源 IP 或使用反向代理鉴权
|
||||||
|
- `ENCRYPTION_KEY` 一旦生产使用不要随意更换,否则已加密数据会无法解密
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
- 节点入站与客户端运行配置以 3x-ui 为准,J-Board 只保存售卖镜像
|
||||||
|
- 后端只保留探测上报接口,不再新增节点控制面接口
|
||||||
|
- Server Actions 负责权限、校验、审计和缓存刷新
|
||||||
|
- Route Handlers 仅用于外部 HTTP 接口或文件下载
|
||||||
|
- 重要副作用必须记录审计日志或任务记录
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- `docs/API.md`:HTTP 接口与 Server Actions 参考
|
||||||
|
- `docs/openapi.yaml`:对外 HTTP 接口的 OpenAPI 3.1 描述
|
||||||
|
- `agent/jboard-agent/README.md`:探测程序说明
|
||||||
|
|
||||||
|
## 请我喝杯咖啡
|
||||||
|

|
||||||
|
|
||||||
|
USDT-TRC20: TQfaGEBdnB89V4y6R6bypZXx7Za5QfXBCi
|
||||||
|
|
||||||
|
Telegram:[@JetSprow](https://t.me/JetSprow)
|
||||||
3
agent/jboard-agent/.gitignore
vendored
Normal file
3
agent/jboard-agent/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
jboard-agent
|
||||||
|
jboard-agent-linux-amd64
|
||||||
|
jboard-agent-linux-arm64
|
||||||
15
agent/jboard-agent/Makefile
Normal file
15
agent/jboard-agent/Makefile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
BINARY := jboard-agent
|
||||||
|
VERSION := 2.3.0
|
||||||
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
|
.PHONY: build build-linux clean
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/agent
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-amd64 ./cmd/agent
|
||||||
|
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/agent
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
||||||
41
agent/jboard-agent/README.md
Normal file
41
agent/jboard-agent/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# jboard-agent
|
||||||
|
|
||||||
|
`jboard-agent` 只负责节点探测上报:
|
||||||
|
|
||||||
|
- 三网 TCP 延迟:`POST /api/agent/latency`
|
||||||
|
- 三网路由跟踪:`POST /api/agent/trace`
|
||||||
|
|
||||||
|
节点入站、客户端开通、暂停、删除、流量限制等配置均由 3x-ui 面板维护。J-Board 后端不向节点下发 Xray/Hy2 配置。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
make build
|
||||||
|
make build-linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVER_URL=https://your-domain.com \
|
||||||
|
AUTH_TOKEN=后台生成的探测Token \
|
||||||
|
./jboard-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
可选环境变量:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `LATENCY_INTERVAL` | `5m` | 延迟探测间隔,支持 `30s`、`5m` 或秒数 |
|
||||||
|
| `TRACE_INTERVAL` | `30m` | 路由探测间隔,支持 `30m` 或秒数 |
|
||||||
|
|
||||||
|
路由探测依赖 `nexttrace` 命令;延迟探测无需额外依赖。
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
推荐从 J-Board 后台节点页复制一键安装命令。该命令会下载 release 二进制、安装 `nexttrace`、写入 systemd 服务并启动。
|
||||||
|
|
||||||
|
## 延迟算法
|
||||||
|
|
||||||
|
延迟探测使用三组 zstaticcdn 运营商目标,先解析域名再开始计时,只统计 TCP connect 耗时,避免 DNS 抖动混入延迟;当单次结果超过 1000ms 时会额外重试最多 3 次并采用更低的有效结果。
|
||||||
35
agent/jboard-agent/cmd/agent/main.go
Normal file
35
agent/jboard-agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime/debug"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/jboard/jboard-agent/internal/config"
|
||||||
|
"github.com/jboard/jboard-agent/internal/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "2.3.0"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
debug.SetGCPercent(50)
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
log.Printf("[agent] jboard-agent v%s starting in probe-only mode — server=%s", version, cfg.ServerURL)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go probe.LatencyLoop(ctx, cfg)
|
||||||
|
go probe.TraceLoop(ctx, cfg)
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
log.Println("[agent] shutting down...")
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
3
agent/jboard-agent/go.mod
Normal file
3
agent/jboard-agent/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/jboard/jboard-agent
|
||||||
|
|
||||||
|
go 1.22
|
||||||
55
agent/jboard-agent/internal/config/config.go
Normal file
55
agent/jboard-agent/internal/config/config.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string
|
||||||
|
AuthToken string
|
||||||
|
|
||||||
|
LatencyInterval time.Duration
|
||||||
|
TraceInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
cfg := &Config{
|
||||||
|
ServerURL: envOrDefault("SERVER_URL", ""),
|
||||||
|
AuthToken: envOrDefault("AUTH_TOKEN", ""),
|
||||||
|
LatencyInterval: envDuration("LATENCY_INTERVAL", 5*time.Minute),
|
||||||
|
TraceInterval: envDuration("TRACE_INTERVAL", 30*time.Minute),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ServerURL == "" || cfg.AuthToken == "" {
|
||||||
|
log.Fatal("[config] SERVER_URL and AUTH_TOKEN are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
if seconds, err := strconv.Atoi(v); err == nil && seconds > 0 {
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
171
agent/jboard-agent/internal/probe/latency.go
Normal file
171
agent/jboard-agent/internal/probe/latency.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jboard/jboard-agent/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Three-carrier TCP ping targets (Chinese ISP backbone nodes)
|
||||||
|
var latencyTargets = []struct {
|
||||||
|
Carrier string
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
}{
|
||||||
|
{"mobile", "js-cm-v4.ip.zstaticcdn.com", "80"},
|
||||||
|
{"unicom", "js-cu-v4.ip.zstaticcdn.com", "80"},
|
||||||
|
{"telecom", "js-ct-v4.ip.zstaticcdn.com", "80"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type latencyEntry struct {
|
||||||
|
Carrier string `json:"carrier"`
|
||||||
|
LatencyMs int `json:"latencyMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type latencyPayload struct {
|
||||||
|
Latencies []latencyEntry `json:"latencies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyLoop periodically measures TCP ping latency to three carriers and pushes to J-Board.
|
||||||
|
func LatencyLoop(ctx context.Context, cfg *config.Config) {
|
||||||
|
ticker := time.NewTicker(cfg.LatencyInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately
|
||||||
|
measureAndPush(cfg)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
measureAndPush(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureAndPush(cfg *config.Config) {
|
||||||
|
var entries []latencyEntry
|
||||||
|
|
||||||
|
for _, target := range latencyTargets {
|
||||||
|
ms := tcpPing(target.Host, target.Port)
|
||||||
|
if ms >= 0 {
|
||||||
|
entries = append(entries, latencyEntry{
|
||||||
|
Carrier: target.Carrier,
|
||||||
|
LatencyMs: ms,
|
||||||
|
})
|
||||||
|
log.Printf("[latency] %s: %dms", target.Carrier, ms)
|
||||||
|
} else {
|
||||||
|
log.Printf("[latency] %s: timeout", target.Carrier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := latencyPayload{Latencies: entries}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
if err := postToServer(cfg, "/api/agent/latency", body); err != nil {
|
||||||
|
log.Printf("[latency] push error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tcpPing measures TCP handshake latency in milliseconds. Returns -1 on failure.
|
||||||
|
// The DNS lookup is intentionally performed before timing starts, matching
|
||||||
|
// classic probe panels such as Komari, so DNS jitter is not mixed into latency.
|
||||||
|
func tcpPing(host, port string) int {
|
||||||
|
const (
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
highLatencyThreshold = 1000
|
||||||
|
highLatencyRetries = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
ip, err := resolveIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
latency, err := measureTCPConnect(ip, port, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
best := latency
|
||||||
|
|
||||||
|
if latency > highLatencyThreshold {
|
||||||
|
for i := 0; i < highLatencyRetries; i++ {
|
||||||
|
retryLatency, retryErr := measureTCPConnect(ip, port, timeout)
|
||||||
|
if retryErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if retryLatency < best {
|
||||||
|
best = retryLatency
|
||||||
|
}
|
||||||
|
if retryLatency <= highLatencyThreshold {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIP(host string) (string, error) {
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := net.LookupHost(host)
|
||||||
|
if err != nil || len(addrs) == 0 {
|
||||||
|
return "", errors.New("failed to resolve target")
|
||||||
|
}
|
||||||
|
|
||||||
|
return addrs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func measureTCPConnect(ip string, port string, timeout time.Duration) (int, error) {
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, port), timeout)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return int(time.Since(start).Milliseconds()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postToServer(cfg *config.Config, path string, body []byte) error {
|
||||||
|
req, err := http.NewRequest("POST", cfg.ServerURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &httpError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError struct {
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return "server returned " + http.StatusText(e.StatusCode)
|
||||||
|
}
|
||||||
200
agent/jboard-agent/internal/probe/trace.go
Normal file
200
agent/jboard-agent/internal/probe/trace.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jboard/jboard-agent/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Traceroute targets — same as latency targets
|
||||||
|
var traceTargets = []struct {
|
||||||
|
Carrier string
|
||||||
|
IP string
|
||||||
|
}{
|
||||||
|
{"telecom", "219.141.136.12"},
|
||||||
|
{"mobile", "211.136.25.153"},
|
||||||
|
{"unicom", "210.22.70.3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type hopDetail struct {
|
||||||
|
Hop int `json:"hop"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Geo string `json:"geo"`
|
||||||
|
Latency float64 `json:"latency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type traceResult struct {
|
||||||
|
Carrier string `json:"carrier"`
|
||||||
|
Hops []hopDetail `json:"hops"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
HopCount int `json:"hopCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tracePayload struct {
|
||||||
|
Traces []traceResult `json:"traces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nexttrace JSON output structures
|
||||||
|
type ntHop struct {
|
||||||
|
Success bool `json:"Success"`
|
||||||
|
Address *struct {
|
||||||
|
IP string `json:"IP"`
|
||||||
|
} `json:"Address"`
|
||||||
|
Geo *struct {
|
||||||
|
Asnumber string `json:"asnumber"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Prov string `json:"prov"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Isp string `json:"isp"`
|
||||||
|
} `json:"Geo"`
|
||||||
|
TTL int `json:"TTL"`
|
||||||
|
RTT int64 `json:"RTT"` // nanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
type ntOutput struct {
|
||||||
|
Hops [][]ntHop `json:"Hops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceLoop periodically runs traceroute to three carriers and pushes to J-Board.
|
||||||
|
func TraceLoop(ctx context.Context, cfg *config.Config) {
|
||||||
|
ticker := time.NewTicker(cfg.TraceInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately
|
||||||
|
traceAndPush(cfg)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
traceAndPush(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceAndPush(cfg *config.Config) {
|
||||||
|
log.Println("[trace] starting trace cycle")
|
||||||
|
|
||||||
|
var results []traceResult
|
||||||
|
for _, target := range traceTargets {
|
||||||
|
hops, summary, err := runTrace(target.IP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[trace] %s (%s): %v", target.Carrier, target.IP, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, traceResult{
|
||||||
|
Carrier: target.Carrier,
|
||||||
|
Hops: hops,
|
||||||
|
Summary: summary,
|
||||||
|
HopCount: len(hops),
|
||||||
|
})
|
||||||
|
log.Printf("[trace] %s: %s (%d hops)", target.Carrier, summary, len(hops))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
log.Println("[trace] no results, skipping upload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := tracePayload{Traces: results}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
if err := postToServer(cfg, "/api/agent/trace", body); err != nil {
|
||||||
|
log.Printf("[trace] push error: %v — retrying in 10s", err)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
if err := postToServer(cfg, "/api/agent/trace", body); err != nil {
|
||||||
|
log.Printf("[trace] retry failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrace(ip string) ([]hopDetail, string, error) {
|
||||||
|
cmd := exec.Command("nexttrace", "-j", "--no-color", "-n", ip)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("nexttrace failed for %s: %w", ip, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed ntOutput
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("parse nexttrace output for %s: %w", ip, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hops []hopDetail
|
||||||
|
var asnumbers []string
|
||||||
|
for i, hopGroup := range parsed.Hops {
|
||||||
|
hop := hopDetail{Hop: i + 1}
|
||||||
|
for _, probe := range hopGroup {
|
||||||
|
if probe.Success && probe.Address != nil && probe.Address.IP != "" {
|
||||||
|
hop.IP = probe.Address.IP
|
||||||
|
hop.Latency = float64(probe.RTT) / 1e6
|
||||||
|
if probe.Geo != nil {
|
||||||
|
var parts []string
|
||||||
|
if probe.Geo.Country != "" {
|
||||||
|
parts = append(parts, probe.Geo.Country)
|
||||||
|
}
|
||||||
|
if probe.Geo.Prov != "" {
|
||||||
|
parts = append(parts, probe.Geo.Prov)
|
||||||
|
}
|
||||||
|
if probe.Geo.City != "" {
|
||||||
|
parts = append(parts, probe.Geo.City)
|
||||||
|
}
|
||||||
|
if probe.Geo.Owner != "" {
|
||||||
|
parts = append(parts, probe.Geo.Owner)
|
||||||
|
}
|
||||||
|
hop.Geo = strings.Join(parts, " ")
|
||||||
|
if probe.Geo.Asnumber != "" {
|
||||||
|
asnumbers = append(asnumbers, probe.Geo.Asnumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hops = append(hops, hop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the first hop (server gateway IP) for security
|
||||||
|
if len(hops) > 0 {
|
||||||
|
hops[0].IP = "*"
|
||||||
|
hops[0].Geo = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := detectSummary(hops, asnumbers)
|
||||||
|
return hops, summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectSummary(hops []hopDetail, asnumbers []string) string {
|
||||||
|
combined := ""
|
||||||
|
for _, h := range hops {
|
||||||
|
combined += " " + strings.ToUpper(h.Geo)
|
||||||
|
}
|
||||||
|
asSet := ""
|
||||||
|
for _, asn := range asnumbers {
|
||||||
|
asSet += " " + asn
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(combined, "CN2") && strings.Contains(combined, "GIA"):
|
||||||
|
return "CN2 GIA"
|
||||||
|
case strings.Contains(combined, "CN2"):
|
||||||
|
return "CN2 GT"
|
||||||
|
case strings.Contains(asSet, "9929") || strings.Contains(combined, "CUII") || strings.Contains(combined, "A网"):
|
||||||
|
return "AS9929"
|
||||||
|
case strings.Contains(asSet, "4837"):
|
||||||
|
return "AS4837"
|
||||||
|
case strings.Contains(combined, "CMI") || strings.Contains(asSet, "58453"):
|
||||||
|
return "CMI"
|
||||||
|
case strings.Contains(combined, "CMIN2") || strings.Contains(asSet, "59807"):
|
||||||
|
return "CMIN2"
|
||||||
|
default:
|
||||||
|
return "普通线路"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
components.json
Normal file
25
components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
74
docker-compose.yml
Normal file
74
docker-compose.yml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: runner
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://jboard:jboard123@db:5432/jboard
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/public/app-info || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# 数据库初始化:首次部署或 schema 变更时运行
|
||||||
|
# docker compose run --rm init
|
||||||
|
init:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: init
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://jboard:jboard123@db:5432/jboard
|
||||||
|
profiles:
|
||||||
|
- setup
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: jboard
|
||||||
|
POSTGRES_USER: jboard
|
||||||
|
POSTGRES_PASSWORD: jboard123
|
||||||
|
# 仅暴露给内部网络,生产环境不要对外开放
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U jboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
# 仅暴露给内部网络,生产环境不要对外开放
|
||||||
|
# ports:
|
||||||
|
# - "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
219
docs/API.md
Normal file
219
docs/API.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# J-Board API
|
||||||
|
|
||||||
|
本文整理当前有效的 HTTP Route Handlers 和内部 Server Actions。对外 HTTP 结构化描述见 `docs/openapi.yaml`。
|
||||||
|
|
||||||
|
## 1. 通用约定
|
||||||
|
|
||||||
|
- 用户会话:NextAuth Cookie
|
||||||
|
- 管理接口:必须是管理员会话
|
||||||
|
- 探测接口:`Authorization: Bearer <probe-token>`
|
||||||
|
- 返回格式:JSON,文件下载接口除外
|
||||||
|
- 时间字段:ISO 8601 字符串
|
||||||
|
|
||||||
|
## 2. 认证
|
||||||
|
|
||||||
|
### `POST /api/auth/register`
|
||||||
|
|
||||||
|
注册普通用户。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "Alice",
|
||||||
|
"inviteCode": "optional",
|
||||||
|
"turnstileToken": "optional"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET|POST /api/auth/[...nextauth]`
|
||||||
|
|
||||||
|
NextAuth 内置登录、登出、会话接口。
|
||||||
|
|
||||||
|
## 3. 公共数据
|
||||||
|
|
||||||
|
### `GET /api/public/app-info`
|
||||||
|
|
||||||
|
返回站点名称、注册策略、维护公告、Turnstile 配置等公开信息。
|
||||||
|
|
||||||
|
### `GET /api/latency?nodeId=<id>`
|
||||||
|
|
||||||
|
返回节点最新三网延迟。
|
||||||
|
|
||||||
|
### `GET /api/latency/history?nodeId=<id>&carrier=telecom&range=7d`
|
||||||
|
|
||||||
|
返回节点延迟历史,`range` 支持 `1d`、`7d`、`30d`。
|
||||||
|
|
||||||
|
### `GET /api/traces?nodeId=<id>`
|
||||||
|
|
||||||
|
返回节点三网路由追踪结果。
|
||||||
|
|
||||||
|
## 4. 支付
|
||||||
|
|
||||||
|
### `GET /api/payment/providers`
|
||||||
|
|
||||||
|
返回当前启用的支付方式。
|
||||||
|
|
||||||
|
### `POST /api/payment/create`
|
||||||
|
|
||||||
|
为待支付订单创建支付参数。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "order-id",
|
||||||
|
"provider": "epay"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/payment/order/{orderId}`
|
||||||
|
|
||||||
|
查询当前用户自己的订单支付状态。
|
||||||
|
|
||||||
|
### `GET /api/payment/query/{tradeNo}`
|
||||||
|
|
||||||
|
按支付流水号查询支付状态。
|
||||||
|
|
||||||
|
### `GET|POST /api/payment/notify/{provider}`
|
||||||
|
|
||||||
|
支付平台异步通知入口。
|
||||||
|
|
||||||
|
## 5. 管理导出与节点读取
|
||||||
|
|
||||||
|
### `GET /api/admin/nodes`
|
||||||
|
|
||||||
|
返回节点和已同步入站的简要信息。
|
||||||
|
|
||||||
|
### `GET /api/admin/nodes/{id}/inbounds`
|
||||||
|
|
||||||
|
返回指定节点的已同步入站。
|
||||||
|
|
||||||
|
### `GET /api/admin/export/config`
|
||||||
|
|
||||||
|
导出配置快照,包含站点设置、公告、服务、套餐、节点、入站、支付配置等。
|
||||||
|
|
||||||
|
### `GET /api/admin/export/audit-logs?q=<keyword>`
|
||||||
|
|
||||||
|
导出审计日志。
|
||||||
|
|
||||||
|
### `GET /api/admin/backup/database`
|
||||||
|
|
||||||
|
导出 SQL 数据库备份。
|
||||||
|
|
||||||
|
## 6. 探测上报
|
||||||
|
|
||||||
|
以下接口由 `agent/jboard-agent` 调用,探测 Token 存储在 `NodeServer.agentToken` 中并加密保存。
|
||||||
|
|
||||||
|
### `POST /api/agent/latency`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"latencies": [
|
||||||
|
{ "carrier": "telecom", "latencyMs": 35 },
|
||||||
|
{ "carrier": "unicom", "latencyMs": 42 },
|
||||||
|
{ "carrier": "mobile", "latencyMs": 28 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:更新 `NodeLatency` 并写入 `NodeLatencyLog`。
|
||||||
|
|
||||||
|
### `POST /api/agent/trace`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"traces": [
|
||||||
|
{
|
||||||
|
"carrier": "telecom",
|
||||||
|
"hops": [{ "hop": 1, "ip": "*", "geo": "", "latency": 0 }],
|
||||||
|
"summary": "CN2 GIA",
|
||||||
|
"hopCount": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:按 `nodeId + carrier` 更新 `RouteTrace`。
|
||||||
|
|
||||||
|
## 7. 订阅与附件
|
||||||
|
|
||||||
|
### `GET /api/subscription/{id}?token=<downloadToken>`
|
||||||
|
|
||||||
|
无需会话,但必须提供合法下载 token。成功返回 `text/plain` 订阅内容。
|
||||||
|
|
||||||
|
### `GET /api/support/attachments/{id}`
|
||||||
|
|
||||||
|
工单附件访问接口,要求登录用户是附件所属工单用户本人或管理员。加 `?download=1` 可触发下载。
|
||||||
|
|
||||||
|
## 8. Server Actions
|
||||||
|
|
||||||
|
Server Actions 是后台和用户端写操作的主要入口。所有管理动作必须经过 `requireAdmin()`,用户动作必须校验资源归属。
|
||||||
|
|
||||||
|
### 管理端
|
||||||
|
|
||||||
|
节点:`src/actions/admin/nodes.ts`
|
||||||
|
|
||||||
|
- `createNode(formData)`:创建 3x-ui 节点并同步入站
|
||||||
|
- `updateNode(id, formData)`:更新 3x-ui 节点连接信息并重新同步入站
|
||||||
|
- `deleteNode(id)`:删除节点及本地关联数据
|
||||||
|
- `testNodeConnection(id)`:测试 3x-ui 登录并同步入站
|
||||||
|
- `batchTestNodeConnections(formData)`:批量测试并同步节点
|
||||||
|
- `updateInboundDisplayName(id, formData)`:修改同步入站的前台展示名称
|
||||||
|
- `deleteInbound(id)`:仅删除本地入站镜像,不删除 3x-ui 入站
|
||||||
|
- `generateAgentToken(nodeId)`:生成探测 Token
|
||||||
|
- `revokeAgentToken(nodeId)`:撤销探测 Token
|
||||||
|
|
||||||
|
订阅:`src/actions/admin/subscriptions.ts`
|
||||||
|
|
||||||
|
- `suspendSubscription(id)`:暂停订阅,并通过 3x-ui 禁用代理客户端
|
||||||
|
- `activateSubscription(id)`:恢复订阅,并通过 3x-ui 启用代理客户端
|
||||||
|
- `cancelSubscription(id)`:取消订阅
|
||||||
|
- `deleteSubscriptionPermanently(id)`:删除订阅,并通过 3x-ui 删除代理客户端
|
||||||
|
- `reassignStreamingSlot(...)`:调整流媒体槽位
|
||||||
|
- `batchSubscriptionOperation(formData)`:批量处理订阅
|
||||||
|
|
||||||
|
订单:`src/actions/admin/orders.ts`
|
||||||
|
|
||||||
|
- `confirmOrder(orderId)`:手动确认订单并触发开通
|
||||||
|
- `cancelOrder(orderId)`:取消订单
|
||||||
|
- `updateOrderReview(...)`:更新风控/复核状态
|
||||||
|
- `batchOrderOperation(formData)`:批量操作订单
|
||||||
|
|
||||||
|
其他管理动作:
|
||||||
|
|
||||||
|
- 用户:`src/actions/admin/users.ts`
|
||||||
|
- 套餐:`src/actions/admin/plans.ts`
|
||||||
|
- 流媒体服务:`src/actions/admin/services.ts`
|
||||||
|
- 支付配置:`src/actions/admin/payments.ts`
|
||||||
|
- 公告:`src/actions/admin/announcements.ts`
|
||||||
|
- 工单:`src/actions/admin/support.ts`
|
||||||
|
- 系统设置:`src/actions/admin/settings.ts`
|
||||||
|
- 备份恢复:`src/actions/admin/backups.ts`
|
||||||
|
- 任务重试:`src/actions/admin/tasks.ts`
|
||||||
|
- 流量视图刷新:`src/actions/admin/traffic.ts`
|
||||||
|
- 优惠券与促销:`src/actions/admin/commerce.ts`
|
||||||
|
|
||||||
|
### 用户端
|
||||||
|
|
||||||
|
- `src/actions/user/purchase.ts`:立即购买、续费、增流量、查询库存
|
||||||
|
- `src/actions/user/cart.ts`:加入购物车、移除、清空、结算
|
||||||
|
- `rotateSubscriptionAccess(subscriptionId)`:重置代理订阅访问凭据,并同步更新 3x-ui 客户端
|
||||||
|
- `src/actions/user/account.ts`:资料、密码、邀请码
|
||||||
|
- `src/actions/user/notifications.ts`:已读、删除、清空
|
||||||
|
- `src/actions/user/support.ts`:创建、回复、关闭、删除工单
|
||||||
|
- `src/actions/user/orders.ts`:取消待支付订单、重新选择支付方式
|
||||||
|
|
||||||
|
### 约定
|
||||||
|
|
||||||
|
- 写操作必须做权限校验和输入校验
|
||||||
|
- 重要副作用必须记录审计日志
|
||||||
|
- 影响页面展示后必须 `revalidatePath`
|
||||||
|
- 代理客户端变更必须通过 `src/services/node-panel` 同步 3x-ui
|
||||||
|
- 不再新增节点控制面、运行配置下发、进程管理类 Action
|
||||||
385
docs/openapi.yaml
Normal file
385
docs/openapi.yaml
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: J-Board API
|
||||||
|
version: 0.2.0
|
||||||
|
description: Current J-Board Route Handlers. Node provisioning uses 3x-ui; probe API only accepts latency and trace uploads.
|
||||||
|
servers:
|
||||||
|
- url: https://your-domain.com
|
||||||
|
security: []
|
||||||
|
tags:
|
||||||
|
- name: Auth
|
||||||
|
- name: Public
|
||||||
|
- name: Payment
|
||||||
|
- name: Admin
|
||||||
|
- name: Probe
|
||||||
|
- name: Subscription
|
||||||
|
paths:
|
||||||
|
/api/auth/register:
|
||||||
|
post:
|
||||||
|
tags: [Auth]
|
||||||
|
summary: Register a user
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Registered
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'400':
|
||||||
|
description: Invalid request
|
||||||
|
/api/public/app-info:
|
||||||
|
get:
|
||||||
|
tags: [Public]
|
||||||
|
summary: Get public app settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: App info
|
||||||
|
/api/latency:
|
||||||
|
get:
|
||||||
|
tags: [Public]
|
||||||
|
summary: Get latest carrier latency for a node
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/NodeId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Latest latencies
|
||||||
|
/api/latency/history:
|
||||||
|
get:
|
||||||
|
tags: [Public]
|
||||||
|
summary: Get latency history for a node and carrier
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/NodeId'
|
||||||
|
- name: carrier
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Carrier'
|
||||||
|
- name: range
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [1d, 7d, 30d]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Latency history
|
||||||
|
/api/traces:
|
||||||
|
get:
|
||||||
|
tags: [Public]
|
||||||
|
summary: Get latest route traces for a node
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/NodeId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Route traces
|
||||||
|
/api/payment/providers:
|
||||||
|
get:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: List enabled payment providers
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Providers
|
||||||
|
/api/payment/create:
|
||||||
|
post:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: Create payment parameters for an order
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreatePaymentRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Payment created
|
||||||
|
/api/payment/order/{orderId}:
|
||||||
|
get:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: Get order payment state
|
||||||
|
parameters:
|
||||||
|
- name: orderId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Order state
|
||||||
|
/api/payment/query/{tradeNo}:
|
||||||
|
get:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: Query payment by trade number
|
||||||
|
parameters:
|
||||||
|
- name: tradeNo
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Payment state
|
||||||
|
/api/payment/notify/{provider}:
|
||||||
|
get:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: Payment provider notification callback
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Provider'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Callback handled
|
||||||
|
post:
|
||||||
|
tags: [Payment]
|
||||||
|
summary: Payment provider notification callback
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Provider'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Callback handled
|
||||||
|
/api/admin/nodes:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: List nodes and synced inbounds
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Nodes
|
||||||
|
/api/admin/nodes/{id}/inbounds:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: List synced inbounds for a node
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Inbounds
|
||||||
|
/api/admin/export/config:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Export configuration snapshot
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: JSON file
|
||||||
|
/api/admin/export/audit-logs:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Export audit logs
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: JSON file
|
||||||
|
/api/admin/backup/database:
|
||||||
|
get:
|
||||||
|
tags: [Admin]
|
||||||
|
summary: Export SQL database backup
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: SQL file
|
||||||
|
/api/agent/latency:
|
||||||
|
post:
|
||||||
|
tags: [Probe]
|
||||||
|
summary: Upload carrier latency probe results
|
||||||
|
security:
|
||||||
|
- probeToken: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LatencyUpload'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'401':
|
||||||
|
description: Invalid probe token
|
||||||
|
/api/agent/trace:
|
||||||
|
post:
|
||||||
|
tags: [Probe]
|
||||||
|
summary: Upload carrier route trace results
|
||||||
|
security:
|
||||||
|
- probeToken: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TraceUpload'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OkResponse'
|
||||||
|
'401':
|
||||||
|
description: Invalid probe token
|
||||||
|
/api/subscription/{id}:
|
||||||
|
get:
|
||||||
|
tags: [Subscription]
|
||||||
|
summary: Download subscription content with token
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: token
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Subscription text
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
/api/support/attachments/{id}:
|
||||||
|
get:
|
||||||
|
tags: [Subscription]
|
||||||
|
summary: Download or preview support attachment
|
||||||
|
security:
|
||||||
|
- cookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: download
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Attachment file
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
cookieAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: next-auth.session-token
|
||||||
|
probeToken:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
parameters:
|
||||||
|
NodeId:
|
||||||
|
name: nodeId
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
Provider:
|
||||||
|
name: provider
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
schemas:
|
||||||
|
OkResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
required: [ok]
|
||||||
|
Carrier:
|
||||||
|
type: string
|
||||||
|
enum: [telecom, unicom, mobile]
|
||||||
|
RegisterRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
inviteCode:
|
||||||
|
type: string
|
||||||
|
turnstileToken:
|
||||||
|
type: string
|
||||||
|
required: [email, password]
|
||||||
|
CreatePaymentRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
orderId:
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
required: [orderId, provider]
|
||||||
|
LatencyUpload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
latencies:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
carrier:
|
||||||
|
$ref: '#/components/schemas/Carrier'
|
||||||
|
latencyMs:
|
||||||
|
type: number
|
||||||
|
required: [carrier, latencyMs]
|
||||||
|
required: [latencies]
|
||||||
|
TraceHop:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
hop:
|
||||||
|
type: integer
|
||||||
|
ip:
|
||||||
|
type: string
|
||||||
|
geo:
|
||||||
|
type: string
|
||||||
|
latency:
|
||||||
|
type: number
|
||||||
|
TraceUpload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
traces:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
carrier:
|
||||||
|
$ref: '#/components/schemas/Carrier'
|
||||||
|
hops:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TraceHop'
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
hopCount:
|
||||||
|
type: integer
|
||||||
|
required: [carrier, hops]
|
||||||
|
required: [traces]
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
12845
package-lock.json
generated
Normal file
12845
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "j-board",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
|
"db:push": "prisma db push --accept-data-loss"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.1",
|
||||||
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
|
"@prisma/adapter-pg": "^7.7.0",
|
||||||
|
"@prisma/client": "^7.7.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"next": "16.2.4",
|
||||||
|
"next-auth": "^4.24.14",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"shadcn": "^4.3.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"uuid": "^14.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.4",
|
||||||
|
"prisma": "^7.7.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@prisma/dev": {
|
||||||
|
"@hono/node-server": "1.19.13"
|
||||||
|
},
|
||||||
|
"next-auth": {
|
||||||
|
"uuid": "14.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
11
prisma.config.ts
Normal file
11
prisma.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
750
prisma/schema.prisma
Normal file
750
prisma/schema.prisma
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserStatus {
|
||||||
|
ACTIVE
|
||||||
|
DISABLED
|
||||||
|
BANNED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionType {
|
||||||
|
STREAMING
|
||||||
|
PROXY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
ACTIVE
|
||||||
|
EXPIRED
|
||||||
|
CANCELLED
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlanPricingMode {
|
||||||
|
TRAFFIC_SLIDER
|
||||||
|
FIXED_PACKAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
PENDING
|
||||||
|
PAID
|
||||||
|
CANCELLED
|
||||||
|
REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderKind {
|
||||||
|
NEW_PURCHASE
|
||||||
|
RENEWAL
|
||||||
|
TRAFFIC_TOPUP
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Protocol {
|
||||||
|
VMESS
|
||||||
|
VLESS
|
||||||
|
TROJAN
|
||||||
|
SHADOWSOCKS
|
||||||
|
HYSTERIA2
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationType {
|
||||||
|
ORDER
|
||||||
|
SUBSCRIPTION
|
||||||
|
TRAFFIC
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationLevel {
|
||||||
|
INFO
|
||||||
|
SUCCESS
|
||||||
|
WARNING
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnnouncementAudience {
|
||||||
|
PUBLIC
|
||||||
|
USERS
|
||||||
|
ADMINS
|
||||||
|
SPECIFIC_USER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnnouncementDisplayType {
|
||||||
|
INLINE
|
||||||
|
BIG
|
||||||
|
POPUP
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskKind {
|
||||||
|
REMINDER_DISPATCH
|
||||||
|
ORDER_PROVISION_RETRY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
PENDING
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderReviewStatus {
|
||||||
|
NORMAL
|
||||||
|
FLAGGED
|
||||||
|
RESOLVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CouponDiscountType {
|
||||||
|
AMOUNT_OFF
|
||||||
|
PERCENT_OFF
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InviteRewardStatus {
|
||||||
|
ISSUED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SupportTicketStatus {
|
||||||
|
OPEN
|
||||||
|
USER_REPLIED
|
||||||
|
ADMIN_REPLIED
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SupportTicketPriority {
|
||||||
|
LOW
|
||||||
|
NORMAL
|
||||||
|
HIGH
|
||||||
|
URGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
role Role @default(USER)
|
||||||
|
status UserStatus @default(ACTIVE)
|
||||||
|
inviteCode String? @unique
|
||||||
|
invitedById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
subscriptions UserSubscription[]
|
||||||
|
orders Order[]
|
||||||
|
cartItems ShoppingCartItem[]
|
||||||
|
couponGrants CouponGrant[]
|
||||||
|
inviteRewardLedgers InviteRewardLedger[] @relation("InviteRewardInviter")
|
||||||
|
inviteeRewardLedgers InviteRewardLedger[] @relation("InviteRewardInvitee")
|
||||||
|
streamingSlots StreamingSlot[]
|
||||||
|
nodeClients NodeClient[]
|
||||||
|
notifications UserNotification[]
|
||||||
|
auditLogs AuditLog[] @relation("AuditActor")
|
||||||
|
invitedBy User? @relation("UserInvites", fields: [invitedById], references: [id], onDelete: SetNull)
|
||||||
|
invitedUsers User[] @relation("UserInvites")
|
||||||
|
createdAnnouncements Announcement[] @relation("AnnouncementCreator")
|
||||||
|
receivedAnnouncements Announcement[] @relation("AnnouncementTarget")
|
||||||
|
taskRuns TaskRun[] @relation("TaskTriggeredBy")
|
||||||
|
supportTickets SupportTicket[]
|
||||||
|
supportReplies SupportTicketReply[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model SubscriptionCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
type SubscriptionType
|
||||||
|
description String?
|
||||||
|
sortOrder Int @default(100)
|
||||||
|
accent String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
plans SubscriptionPlan[]
|
||||||
|
|
||||||
|
@@index([type, isActive, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SubscriptionPlan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
type SubscriptionType
|
||||||
|
description String?
|
||||||
|
durationDays Int
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
recommendationLabel String?
|
||||||
|
recommendationReason String?
|
||||||
|
sortOrder Int @default(100)
|
||||||
|
totalLimit Int?
|
||||||
|
perUserLimit Int?
|
||||||
|
totalTrafficGb Int?
|
||||||
|
allowRenewal Boolean @default(false)
|
||||||
|
allowTrafficTopup Boolean @default(false)
|
||||||
|
renewalPrice Decimal? @db.Decimal(10, 2)
|
||||||
|
renewalPricingMode String @default("FIXED_DURATION")
|
||||||
|
renewalDurationDays Int?
|
||||||
|
renewalMinDays Int?
|
||||||
|
renewalMaxDays Int?
|
||||||
|
renewalTrafficGb Int?
|
||||||
|
topupPricingMode String @default("PER_GB")
|
||||||
|
topupPricePerGb Decimal? @db.Decimal(10, 2)
|
||||||
|
topupFixedPrice Decimal? @db.Decimal(10, 2)
|
||||||
|
minTopupGb Int?
|
||||||
|
maxTopupGb Int?
|
||||||
|
streamingServiceId String?
|
||||||
|
categoryId String?
|
||||||
|
pricingMode PlanPricingMode @default(TRAFFIC_SLIDER)
|
||||||
|
fixedTrafficGb Int?
|
||||||
|
fixedPrice Decimal? @db.Decimal(10, 2)
|
||||||
|
// STREAMING: fixed price per slot
|
||||||
|
price Decimal? @db.Decimal(10, 2)
|
||||||
|
// PROXY: linked to a node, price per GB, slider purchase
|
||||||
|
nodeId String?
|
||||||
|
inboundId String?
|
||||||
|
pricePerGb Decimal? @db.Decimal(10, 2)
|
||||||
|
minTrafficGb Int? @default(10)
|
||||||
|
maxTrafficGb Int? @default(1000)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
node NodeServer? @relation(fields: [nodeId], references: [id])
|
||||||
|
category SubscriptionCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
inbound NodeInbound? @relation(fields: [inboundId], references: [id])
|
||||||
|
streamingService StreamingService? @relation(fields: [streamingServiceId], references: [id])
|
||||||
|
inboundOptions PlanInboundOption[]
|
||||||
|
subscriptions UserSubscription[]
|
||||||
|
orders Order[]
|
||||||
|
cartItems ShoppingCartItem[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@index([type, isActive, isFeatured, sortOrder])
|
||||||
|
@@index([inboundId])
|
||||||
|
@@index([streamingServiceId])
|
||||||
|
@@index([categoryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
planId String
|
||||||
|
downloadToken String @unique @default(cuid())
|
||||||
|
status SubscriptionStatus @default(ACTIVE)
|
||||||
|
startDate DateTime @default(now())
|
||||||
|
endDate DateTime
|
||||||
|
trafficUsed BigInt @default(0)
|
||||||
|
trafficLimit BigInt?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
plan SubscriptionPlan @relation(fields: [planId], references: [id])
|
||||||
|
streamingSlot StreamingSlot?
|
||||||
|
nodeClient NodeClient?
|
||||||
|
createOrder Order? @relation("OrderCreatedSubscription")
|
||||||
|
targetOrders Order[] @relation("OrderTargetSubscription")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StreamingService {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
credentials String
|
||||||
|
maxSlots Int
|
||||||
|
usedSlots Int @default(0)
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
slots StreamingSlot[]
|
||||||
|
plans SubscriptionPlan[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model StreamingSlot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
serviceId String
|
||||||
|
userId String
|
||||||
|
subscriptionId String @unique
|
||||||
|
assignedAt DateTime @default(now())
|
||||||
|
|
||||||
|
service StreamingService @relation(fields: [serviceId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
subscription UserSubscription @relation(fields: [subscriptionId], references: [id])
|
||||||
|
|
||||||
|
@@index([serviceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NodeServer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
panelUrl String?
|
||||||
|
panelUsername String?
|
||||||
|
panelPassword String?
|
||||||
|
panelType String @default("3x-ui")
|
||||||
|
agentToken String?
|
||||||
|
status String @default("active")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
inbounds NodeInbound[]
|
||||||
|
plans SubscriptionPlan[]
|
||||||
|
routeTraces RouteTrace[]
|
||||||
|
latencies NodeLatency[]
|
||||||
|
latencyLogs NodeLatencyLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model RouteTrace {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nodeId String
|
||||||
|
carrier String
|
||||||
|
hops Json
|
||||||
|
summary String
|
||||||
|
hopCount Int
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
node NodeServer @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([nodeId, carrier])
|
||||||
|
@@index([nodeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NodeInbound {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
serverId String
|
||||||
|
panelInboundId Int?
|
||||||
|
protocol Protocol
|
||||||
|
port Int
|
||||||
|
tag String
|
||||||
|
settings Json
|
||||||
|
streamSettings Json?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
server NodeServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||||
|
clients NodeClient[]
|
||||||
|
plans SubscriptionPlan[]
|
||||||
|
planOptions PlanInboundOption[]
|
||||||
|
selectedByOrders Order[]
|
||||||
|
cartItems ShoppingCartItem[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@unique([serverId, tag])
|
||||||
|
@@unique([serverId, panelInboundId])
|
||||||
|
@@index([serverId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlanInboundOption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
planId String
|
||||||
|
inboundId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
inbound NodeInbound @relation(fields: [inboundId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([planId, inboundId])
|
||||||
|
@@index([planId])
|
||||||
|
@@index([inboundId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShoppingCartItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
planId String
|
||||||
|
selectedInboundId String?
|
||||||
|
trafficGb Int?
|
||||||
|
quantity Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
|
selectedInbound NodeInbound? @relation(fields: [selectedInboundId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([planId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
planId String
|
||||||
|
selectedInboundId String?
|
||||||
|
trafficGb Int?
|
||||||
|
quantity Int @default(1)
|
||||||
|
unitAmount Decimal @db.Decimal(10, 2)
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
plan SubscriptionPlan @relation(fields: [planId], references: [id])
|
||||||
|
selectedInbound NodeInbound? @relation(fields: [selectedInboundId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([planId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NodeClient {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
inboundId String
|
||||||
|
userId String
|
||||||
|
subscriptionId String @unique
|
||||||
|
email String
|
||||||
|
uuid String
|
||||||
|
trafficUp BigInt @default(0)
|
||||||
|
trafficDown BigInt @default(0)
|
||||||
|
expiryTime DateTime?
|
||||||
|
isEnabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
inbound NodeInbound @relation(fields: [inboundId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
subscription UserSubscription @relation(fields: [subscriptionId], references: [id])
|
||||||
|
|
||||||
|
@@index([inboundId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
planId String
|
||||||
|
kind OrderKind @default(NEW_PURCHASE)
|
||||||
|
selectedInboundId String?
|
||||||
|
targetSubscriptionId String?
|
||||||
|
subscriptionId String? @unique
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
subtotalAmount Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
discountAmount Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
couponId String?
|
||||||
|
couponCode String?
|
||||||
|
promotionName String?
|
||||||
|
trafficGb Int?
|
||||||
|
durationDays Int?
|
||||||
|
status OrderStatus @default(PENDING)
|
||||||
|
paymentMethod String?
|
||||||
|
paymentRef String?
|
||||||
|
paymentUrl String?
|
||||||
|
tradeNo String? @unique
|
||||||
|
expireAt DateTime?
|
||||||
|
note String?
|
||||||
|
reviewStatus OrderReviewStatus @default(NORMAL)
|
||||||
|
reviewNote String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
plan SubscriptionPlan @relation(fields: [planId], references: [id])
|
||||||
|
selectedInbound NodeInbound? @relation(fields: [selectedInboundId], references: [id])
|
||||||
|
targetSubscription UserSubscription? @relation("OrderTargetSubscription", fields: [targetSubscriptionId], references: [id])
|
||||||
|
subscription UserSubscription? @relation("OrderCreatedSubscription", fields: [subscriptionId], references: [id])
|
||||||
|
coupon Coupon? @relation(fields: [couponId], references: [id], onDelete: SetNull)
|
||||||
|
items OrderItem[]
|
||||||
|
couponGrants CouponGrant[] @relation("CouponGrantUsedOrder")
|
||||||
|
inviteRewards InviteRewardLedger[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([kind])
|
||||||
|
@@index([targetSubscriptionId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([tradeNo])
|
||||||
|
@@index([reviewStatus])
|
||||||
|
@@index([couponId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NodeLatency {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nodeId String
|
||||||
|
carrier String
|
||||||
|
latencyMs Int
|
||||||
|
checkedAt DateTime @default(now())
|
||||||
|
|
||||||
|
node NodeServer @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([nodeId, carrier])
|
||||||
|
@@index([nodeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model NodeLatencyLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nodeId String
|
||||||
|
carrier String
|
||||||
|
latencyMs Int
|
||||||
|
checkedAt DateTime @default(now())
|
||||||
|
|
||||||
|
node NodeServer @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([nodeId, carrier, checkedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Coupon {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
discountType CouponDiscountType @default(AMOUNT_OFF)
|
||||||
|
discountValue Decimal @db.Decimal(10, 2)
|
||||||
|
thresholdAmount Decimal? @db.Decimal(10, 2)
|
||||||
|
maxDiscountAmount Decimal? @db.Decimal(10, 2)
|
||||||
|
totalLimit Int?
|
||||||
|
perUserLimit Int?
|
||||||
|
isPublic Boolean @default(true)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
startsAt DateTime?
|
||||||
|
endsAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
grants CouponGrant[]
|
||||||
|
|
||||||
|
@@index([isActive, startsAt, endsAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CouponGrant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
couponId String
|
||||||
|
userId String
|
||||||
|
source String?
|
||||||
|
sourceOrderId String?
|
||||||
|
usedOrderId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
usedAt DateTime?
|
||||||
|
|
||||||
|
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
usedOrder Order? @relation("CouponGrantUsedOrder", fields: [usedOrderId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId, usedAt])
|
||||||
|
@@index([couponId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PromotionRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
thresholdAmount Decimal @db.Decimal(10, 2)
|
||||||
|
discountAmount Decimal @db.Decimal(10, 2)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
startsAt DateTime?
|
||||||
|
endsAt DateTime?
|
||||||
|
sortOrder Int @default(100)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([isActive, thresholdAmount, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteRewardLedger {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
inviterId String
|
||||||
|
inviteeId String
|
||||||
|
orderId String
|
||||||
|
rewardAmount Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
couponCode String?
|
||||||
|
status InviteRewardStatus @default(ISSUED)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
inviter User @relation("InviteRewardInviter", fields: [inviterId], references: [id], onDelete: Cascade)
|
||||||
|
invitee User @relation("InviteRewardInvitee", fields: [inviteeId], references: [id], onDelete: Cascade)
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([orderId, inviterId])
|
||||||
|
@@index([inviterId, createdAt])
|
||||||
|
@@index([inviteeId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
provider String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
config Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrafficLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clientId String
|
||||||
|
upload BigInt
|
||||||
|
download BigInt
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([clientId])
|
||||||
|
@@index([timestamp])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserNotification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type NotificationType
|
||||||
|
level NotificationLevel @default(INFO)
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
link String?
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
readAt DateTime?
|
||||||
|
dedupeKey String? @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, isRead, createdAt])
|
||||||
|
@@index([type, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actorUserId String?
|
||||||
|
actorEmail String?
|
||||||
|
actorRole Role?
|
||||||
|
action String
|
||||||
|
targetType String
|
||||||
|
targetId String?
|
||||||
|
targetLabel String?
|
||||||
|
message String
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
actor User? @relation("AuditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([actorUserId, createdAt])
|
||||||
|
@@index([action, createdAt])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AppConfig {
|
||||||
|
id String @id @default("default")
|
||||||
|
siteName String @default("J-Board")
|
||||||
|
siteUrl String?
|
||||||
|
allowRegistration Boolean @default(true)
|
||||||
|
requireInviteCode Boolean @default(false)
|
||||||
|
supportContact String?
|
||||||
|
maintenanceNotice String?
|
||||||
|
siteNotice String?
|
||||||
|
autoReminderDispatchEnabled Boolean @default(true)
|
||||||
|
reminderDispatchIntervalMinutes Int @default(60)
|
||||||
|
trafficSyncEnabled Boolean @default(true)
|
||||||
|
trafficSyncIntervalSeconds Int @default(60)
|
||||||
|
inviteRewardCouponId String?
|
||||||
|
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
||||||
|
inviteRewardEnabled Boolean @default(false)
|
||||||
|
turnstileSiteKey String?
|
||||||
|
turnstileSecretKey String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Announcement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
audience AnnouncementAudience @default(USERS)
|
||||||
|
displayType AnnouncementDisplayType @default(INLINE)
|
||||||
|
targetUserId String?
|
||||||
|
createdById String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
dismissible Boolean @default(true)
|
||||||
|
sendNotification Boolean @default(true)
|
||||||
|
startAt DateTime?
|
||||||
|
endAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
targetUser User? @relation("AnnouncementTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
|
||||||
|
createdBy User? @relation("AnnouncementCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([audience, isActive, createdAt])
|
||||||
|
@@index([targetUserId, isActive])
|
||||||
|
@@index([startAt, endAt, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kind TaskKind
|
||||||
|
status TaskStatus @default(PENDING)
|
||||||
|
title String
|
||||||
|
targetType String?
|
||||||
|
targetId String?
|
||||||
|
payload Json?
|
||||||
|
result Json?
|
||||||
|
errorMessage String?
|
||||||
|
retryable Boolean @default(false)
|
||||||
|
retryCount Int @default(0)
|
||||||
|
triggeredById String?
|
||||||
|
startedAt DateTime?
|
||||||
|
finishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
triggeredBy User? @relation("TaskTriggeredBy", fields: [triggeredById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([kind, createdAt])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupportTicket {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
subject String
|
||||||
|
category String?
|
||||||
|
status SupportTicketStatus @default(OPEN)
|
||||||
|
priority SupportTicketPriority @default(NORMAL)
|
||||||
|
lastReplyAt DateTime?
|
||||||
|
closedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
replies SupportTicketReply[]
|
||||||
|
attachments SupportTicketAttachment[]
|
||||||
|
|
||||||
|
@@index([userId, status, createdAt])
|
||||||
|
@@index([status, priority, updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupportTicketReply {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ticketId String
|
||||||
|
authorUserId String?
|
||||||
|
isAdmin Boolean @default(false)
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
author User? @relation(fields: [authorUserId], references: [id], onDelete: SetNull)
|
||||||
|
attachments SupportTicketAttachment[]
|
||||||
|
|
||||||
|
@@index([ticketId, createdAt])
|
||||||
|
@@index([authorUserId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SupportTicketAttachment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ticketId String
|
||||||
|
replyId String
|
||||||
|
fileName String
|
||||||
|
mimeType String
|
||||||
|
size Int
|
||||||
|
content Bytes
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
reply SupportTicketReply @relation(fields: [replyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([ticketId, createdAt])
|
||||||
|
@@index([replyId, createdAt])
|
||||||
|
}
|
||||||
31
prisma/seed.ts
Normal file
31
prisma/seed.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hashedPassword = await bcrypt.hash("admin123", 12);
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: "admin@jboard.local" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "admin@jboard.local",
|
||||||
|
password: hashedPassword,
|
||||||
|
name: "Admin",
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seed completed: admin@jboard.local / admin123");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
150
scripts/install-jboard-agent.sh
Executable file
150
scripts/install-jboard-agent.sh
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GH_REPO="${GH_REPO:-JetSprow/J-Board}"
|
||||||
|
AGENT_TAG="${AGENT_TAG:-latest}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-jboard-agent}"
|
||||||
|
ENV_FILE="${ENV_FILE:-/etc/jboard-agent.env}"
|
||||||
|
LATENCY_INTERVAL="${LATENCY_INTERVAL:-5m}"
|
||||||
|
TRACE_INTERVAL="${TRACE_INTERVAL:-30m}"
|
||||||
|
INSTALL_NEXTTRACE="${INSTALL_NEXTTRACE:-1}"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
if [ -z "${SERVER_URL:-}" ] || [ -z "${AUTH_TOKEN:-}" ]; then
|
||||||
|
echo "SERVER_URL and AUTH_TOKEN are required." >&2
|
||||||
|
echo "Example:" >&2
|
||||||
|
echo "curl -fsSL https://raw.githubusercontent.com/${GH_REPO}/main/scripts/install-jboard-agent.sh | SERVER_URL=https://example.com AUTH_TOKEN=token bash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_as_root() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo "$@"
|
||||||
|
else
|
||||||
|
echo "This script needs root privileges. Re-run as root or install sudo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_asset() {
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64)
|
||||||
|
echo "jboard-agent-linux-amd64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
echo "jboard-agent-linux-arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: $ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_release_tag() {
|
||||||
|
if [ "$AGENT_TAG" != "latest" ]; then
|
||||||
|
echo "$AGENT_TAG"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL "https://api.github.com/repos/${GH_REPO}/releases/latest" \
|
||||||
|
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||||
|
| head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSET="$(detect_asset)"
|
||||||
|
RESOLVED_TAG="$(resolve_release_tag)"
|
||||||
|
|
||||||
|
if [ -z "$RESOLVED_TAG" ]; then
|
||||||
|
echo "Failed to resolve release tag for ${GH_REPO}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${RESOLVED_TAG}"
|
||||||
|
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
||||||
|
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
||||||
|
|
||||||
|
echo "[1/8] Release tag: ${RESOLVED_TAG}"
|
||||||
|
echo "[2/8] Downloading probe agent binary: ${ASSET}"
|
||||||
|
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
||||||
|
|
||||||
|
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
||||||
|
echo "[3/8] Verifying checksum..."
|
||||||
|
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
||||||
|
(
|
||||||
|
cd "$TMP_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum -c SHA256SUMS.current >/dev/null
|
||||||
|
else
|
||||||
|
shasum -a 256 -c SHA256SUMS.current >/dev/null
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "[3/8] Checksum file not found; skipping verification."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[4/8] Installing binary..."
|
||||||
|
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
||||||
|
run_as_root mkdir -p /var/log/jboard
|
||||||
|
|
||||||
|
if [ "$INSTALL_NEXTTRACE" = "1" ] && ! command -v nexttrace >/dev/null 2>&1; then
|
||||||
|
echo "[5/8] Installing nexttrace for route probing..."
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/nxtrace/NTrace-core/main/nt_install.sh -o "$TMP_DIR/nt_install.sh"
|
||||||
|
run_as_root bash "$TMP_DIR/nt_install.sh"
|
||||||
|
else
|
||||||
|
echo "[5/8] nexttrace already installed or skipped."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/8] Writing environment file..."
|
||||||
|
ENV_TMP="$TMP_DIR/jboard-agent.env"
|
||||||
|
{
|
||||||
|
printf 'SERVER_URL=%q\n' "$SERVER_URL"
|
||||||
|
printf 'AUTH_TOKEN=%q\n' "$AUTH_TOKEN"
|
||||||
|
printf 'LATENCY_INTERVAL=%q\n' "$LATENCY_INTERVAL"
|
||||||
|
printf 'TRACE_INTERVAL=%q\n' "$TRACE_INTERVAL"
|
||||||
|
} > "$ENV_TMP"
|
||||||
|
run_as_root install -m 0600 "$ENV_TMP" "$ENV_FILE"
|
||||||
|
|
||||||
|
echo "[7/8] Writing systemd service..."
|
||||||
|
SERVICE_TMP="$TMP_DIR/${SERVICE_NAME}.service"
|
||||||
|
cat > "$SERVICE_TMP" <<SERVICE
|
||||||
|
[Unit]
|
||||||
|
Description=J-Board Probe Agent
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
EnvironmentFile=${ENV_FILE}
|
||||||
|
ExecStart=${INSTALL_DIR}/jboard-agent
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
MemoryMax=64M
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
SERVICE
|
||||||
|
run_as_root install -m 0644 "$SERVICE_TMP" "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
echo "[8/8] Enabling and starting service..."
|
||||||
|
run_as_root systemctl daemon-reload
|
||||||
|
run_as_root systemctl enable --now "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Install complete."
|
||||||
|
echo
|
||||||
|
echo "Service status:"
|
||||||
|
run_as_root systemctl --no-pager --full status "$SERVICE_NAME" || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Recent logs:"
|
||||||
|
run_as_root journalctl -u "$SERVICE_NAME" -n 30 --no-pager || true
|
||||||
100
scripts/upgrade-jboard-agent.sh
Executable file
100
scripts/upgrade-jboard-agent.sh
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GH_REPO="${GH_REPO:-JetSprow/J-Board}"
|
||||||
|
AGENT_TAG="${AGENT_TAG:-latest}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-jboard-agent}"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
run_as_root() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo "$@"
|
||||||
|
else
|
||||||
|
echo "This script needs root privileges. Re-run as root or install sudo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_asset() {
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64)
|
||||||
|
echo "jboard-agent-linux-amd64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
echo "jboard-agent-linux-arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: $ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_release_tag() {
|
||||||
|
if [ "$AGENT_TAG" != "latest" ]; then
|
||||||
|
echo "$AGENT_TAG"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL "https://api.github.com/repos/${GH_REPO}/releases/latest" \
|
||||||
|
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||||
|
| head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ASSET="$(detect_asset)"
|
||||||
|
RESOLVED_TAG="$(resolve_release_tag)"
|
||||||
|
|
||||||
|
if [ -z "$RESOLVED_TAG" ]; then
|
||||||
|
echo "Failed to resolve release tag for ${GH_REPO}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${RESOLVED_TAG}"
|
||||||
|
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
||||||
|
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
||||||
|
|
||||||
|
echo "[1/5] Release tag: ${RESOLVED_TAG}"
|
||||||
|
echo "[2/5] Downloading probe agent binary: ${ASSET}"
|
||||||
|
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
||||||
|
|
||||||
|
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
||||||
|
echo "[3/5] Verifying checksum..."
|
||||||
|
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
||||||
|
(
|
||||||
|
cd "$TMP_DIR"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum -c SHA256SUMS.current >/dev/null
|
||||||
|
else
|
||||||
|
shasum -a 256 -c SHA256SUMS.current >/dev/null
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "[3/5] Checksum file not found; skipping verification."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[4/5] Installing binary..."
|
||||||
|
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
||||||
|
run_as_root mkdir -p /var/log/jboard
|
||||||
|
|
||||||
|
echo "[5/5] Restarting service..."
|
||||||
|
run_as_root systemctl daemon-reload
|
||||||
|
run_as_root systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Upgrade complete."
|
||||||
|
echo
|
||||||
|
echo "Service status:"
|
||||||
|
run_as_root systemctl --no-pager --full status "$SERVICE_NAME" || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Recent logs:"
|
||||||
|
run_as_root journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
|
||||||
46
scripts/upgrade-jboard-panel.sh
Executable file
46
scripts/upgrade-jboard-panel.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/jboard}"
|
||||||
|
COMPOSE="${COMPOSE:-docker compose}"
|
||||||
|
BACKUP="${BACKUP:-1}"
|
||||||
|
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/api/public/app-info}"
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
echo "[1/7] Pulling latest code..."
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
if [ "$BACKUP" = "1" ]; then
|
||||||
|
echo "[2/7] Backing up database..."
|
||||||
|
mkdir -p backups
|
||||||
|
$COMPOSE exec -T db pg_dump -U jboard jboard > "backups/jboard-db-$(date +%F-%H%M%S).sql"
|
||||||
|
else
|
||||||
|
echo "[2/7] Skipping database backup..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[3/7] Building updated images..."
|
||||||
|
$COMPOSE build init app
|
||||||
|
|
||||||
|
echo "[4/7] Syncing Prisma schema inside Docker network..."
|
||||||
|
$COMPOSE --profile setup run --rm init sh -lc 'npx prisma db push --accept-data-loss'
|
||||||
|
|
||||||
|
echo "[5/7] Restarting services..."
|
||||||
|
$COMPOSE up -d app
|
||||||
|
|
||||||
|
echo "[6/7] Waiting for app to boot..."
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
echo "[7/7] Checking service status..."
|
||||||
|
$COMPOSE ps
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "App health:"
|
||||||
|
curl -fsS "$HEALTH_URL" || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Recent app logs:"
|
||||||
|
$COMPOSE logs --tail=80 app || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Upgrade complete."
|
||||||
189
src/actions/admin/announcements.ts
Normal file
189
src/actions/admin/announcements.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import {
|
||||||
|
deleteAnnouncementNotifications,
|
||||||
|
dispatchAnnouncementNotifications,
|
||||||
|
syncAnnouncementNotifications,
|
||||||
|
} from "@/services/announcements";
|
||||||
|
|
||||||
|
const announcementSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "标题不能为空"),
|
||||||
|
body: z.string().trim().min(1, "内容不能为空"),
|
||||||
|
audience: z.enum(["PUBLIC", "USERS", "ADMINS", "SPECIFIC_USER"]),
|
||||||
|
displayType: z.enum(["INLINE", "BIG", "POPUP"]).default("INLINE"),
|
||||||
|
targetUserId: z.string().optional(),
|
||||||
|
dismissible: z.string().optional(),
|
||||||
|
sendNotification: z.string().optional(),
|
||||||
|
startAt: z.string().optional(),
|
||||||
|
endAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function revalidateAnnouncementViews() {
|
||||||
|
revalidatePath("/admin/announcements");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/account");
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
revalidatePath("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnnouncementInput(formData: FormData) {
|
||||||
|
const data = announcementSchema.parse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
if (data.audience === "SPECIFIC_USER" && !data.targetUserId) {
|
||||||
|
throw new Error("定向消息必须选择用户");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = data.startAt ? new Date(data.startAt) : null;
|
||||||
|
const endAt = data.endAt ? new Date(data.endAt) : null;
|
||||||
|
if (startAt && Number.isNaN(startAt.getTime())) {
|
||||||
|
throw new Error("开始时间格式无效");
|
||||||
|
}
|
||||||
|
if (endAt && Number.isNaN(endAt.getTime())) {
|
||||||
|
throw new Error("结束时间格式无效");
|
||||||
|
}
|
||||||
|
if (startAt && endAt && endAt <= startAt) {
|
||||||
|
throw new Error("结束时间必须晚于开始时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title,
|
||||||
|
body: data.body,
|
||||||
|
audience: data.audience,
|
||||||
|
displayType: data.displayType,
|
||||||
|
targetUserId: data.audience === "SPECIFIC_USER" ? data.targetUserId ?? null : null,
|
||||||
|
dismissible: data.dismissible === "true",
|
||||||
|
sendNotification: data.sendNotification === "true",
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAnnouncement(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = parseAnnouncementInput(formData);
|
||||||
|
|
||||||
|
const announcement = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
body: data.body,
|
||||||
|
audience: data.audience,
|
||||||
|
displayType: data.displayType,
|
||||||
|
targetUserId: data.targetUserId,
|
||||||
|
createdById: session.user.id,
|
||||||
|
isActive: true,
|
||||||
|
dismissible: data.dismissible,
|
||||||
|
sendNotification: data.sendNotification,
|
||||||
|
startAt: data.startAt,
|
||||||
|
endAt: data.endAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchAnnouncementNotifications(announcement.id);
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "announcement.create",
|
||||||
|
targetType: "Announcement",
|
||||||
|
targetId: announcement.id,
|
||||||
|
targetLabel: announcement.title,
|
||||||
|
message: `创建公告/消息 ${announcement.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateAnnouncementViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAnnouncement(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = parseAnnouncementInput(formData);
|
||||||
|
|
||||||
|
const announcement = await prisma.announcement.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
body: data.body,
|
||||||
|
audience: data.audience,
|
||||||
|
displayType: data.displayType,
|
||||||
|
targetUserId: data.targetUserId,
|
||||||
|
dismissible: data.dismissible,
|
||||||
|
sendNotification: data.sendNotification,
|
||||||
|
startAt: data.startAt,
|
||||||
|
endAt: data.endAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncAnnouncementNotifications(announcement.id);
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "announcement.update",
|
||||||
|
targetType: "Announcement",
|
||||||
|
targetId: announcement.id,
|
||||||
|
targetLabel: announcement.title,
|
||||||
|
message: `更新公告/消息 ${announcement.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateAnnouncementViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAnnouncement(id: string, isActive: boolean) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const announcement = await prisma.announcement.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: isActive ? "announcement.enable" : "announcement.disable",
|
||||||
|
targetType: "Announcement",
|
||||||
|
targetId: announcement.id,
|
||||||
|
targetLabel: announcement.title,
|
||||||
|
message: `${isActive ? "启用" : "停用"}公告/消息 ${announcement.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
await dispatchAnnouncementNotifications(announcement.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateAnnouncementViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAnnouncement(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const announcement = await prisma.announcement.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!announcement) {
|
||||||
|
throw new Error("公告不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await deleteAnnouncementNotifications(announcement.id, tx);
|
||||||
|
await tx.announcement.delete({
|
||||||
|
where: {
|
||||||
|
id: announcement.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAuditLog(
|
||||||
|
{
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "announcement.delete",
|
||||||
|
targetType: "Announcement",
|
||||||
|
targetId: announcement.id,
|
||||||
|
targetLabel: announcement.title,
|
||||||
|
message: `删除公告/消息 ${announcement.title}`,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateAnnouncementViews();
|
||||||
|
}
|
||||||
34
src/actions/admin/backups.ts
Normal file
34
src/actions/admin/backups.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { restoreDatabaseBackupFile, restoreDatabaseBackupSql } from "@/services/database-backup";
|
||||||
|
|
||||||
|
export async function restoreDatabaseBackup(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const sqlText = String(formData.get("sqlText") || "").trim();
|
||||||
|
const file = formData.get("sqlFile");
|
||||||
|
const confirmation = String(formData.get("confirmation") || "");
|
||||||
|
|
||||||
|
if (confirmation !== "RESTORE") {
|
||||||
|
throw new Error("请输入 RESTORE 确认恢复操作");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file instanceof File && file.size > 0) {
|
||||||
|
await restoreDatabaseBackupFile(file);
|
||||||
|
} else if (sqlText) {
|
||||||
|
await restoreDatabaseBackupSql(sqlText);
|
||||||
|
} else {
|
||||||
|
throw new Error("请上传 SQL 备份文件或粘贴 SQL 内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "backup.restore",
|
||||||
|
targetType: "Database",
|
||||||
|
message: "执行数据库恢复",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/backups");
|
||||||
|
}
|
||||||
127
src/actions/admin/commerce.ts
Normal file
127
src/actions/admin/commerce.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
|
||||||
|
const optionalNumber = z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||||
|
z.number().optional(),
|
||||||
|
);
|
||||||
|
const optionalInt = z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||||
|
z.number().int().optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const couponSchema = z.object({
|
||||||
|
code: z.string().trim().min(2).transform((value) => value.toUpperCase()),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: z.string().trim().optional(),
|
||||||
|
discountType: z.enum(["AMOUNT_OFF", "PERCENT_OFF"]),
|
||||||
|
discountValue: z.coerce.number().positive(),
|
||||||
|
thresholdAmount: optionalNumber,
|
||||||
|
maxDiscountAmount: optionalNumber,
|
||||||
|
totalLimit: optionalInt,
|
||||||
|
perUserLimit: optionalInt,
|
||||||
|
isPublic: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promotionSchema = z.object({
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
thresholdAmount: z.coerce.number().positive(),
|
||||||
|
discountAmount: z.coerce.number().positive(),
|
||||||
|
sortOrder: z.coerce.number().int().default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createCoupon(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = couponSchema.parse(Object.fromEntries(formData));
|
||||||
|
if (data.discountType === "PERCENT_OFF" && data.discountValue > 100) {
|
||||||
|
throw new Error("折扣百分比不能超过 100");
|
||||||
|
}
|
||||||
|
const coupon = await prisma.coupon.create({
|
||||||
|
data: {
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
discountType: data.discountType,
|
||||||
|
discountValue: data.discountValue,
|
||||||
|
thresholdAmount: data.thresholdAmount ?? null,
|
||||||
|
maxDiscountAmount: data.maxDiscountAmount ?? null,
|
||||||
|
totalLimit: data.totalLimit ?? null,
|
||||||
|
perUserLimit: data.perUserLimit ?? null,
|
||||||
|
isPublic: data.isPublic === "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "coupon.create",
|
||||||
|
targetType: "Coupon",
|
||||||
|
targetId: coupon.id,
|
||||||
|
targetLabel: coupon.code,
|
||||||
|
message: `创建优惠券 ${coupon.code}`,
|
||||||
|
});
|
||||||
|
revalidateCommerce();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleCoupon(id: string, isActive: boolean) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const coupon = await prisma.coupon.update({ where: { id }, data: { isActive } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "coupon.toggle",
|
||||||
|
targetType: "Coupon",
|
||||||
|
targetId: id,
|
||||||
|
targetLabel: coupon.code,
|
||||||
|
message: `${isActive ? "启用" : "停用"}优惠券 ${coupon.code}`,
|
||||||
|
});
|
||||||
|
revalidateCommerce();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPromotionRule(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = promotionSchema.parse(Object.fromEntries(formData));
|
||||||
|
if (data.discountAmount >= data.thresholdAmount) {
|
||||||
|
throw new Error("满减金额应小于门槛金额");
|
||||||
|
}
|
||||||
|
const rule = await prisma.promotionRule.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
thresholdAmount: data.thresholdAmount,
|
||||||
|
discountAmount: data.discountAmount,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "promotion.create",
|
||||||
|
targetType: "PromotionRule",
|
||||||
|
targetId: rule.id,
|
||||||
|
targetLabel: rule.name,
|
||||||
|
message: `创建满减规则 ${rule.name}`,
|
||||||
|
});
|
||||||
|
revalidateCommerce();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function togglePromotionRule(id: string, isActive: boolean) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const rule = await prisma.promotionRule.update({ where: { id }, data: { isActive } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "promotion.toggle",
|
||||||
|
targetType: "PromotionRule",
|
||||||
|
targetId: id,
|
||||||
|
targetLabel: rule.name,
|
||||||
|
message: `${isActive ? "启用" : "停用"}满减规则 ${rule.name}`,
|
||||||
|
});
|
||||||
|
revalidateCommerce();
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateCommerce() {
|
||||||
|
revalidatePath("/admin/commerce");
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
revalidatePath("/cart");
|
||||||
|
}
|
||||||
245
src/actions/admin/nodes.ts
Normal file
245
src/actions/admin/nodes.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { encrypt } from "@/lib/crypto";
|
||||||
|
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
|
||||||
|
|
||||||
|
const nodeSchema = z.object({
|
||||||
|
name: z.string().trim().optional(),
|
||||||
|
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
|
||||||
|
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
|
||||||
|
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizePanelUrl(raw: string): string {
|
||||||
|
try {
|
||||||
|
let value = raw.trim();
|
||||||
|
if (!/^https?:\/\//i.test(value)) {
|
||||||
|
value = `http://${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(value);
|
||||||
|
let pathname = url.pathname.replace(/\/+$/, "");
|
||||||
|
pathname = pathname.replace(/\/panel\/login$/i, "");
|
||||||
|
pathname = pathname.replace(/\/panel$/i, "");
|
||||||
|
pathname = pathname.replace(/\/login$/i, "");
|
||||||
|
return `${url.origin}${pathname}`;
|
||||||
|
} catch {
|
||||||
|
throw new Error("面板地址格式不正确,请填写 IP:端口 或 http://IP:端口");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodeData(formData: FormData) {
|
||||||
|
const raw = nodeSchema.parse(Object.fromEntries(formData));
|
||||||
|
const panelUrl = normalizePanelUrl(raw.panelUrl);
|
||||||
|
const panel = new URL(panelUrl);
|
||||||
|
|
||||||
|
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
panelUrl,
|
||||||
|
panelUsername: raw.panelUsername,
|
||||||
|
panelPassword: raw.panelPassword,
|
||||||
|
panelType: "3x-ui",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNode(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = parseNodeData(formData);
|
||||||
|
const node = await prisma.nodeServer.create({ data });
|
||||||
|
const result = await testAndSyncNodeInbounds(node);
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.create",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: node.id,
|
||||||
|
targetLabel: node.name,
|
||||||
|
message: `创建 3x-ui 节点 ${node.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
message: result.success ? `节点创建成功,${result.message}` : `节点已创建,但${result.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNode(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = parseNodeData(formData);
|
||||||
|
const node = await prisma.nodeServer.update({ where: { id }, data });
|
||||||
|
const result = await testAndSyncNodeInbounds(node);
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.update",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: node.id,
|
||||||
|
targetLabel: node.name,
|
||||||
|
message: `更新 3x-ui 节点 ${node.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNode(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const node = await prisma.nodeServer.delete({ where: { id } });
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.delete",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: node.id,
|
||||||
|
targetLabel: node.name,
|
||||||
|
message: `删除节点 ${node.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testNodeConnection(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const server = await prisma.nodeServer.findUniqueOrThrow({ where: { id } });
|
||||||
|
const result = await testAndSyncNodeInbounds(server);
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.test",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: server.id,
|
||||||
|
targetLabel: server.name,
|
||||||
|
message: `测试 3x-ui 节点 ${server.name}:${result.message}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchTestNodeConnections(formData: FormData) {
|
||||||
|
const nodeIds = formData.getAll("nodeIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (nodeIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个节点");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nodeId of nodeIds) {
|
||||||
|
await testNodeConnection(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withInboundDisplayName(settings: unknown, displayName: string) {
|
||||||
|
const base = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||||
|
? settings as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return { ...base, displayName: displayName.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundDisplayNameSchema = z.object({
|
||||||
|
displayName: z.string().trim().min(1, "前台名称不能为空").max(60, "前台名称不能超过 60 个字符"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateInboundDisplayName(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
|
||||||
|
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { server: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.nodeInbound.update({
|
||||||
|
where: { id },
|
||||||
|
data: { settings: withInboundDisplayName(inbound.settings, displayName) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "inbound.display_name.update",
|
||||||
|
targetType: "NodeInbound",
|
||||||
|
targetId: inbound.id,
|
||||||
|
targetLabel: displayName,
|
||||||
|
message: `更新节点 ${inbound.server.name} 的前台线路名称`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInbound(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { server: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.nodeInbound.delete({ where: { id } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "inbound.delete",
|
||||||
|
targetType: "NodeInbound",
|
||||||
|
targetId: inbound.id,
|
||||||
|
targetLabel: `${inbound.protocol}:${inbound.port}`,
|
||||||
|
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAgentToken(nodeId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const node = await prisma.nodeServer.findUniqueOrThrow({
|
||||||
|
where: { id: nodeId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const plainToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const encrypted = encrypt(plainToken);
|
||||||
|
|
||||||
|
await prisma.nodeServer.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: { agentToken: encrypted },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.probe_token.generate",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: node.id,
|
||||||
|
targetLabel: node.name,
|
||||||
|
message: `为节点 ${node.name} 生成探测 Token`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
return plainToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAgentToken(nodeId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const node = await prisma.nodeServer.findUniqueOrThrow({
|
||||||
|
where: { id: nodeId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.nodeServer.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: { agentToken: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "node.probe_token.revoke",
|
||||||
|
targetType: "NodeServer",
|
||||||
|
targetId: node.id,
|
||||||
|
targetLabel: node.name,
|
||||||
|
message: `撤销节点 ${node.name} 的探测 Token`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
}
|
||||||
92
src/actions/admin/orders.ts
Normal file
92
src/actions/admin/orders.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { confirmPendingOrder } from "@/services/payment/process";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
|
||||||
|
export async function confirmOrder(orderId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const order = await prisma.order.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { status: true, id: true },
|
||||||
|
});
|
||||||
|
if (order.status !== "PENDING") {
|
||||||
|
throw new Error("订单状态不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await confirmPendingOrder(orderId);
|
||||||
|
if (result.finalStatus !== "PAID") {
|
||||||
|
throw new Error(result.errorMessage ?? "订单处理失败");
|
||||||
|
}
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "order.confirm",
|
||||||
|
targetType: "Order",
|
||||||
|
targetId: order.id,
|
||||||
|
targetLabel: order.id,
|
||||||
|
message: `确认订单 ${order.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(orderId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
await prisma.order.update({ where: { id: orderId }, data: { status: "CANCELLED" } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "order.cancel",
|
||||||
|
targetType: "Order",
|
||||||
|
targetId: orderId,
|
||||||
|
targetLabel: orderId,
|
||||||
|
message: `取消订单 ${orderId}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrderReview(
|
||||||
|
orderId: string,
|
||||||
|
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED",
|
||||||
|
reviewNote?: string,
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const order = await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
reviewStatus,
|
||||||
|
reviewNote: reviewNote?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "order.review",
|
||||||
|
targetType: "Order",
|
||||||
|
targetId: order.id,
|
||||||
|
targetLabel: order.id,
|
||||||
|
message: `将订单 ${order.id} 标记为 ${reviewStatus}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchOrderOperation(formData: FormData) {
|
||||||
|
const action = String(formData.get("action") || "");
|
||||||
|
const orderIds = formData.getAll("orderIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (orderIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个订单");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const orderId of orderIds) {
|
||||||
|
if (action === "confirm") {
|
||||||
|
await confirmOrder(orderId);
|
||||||
|
} else if (action === "cancel") {
|
||||||
|
await cancelOrder(orderId);
|
||||||
|
} else {
|
||||||
|
throw new Error("不支持的批量操作");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/actions/admin/payments.ts
Normal file
51
src/actions/admin/payments.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
normalizePaymentConfig,
|
||||||
|
parsePaymentConfig,
|
||||||
|
} from "@/services/payment/catalog";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function savePaymentConfig(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, string>,
|
||||||
|
enabled: boolean
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const normalizedConfig = normalizePaymentConfig(config);
|
||||||
|
let finalConfig = normalizedConfig as Record<string, string | number>;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record<string, string | number>;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const messages = error.issues.map((e) => e.message).join(";");
|
||||||
|
throw new Error(messages);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonConfig = JSON.parse(JSON.stringify(finalConfig));
|
||||||
|
|
||||||
|
await prisma.paymentConfig.upsert({
|
||||||
|
where: { provider },
|
||||||
|
create: { provider, config: jsonConfig, enabled },
|
||||||
|
update: { config: jsonConfig, enabled },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "payment.config",
|
||||||
|
targetType: "PaymentConfig",
|
||||||
|
targetId: provider,
|
||||||
|
targetLabel: provider,
|
||||||
|
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/payments");
|
||||||
|
}
|
||||||
689
src/actions/admin/plans.ts
Normal file
689
src/actions/admin/plans.ts
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { deleteSubscriptionPermanently } from "./subscriptions";
|
||||||
|
|
||||||
|
const optionalNumber = z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||||
|
z.number().optional(),
|
||||||
|
);
|
||||||
|
const optionalInt = z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||||
|
z.number().int().optional(),
|
||||||
|
);
|
||||||
|
const optionalBool = z.preprocess((value) => {
|
||||||
|
if (value === "" || value == null) return undefined;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === "true" || normalized === "1" || normalized === "on";
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
}, z.boolean().optional());
|
||||||
|
|
||||||
|
const planSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.enum(["STREAMING", "PROXY"]),
|
||||||
|
description: z.string().optional(),
|
||||||
|
durationDays: z.coerce.number().int().positive(),
|
||||||
|
sortOrder: z.coerce.number().int().default(100),
|
||||||
|
price: optionalNumber,
|
||||||
|
nodeId: z.string().optional(),
|
||||||
|
inboundId: z.string().optional(),
|
||||||
|
inboundIds: z.string().optional(),
|
||||||
|
streamingServiceId: z.string().optional(),
|
||||||
|
pricingMode: z.enum(["TRAFFIC_SLIDER", "FIXED_PACKAGE"]).optional(),
|
||||||
|
fixedTrafficGb: optionalInt,
|
||||||
|
fixedPrice: optionalNumber,
|
||||||
|
totalLimit: optionalInt,
|
||||||
|
perUserLimit: optionalInt,
|
||||||
|
totalTrafficGb: optionalInt,
|
||||||
|
allowRenewal: optionalBool,
|
||||||
|
allowTrafficTopup: optionalBool,
|
||||||
|
renewalPrice: optionalNumber,
|
||||||
|
renewalPricingMode: z.enum(["PER_DAY", "FIXED_DURATION"]).optional(),
|
||||||
|
renewalDurationDays: optionalInt,
|
||||||
|
renewalMinDays: optionalInt,
|
||||||
|
renewalMaxDays: optionalInt,
|
||||||
|
renewalTrafficGb: optionalInt,
|
||||||
|
topupPricingMode: z.enum(["PER_GB", "FIXED_AMOUNT"]).optional(),
|
||||||
|
topupPricePerGb: optionalNumber,
|
||||||
|
topupFixedPrice: optionalNumber,
|
||||||
|
minTopupGb: optionalInt,
|
||||||
|
maxTopupGb: optionalInt,
|
||||||
|
pricePerGb: optionalNumber,
|
||||||
|
minTrafficGb: optionalInt,
|
||||||
|
maxTrafficGb: optionalInt,
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseInboundIds(raw: string | undefined, fallbackInboundId?: string): string[] {
|
||||||
|
const list = (raw ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (list.length > 0) {
|
||||||
|
return Array.from(new Set(list));
|
||||||
|
}
|
||||||
|
if (fallbackInboundId) {
|
||||||
|
return [fallbackInboundId];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProxyPricing(data: z.infer<typeof planSchema>) {
|
||||||
|
const pricingMode = data.pricingMode ?? "TRAFFIC_SLIDER";
|
||||||
|
if (pricingMode === "FIXED_PACKAGE") {
|
||||||
|
if (data.fixedTrafficGb == null || data.fixedTrafficGb <= 0) {
|
||||||
|
throw new Error("固定流量套餐必须填写固定流量");
|
||||||
|
}
|
||||||
|
if (data.fixedPrice == null || data.fixedPrice <= 0) {
|
||||||
|
throw new Error("固定流量套餐必须填写固定价格");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (data.pricePerGb == null || data.pricePerGb <= 0) {
|
||||||
|
throw new Error("自选流量套餐必须填写每 GB 价格");
|
||||||
|
}
|
||||||
|
if (data.minTrafficGb == null || data.minTrafficGb <= 0) {
|
||||||
|
throw new Error("自选流量套餐必须填写最小流量");
|
||||||
|
}
|
||||||
|
if (data.maxTrafficGb == null || data.maxTrafficGb <= 0) {
|
||||||
|
throw new Error("自选流量套餐必须填写最大流量");
|
||||||
|
}
|
||||||
|
if (data.maxTrafficGb < data.minTrafficGb) {
|
||||||
|
throw new Error("最大流量不能小于最小流量");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pricingMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanFormData = z.infer<typeof planSchema>;
|
||||||
|
|
||||||
|
function getRenewalPolicy(data: PlanFormData, allowRenewal: boolean) {
|
||||||
|
if (!allowRenewal) {
|
||||||
|
return {
|
||||||
|
renewalPrice: null,
|
||||||
|
renewalPricingMode: "FIXED_DURATION",
|
||||||
|
renewalDurationDays: null,
|
||||||
|
renewalMinDays: null,
|
||||||
|
renewalMaxDays: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.renewalPrice == null || data.renewalPrice <= 0) {
|
||||||
|
throw new Error("开启续费时,续费金额必须大于 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewalPricingMode = data.renewalPricingMode ?? "FIXED_DURATION";
|
||||||
|
|
||||||
|
if (renewalPricingMode === "FIXED_DURATION") {
|
||||||
|
const renewalDurationDays = data.renewalDurationDays ?? data.durationDays;
|
||||||
|
if (!renewalDurationDays || renewalDurationDays <= 0) {
|
||||||
|
throw new Error("固定周期续费必须填写续费天数");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
renewalPrice: data.renewalPrice,
|
||||||
|
renewalPricingMode,
|
||||||
|
renewalDurationDays,
|
||||||
|
renewalMinDays: renewalDurationDays,
|
||||||
|
renewalMaxDays: renewalDurationDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewalMinDays = data.renewalMinDays ?? 1;
|
||||||
|
const renewalMaxDays = data.renewalMaxDays ?? data.durationDays;
|
||||||
|
|
||||||
|
if (renewalMinDays <= 0 || renewalMaxDays <= 0) {
|
||||||
|
throw new Error("续费天数范围必须大于 0");
|
||||||
|
}
|
||||||
|
if (renewalMaxDays < renewalMinDays) {
|
||||||
|
throw new Error("续费最大天数不能小于最小天数");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
renewalPrice: data.renewalPrice,
|
||||||
|
renewalPricingMode,
|
||||||
|
renewalDurationDays: null,
|
||||||
|
renewalMinDays,
|
||||||
|
renewalMaxDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopupPolicy(data: PlanFormData, allowTrafficTopup: boolean) {
|
||||||
|
if (data.type !== "PROXY" || !allowTrafficTopup) {
|
||||||
|
return {
|
||||||
|
topupPricingMode: "PER_GB",
|
||||||
|
topupPricePerGb: null,
|
||||||
|
topupFixedPrice: null,
|
||||||
|
minTopupGb: null,
|
||||||
|
maxTopupGb: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const topupPricingMode = data.topupPricingMode ?? "PER_GB";
|
||||||
|
if (topupPricingMode === "PER_GB") {
|
||||||
|
if (data.topupPricePerGb == null || data.topupPricePerGb <= 0) {
|
||||||
|
throw new Error("开启增流量时,每 GB 加流量价格必须大于 0");
|
||||||
|
}
|
||||||
|
} else if (data.topupFixedPrice == null || data.topupFixedPrice <= 0) {
|
||||||
|
throw new Error("开启增流量时,固定加流量金额必须大于 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTopupGb = data.minTopupGb ?? 1;
|
||||||
|
const maxTopupGb = data.maxTopupGb ?? null;
|
||||||
|
if (minTopupGb <= 0) {
|
||||||
|
throw new Error("最小增流量必须大于 0");
|
||||||
|
}
|
||||||
|
if (maxTopupGb != null && maxTopupGb <= 0) {
|
||||||
|
throw new Error("最大增流量必须大于 0");
|
||||||
|
}
|
||||||
|
if (maxTopupGb != null && maxTopupGb < minTopupGb) {
|
||||||
|
throw new Error("最大增流量不能小于最小增流量");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topupPricingMode,
|
||||||
|
topupPricePerGb: topupPricingMode === "PER_GB" ? data.topupPricePerGb : null,
|
||||||
|
topupFixedPrice: topupPricingMode === "FIXED_AMOUNT" ? data.topupFixedPrice : null,
|
||||||
|
minTopupGb,
|
||||||
|
maxTopupGb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlan(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const raw = Object.fromEntries(formData);
|
||||||
|
const data = planSchema.parse(raw);
|
||||||
|
const allowRenewal = data.allowRenewal ?? false;
|
||||||
|
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||||
|
|
||||||
|
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||||
|
throw new Error("总量上限必须大于 0");
|
||||||
|
}
|
||||||
|
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||||
|
throw new Error("每用户限购必须大于 0");
|
||||||
|
}
|
||||||
|
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||||
|
throw new Error("总流量池必须大于 0");
|
||||||
|
}
|
||||||
|
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||||
|
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||||
|
|
||||||
|
if (data.type === "PROXY") {
|
||||||
|
const pricingMode = assertProxyPricing(data);
|
||||||
|
if (!data.nodeId) throw new Error("代理套餐必须选择节点");
|
||||||
|
|
||||||
|
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||||
|
if (inboundIds.length === 0) {
|
||||||
|
throw new Error("请至少配置一个可售入站");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inbounds = await prisma.nodeInbound.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: inboundIds },
|
||||||
|
},
|
||||||
|
select: { id: true, serverId: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (inbounds.length !== inboundIds.length) {
|
||||||
|
throw new Error("存在无效入站,请重新选择");
|
||||||
|
}
|
||||||
|
for (const inbound of inbounds) {
|
||||||
|
if (inbound.serverId !== data.nodeId) {
|
||||||
|
throw new Error("入站与节点不匹配");
|
||||||
|
}
|
||||||
|
if (!inbound.isActive) {
|
||||||
|
throw new Error("入站未启用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdPlanId = "";
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const plan = await tx.subscriptionPlan.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
type: "PROXY",
|
||||||
|
description: data.description || null,
|
||||||
|
durationDays: data.durationDays,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
totalLimit: data.totalLimit ?? null,
|
||||||
|
perUserLimit: data.perUserLimit ?? null,
|
||||||
|
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||||
|
allowRenewal,
|
||||||
|
allowTrafficTopup,
|
||||||
|
renewalPrice: renewalPolicy.renewalPrice,
|
||||||
|
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||||
|
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||||
|
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||||
|
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||||
|
renewalTrafficGb: null,
|
||||||
|
topupPricingMode: topupPolicy.topupPricingMode,
|
||||||
|
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||||
|
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||||
|
minTopupGb: topupPolicy.minTopupGb,
|
||||||
|
maxTopupGb: topupPolicy.maxTopupGb,
|
||||||
|
nodeId: data.nodeId,
|
||||||
|
inboundId: inboundIds[0],
|
||||||
|
streamingServiceId: null,
|
||||||
|
categoryId: null,
|
||||||
|
pricingMode,
|
||||||
|
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||||
|
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||||
|
price: null,
|
||||||
|
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||||
|
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||||
|
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdPlanId = plan.id;
|
||||||
|
|
||||||
|
await tx.planInboundOption.createMany({
|
||||||
|
data: inboundIds.map((inboundId) => ({
|
||||||
|
planId: plan.id,
|
||||||
|
inboundId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "plan.create",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: createdPlanId,
|
||||||
|
targetLabel: data.name,
|
||||||
|
message: `创建代理套餐 ${data.name}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!data.streamingServiceId) {
|
||||||
|
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await prisma.streamingService.findUnique({
|
||||||
|
where: { id: data.streamingServiceId },
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (!service || !service.isActive) {
|
||||||
|
throw new Error("所选流媒体服务不存在或未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await prisma.subscriptionPlan.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
type: "STREAMING",
|
||||||
|
description: data.description || null,
|
||||||
|
durationDays: data.durationDays,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
totalLimit: data.totalLimit ?? null,
|
||||||
|
perUserLimit: data.perUserLimit ?? null,
|
||||||
|
totalTrafficGb: null,
|
||||||
|
allowRenewal,
|
||||||
|
allowTrafficTopup: false,
|
||||||
|
renewalPrice: renewalPolicy.renewalPrice,
|
||||||
|
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||||
|
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||||
|
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||||
|
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||||
|
renewalTrafficGb: null,
|
||||||
|
topupPricingMode: "PER_GB",
|
||||||
|
topupPricePerGb: null,
|
||||||
|
topupFixedPrice: null,
|
||||||
|
minTopupGb: null,
|
||||||
|
maxTopupGb: null,
|
||||||
|
streamingServiceId: data.streamingServiceId,
|
||||||
|
categoryId: null,
|
||||||
|
pricingMode: "TRAFFIC_SLIDER",
|
||||||
|
fixedTrafficGb: null,
|
||||||
|
fixedPrice: null,
|
||||||
|
price: data.price ?? 0,
|
||||||
|
nodeId: null,
|
||||||
|
inboundId: null,
|
||||||
|
pricePerGb: null,
|
||||||
|
minTrafficGb: null,
|
||||||
|
maxTrafficGb: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "plan.create",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: plan.id,
|
||||||
|
targetLabel: plan.name,
|
||||||
|
message: `创建流媒体套餐 ${plan.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlan(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const raw = Object.fromEntries(formData);
|
||||||
|
const data = planSchema.parse(raw);
|
||||||
|
const allowRenewal = data.allowRenewal ?? false;
|
||||||
|
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||||
|
const existing = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: { type: true, nodeId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.type !== data.type) {
|
||||||
|
throw new Error("暂不支持修改套餐类型,请新建套餐");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||||
|
throw new Error("总量上限必须大于 0");
|
||||||
|
}
|
||||||
|
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||||
|
throw new Error("每用户限购必须大于 0");
|
||||||
|
}
|
||||||
|
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||||
|
throw new Error("总流量池必须大于 0");
|
||||||
|
}
|
||||||
|
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||||
|
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||||
|
|
||||||
|
if (data.type === "PROXY") {
|
||||||
|
const pricingMode = assertProxyPricing(data);
|
||||||
|
const nodeId = data.nodeId ?? existing.nodeId;
|
||||||
|
|
||||||
|
if (!nodeId) throw new Error("代理套餐必须选择节点");
|
||||||
|
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
|
||||||
|
throw new Error("代理套餐必须填写总流量池,且大于 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||||
|
if (inboundIds.length === 0) {
|
||||||
|
throw new Error("请至少配置一个可售入站");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inbounds = await prisma.nodeInbound.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: inboundIds },
|
||||||
|
},
|
||||||
|
select: { id: true, serverId: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (inbounds.length !== inboundIds.length) {
|
||||||
|
throw new Error("存在无效入站,请重新选择");
|
||||||
|
}
|
||||||
|
for (const inbound of inbounds) {
|
||||||
|
if (inbound.serverId !== nodeId) {
|
||||||
|
throw new Error("入站与节点不匹配");
|
||||||
|
}
|
||||||
|
if (!inbound.isActive) {
|
||||||
|
throw new Error("入站未启用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.subscriptionPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
durationDays: data.durationDays,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
totalLimit: data.totalLimit ?? null,
|
||||||
|
perUserLimit: data.perUserLimit ?? null,
|
||||||
|
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||||
|
allowRenewal,
|
||||||
|
allowTrafficTopup,
|
||||||
|
renewalPrice: renewalPolicy.renewalPrice,
|
||||||
|
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||||
|
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||||
|
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||||
|
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||||
|
renewalTrafficGb: null,
|
||||||
|
topupPricingMode: topupPolicy.topupPricingMode,
|
||||||
|
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||||
|
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||||
|
minTopupGb: topupPolicy.minTopupGb,
|
||||||
|
maxTopupGb: topupPolicy.maxTopupGb,
|
||||||
|
nodeId,
|
||||||
|
inboundId: inboundIds[0],
|
||||||
|
streamingServiceId: null,
|
||||||
|
categoryId: null,
|
||||||
|
pricingMode,
|
||||||
|
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||||
|
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||||
|
price: null,
|
||||||
|
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||||
|
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||||
|
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.planInboundOption.deleteMany({ where: { planId: id } });
|
||||||
|
await tx.planInboundOption.createMany({
|
||||||
|
data: inboundIds.map((inboundId) => ({ planId: id, inboundId })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "plan.update",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: id,
|
||||||
|
targetLabel: data.name,
|
||||||
|
message: `更新代理套餐 ${data.name}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!data.streamingServiceId) {
|
||||||
|
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await prisma.streamingService.findUnique({
|
||||||
|
where: { id: data.streamingServiceId },
|
||||||
|
select: { id: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (!service || !service.isActive) {
|
||||||
|
throw new Error("所选流媒体服务不存在或未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.subscriptionPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
durationDays: data.durationDays,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
totalLimit: data.totalLimit ?? null,
|
||||||
|
perUserLimit: data.perUserLimit ?? null,
|
||||||
|
totalTrafficGb: null,
|
||||||
|
allowRenewal,
|
||||||
|
allowTrafficTopup: false,
|
||||||
|
renewalPrice: renewalPolicy.renewalPrice,
|
||||||
|
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||||
|
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||||
|
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||||
|
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||||
|
renewalTrafficGb: null,
|
||||||
|
topupPricingMode: "PER_GB",
|
||||||
|
topupPricePerGb: null,
|
||||||
|
topupFixedPrice: null,
|
||||||
|
minTopupGb: null,
|
||||||
|
maxTopupGb: null,
|
||||||
|
streamingServiceId: data.streamingServiceId,
|
||||||
|
categoryId: null,
|
||||||
|
pricingMode: "TRAFFIC_SLIDER",
|
||||||
|
fixedTrafficGb: null,
|
||||||
|
fixedPrice: null,
|
||||||
|
price: data.price,
|
||||||
|
nodeId: null,
|
||||||
|
inboundId: null,
|
||||||
|
pricePerGb: null,
|
||||||
|
minTrafficGb: null,
|
||||||
|
maxTrafficGb: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.planInboundOption.deleteMany({ where: { planId: id } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "plan.update",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: id,
|
||||||
|
targetLabel: data.name,
|
||||||
|
message: `更新流媒体套餐 ${data.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlan(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const plan = await prisma.subscriptionPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "plan.disable",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: plan.id,
|
||||||
|
targetLabel: plan.name,
|
||||||
|
message: `下架套餐 ${plan.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlanPermanently(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
inboundOptions: {
|
||||||
|
include: {
|
||||||
|
inbound: {
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
clients: true,
|
||||||
|
planOptions: true,
|
||||||
|
plans: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscriptions: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subscription of plan.subscriptions) {
|
||||||
|
await deleteSubscriptionPermanently(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relatedOrders = await prisma.order.findMany({
|
||||||
|
where: { planId: plan.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.order.deleteMany({
|
||||||
|
where: { planId: plan.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletableInboundIds: string[] = [];
|
||||||
|
for (const option of plan.inboundOptions) {
|
||||||
|
const inbound = option.inbound;
|
||||||
|
const otherPlanRefs = inbound._count.planOptions - 1;
|
||||||
|
const otherPlanDirectRefs = inbound._count.plans - (plan.inboundId === inbound.id ? 1 : 0);
|
||||||
|
const hasClients = inbound._count.clients > 0;
|
||||||
|
|
||||||
|
if (otherPlanRefs <= 0 && otherPlanDirectRefs <= 0 && !hasClients) {
|
||||||
|
deletableInboundIds.push(inbound.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.planInboundOption.deleteMany({
|
||||||
|
where: { planId: plan.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletableInboundIds.length > 0) {
|
||||||
|
await tx.nodeInbound.deleteMany({
|
||||||
|
where: { id: { in: deletableInboundIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.subscriptionPlan.delete({
|
||||||
|
where: { id: plan.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "plan.delete",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: plan.id,
|
||||||
|
targetLabel: plan.name,
|
||||||
|
message: `彻底删除套餐 ${plan.name}`,
|
||||||
|
metadata: {
|
||||||
|
deletedOrderIds: relatedOrders.map((order) => order.id),
|
||||||
|
deletedInboundIds: deletableInboundIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function togglePlan(id: string, isActive: boolean) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const plan = await prisma.subscriptionPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: isActive ? "plan.enable" : "plan.disable",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
targetId: plan.id,
|
||||||
|
targetLabel: plan.name,
|
||||||
|
message: `${isActive ? "上架" : "下架"}套餐 ${plan.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchPlanOperation(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const action = String(formData.get("action") || "");
|
||||||
|
const planIds = formData.getAll("planIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (planIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个套餐");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "enable" || action === "disable") {
|
||||||
|
const isActive = action === "enable";
|
||||||
|
await prisma.subscriptionPlan.updateMany({
|
||||||
|
where: { id: { in: planIds } },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: isActive ? "plan.batch_enable" : "plan.batch_disable",
|
||||||
|
targetType: "SubscriptionPlan",
|
||||||
|
message: `${isActive ? "批量上架" : "批量下架"} ${planIds.length} 个套餐`,
|
||||||
|
metadata: { planIds },
|
||||||
|
});
|
||||||
|
} else if (action === "delete") {
|
||||||
|
for (const planId of planIds) {
|
||||||
|
await deletePlanPermanently(planId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error("不支持的批量操作");
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
153
src/actions/admin/services.ts
Normal file
153
src/actions/admin/services.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { encrypt } from "@/lib/crypto";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
|
||||||
|
const serviceSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
credentials: z.string().min(1),
|
||||||
|
maxSlots: z.coerce.number().int().positive(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createService(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = serviceSchema.parse(Object.fromEntries(formData));
|
||||||
|
const service = await prisma.streamingService.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
credentials: encrypt(data.credentials),
|
||||||
|
maxSlots: data.maxSlots,
|
||||||
|
description: data.description || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "service.create",
|
||||||
|
targetType: "StreamingService",
|
||||||
|
targetId: service.id,
|
||||||
|
targetLabel: service.name,
|
||||||
|
message: `创建流媒体服务 ${service.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateService(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = serviceSchema.parse(Object.fromEntries(formData));
|
||||||
|
const affectedUsers = await prisma.streamingSlot.findMany({
|
||||||
|
where: { serviceId: id },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ["userId"],
|
||||||
|
});
|
||||||
|
const service = await prisma.streamingService.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
credentials: encrypt(data.credentials),
|
||||||
|
maxSlots: data.maxSlots,
|
||||||
|
description: data.description || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const row of affectedUsers) {
|
||||||
|
await createNotification({
|
||||||
|
userId: row.userId,
|
||||||
|
type: "SYSTEM",
|
||||||
|
level: "INFO",
|
||||||
|
title: "流媒体凭据已更新",
|
||||||
|
body: `${service.name} 的共享凭据已更新,请重新查看最新账号信息。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
dedupeKey: `service-credential-update:${service.id}:${row.userId}:${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "service.update",
|
||||||
|
targetType: "StreamingService",
|
||||||
|
targetId: service.id,
|
||||||
|
targetLabel: service.name,
|
||||||
|
message: `更新流媒体服务 ${service.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteService(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const service = await prisma.streamingService.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { slots: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (service._count.slots > 0) {
|
||||||
|
throw new Error(`该服务仍有 ${service._count.slots} 个关联槽位,请先清理后再删除`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.streamingService.delete({ where: { id } });
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "service.delete",
|
||||||
|
targetType: "StreamingService",
|
||||||
|
targetId: service.id,
|
||||||
|
targetLabel: service.name,
|
||||||
|
message: `彻底删除流媒体服务 ${service.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleServiceStatus(id: string, isActive: boolean) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const service = await prisma.streamingService.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: isActive ? "service.enable" : "service.disable",
|
||||||
|
targetType: "StreamingService",
|
||||||
|
targetId: service.id,
|
||||||
|
targetLabel: service.name,
|
||||||
|
message: `${isActive ? "启用" : "停用"}流媒体服务 ${service.name}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchToggleServiceStatus(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const isActive = String(formData.get("isActive")) === "true";
|
||||||
|
const serviceIds = formData.getAll("serviceIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (serviceIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = await prisma.streamingService.findMany({
|
||||||
|
where: { id: { in: serviceIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.streamingService.updateMany({
|
||||||
|
where: { id: { in: serviceIds } },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: isActive ? "service.batch_enable" : "service.batch_disable",
|
||||||
|
targetType: "StreamingService",
|
||||||
|
message: `${isActive ? "批量启用" : "批量停用"} ${services.length} 个流媒体服务`,
|
||||||
|
metadata: {
|
||||||
|
serviceIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
79
src/actions/admin/settings.ts
Normal file
79
src/actions/admin/settings.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { getAppConfig } from "@/services/app-config";
|
||||||
|
import { normalizeSiteUrl } from "@/services/site-url";
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
siteName: z.string().trim().min(1, "站点名称不能为空"),
|
||||||
|
siteUrl: z.string().trim().optional(),
|
||||||
|
supportContact: z.string().trim().optional(),
|
||||||
|
maintenanceNotice: z.string().trim().optional(),
|
||||||
|
siteNotice: z.string().trim().optional(),
|
||||||
|
allowRegistration: z.string().optional(),
|
||||||
|
requireInviteCode: z.string().optional(),
|
||||||
|
autoReminderDispatchEnabled: z.string().optional(),
|
||||||
|
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||||
|
trafficSyncEnabled: z.string().optional(),
|
||||||
|
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
||||||
|
inviteRewardEnabled: z.string().optional(),
|
||||||
|
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||||
|
inviteRewardCouponId: z.string().trim().optional(),
|
||||||
|
turnstileSiteKey: z.string().trim().optional(),
|
||||||
|
turnstileSecretKey: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function saveAppSettings(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const parsed = settingsSchema.parse(Object.fromEntries(formData));
|
||||||
|
const current = await getAppConfig();
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
siteName: parsed.siteName,
|
||||||
|
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
|
||||||
|
supportContact: parsed.supportContact || null,
|
||||||
|
maintenanceNotice: parsed.maintenanceNotice || null,
|
||||||
|
siteNotice: parsed.siteNotice || null,
|
||||||
|
allowRegistration: parsed.allowRegistration === "true",
|
||||||
|
requireInviteCode: parsed.requireInviteCode === "true",
|
||||||
|
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
|
||||||
|
reminderDispatchIntervalMinutes:
|
||||||
|
parsed.reminderDispatchIntervalMinutes ?? current.reminderDispatchIntervalMinutes,
|
||||||
|
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
|
||||||
|
trafficSyncIntervalSeconds:
|
||||||
|
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
||||||
|
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
|
||||||
|
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||||
|
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||||
|
turnstileSiteKey: parsed.turnstileSiteKey || null,
|
||||||
|
turnstileSecretKey: parsed.turnstileSecretKey || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { id: current.id },
|
||||||
|
create: { id: current.id, ...next },
|
||||||
|
update: next,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "settings.update",
|
||||||
|
targetType: "AppConfig",
|
||||||
|
targetId: current.id,
|
||||||
|
targetLabel: next.siteName,
|
||||||
|
message: "更新系统设置",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
revalidatePath("/login");
|
||||||
|
revalidatePath("/register");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/subscriptions");
|
||||||
|
revalidatePath("/admin/nodes");
|
||||||
|
revalidatePath("/account");
|
||||||
|
revalidatePath("/admin/commerce");
|
||||||
|
}
|
||||||
452
src/actions/admin/subscriptions.ts
Normal file
452
src/actions/admin/subscriptions.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
|
||||||
|
async function setProxyClientEnabled(subscriptionId: string, enable: boolean) {
|
||||||
|
const client = await prisma.nodeClient.findUnique({
|
||||||
|
where: { subscriptionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
inbound: {
|
||||||
|
select: {
|
||||||
|
serverId: true,
|
||||||
|
panelInboundId: true,
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.inbound.panelInboundId == null) {
|
||||||
|
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = createPanelAdapter(client.inbound.server);
|
||||||
|
await adapter.login();
|
||||||
|
await adapter.updateClientEnable(client.inbound.panelInboundId, client.uuid, enable);
|
||||||
|
|
||||||
|
await prisma.nodeClient.update({
|
||||||
|
where: { id: client.id },
|
||||||
|
data: { isEnabled: enable },
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.inbound.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hardDeleteProxyClient(subscriptionId: string) {
|
||||||
|
const client = await prisma.nodeClient.findUnique({
|
||||||
|
where: { subscriptionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
inbound: {
|
||||||
|
select: {
|
||||||
|
serverId: true,
|
||||||
|
panelInboundId: true,
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.inbound.panelInboundId == null) {
|
||||||
|
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = createPanelAdapter(client.inbound.server);
|
||||||
|
await adapter.login();
|
||||||
|
await adapter.deleteClient(client.inbound.panelInboundId, client.uuid);
|
||||||
|
|
||||||
|
await prisma.nodeClient.delete({
|
||||||
|
where: { id: client.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.inbound.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hardDeleteSubscriptionInternal(
|
||||||
|
subscriptionId: string,
|
||||||
|
options: {
|
||||||
|
actor: ReturnType<typeof actorFromSession>;
|
||||||
|
revalidate?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
user: true,
|
||||||
|
streamingSlot: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.plan.type === "PROXY") {
|
||||||
|
await hardDeleteProxyClient(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (subscription.streamingSlot) {
|
||||||
|
await tx.streamingSlot.delete({
|
||||||
|
where: { id: subscription.streamingSlot.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.streamingService.updateMany({
|
||||||
|
where: {
|
||||||
|
id: subscription.streamingSlot.serviceId,
|
||||||
|
usedSlots: { gt: 0 },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usedSlots: { decrement: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.order.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ targetSubscriptionId: subscription.id },
|
||||||
|
{ subscriptionId: subscription.id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.userSubscription.delete({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "WARNING",
|
||||||
|
title: "订阅已被删除",
|
||||||
|
body: `${subscription.plan.name} 已被管理员彻底删除。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: options.actor,
|
||||||
|
action: "subscription.delete",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: subscription.id,
|
||||||
|
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||||
|
message: `彻底删除订阅 ${subscription.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.revalidate !== false) {
|
||||||
|
revalidateSubscriptionViews();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateSubscriptionViews() {
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
|
revalidatePath("/admin/traffic");
|
||||||
|
revalidatePath("/subscriptions");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suspendSubscription(subscriptionId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.status !== "ACTIVE") {
|
||||||
|
throw new Error("仅活跃订阅可暂停");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.plan.type === "PROXY") {
|
||||||
|
await setProxyClientEnabled(subscription.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userSubscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { status: "SUSPENDED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "WARNING",
|
||||||
|
title: "订阅已暂停",
|
||||||
|
body: `${subscription.plan.name} 已被管理员暂停,如有疑问请联系管理员。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "subscription.suspend",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: subscription.id,
|
||||||
|
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||||
|
message: `暂停订阅 ${subscription.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateSubscriptionViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateSubscription(subscriptionId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.status !== "SUSPENDED") {
|
||||||
|
throw new Error("仅已暂停订阅可恢复");
|
||||||
|
}
|
||||||
|
if (subscription.endDate <= new Date()) {
|
||||||
|
throw new Error("订阅已过期,无法恢复");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.plan.type === "PROXY") {
|
||||||
|
await setProxyClientEnabled(subscription.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userSubscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { status: "ACTIVE" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "SUCCESS",
|
||||||
|
title: "订阅已恢复",
|
||||||
|
body: `${subscription.plan.name} 已恢复为可用状态。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "subscription.activate",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: subscription.id,
|
||||||
|
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||||
|
message: `恢复订阅 ${subscription.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateSubscriptionViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelSubscription(subscriptionId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
user: true,
|
||||||
|
streamingSlot: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.status === "CANCELLED") {
|
||||||
|
throw new Error("订阅已取消");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.plan.type === "PROXY") {
|
||||||
|
await setProxyClientEnabled(subscription.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (subscription.streamingSlot) {
|
||||||
|
await tx.streamingSlot.delete({
|
||||||
|
where: { id: subscription.streamingSlot.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.streamingService.update({
|
||||||
|
where: { id: subscription.streamingSlot.serviceId },
|
||||||
|
data: {
|
||||||
|
usedSlots: {
|
||||||
|
decrement: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.userSubscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { status: "CANCELLED" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "WARNING",
|
||||||
|
title: "订阅已取消",
|
||||||
|
body: `${subscription.plan.name} 已被管理员取消。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "subscription.cancel",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: subscription.id,
|
||||||
|
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||||
|
message: `取消订阅 ${subscription.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateSubscriptionViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSubscriptionPermanently(subscriptionId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
await hardDeleteSubscriptionInternal(subscriptionId, {
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reassignStreamingSlot(
|
||||||
|
subscriptionId: string,
|
||||||
|
targetServiceId: string,
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
plan: true,
|
||||||
|
streamingSlot: {
|
||||||
|
include: {
|
||||||
|
service: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription.plan.type !== "STREAMING") {
|
||||||
|
throw new Error("仅流媒体订阅支持调配槽位");
|
||||||
|
}
|
||||||
|
if (subscription.status === "CANCELLED" || subscription.status === "EXPIRED") {
|
||||||
|
throw new Error("当前订阅状态不支持调配槽位");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetService = await prisma.streamingService.findUniqueOrThrow({
|
||||||
|
where: { id: targetServiceId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
isActive: true,
|
||||||
|
usedSlots: true,
|
||||||
|
maxSlots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetService.isActive) {
|
||||||
|
throw new Error("目标流媒体服务未启用");
|
||||||
|
}
|
||||||
|
if (subscription.streamingSlot?.serviceId === targetService.id) {
|
||||||
|
throw new Error("已在当前服务上,无需重复调配");
|
||||||
|
}
|
||||||
|
if (targetService.usedSlots >= targetService.maxSlots) {
|
||||||
|
throw new Error("目标流媒体服务已满");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (subscription.streamingSlot) {
|
||||||
|
await tx.streamingSlot.update({
|
||||||
|
where: { id: subscription.streamingSlot.id },
|
||||||
|
data: {
|
||||||
|
serviceId: targetService.id,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.streamingService.updateMany({
|
||||||
|
where: {
|
||||||
|
id: subscription.streamingSlot.serviceId,
|
||||||
|
usedSlots: { gt: 0 },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
usedSlots: { decrement: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.streamingService.update({
|
||||||
|
where: { id: targetService.id },
|
||||||
|
data: {
|
||||||
|
usedSlots: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.streamingSlot.create({
|
||||||
|
data: {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
userId: subscription.userId,
|
||||||
|
serviceId: targetService.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.streamingService.update({
|
||||||
|
where: { id: targetService.id },
|
||||||
|
data: {
|
||||||
|
usedSlots: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "INFO",
|
||||||
|
title: "流媒体服务已调整",
|
||||||
|
body: `${subscription.plan.name} 已调整到服务 ${targetService.name}。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "streaming-slot.reassign",
|
||||||
|
targetType: "StreamingSlot",
|
||||||
|
targetId: subscription.streamingSlot?.id ?? subscription.id,
|
||||||
|
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||||
|
message: `将流媒体订阅 ${subscription.plan.name} 调配到 ${targetService.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateSubscriptionViews();
|
||||||
|
revalidatePath("/admin/services");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchSubscriptionOperation(formData: FormData) {
|
||||||
|
const action = String(formData.get("action") || "");
|
||||||
|
const subscriptionIds = formData.getAll("subscriptionIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (subscriptionIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个订阅");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subscriptionId of subscriptionIds) {
|
||||||
|
if (action === "suspend") {
|
||||||
|
await suspendSubscription(subscriptionId);
|
||||||
|
} else if (action === "activate") {
|
||||||
|
await activateSubscription(subscriptionId);
|
||||||
|
} else if (action === "cancel") {
|
||||||
|
await cancelSubscription(subscriptionId);
|
||||||
|
} else if (action === "delete") {
|
||||||
|
await deleteSubscriptionPermanently(subscriptionId);
|
||||||
|
} else {
|
||||||
|
throw new Error("不支持的批量操作");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/actions/admin/support.ts
Normal file
158
src/actions/admin/support.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import {
|
||||||
|
createSupportAttachments,
|
||||||
|
deleteSupportTicketRecords,
|
||||||
|
parseSupportAttachments,
|
||||||
|
} from "@/services/support";
|
||||||
|
|
||||||
|
const replySchema = z.object({
|
||||||
|
body: z.string().trim().min(1, "回复内容不能为空"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const supportStatusSchema = z.enum(["OPEN", "USER_REPLIED", "ADMIN_REPLIED", "CLOSED"]);
|
||||||
|
const supportPrioritySchema = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
|
||||||
|
|
||||||
|
export async function replySupportAsAdmin(ticketId: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = replySchema.parse(Object.fromEntries(formData));
|
||||||
|
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||||
|
|
||||||
|
const ticket = await prisma.supportTicket.findUniqueOrThrow({
|
||||||
|
where: { id: ticketId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (ticket.status === "CLOSED") {
|
||||||
|
throw new Error("已关闭的工单不能继续回复,请先重新打开");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.supportTicket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: {
|
||||||
|
status: "ADMIN_REPLIED",
|
||||||
|
closedAt: null,
|
||||||
|
lastReplyAt: new Date(),
|
||||||
|
replies: {
|
||||||
|
create: {
|
||||||
|
authorUserId: session.user.id,
|
||||||
|
isAdmin: true,
|
||||||
|
body: data.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const createdReply = updated.replies[0];
|
||||||
|
if (createdReply && attachments.length > 0) {
|
||||||
|
await createSupportAttachments({
|
||||||
|
ticketId: ticket.id,
|
||||||
|
replyId: createdReply.id,
|
||||||
|
files: attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: ticket.user.id,
|
||||||
|
type: "SYSTEM",
|
||||||
|
level: "INFO",
|
||||||
|
title: "工单有新回复",
|
||||||
|
body: `管理员已回复工单「${ticket.subject}」。`,
|
||||||
|
link: `/support/${ticket.id}`,
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "support.reply",
|
||||||
|
targetType: "SupportTicket",
|
||||||
|
targetId: ticket.id,
|
||||||
|
targetLabel: ticket.subject,
|
||||||
|
message: `回复工单 ${ticket.subject}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSupportTicketMeta(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const ticketId = String(formData.get("ticketId") || "");
|
||||||
|
const status = supportStatusSchema.parse(String(formData.get("status") || ""));
|
||||||
|
const priority = supportPrioritySchema.parse(String(formData.get("priority") || ""));
|
||||||
|
|
||||||
|
if (!ticketId) {
|
||||||
|
throw new Error("工单 ID 缺失");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await prisma.supportTicket.update({
|
||||||
|
where: { id: ticketId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
closedAt: status === "CLOSED" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "support.update",
|
||||||
|
targetType: "SupportTicket",
|
||||||
|
targetId: ticket.id,
|
||||||
|
targetLabel: ticket.subject,
|
||||||
|
message: `更新工单 ${ticket.subject} 状态/优先级`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSupportTicketAsAdmin(ticketId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const ticket = await prisma.supportTicket.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("工单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await deleteSupportTicketRecords(ticket.id, tx);
|
||||||
|
await recordAuditLog(
|
||||||
|
{
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "support.delete",
|
||||||
|
targetType: "SupportTicket",
|
||||||
|
targetId: ticket.id,
|
||||||
|
targetLabel: ticket.subject,
|
||||||
|
message: `管理员删除工单 ${ticket.subject}`,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
110
src/actions/admin/tasks.ts
Normal file
110
src/actions/admin/tasks.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { dispatchSubscriptionReminders } from "@/services/notifications";
|
||||||
|
import { confirmPendingOrder } from "@/services/payment/process";
|
||||||
|
import { runTask, updateTaskRun } from "@/services/task-center";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function revalidateTaskViews() {
|
||||||
|
revalidatePath("/admin/tasks");
|
||||||
|
revalidatePath("/admin/health");
|
||||||
|
revalidatePath("/admin/traffic");
|
||||||
|
revalidatePath("/admin/audit-logs");
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReminderTask() {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
|
||||||
|
const outcome = await runTask(
|
||||||
|
{
|
||||||
|
kind: "REMINDER_DISPATCH",
|
||||||
|
title: "手动派发提醒任务",
|
||||||
|
triggeredById: session.user.id,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await dispatchSubscriptionReminders();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "task.run",
|
||||||
|
targetType: "TaskRun",
|
||||||
|
targetId: outcome.taskId,
|
||||||
|
targetLabel: "REMINDER_DISPATCH",
|
||||||
|
message: "手动执行提醒派发任务",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateTaskViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryTaskRun(taskId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
const task = await prisma.taskRun.findUniqueOrThrow({
|
||||||
|
where: { id: taskId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateTaskRun(task.id, {
|
||||||
|
status: "RUNNING",
|
||||||
|
errorMessage: null,
|
||||||
|
startedAt: new Date(),
|
||||||
|
retryCountIncrement: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: unknown = { ok: true };
|
||||||
|
|
||||||
|
if (task.kind === "ORDER_PROVISION_RETRY") {
|
||||||
|
const orderId = (task.payload as { orderId?: string } | null)?.orderId;
|
||||||
|
if (!orderId) {
|
||||||
|
throw new Error("任务缺少订单 ID");
|
||||||
|
}
|
||||||
|
result = await confirmPendingOrder(orderId);
|
||||||
|
} else if (task.kind === "REMINDER_DISPATCH") {
|
||||||
|
await dispatchSubscriptionReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTaskRun(task.id, {
|
||||||
|
status: "SUCCESS",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
result: result as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "task.retry",
|
||||||
|
targetType: "TaskRun",
|
||||||
|
targetId: task.id,
|
||||||
|
targetLabel: task.kind,
|
||||||
|
message: `重试任务 ${task.title}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await updateTaskRun(task.id, {
|
||||||
|
status: "FAILED",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
errorMessage: error instanceof Error ? error.message : "重试失败",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateTaskViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchRetryTaskRuns(formData: FormData) {
|
||||||
|
const taskIds = formData.getAll("taskIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (taskIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const taskId of taskIds) {
|
||||||
|
await retryTaskRun(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/actions/admin/traffic.ts
Normal file
35
src/actions/admin/traffic.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { syncNodeClientTraffic } from "@/services/traffic-sync";
|
||||||
|
|
||||||
|
export async function syncTrafficViews() {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const result = await syncNodeClientTraffic({ maxAgeMs: 0 });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "traffic.sync",
|
||||||
|
targetType: "TrafficSync",
|
||||||
|
message: `同步 3x-ui 流量:成功 ${result.synced} 个,失败 ${result.failed} 个`,
|
||||||
|
metadata: {
|
||||||
|
scanned: result.scanned,
|
||||||
|
synced: result.synced,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
uploadDelta: result.uploadDelta,
|
||||||
|
downloadDelta: result.downloadDelta,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/traffic");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/subscriptions");
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revalidateTrafficViews() {
|
||||||
|
return syncTrafficViews();
|
||||||
|
}
|
||||||
134
src/actions/admin/users.ts
Normal file
134
src/actions/admin/users.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
name: z.string().optional(),
|
||||||
|
role: z.enum(["ADMIN", "USER"]).default("USER"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
role: z.enum(["ADMIN", "USER"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createUser(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = createUserSchema.parse(Object.fromEntries(formData));
|
||||||
|
const hashed = await bcrypt.hash(data.password, 12);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: data.email, password: hashed, name: data.name || null, role: data.role },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "user.create",
|
||||||
|
targetType: "User",
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.email,
|
||||||
|
message: `创建用户 ${user.email}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = updateUserSchema.parse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
const updateData: {
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: "ADMIN" | "USER";
|
||||||
|
password?: string;
|
||||||
|
} = {
|
||||||
|
email: data.email,
|
||||||
|
name: data.name || null,
|
||||||
|
role: data.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.password && data.password.trim()) {
|
||||||
|
updateData.password = await bcrypt.hash(data.password.trim(), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "user.update",
|
||||||
|
targetType: "User",
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.email,
|
||||||
|
message: `更新用户 ${user.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const user = await prisma.user.update({ where: { id }, data: { status } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "user.status",
|
||||||
|
targetType: "User",
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.email,
|
||||||
|
message: `将用户 ${user.email} 状态改为 ${status}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const user = await prisma.user.delete({ where: { id } });
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "user.delete",
|
||||||
|
targetType: "User",
|
||||||
|
targetId: user.id,
|
||||||
|
targetLabel: user.email,
|
||||||
|
message: `删除用户 ${user.email}`,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchUpdateUserStatus(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const status = formData.get("status");
|
||||||
|
const userIds = formData.getAll("userIds").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
if (!status || !["ACTIVE", "DISABLED", "BANNED"].includes(String(status))) {
|
||||||
|
throw new Error("批量状态无效");
|
||||||
|
}
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个用户");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
data: { status: status as "ACTIVE" | "DISABLED" | "BANNED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "user.batch_status",
|
||||||
|
targetType: "User",
|
||||||
|
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
|
||||||
|
metadata: {
|
||||||
|
userIds,
|
||||||
|
status: String(status),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
}
|
||||||
85
src/actions/user/account.ts
Normal file
85
src/actions/user/account.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "昵称不能为空").max(50, "昵称不能超过 50 个字符"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(6, "当前密码不能为空"),
|
||||||
|
newPassword: z.string().min(6, "新密码至少 6 位"),
|
||||||
|
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function generateUniqueInviteCode(): Promise<string> {
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const code = randomBytes(4).toString("hex").toUpperCase();
|
||||||
|
const exists = await prisma.user.findUnique({
|
||||||
|
where: { inviteCode: code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!exists) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("邀请码生成失败,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccountProfile(formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = profileSchema.parse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { name: data.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/account");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeAccountPassword(formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = passwordSchema.parse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
if (data.newPassword !== data.confirmPassword) {
|
||||||
|
throw new Error("两次输入的新密码不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { password: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(data.currentPassword, user.password);
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error("当前密码不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(data.newPassword, 12);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { password: hashed },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/account");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateInviteCode() {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const code = await generateUniqueInviteCode();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { inviteCode: code },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/account");
|
||||||
|
return code;
|
||||||
|
}
|
||||||
266
src/actions/user/cart.ts
Normal file
266
src/actions/user/cart.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
|
||||||
|
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
|
||||||
|
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
|
||||||
|
|
||||||
|
async function assertNoPendingOrder(userId: string) {
|
||||||
|
const pendingOrder = await prisma.order.findFirst({
|
||||||
|
where: { userId, status: "PENDING" },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingOrder) {
|
||||||
|
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProxyPlanForCart(planId: string) {
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id: planId },
|
||||||
|
include: {
|
||||||
|
inboundOptions: {
|
||||||
|
include: {
|
||||||
|
inbound: {
|
||||||
|
select: { id: true, serverId: true, isActive: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||||
|
if (!plan.isActive) throw new Error("套餐已下架");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInboundSelectable(
|
||||||
|
plan: Awaited<ReturnType<typeof getProxyPlanForCart>>,
|
||||||
|
selectedInboundId: string,
|
||||||
|
) {
|
||||||
|
const selectableInboundIds = plan.inboundOptions
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.inbound.isActive
|
||||||
|
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||||
|
)
|
||||||
|
.map((item) => item.inboundId);
|
||||||
|
|
||||||
|
const selectable = selectableInboundIds.length > 0
|
||||||
|
? selectableInboundIds
|
||||||
|
: plan.inboundId
|
||||||
|
? [plan.inboundId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!selectedInboundId || !selectable.includes(selectedInboundId)) {
|
||||||
|
throw new Error("请选择有效的线路入口");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProxyPlanToCart(
|
||||||
|
planId: string,
|
||||||
|
trafficGb: number,
|
||||||
|
selectedInboundId: string,
|
||||||
|
) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const plan = await getProxyPlanForCart(planId);
|
||||||
|
assertInboundSelectable(plan, selectedInboundId);
|
||||||
|
|
||||||
|
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||||
|
if (price.trafficGb != null) {
|
||||||
|
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||||
|
messagePrefix: "这款套餐额度暂时不足",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new Error(buildUnavailableMessage(availability));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.shoppingCartItem.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId,
|
||||||
|
selectedInboundId,
|
||||||
|
trafficGb: price.trafficGb,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.shoppingCartItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.shoppingCartItem.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId,
|
||||||
|
selectedInboundId,
|
||||||
|
trafficGb: price.trafficGb,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/cart");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addStreamingPlanToCart(planId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
||||||
|
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||||
|
if (!plan.isActive) throw new Error("套餐已下架");
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new Error(buildUnavailableMessage(availability));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.shoppingCartItem.findFirst({
|
||||||
|
where: { userId: session.user.id, planId, selectedInboundId: null, trafficGb: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.shoppingCartItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.shoppingCartItem.create({ data: { userId: session.user.id, planId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/cart");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCartItem(itemId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await prisma.shoppingCartItem.deleteMany({
|
||||||
|
where: { id: itemId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
revalidatePath("/cart");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCart() {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await prisma.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||||
|
revalidatePath("/cart");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkoutCart(couponCode?: string | null): Promise<string> {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
|
const items = await prisma.shoppingCartItem.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
selectedInbound: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) throw new Error("购物车还是空的");
|
||||||
|
|
||||||
|
const orderItems: Array<{
|
||||||
|
planId: string;
|
||||||
|
selectedInboundId: string | null;
|
||||||
|
trafficGb: number | null;
|
||||||
|
unitAmount: number;
|
||||||
|
amount: number;
|
||||||
|
}> = [];
|
||||||
|
const trafficByPlan = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.plan.isActive) throw new Error(`${item.plan.name} 已下架,请先移出购物车`);
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(item.plan, { userId: session.user.id });
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new Error(`${item.plan.name}:${buildUnavailableMessage(availability)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.plan.type === "PROXY") {
|
||||||
|
if (!item.selectedInboundId) throw new Error(`${item.plan.name} 缺少线路入口`);
|
||||||
|
const plan = await getProxyPlanForCart(item.planId);
|
||||||
|
assertInboundSelectable(plan, item.selectedInboundId);
|
||||||
|
const price = getPlanPurchasePrice(item.plan, item.trafficGb);
|
||||||
|
if (!price.trafficGb) throw new Error(`${item.plan.name} 缺少流量配置`);
|
||||||
|
trafficByPlan.set(item.planId, (trafficByPlan.get(item.planId) ?? 0) + price.trafficGb);
|
||||||
|
orderItems.push({
|
||||||
|
planId: item.planId,
|
||||||
|
selectedInboundId: item.selectedInboundId,
|
||||||
|
trafficGb: price.trafficGb,
|
||||||
|
unitAmount: price.unitAmount,
|
||||||
|
amount: price.amount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const price = getPlanPurchasePrice(item.plan);
|
||||||
|
orderItems.push({
|
||||||
|
planId: item.planId,
|
||||||
|
selectedInboundId: null,
|
||||||
|
trafficGb: null,
|
||||||
|
unitAmount: price.unitAmount,
|
||||||
|
amount: price.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [planId, trafficGb] of trafficByPlan) {
|
||||||
|
await ensurePlanTrafficPoolCapacity(planId, trafficGb, {
|
||||||
|
messagePrefix: "购物车中的代理套餐额度暂时不足",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = orderItems.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
const discounts = await calculateCheckoutDiscounts({
|
||||||
|
userId: session.user.id,
|
||||||
|
subtotal,
|
||||||
|
couponCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId: orderItems[0].planId,
|
||||||
|
kind: "NEW_PURCHASE",
|
||||||
|
amount: discounts.payable,
|
||||||
|
subtotalAmount: discounts.subtotal,
|
||||||
|
discountAmount: discounts.totalDiscount,
|
||||||
|
couponId: discounts.coupon?.id ?? null,
|
||||||
|
couponCode: discounts.coupon?.code ?? null,
|
||||||
|
promotionName: discounts.promotion?.name ?? null,
|
||||||
|
status: "PENDING",
|
||||||
|
items: {
|
||||||
|
create: orderItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (discounts.couponGrantId) {
|
||||||
|
await tx.couponGrant.update({
|
||||||
|
where: { id: discounts.couponGrantId },
|
||||||
|
data: { usedOrderId: created.id, usedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/cart");
|
||||||
|
revalidatePath("/store");
|
||||||
|
revalidatePath("/orders");
|
||||||
|
|
||||||
|
return order.id;
|
||||||
|
}
|
||||||
34
src/actions/user/notifications.ts
Normal file
34
src/actions/user/notifications.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
import {
|
||||||
|
deleteNotification,
|
||||||
|
deleteReadNotifications,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
} from "@/services/notifications";
|
||||||
|
|
||||||
|
export async function markNotificationAsRead(notificationId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await markNotificationRead(notificationId, session.user.id);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markEveryNotificationAsRead() {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await markAllNotificationsRead(session.user.id);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeNotification(notificationId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await deleteNotification(notificationId, session.user.id);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeReadNotifications() {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await deleteReadNotifications(session.user.id);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
71
src/actions/user/orders.ts
Normal file
71
src/actions/user/orders.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
|
||||||
|
async function findOwnPendingOrder(orderId: string, userId: string) {
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error("订单不存在");
|
||||||
|
}
|
||||||
|
if (order.status !== "PENDING") {
|
||||||
|
throw new Error("这笔订单已经不在待支付状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOwnPendingOrder(orderId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await findOwnPendingOrder(orderId, session.user.id);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
paymentMethod: null,
|
||||||
|
paymentRef: null,
|
||||||
|
paymentUrl: null,
|
||||||
|
tradeNo: null,
|
||||||
|
expireAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.couponGrant.updateMany({
|
||||||
|
where: { usedOrderId: orderId },
|
||||||
|
data: { usedOrderId: null, usedAt: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/orders");
|
||||||
|
revalidatePath("/store");
|
||||||
|
revalidatePath(`/pay/${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetOwnPendingPaymentChoice(orderId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
await findOwnPendingOrder(orderId, session.user.id);
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
paymentMethod: null,
|
||||||
|
paymentRef: null,
|
||||||
|
paymentUrl: null,
|
||||||
|
tradeNo: null,
|
||||||
|
expireAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/orders");
|
||||||
|
revalidatePath(`/pay/${orderId}`);
|
||||||
|
}
|
||||||
387
src/actions/user/purchase.ts
Normal file
387
src/actions/user/purchase.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
buildUnavailableMessage,
|
||||||
|
formatAvailabilityDateTime,
|
||||||
|
getPlanAvailability,
|
||||||
|
} from "@/services/plan-availability";
|
||||||
|
import {
|
||||||
|
ensurePlanTrafficPoolCapacity,
|
||||||
|
getPlanTrafficPoolState,
|
||||||
|
} from "@/services/plan-traffic-pool";
|
||||||
|
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
|
||||||
|
|
||||||
|
async function assertNoPendingOrder(userId: string) {
|
||||||
|
const pendingOrder = await prisma.order.findFirst({
|
||||||
|
where: { userId, status: "PENDING" },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingOrder) {
|
||||||
|
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRenewalOrderPrice(
|
||||||
|
plan: {
|
||||||
|
durationDays: number;
|
||||||
|
renewalPrice: unknown;
|
||||||
|
renewalPricingMode: string;
|
||||||
|
renewalDurationDays: number | null;
|
||||||
|
renewalMinDays: number | null;
|
||||||
|
renewalMaxDays: number | null;
|
||||||
|
},
|
||||||
|
requestedDays?: number,
|
||||||
|
) {
|
||||||
|
const unitPrice = Number(plan.renewalPrice ?? 0);
|
||||||
|
if (!Number.isFinite(unitPrice) || unitPrice <= 0) {
|
||||||
|
throw new Error("这款套餐暂时不支持续费");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION";
|
||||||
|
if (mode === "PER_DAY") {
|
||||||
|
const minDays = plan.renewalMinDays ?? 1;
|
||||||
|
const maxDays = plan.renewalMaxDays ?? plan.durationDays;
|
||||||
|
const durationDays = requestedDays ?? minDays;
|
||||||
|
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||||
|
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
durationDays,
|
||||||
|
amount: roundMoney(unitPrice * durationDays),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitDays = plan.renewalDurationDays ?? plan.durationDays;
|
||||||
|
const minDays = plan.renewalMinDays ?? unitDays;
|
||||||
|
const maxDays = plan.renewalMaxDays ?? unitDays;
|
||||||
|
const durationDays = requestedDays ?? unitDays;
|
||||||
|
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||||
|
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||||
|
}
|
||||||
|
if (durationDays % unitDays !== 0) {
|
||||||
|
throw new Error(`续费天数必须是 ${unitDays} 天的整数倍`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationDays,
|
||||||
|
amount: roundMoney(unitPrice * (durationDays / unitDays)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrafficTopupOrderPrice(
|
||||||
|
plan: {
|
||||||
|
topupPricingMode: string;
|
||||||
|
topupPricePerGb: unknown;
|
||||||
|
topupFixedPrice: unknown;
|
||||||
|
minTopupGb: number | null;
|
||||||
|
maxTopupGb: number | null;
|
||||||
|
pricePerGb: unknown;
|
||||||
|
},
|
||||||
|
trafficGb: number,
|
||||||
|
) {
|
||||||
|
const minTopupGb = plan.minTopupGb ?? 1;
|
||||||
|
const maxTopupGb = plan.maxTopupGb ?? null;
|
||||||
|
if (trafficGb < minTopupGb) {
|
||||||
|
throw new Error(`单次至少增加 ${minTopupGb} GB`);
|
||||||
|
}
|
||||||
|
if (maxTopupGb != null && trafficGb > maxTopupGb) {
|
||||||
|
throw new Error(`单次最多增加 ${maxTopupGb} GB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.topupPricingMode === "FIXED_AMOUNT") {
|
||||||
|
const fixedAmount = Number(plan.topupFixedPrice ?? 0);
|
||||||
|
if (!Number.isFinite(fixedAmount) || fixedAmount <= 0) {
|
||||||
|
throw new Error("这款套餐暂时不能增加流量");
|
||||||
|
}
|
||||||
|
return roundMoney(fixedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricePerGb = Number(plan.topupPricePerGb ?? plan.pricePerGb ?? 0);
|
||||||
|
if (!Number.isFinite(pricePerGb) || pricePerGb <= 0) {
|
||||||
|
throw new Error("这款套餐暂时不能增加流量");
|
||||||
|
}
|
||||||
|
return roundMoney(pricePerGb * trafficGb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseProxy(
|
||||||
|
planId: string,
|
||||||
|
trafficGb: number,
|
||||||
|
selectedInboundId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("未登录");
|
||||||
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id: planId },
|
||||||
|
include: {
|
||||||
|
inboundOptions: {
|
||||||
|
include: {
|
||||||
|
inbound: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isActive: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||||
|
if (!plan.isActive) throw new Error("套餐已下架");
|
||||||
|
|
||||||
|
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||||
|
|
||||||
|
const poolState = await getPlanTrafficPoolState(plan.id);
|
||||||
|
if (poolState.enabled && price.trafficGb != null) {
|
||||||
|
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||||
|
messagePrefix: "这款套餐额度暂时不足",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new Error(buildUnavailableMessage(availability));
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectableInboundIds = plan.inboundOptions
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.inbound.isActive
|
||||||
|
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||||
|
)
|
||||||
|
.map((item) => item.inboundId);
|
||||||
|
|
||||||
|
let fallbackInboundId = "";
|
||||||
|
if (plan.inboundId && plan.nodeId) {
|
||||||
|
const fallbackInbound = await prisma.nodeInbound.findFirst({
|
||||||
|
where: {
|
||||||
|
id: plan.inboundId,
|
||||||
|
serverId: plan.nodeId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
fallbackInboundId = fallbackInbound?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = selectableInboundIds.length > 0 ? selectableInboundIds : [fallbackInboundId];
|
||||||
|
|
||||||
|
if (!selectedInboundId || !selectable.filter(Boolean).includes(selectedInboundId)) {
|
||||||
|
throw new Error("请选择有效的线路入口");
|
||||||
|
}
|
||||||
|
if (!selectable[0]) {
|
||||||
|
throw new Error("这款套餐的线路入口正在整理中,暂时不能购买");
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId,
|
||||||
|
kind: "NEW_PURCHASE",
|
||||||
|
amount: price.amount,
|
||||||
|
subtotalAmount: price.amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
selectedInboundId,
|
||||||
|
status: "PENDING",
|
||||||
|
items: {
|
||||||
|
create: {
|
||||||
|
planId,
|
||||||
|
selectedInboundId,
|
||||||
|
trafficGb: price.trafficGb,
|
||||||
|
unitAmount: price.unitAmount,
|
||||||
|
amount: price.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return order.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseStreaming(planId: string): Promise<string> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("未登录");
|
||||||
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id: planId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||||
|
if (!plan.isActive) throw new Error("套餐已下架");
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new Error(buildUnavailableMessage(availability));
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = getPlanPurchasePrice(plan);
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId,
|
||||||
|
kind: "NEW_PURCHASE",
|
||||||
|
amount: price.amount,
|
||||||
|
subtotalAmount: price.amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
status: "PENDING",
|
||||||
|
items: {
|
||||||
|
create: {
|
||||||
|
planId,
|
||||||
|
trafficGb: null,
|
||||||
|
unitAmount: price.unitAmount,
|
||||||
|
amount: price.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return order.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurchaseRenewalResult =
|
||||||
|
| { ok: true; orderId: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function purchaseRenewal(
|
||||||
|
subscriptionId: string,
|
||||||
|
renewalDays?: number,
|
||||||
|
): Promise<PurchaseRenewalResult> {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("未登录");
|
||||||
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findFirst({
|
||||||
|
where: { id: subscriptionId, userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!subscription) throw new Error("订阅不存在");
|
||||||
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||||
|
throw new Error("仅支持对活跃订阅续费");
|
||||||
|
}
|
||||||
|
if (!subscription.plan.allowRenewal) {
|
||||||
|
throw new Error("这款套餐暂时不支持续费");
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = getRenewalOrderPrice(subscription.plan, renewalDays);
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId: subscription.planId,
|
||||||
|
kind: "RENEWAL",
|
||||||
|
targetSubscriptionId: subscription.id,
|
||||||
|
amount: price.amount,
|
||||||
|
subtotalAmount: price.amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
durationDays: price.durationDays,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, orderId: order.id };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: getErrorMessage(error, "创建续费订单失败") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseTrafficTopup(
|
||||||
|
subscriptionId: string,
|
||||||
|
trafficGb: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("未登录");
|
||||||
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
|
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
|
||||||
|
throw new Error("增流量必须是正整数 GB");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findFirst({
|
||||||
|
where: { id: subscriptionId, userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!subscription) throw new Error("订阅不存在");
|
||||||
|
if (subscription.plan.type !== "PROXY") {
|
||||||
|
throw new Error("仅代理订阅支持增流量");
|
||||||
|
}
|
||||||
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||||
|
throw new Error("增流量仅在当前套餐有效期内生效");
|
||||||
|
}
|
||||||
|
if (!subscription.plan.allowTrafficTopup) {
|
||||||
|
throw new Error("这款套餐暂时不支持增加流量");
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = getTrafficTopupOrderPrice(subscription.plan, trafficGb);
|
||||||
|
const poolState = await getPlanTrafficPoolState(subscription.planId);
|
||||||
|
if (poolState.enabled) {
|
||||||
|
const remainingGb = Math.max(0, Math.floor(poolState.remainingGb));
|
||||||
|
if (trafficGb > remainingGb) {
|
||||||
|
throw new Error(`剩余总流量不足,当前最多可增 ${remainingGb} GB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
planId: subscription.planId,
|
||||||
|
kind: "TRAFFIC_TOPUP",
|
||||||
|
targetSubscriptionId: subscription.id,
|
||||||
|
amount,
|
||||||
|
subtotalAmount: amount,
|
||||||
|
discountAmount: 0,
|
||||||
|
trafficGb,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return order.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryPlanNextAvailability(planId: string): Promise<{
|
||||||
|
available: boolean;
|
||||||
|
message: string;
|
||||||
|
nextAvailableAt: string | null;
|
||||||
|
}> {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("未登录");
|
||||||
|
|
||||||
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
|
where: { id: planId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
totalLimit: true,
|
||||||
|
perUserLimit: true,
|
||||||
|
streamingServiceId: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!plan.isActive) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
message: "这款套餐暂时不可购买",
|
||||||
|
nextAvailableAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||||
|
return {
|
||||||
|
available: availability.available,
|
||||||
|
message: availability.available ? "这款套餐现在可以购买" : buildUnavailableMessage(availability),
|
||||||
|
nextAvailableAt: availability.nextAvailableAt
|
||||||
|
? formatAvailabilityDateTime(availability.nextAvailableAt)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
108
src/actions/user/subscription-security.ts
Normal file
108
src/actions/user/subscription-security.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
||||||
|
import { bytesToGb } from "@/lib/utils";
|
||||||
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import { recordAuditLog } from "@/services/audit";
|
||||||
|
|
||||||
|
function newDownloadToken() {
|
||||||
|
return randomUUID().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateSubscriptionAccess(subscriptionId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
|
||||||
|
const subscription = await prisma.userSubscription.findFirst({
|
||||||
|
where: {
|
||||||
|
id: subscriptionId,
|
||||||
|
userId: session.user.id,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
plan: true,
|
||||||
|
nodeClient: {
|
||||||
|
include: {
|
||||||
|
inbound: {
|
||||||
|
include: {
|
||||||
|
server: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("订阅不存在或不可操作");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextToken = newDownloadToken();
|
||||||
|
if (subscription.plan.type === "PROXY" && subscription.nodeClient) {
|
||||||
|
const nextCredential = generateNodeClientCredential(
|
||||||
|
subscription.nodeClient.inbound.protocol,
|
||||||
|
subscription.nodeClient.inbound.settings,
|
||||||
|
);
|
||||||
|
const panelInboundId = subscription.nodeClient.inbound.panelInboundId;
|
||||||
|
if (panelInboundId == null) {
|
||||||
|
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = createPanelAdapter(subscription.nodeClient.inbound.server);
|
||||||
|
await adapter.login();
|
||||||
|
await adapter.deleteClient(panelInboundId, subscription.nodeClient.uuid);
|
||||||
|
await adapter.addClient({
|
||||||
|
inboundId: panelInboundId,
|
||||||
|
email: subscription.nodeClient.email,
|
||||||
|
uuid: nextCredential,
|
||||||
|
subId: subscription.id,
|
||||||
|
totalGB: subscription.trafficLimit ? bytesToGb(subscription.trafficLimit) : 0,
|
||||||
|
expiryTime: subscription.endDate.getTime(),
|
||||||
|
protocol: subscription.nodeClient.inbound.protocol,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.nodeClient.update({
|
||||||
|
where: { id: subscription.nodeClient!.id },
|
||||||
|
data: { uuid: nextCredential },
|
||||||
|
});
|
||||||
|
await tx.userSubscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { downloadToken: nextToken },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.userSubscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { downloadToken: nextToken },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
userId: subscription.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "SUCCESS",
|
||||||
|
title: "订阅访问已重置",
|
||||||
|
body: `${subscription.plan.name} 的订阅链接和访问凭据已更新,旧配置已失效。`,
|
||||||
|
link: "/subscriptions",
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: {
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email ?? undefined,
|
||||||
|
role: session.user.role === "ADMIN" || session.user.role === "USER" ? session.user.role : undefined,
|
||||||
|
},
|
||||||
|
action: "subscription.rotate_access",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: subscription.id,
|
||||||
|
targetLabel: subscription.plan.name,
|
||||||
|
message: `用户重置订阅访问 ${subscription.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/subscriptions");
|
||||||
|
revalidatePath(`/subscriptions/${subscriptionId}`);
|
||||||
|
}
|
||||||
238
src/actions/user/support.ts
Normal file
238
src/actions/user/support.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import {
|
||||||
|
createSupportAttachments,
|
||||||
|
deleteSupportTicketRecords,
|
||||||
|
parseSupportAttachments,
|
||||||
|
} from "@/services/support";
|
||||||
|
|
||||||
|
const createTicketSchema = z.object({
|
||||||
|
subject: z.string().trim().min(1, "标题不能为空"),
|
||||||
|
category: z.string().trim().optional(),
|
||||||
|
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||||
|
body: z.string().trim().min(1, "内容不能为空"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createSupportTicket(formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = createTicketSchema.parse(Object.fromEntries(formData));
|
||||||
|
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||||
|
|
||||||
|
const ticket = await prisma.supportTicket.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
subject: data.subject,
|
||||||
|
category: data.category || null,
|
||||||
|
priority: data.priority,
|
||||||
|
status: "OPEN",
|
||||||
|
lastReplyAt: new Date(),
|
||||||
|
replies: {
|
||||||
|
create: {
|
||||||
|
authorUserId: session.user.id,
|
||||||
|
isAdmin: false,
|
||||||
|
body: data.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const firstReply = ticket.replies[0];
|
||||||
|
if (firstReply && attachments.length > 0) {
|
||||||
|
await createSupportAttachments({
|
||||||
|
ticketId: ticket.id,
|
||||||
|
replyId: firstReply.id,
|
||||||
|
files: attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = await prisma.user.findMany({
|
||||||
|
where: { role: "ADMIN", status: "ACTIVE" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const admin of admins) {
|
||||||
|
await createNotification({
|
||||||
|
userId: admin.id,
|
||||||
|
type: "SYSTEM",
|
||||||
|
level: "INFO",
|
||||||
|
title: "新工单待处理",
|
||||||
|
body: `收到新工单:${data.subject}`,
|
||||||
|
link: `/admin/support/${ticket.id}`,
|
||||||
|
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replySupportTicket(ticketId: string, formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const body = String(formData.get("body") || "").trim();
|
||||||
|
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("回复内容不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await prisma.supportTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ticketId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("工单不存在");
|
||||||
|
}
|
||||||
|
if (ticket.status === "CLOSED") {
|
||||||
|
throw new Error("已关闭的工单不能继续回复");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.supportTicket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: {
|
||||||
|
status: "USER_REPLIED",
|
||||||
|
closedAt: null,
|
||||||
|
lastReplyAt: new Date(),
|
||||||
|
replies: {
|
||||||
|
create: {
|
||||||
|
authorUserId: session.user.id,
|
||||||
|
isAdmin: false,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const createdReply = updated.replies[0];
|
||||||
|
if (createdReply && attachments.length > 0) {
|
||||||
|
await createSupportAttachments({
|
||||||
|
ticketId: ticket.id,
|
||||||
|
replyId: createdReply.id,
|
||||||
|
files: attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = await prisma.user.findMany({
|
||||||
|
where: { role: "ADMIN", status: "ACTIVE" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const admin of admins) {
|
||||||
|
await createNotification({
|
||||||
|
userId: admin.id,
|
||||||
|
type: "SYSTEM",
|
||||||
|
level: "INFO",
|
||||||
|
title: "工单有新回复",
|
||||||
|
body: `工单「${ticket.subject}」有用户新回复。`,
|
||||||
|
link: `/admin/support/${ticket.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSupportTicket(ticketId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const ticket = await prisma.supportTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ticketId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("工单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.status === "CLOSED") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.supportTicket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: {
|
||||||
|
status: "CLOSED",
|
||||||
|
closedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "support.close",
|
||||||
|
targetType: "SupportTicket",
|
||||||
|
targetId: ticket.id,
|
||||||
|
targetLabel: ticket.subject,
|
||||||
|
message: `用户关闭工单 ${ticket.subject}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSupportTicket(ticketId: string) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const ticket = await prisma.supportTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ticketId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new Error("工单不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await deleteSupportTicketRecords(ticket.id, tx);
|
||||||
|
await recordAuditLog(
|
||||||
|
{
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "support.delete",
|
||||||
|
targetType: "SupportTicket",
|
||||||
|
targetId: ticket.id,
|
||||||
|
targetLabel: ticket.subject,
|
||||||
|
message: `用户删除工单 ${ticket.subject}`,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath(`/support/${ticket.id}`);
|
||||||
|
revalidatePath("/admin/support");
|
||||||
|
revalidatePath(`/admin/support/${ticket.id}`);
|
||||||
|
revalidatePath("/notifications");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeadCell,
|
||||||
|
DataTableHeaderRow,
|
||||||
|
DataTableRow,
|
||||||
|
} from "@/components/shared/data-table";
|
||||||
|
import {
|
||||||
|
announcementAudienceLabels,
|
||||||
|
announcementDisplayTypeLabels,
|
||||||
|
getAnnouncementAudienceTone,
|
||||||
|
} from "@/components/shared/domain-badges";
|
||||||
|
import { StatusBadge, ActiveStatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { AnnouncementActions } from "../announcement-actions";
|
||||||
|
import type { AnnouncementOptionUser, AnnouncementRow } from "../announcements-data";
|
||||||
|
|
||||||
|
interface AnnouncementsTableProps {
|
||||||
|
announcements: AnnouncementRow[];
|
||||||
|
users: AnnouncementOptionUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWindow(startAt: Date | null, endAt: Date | null) {
|
||||||
|
return `${startAt ? formatDate(startAt) : "立即开始"} ~ ${endAt ? formatDate(endAt) : "长期有效"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementsTable({ announcements, users }: AnnouncementsTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={announcements.length === 0}
|
||||||
|
emptyTitle="暂无公告或消息"
|
||||||
|
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。"
|
||||||
|
>
|
||||||
|
<DataTable aria-label="公告列表" className="min-w-[1040px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>标题</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>范围</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>展示</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>时间窗口</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>通知</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>创建人</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<DataTableRow key={announcement.id}>
|
||||||
|
<DataTableCell className="max-w-sm">
|
||||||
|
<p className="font-medium">{announcement.title}</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground">
|
||||||
|
{announcement.body}
|
||||||
|
</p>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<StatusBadge tone={getAnnouncementAudienceTone(announcement.audience)}>
|
||||||
|
{announcementAudienceLabels[announcement.audience]}
|
||||||
|
</StatusBadge>
|
||||||
|
{announcement.targetUser?.email && (
|
||||||
|
<p className="text-xs text-muted-foreground">{announcement.targetUser.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<p>{announcementDisplayTypeLabels[announcement.displayType]}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{announcement.dismissible ? "可关闭" : "常驻"}
|
||||||
|
</p>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-52 text-xs leading-5 text-muted-foreground">
|
||||||
|
{formatWindow(announcement.startAt, announcement.endAt)}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<StatusBadge tone={announcement.sendNotification ? "info" : "neutral"}>
|
||||||
|
{announcement.sendNotification ? "同步" : "不同步"}
|
||||||
|
</StatusBadge>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-56 whitespace-normal break-all">{announcement.createdBy?.email ?? "系统"}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<ActiveStatusBadge active={announcement.isActive} activeLabel="启用" inactiveLabel="停用" />
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<AnnouncementActions announcement={announcement} users={users} />
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/app/(admin)/admin/announcements/announcement-actions.tsx
Normal file
76
src/app/(admin)/admin/announcements/announcement-actions.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnnouncementAudience,
|
||||||
|
AnnouncementDisplayType,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
deleteAnnouncement,
|
||||||
|
toggleAnnouncement,
|
||||||
|
} from "@/actions/admin/announcements";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { AnnouncementForm } from "./announcement-form";
|
||||||
|
|
||||||
|
interface AnnouncementOptionUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementActionItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
audience: AnnouncementAudience;
|
||||||
|
displayType: AnnouncementDisplayType;
|
||||||
|
targetUserId: string | null;
|
||||||
|
dismissible: boolean;
|
||||||
|
sendNotification: boolean;
|
||||||
|
startAt: Date | string | null;
|
||||||
|
endAt: Date | string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementActions({
|
||||||
|
announcement,
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
announcement: AnnouncementActionItem;
|
||||||
|
users: AnnouncementOptionUser[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<AnnouncementForm announcement={announcement} users={users} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await toggleAnnouncement(announcement.id, !announcement.isActive);
|
||||||
|
toast.success(announcement.isActive ? "公告已停用" : "公告已启用");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "更新状态失败"));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{announcement.isActive ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
<ConfirmActionButton
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
title="删除这条公告?"
|
||||||
|
description="公告本体和已经同步的站内通知会一起删除,此操作无法恢复。"
|
||||||
|
confirmLabel="删除公告"
|
||||||
|
successMessage="公告已删除"
|
||||||
|
errorMessage="删除失败"
|
||||||
|
onConfirm={() => deleteAnnouncement(announcement.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ConfirmActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
src/app/(admin)/admin/announcements/announcement-form.tsx
Normal file
353
src/app/(admin)/admin/announcements/announcement-form.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type {
|
||||||
|
AnnouncementAudience,
|
||||||
|
AnnouncementDisplayType,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
createAnnouncement,
|
||||||
|
updateAnnouncement,
|
||||||
|
} from "@/actions/admin/announcements";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
interface AnnouncementOptionUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementFormData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
audience: AnnouncementAudience;
|
||||||
|
displayType: AnnouncementDisplayType;
|
||||||
|
targetUserId: string | null;
|
||||||
|
dismissible: boolean;
|
||||||
|
sendNotification: boolean;
|
||||||
|
startAt: Date | string | null;
|
||||||
|
endAt: Date | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeLocalValue(value: Date | string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||||
|
return localTime.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementForm({
|
||||||
|
users,
|
||||||
|
announcement,
|
||||||
|
triggerLabel,
|
||||||
|
triggerVariant = "outline",
|
||||||
|
}: {
|
||||||
|
users: AnnouncementOptionUser[];
|
||||||
|
announcement: AnnouncementFormData;
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerVariant?: "default" | "outline" | "ghost";
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [audience, setAudience] = useState<AnnouncementAudience>(announcement.audience);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
try {
|
||||||
|
await updateAnnouncement(announcement.id, formData);
|
||||||
|
toast.success("公告已更新");
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "更新公告失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (nextOpen) {
|
||||||
|
setAudience(announcement.audience);
|
||||||
|
}
|
||||||
|
setOpen(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
|
||||||
|
{triggerLabel ?? "编辑"}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑公告</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`title-${announcement.id}`}>标题</Label>
|
||||||
|
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`audience-${announcement.id}`}>目标范围</Label>
|
||||||
|
<select
|
||||||
|
id={`audience-${announcement.id}`}
|
||||||
|
name="audience"
|
||||||
|
defaultValue={announcement.audience}
|
||||||
|
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||||
|
<option value="USERS">全部用户</option>
|
||||||
|
<option value="ADMINS">全部管理员</option>
|
||||||
|
<option value="SPECIFIC_USER">指定用户</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`displayType-${announcement.id}`}>展示方式</Label>
|
||||||
|
<select
|
||||||
|
id={`displayType-${announcement.id}`}
|
||||||
|
name="displayType"
|
||||||
|
defaultValue={announcement.displayType}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="INLINE">普通公告</option>
|
||||||
|
<option value="BIG">大公告</option>
|
||||||
|
<option value="POPUP">弹窗公告</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`dismissible-${announcement.id}`}>允许用户关闭</Label>
|
||||||
|
<select
|
||||||
|
id={`dismissible-${announcement.id}`}
|
||||||
|
name="dismissible"
|
||||||
|
defaultValue={announcement.dismissible ? "true" : "false"}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`targetUserId-${announcement.id}`}>指定用户(可选)</Label>
|
||||||
|
<select
|
||||||
|
id={`targetUserId-${announcement.id}`}
|
||||||
|
name="targetUserId"
|
||||||
|
defaultValue={announcement.targetUserId ?? ""}
|
||||||
|
disabled={audience !== "SPECIFIC_USER"}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">不指定</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`body-${announcement.id}`}>内容</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`body-${announcement.id}`}
|
||||||
|
name="body"
|
||||||
|
rows={5}
|
||||||
|
defaultValue={announcement.body}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`startAt-${announcement.id}`}>开始时间(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id={`startAt-${announcement.id}`}
|
||||||
|
name="startAt"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={toDateTimeLocalValue(announcement.startAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`endAt-${announcement.id}`}>结束时间(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id={`endAt-${announcement.id}`}
|
||||||
|
name="endAt"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={toDateTimeLocalValue(announcement.endAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`sendNotification-${announcement.id}`}>同步发送站内通知</Label>
|
||||||
|
<select
|
||||||
|
id={`sendNotification-${announcement.id}`}
|
||||||
|
name="sendNotification"
|
||||||
|
defaultValue={announcement.sendNotification ? "true" : "false"}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
保存修改
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateAnnouncementButton({
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
users: AnnouncementOptionUser[];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [audience, setAudience] = useState<AnnouncementAudience>("USERS");
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
try {
|
||||||
|
await createAnnouncement(formData);
|
||||||
|
toast.success("公告已发布");
|
||||||
|
setOpen(false);
|
||||||
|
setAudience("USERS");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "发布公告失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button />}>发布公告</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>发布公告</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-title">标题</Label>
|
||||||
|
<Input id="create-announcement-title" name="title" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-audience">目标范围</Label>
|
||||||
|
<select
|
||||||
|
id="create-announcement-audience"
|
||||||
|
name="audience"
|
||||||
|
defaultValue="USERS"
|
||||||
|
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="PUBLIC">公开(登录/注册页可见)</option>
|
||||||
|
<option value="USERS">全部用户</option>
|
||||||
|
<option value="ADMINS">全部管理员</option>
|
||||||
|
<option value="SPECIFIC_USER">指定用户</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-displayType">展示方式</Label>
|
||||||
|
<select
|
||||||
|
id="create-announcement-displayType"
|
||||||
|
name="displayType"
|
||||||
|
defaultValue="INLINE"
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="INLINE">普通公告</option>
|
||||||
|
<option value="BIG">大公告</option>
|
||||||
|
<option value="POPUP">弹窗公告</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-dismissible">允许用户关闭</Label>
|
||||||
|
<select
|
||||||
|
id="create-announcement-dismissible"
|
||||||
|
name="dismissible"
|
||||||
|
defaultValue="true"
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-targetUserId">指定用户(可选)</Label>
|
||||||
|
<select
|
||||||
|
id="create-announcement-targetUserId"
|
||||||
|
name="targetUserId"
|
||||||
|
defaultValue=""
|
||||||
|
disabled={audience !== "SPECIFIC_USER"}
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="">不指定</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-body">内容</Label>
|
||||||
|
<Textarea id="create-announcement-body" name="body" rows={5} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-startAt">开始时间(可选)</Label>
|
||||||
|
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-endAt">结束时间(可选)</Label>
|
||||||
|
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-announcement-sendNotification">同步发送站内通知</Label>
|
||||||
|
<select
|
||||||
|
id="create-announcement-sendNotification"
|
||||||
|
name="sendNotification"
|
||||||
|
defaultValue="true"
|
||||||
|
className="h-10 w-full px-3 text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/(admin)/admin/announcements/announcements-data.ts
Normal file
63
src/app/(admin)/admin/announcements/announcements-data.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parsePage } from "@/lib/utils";
|
||||||
|
|
||||||
|
const announcementInclude = {
|
||||||
|
targetUser: {
|
||||||
|
select: { email: true },
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
select: { email: true },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.AnnouncementInclude;
|
||||||
|
|
||||||
|
export type AnnouncementRow = Prisma.AnnouncementGetPayload<{
|
||||||
|
include: typeof announcementInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AnnouncementOptionUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAnnouncements(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
|
) {
|
||||||
|
const { page, skip, pageSize } = parsePage(searchParams, 20);
|
||||||
|
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||||
|
const audience = typeof searchParams.audience === "string" ? searchParams.audience : "";
|
||||||
|
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(audience
|
||||||
|
? { audience: audience as "PUBLIC" | "USERS" | "ADMINS" | "SPECIFIC_USER" }
|
||||||
|
: {}),
|
||||||
|
...(status ? { isActive: status === "active" } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ body: { contains: q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies Prisma.AnnouncementWhereInput;
|
||||||
|
|
||||||
|
const [announcements, total, users] = await Promise.all([
|
||||||
|
prisma.announcement.findMany({
|
||||||
|
where,
|
||||||
|
include: announcementInclude,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.announcement.count({ where }),
|
||||||
|
prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: { id: true, email: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { announcements, total, users, page, pageSize, filters: { q, audience, status } };
|
||||||
|
}
|
||||||
62
src/app/(admin)/admin/announcements/page.tsx
Normal file
62
src/app/(admin)/admin/announcements/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { AnnouncementsTable } from "./_components/announcements-table";
|
||||||
|
import { CreateAnnouncementButton } from "./announcement-form";
|
||||||
|
import { getAnnouncements } from "./announcements-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "公告与消息",
|
||||||
|
description: "发布全站公告与定向通知。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AnnouncementsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { announcements, total, users, page, pageSize, filters } = await getAnnouncements(
|
||||||
|
await searchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="用户支持"
|
||||||
|
title="公告与消息"
|
||||||
|
actions={<CreateAnnouncementButton users={users} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminFilterBar
|
||||||
|
q={filters.q}
|
||||||
|
searchPlaceholder="搜索标题或内容"
|
||||||
|
selects={[
|
||||||
|
{
|
||||||
|
name: "audience",
|
||||||
|
value: filters.audience,
|
||||||
|
options: [
|
||||||
|
{ label: "全部范围", value: "" },
|
||||||
|
{ label: "公开", value: "PUBLIC" },
|
||||||
|
{ label: "全部用户", value: "USERS" },
|
||||||
|
{ label: "全部管理员", value: "ADMINS" },
|
||||||
|
{ label: "指定用户", value: "SPECIFIC_USER" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
value: filters.status,
|
||||||
|
options: [
|
||||||
|
{ label: "全部状态", value: "" },
|
||||||
|
{ label: "启用", value: "active" },
|
||||||
|
{ label: "停用", value: "inactive" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnnouncementsTable announcements={announcements} users={users} />
|
||||||
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { AuditLog } from "@prisma/client";
|
||||||
|
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeadCell,
|
||||||
|
DataTableHeaderRow,
|
||||||
|
DataTableRow,
|
||||||
|
} from "@/components/shared/data-table";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
|
||||||
|
return (
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={logs.length === 0}
|
||||||
|
emptyTitle="暂无审计日志"
|
||||||
|
emptyDescription="后台关键操作发生后,会记录在这里。"
|
||||||
|
>
|
||||||
|
<DataTable aria-label="审计日志列表" className="min-w-[980px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>操作者</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>动作</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>目标</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>说明</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<DataTableRow key={log.id}>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDate(log.createdAt)}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{log.actorEmail || "系统"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{log.actorRole || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap font-medium">{log.action}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{log.targetType}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{log.targetLabel || log.targetId || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
|
||||||
|
{log.message}
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/(admin)/admin/audit-logs/audit-logs-data.ts
Normal file
46
src/app/(admin)/admin/audit-logs/audit-logs-data.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parsePage } from "@/lib/utils";
|
||||||
|
|
||||||
|
export async function getAuditLogs(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
|
) {
|
||||||
|
const { page, skip, pageSize } = parsePage(searchParams, 50);
|
||||||
|
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||||
|
const action = typeof searchParams.action === "string" ? searchParams.action : "";
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(action ? { action: { startsWith: action } } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ action: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ targetType: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ targetLabel: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ actorEmail: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ message: { contains: q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies Prisma.AuditLogWhereInput;
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { logs, total, page, pageSize, filters: { q, action } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuditLogExportHref(filters: { q: string; action: string }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.q) params.set("q", filters.q);
|
||||||
|
if (filters.action) params.set("action", filters.action);
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/admin/export/audit-logs${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
63
src/app/(admin)/admin/audit-logs/page.tsx
Normal file
63
src/app/(admin)/admin/audit-logs/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { AuditLogsTable } from "./_components/audit-logs-table";
|
||||||
|
import { buildAuditLogExportHref, getAuditLogs } from "./audit-logs-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "审计日志",
|
||||||
|
description: "查询关键后台操作记录并支持日志导出。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AuditLogsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { logs, total, page, pageSize, filters } = await getAuditLogs(await searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="系统"
|
||||||
|
title="审计日志"
|
||||||
|
actions={
|
||||||
|
<a
|
||||||
|
href={buildAuditLogExportHref(filters)}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
导出日志
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminFilterBar
|
||||||
|
q={filters.q}
|
||||||
|
searchPlaceholder="搜索动作、目标、操作者、说明"
|
||||||
|
selects={[
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
value: filters.action,
|
||||||
|
options: [
|
||||||
|
{ label: "全部动作前缀", value: "" },
|
||||||
|
{ label: "user.", value: "user." },
|
||||||
|
{ label: "order.", value: "order." },
|
||||||
|
{ label: "subscription.", value: "subscription." },
|
||||||
|
{ label: "plan.", value: "plan." },
|
||||||
|
{ label: "service.", value: "service." },
|
||||||
|
{ label: "node.", value: "node." },
|
||||||
|
{ label: "task.", value: "task." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuditLogsTable logs={logs} />
|
||||||
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/(admin)/admin/backups/page.tsx
Normal file
46
src/app/(admin)/admin/backups/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { DatabaseBackup, Download } from "lucide-react";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { RestoreBackupForm } from "./restore-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "备份与恢复",
|
||||||
|
description: "导出数据库备份并支持 SQL 恢复。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="系统"
|
||||||
|
title="备份与恢复"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="surface-card surface-lift overflow-hidden rounded-xl p-5">
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<DatabaseBackup className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">导出数据库</h3>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||||
|
导出为可恢复的 SQL 脚本,适合在升级、迁移和大规模配置调整前做完整备份。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/admin/backup/database"
|
||||||
|
className={buttonVariants({ size: "lg" })}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
下载 SQL 备份
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<RestoreBackupForm />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/(admin)/admin/backups/restore-form.tsx
Normal file
63
src/app/(admin)/admin/backups/restore-form.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { restoreDatabaseBackup } from "@/actions/admin/backups";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function RestoreBackupForm() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await restoreDatabaseBackup(formData);
|
||||||
|
toast.success("数据库恢复已执行,建议检查关键页面和容器日志");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "恢复失败"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={handleSubmit} className="form-panel space-y-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">恢复数据库</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
|
支持上传 SQL 备份文件或直接粘贴 SQL。恢复会覆盖当前数据库对象,请确认备份来源可信。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[0.9fr_1.1fr]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sqlFile">SQL 备份文件</Label>
|
||||||
|
<Input id="sqlFile" name="sqlFile" type="file" accept=".sql,text/plain" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmation">确认口令</Label>
|
||||||
|
<Input id="confirmation" name="confirmation" placeholder="请输入 RESTORE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sqlText">或粘贴 SQL 内容</Label>
|
||||||
|
<Textarea id="sqlText" name="sqlText" rows={8} placeholder="-- paste sql backup here" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto">
|
||||||
|
{loading ? "恢复中..." : "执行恢复"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toggleCoupon, togglePromotionRule } from "@/actions/admin/commerce";
|
||||||
|
|
||||||
|
type ToggleKind = "coupon" | "promotion";
|
||||||
|
|
||||||
|
export function CommerceToggleButton({
|
||||||
|
id,
|
||||||
|
active,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
kind: ToggleKind;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const nextActive = !active;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? "outline" : "default"}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
if (kind === "coupon") await toggleCoupon(id, nextActive);
|
||||||
|
if (kind === "promotion") await togglePromotionRule(id, nextActive);
|
||||||
|
toast.success(nextActive ? "已启用" : "已停用");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "操作失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending ? "处理中..." : active ? "停用" : "启用"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/(admin)/admin/commerce/commerce-data.ts
Normal file
17
src/app/(admin)/admin/commerce/commerce-data.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getCommerceData() {
|
||||||
|
const [coupons, promotions] = await Promise.all([
|
||||||
|
prisma.coupon.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { _count: { select: { orders: true, grants: true } } },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
prisma.promotionRule.findMany({
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { coupons, promotions };
|
||||||
|
}
|
||||||
158
src/app/(admin)/admin/commerce/page.tsx
Normal file
158
src/app/(admin)/admin/commerce/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Gift, Sparkles } from "lucide-react";
|
||||||
|
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
||||||
|
import { DetailItem, DetailList } from "@/components/admin/detail-list";
|
||||||
|
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||||
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { getCommerceData } from "./commerce-data";
|
||||||
|
import { CommerceToggleButton } from "./_components/commerce-actions";
|
||||||
|
|
||||||
|
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "商业配置",
|
||||||
|
description: "管理优惠券与满减规则。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminCommercePage() {
|
||||||
|
const { coupons, promotions } = await getCommerceData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="商业配置"
|
||||||
|
title="优惠与奖励"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="create" className="space-y-6">
|
||||||
|
<TabsList variant="line" className="surface-card p-1">
|
||||||
|
<TabsTrigger value="create">新建规则</TabsTrigger>
|
||||||
|
<TabsTrigger value="manage">规则列表</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="create">
|
||||||
|
<section className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<form action={createCoupon} className="form-panel space-y-4">
|
||||||
|
<SectionHeader title="新建优惠券" />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="coupon-code">优惠码</Label>
|
||||||
|
<Input id="coupon-code" name="code" placeholder="WELCOME10" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="coupon-name">名称</Label>
|
||||||
|
<Input id="coupon-name" name="name" placeholder="新人礼遇" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="coupon-type">优惠类型</Label>
|
||||||
|
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF">
|
||||||
|
<option value="AMOUNT_OFF">立减金额</option>
|
||||||
|
<option value="PERCENT_OFF">折扣百分比</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="coupon-value">优惠值</Label>
|
||||||
|
<Input id="coupon-value" name="discountValue" type="number" step="0.01" min="0.01" placeholder="10 或 15" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Input name="thresholdAmount" type="number" step="0.01" placeholder="满多少可用" />
|
||||||
|
<Input name="totalLimit" type="number" placeholder="总次数" />
|
||||||
|
<Input name="perUserLimit" type="number" placeholder="每人次数" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="coupon-public">用户可见</Label>
|
||||||
|
<select id="coupon-public" name="isPublic" className={selectClassName} defaultValue="true">
|
||||||
|
<option value="true">公开展示</option>
|
||||||
|
<option value="false">仅发放可用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">创建优惠券</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={createPromotionRule} className="form-panel space-y-4">
|
||||||
|
<SectionHeader title="新建满减" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="promotion-name">规则名称</Label>
|
||||||
|
<Input id="promotion-name" name="name" placeholder="满百礼遇" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="promotion-threshold">门槛金额</Label>
|
||||||
|
<Input id="promotion-threshold" name="thresholdAmount" type="number" step="0.01" min="0.01" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="promotion-discount">减免金额</Label>
|
||||||
|
<Input id="promotion-discount" name="discountAmount" type="number" step="0.01" min="0.01" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="promotion-sort">排序</Label>
|
||||||
|
<Input id="promotion-sort" name="sortOrder" type="number" defaultValue={100} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">创建满减</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="manage" className="space-y-6">
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SectionHeader title="优惠券" />
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{coupons.map((coupon) => (
|
||||||
|
<article key={coupon.id} className="surface-card rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{coupon.name}</h3>
|
||||||
|
<p className="mt-1 font-mono text-sm text-primary">{coupon.code}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
|
||||||
|
</div>
|
||||||
|
<DetailList className="mt-4">
|
||||||
|
<DetailItem label="优惠">{coupon.discountType === "PERCENT_OFF" ? `${Number(coupon.discountValue)}%` : `¥${Number(coupon.discountValue).toFixed(2)}`}</DetailItem>
|
||||||
|
<DetailItem label="门槛">{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</DetailItem>
|
||||||
|
<DetailItem label="可见性">{coupon.isPublic ? "公开" : "仅发放"}</DetailItem>
|
||||||
|
<DetailItem label="使用">订单 {coupon._count.orders} · 发放 {coupon._count.grants}</DetailItem>
|
||||||
|
</DetailList>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SectionHeader title="满减规则" />
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{promotions.map((rule) => (
|
||||||
|
<article key={rule.id} className="surface-card rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{rule.name}</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
||||||
|
<StatusBadge>排序 {rule.sortOrder}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/app/(admin)/admin/dashboard/_components/recent-section.tsx
Normal file
116
src/app/(admin)/admin/dashboard/_components/recent-section.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { ReceiptText, UserRound } from "lucide-react";
|
||||||
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
|
import { OrderStatusBadge } from "@/components/shared/domain-badges";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { formatDateShort } from "@/lib/utils";
|
||||||
|
import type { RecentAdminOrder, RecentAdminUser } from "../dashboard-data";
|
||||||
|
|
||||||
|
interface RecentSectionProps {
|
||||||
|
recentOrders: RecentAdminOrder[];
|
||||||
|
recentUsers: RecentAdminUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ReceiptText className="size-4 text-primary" /> 最近订单
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentOrders.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="还没有订单"
|
||||||
|
description="用户创建订单后,这里会显示最新购买和支付状态。"
|
||||||
|
className="border-0 bg-transparent py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentOrders.map((order) => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 transition-colors duration-200 hover:border-primary/20 hover:bg-primary/7"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{order.plan.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{order.user.email} · {formatDateShort(order.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold tabular-nums">
|
||||||
|
¥{Number(order.amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<UserRound className="size-4 text-primary" /> 最近注册
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recentUsers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="还没有新用户"
|
||||||
|
description="新用户注册后,这里会显示最近加入的账户。"
|
||||||
|
className="border-0 bg-transparent py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentUsers.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3 transition-colors duration-200 hover:border-primary/20 hover:bg-primary/7"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{user.name || user.email}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-full bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||||
|
{formatDateShort(user.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentSectionSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-5 w-20 animate-pulse rounded bg-muted" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{[0, 1, 2].map((j) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className="h-14 animate-pulse rounded-[1.15rem] bg-muted/30"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/(admin)/admin/dashboard/dashboard-data.ts
Normal file
52
src/app/(admin)/admin/dashboard/dashboard-data.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Prisma, User } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const recentOrderInclude = {
|
||||||
|
user: true,
|
||||||
|
plan: true,
|
||||||
|
} satisfies Prisma.OrderInclude;
|
||||||
|
|
||||||
|
export type RecentAdminOrder = Prisma.OrderGetPayload<{
|
||||||
|
include: typeof recentOrderInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RecentAdminUser = User;
|
||||||
|
|
||||||
|
export async function getAdminDashboardStats() {
|
||||||
|
const [userCount, activeSubCount, orderCount, nodeCount, revenue] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.userSubscription.count({ where: { status: "ACTIVE" } }),
|
||||||
|
prisma.order.count({ where: { status: "PAID" } }),
|
||||||
|
prisma.nodeServer.count({ where: { status: "active" } }),
|
||||||
|
prisma.order.aggregate({
|
||||||
|
where: { status: "PAID" },
|
||||||
|
_sum: { amount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalRevenue = Number(revenue._sum.amount ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: "总用户", value: userCount },
|
||||||
|
{ label: "活跃订阅", value: activeSubCount },
|
||||||
|
{ label: "已完成订单", value: orderCount },
|
||||||
|
{ label: "在线节点", value: nodeCount },
|
||||||
|
{ label: "总收入", value: `¥${totalRevenue.toFixed(2)}` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentAdminActivity() {
|
||||||
|
const [recentOrders, recentUsers] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
include: recentOrderInclude,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { recentOrders, recentUsers };
|
||||||
|
}
|
||||||
36
src/app/(admin)/admin/dashboard/page.tsx
Normal file
36
src/app/(admin)/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { MetricCard } from "@/components/shared/metric-card";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { getAdminDashboardStats, getRecentAdminActivity } from "./dashboard-data";
|
||||||
|
import { RecentSection } from "./_components/recent-section";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "仪表盘",
|
||||||
|
description: "查看后台核心指标与近期关键活动。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminDashboard() {
|
||||||
|
const [stats, recentActivity] = await Promise.all([
|
||||||
|
getAdminDashboardStats(),
|
||||||
|
getRecentAdminActivity(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="管理概览"
|
||||||
|
title="仪表盘"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<MetricCard key={stat.label} label={stat.label} value={stat.value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecentSection
|
||||||
|
recentOrders={recentActivity.recentOrders}
|
||||||
|
recentUsers={recentActivity.recentUsers}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/(admin)/admin/error.tsx
Normal file
26
src/app/(admin)/admin/error.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function AdminError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardContent className="py-10 text-center space-y-5">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">出了点问题</h1>
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{error.message || "页面加载失败,请稍后重试。"}
|
||||||
|
</p>
|
||||||
|
<Button onClick={reset} className="h-10">重试</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/(admin)/admin/loading.tsx
Normal file
30
src/app/(admin)/admin/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export default function AdminLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in-up">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-8 w-48 rounded-2xl" />
|
||||||
|
<Skeleton className="h-10 w-28 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
<div className="surface-card rounded-xl p-3">
|
||||||
|
<div className="border-b border-border/45 p-3">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-16 rounded-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="border-b border-border/30 p-3 last:border-b-0">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{Array.from({ length: 5 }).map((_, j) => (
|
||||||
|
<Skeleton key={j} className="h-4 w-20 rounded-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type { NodeDetail } from "../node-detail-data";
|
||||||
|
import { InboundsTab } from "./tabs/inbounds-tab";
|
||||||
|
|
||||||
|
export function NodeDetailTabs({ node }: { node: NodeDetail }) {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="inbounds">
|
||||||
|
<TabsList variant="line" className="w-full overflow-x-auto">
|
||||||
|
<TabsTrigger value="inbounds">
|
||||||
|
3x-ui 入站 ({node.inbounds.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="inbounds">
|
||||||
|
<InboundsTab node={node} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Waypoints } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
|
import { InboundDeleteButton } from "../../../inbound-delete-button";
|
||||||
|
import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
|
||||||
|
import type { NodeDetail } from "../../node-detail-data";
|
||||||
|
|
||||||
|
function getDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||||
|
const settings = inbound.settings;
|
||||||
|
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||||
|
const value = (settings as { displayName?: unknown }).displayName;
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return inbound.tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboundsTab({ node }: { node: NodeDetail }) {
|
||||||
|
if (node.inbounds.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无已同步入站"
|
||||||
|
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
入站配置由 3x-ui 面板维护;本页仅展示已同步的线路,并允许调整前台展示名称。
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{node.inbounds.map((inbound) => (
|
||||||
|
<Card key={inbound.id}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<Waypoints className="size-4 shrink-0 text-primary" />
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
<InboundDisplayNameForm
|
||||||
|
inboundId={inbound.id}
|
||||||
|
defaultValue={getDisplayName(inbound)}
|
||||||
|
/>
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{inbound.protocol}</Badge>
|
||||||
|
<Badge variant="outline">:{inbound.port}</Badge>
|
||||||
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>客户端: {inbound.clients.length}</span>
|
||||||
|
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
|
||||||
|
<>
|
||||||
|
{(inbound.streamSettings as Record<string, unknown>).network && (
|
||||||
|
<span>传输: {String((inbound.streamSettings as Record<string, unknown>).network)}</span>
|
||||||
|
)}
|
||||||
|
{(inbound.streamSettings as Record<string, unknown>).security && (
|
||||||
|
<span>安全: {String((inbound.streamSettings as Record<string, unknown>).security)}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
28
src/app/(admin)/admin/nodes/[id]/node-detail-data.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
const nodeDetailInclude = {
|
||||||
|
inbounds: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
clients: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.NodeServerInclude;
|
||||||
|
|
||||||
|
export type NodeDetail = Prisma.NodeServerGetPayload<{
|
||||||
|
include: typeof nodeDetailInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function getNodeDetail(id: string): Promise<NodeDetail> {
|
||||||
|
const node = await prisma.nodeServer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: nodeDetailInclude,
|
||||||
|
});
|
||||||
|
if (!node) notFound();
|
||||||
|
return node;
|
||||||
|
}
|
||||||
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { getNodeDetail } from "./node-detail-data";
|
||||||
|
import { NodeDetailTabs } from "./_components/node-detail-tabs";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "节点详情",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NodeDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const node = await getNodeDetail(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/nodes"
|
||||||
|
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="基础设施"
|
||||||
|
title={node.name}
|
||||||
|
description={`3x-ui · ${node.panelUrl || "未配置面板"}`}
|
||||||
|
actions={
|
||||||
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
|
{node.status}
|
||||||
|
</StatusBadge>
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NodeDetailTabs node={node} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
132
src/app/(admin)/admin/nodes/_components/node-card-list.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Server, Waypoints } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { batchTestNodeConnections } from "@/actions/admin/nodes";
|
||||||
|
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||||
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { InboundDeleteButton } from "../inbound-delete-button";
|
||||||
|
import { InboundDisplayNameForm } from "../inbound-display-name-form";
|
||||||
|
import { NodeActions } from "../node-actions";
|
||||||
|
import { NodeForm } from "../node-form";
|
||||||
|
import type { NodeServerRow } from "../nodes-data";
|
||||||
|
|
||||||
|
const NODE_BATCH_FORM_ID = "node-batch-form";
|
||||||
|
|
||||||
|
function PanelInfoBar({ node }: { node: NodeServerRow }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">3x-ui</span>
|
||||||
|
<span>{node.panelUrl || "未配置面板"}</span>
|
||||||
|
{node.agentToken && <span>探测 Token: 已启用</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4 pb-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<input
|
||||||
|
form={NODE_BATCH_FORM_ID}
|
||||||
|
type="checkbox"
|
||||||
|
name="nodeIds"
|
||||||
|
value={node.id}
|
||||||
|
aria-label={`选择节点 ${node.name}`}
|
||||||
|
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
|
/>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
<Server className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
||||||
|
{node.name}
|
||||||
|
</Link>
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
|
{node.status}
|
||||||
|
</StatusBadge>
|
||||||
|
<NodeForm
|
||||||
|
node={{
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
panelUrl: node.panelUrl,
|
||||||
|
panelUsername: node.panelUsername,
|
||||||
|
panelPassword: node.panelPassword,
|
||||||
|
}}
|
||||||
|
triggerLabel="编辑"
|
||||||
|
triggerVariant="outline"
|
||||||
|
/>
|
||||||
|
<NodeActions
|
||||||
|
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
||||||
|
siteUrl={siteUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<PanelInfoBar node={node} />
|
||||||
|
{node.inbounds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{node.inbounds.map((inbound) => (
|
||||||
|
<div
|
||||||
|
key={inbound.id}
|
||||||
|
className="flex min-w-72 flex-wrap items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium"
|
||||||
|
>
|
||||||
|
<Waypoints className="size-3.5 shrink-0 text-primary" />
|
||||||
|
<span className="shrink-0 text-muted-foreground">{inbound.protocol} · {inbound.port}</span>
|
||||||
|
<InboundDisplayNameForm
|
||||||
|
inboundId={inbound.id}
|
||||||
|
defaultValue={getInboundDisplayName(inbound)}
|
||||||
|
/>
|
||||||
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">暂无已同步入站,请在 3x-ui 创建入站后点击同步</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteUrl: string | null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
|
||||||
|
<BatchActionButton>批量同步入站</BatchActionButton>
|
||||||
|
</BatchActionBar>
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<NodeCard key={node.id} node={node} siteUrl={siteUrl} />
|
||||||
|
))}
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无节点"
|
||||||
|
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
||||||
|
action={<NodeForm triggerLabel="添加节点" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getInboundDisplayName(inbound: { tag: string; settings: unknown }) {
|
||||||
|
const settings = inbound.settings;
|
||||||
|
if (settings && typeof settings === "object" && "displayName" in settings) {
|
||||||
|
const value = (settings as { displayName?: unknown }).displayName;
|
||||||
|
if (typeof value === "string" && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return inbound.tag;
|
||||||
|
}
|
||||||
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
26
src/app/(admin)/admin/nodes/inbound-delete-button.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteInbound } from "@/actions/admin/nodes";
|
||||||
|
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||||
|
|
||||||
|
export function InboundDeleteButton({
|
||||||
|
inboundId,
|
||||||
|
}: {
|
||||||
|
inboundId: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ConfirmActionButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-destructive hover:text-destructive"
|
||||||
|
title="删除这个线路入口?"
|
||||||
|
description="这里只会移除本地同步记录,不会删除 3x-ui 面板中的入站。请确认没有套餐仍依赖它。"
|
||||||
|
confirmLabel="删除入口"
|
||||||
|
successMessage="线路入口已删除"
|
||||||
|
errorMessage="删除线路入口失败"
|
||||||
|
onConfirm={() => deleteInbound(inboundId)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ConfirmActionButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
44
src/app/(admin)/admin/nodes/inbound-display-name-form.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { updateInboundDisplayName } from "@/actions/admin/nodes";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function InboundDisplayNameForm({
|
||||||
|
inboundId,
|
||||||
|
defaultValue,
|
||||||
|
}: {
|
||||||
|
inboundId: string;
|
||||||
|
defaultValue: string;
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(formData: FormData) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateInboundDisplayName(inboundId, formData);
|
||||||
|
toast.success("前台名称已更新");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "保存失败"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<Input
|
||||||
|
name="displayName"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder="例如 悉尼 · 日常优选"
|
||||||
|
className="h-8 min-h-8 rounded-xl px-3 text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="xs" variant="outline" disabled={saving}>
|
||||||
|
{saving ? "保存中" : "保存"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
180
src/app/(admin)/admin/nodes/node-actions.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { KeyRound, Terminal } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface NodeActionValue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
agentToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/main/scripts/install-jboard-agent.sh";
|
||||||
|
|
||||||
|
function shellQuote(value: string) {
|
||||||
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerUrl() {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
const { protocol, host, hostname } = window.location;
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return "";
|
||||||
|
return `${protocol}//${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstallCommand(token: string, siteUrl: string | null) {
|
||||||
|
const serverUrl = siteUrl || getServerUrl() || "https://你的域名";
|
||||||
|
return `curl -fsSL ${INSTALL_SCRIPT_URL} | SERVER_URL=${shellQuote(serverUrl)} AUTH_TOKEN=${shellQuote(token)} bash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl: string | null }) {
|
||||||
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||||
|
const [plainToken, setPlainToken] = useState("");
|
||||||
|
const [installCommand, setInstallCommand] = useState("");
|
||||||
|
const hasToken = !!node.agentToken;
|
||||||
|
|
||||||
|
async function handleGenerateToken() {
|
||||||
|
try {
|
||||||
|
const token = await generateAgentToken(node.id);
|
||||||
|
setPlainToken(token);
|
||||||
|
setInstallCommand(buildInstallCommand(token, siteUrl));
|
||||||
|
setTokenDialogOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "生成 Token 失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const res = await testNodeConnection(node.id);
|
||||||
|
if (res.success) toast.success(res.message);
|
||||||
|
else toast.error(res.message);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "测试失败"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
测试并同步入站
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleGenerateToken}>
|
||||||
|
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{hasToken && (
|
||||||
|
<ConfirmActionButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
title="撤销这个探测 Token?"
|
||||||
|
description="撤销后,延迟和线路探测程序将无法继续上报数据。"
|
||||||
|
confirmLabel="撤销 Token"
|
||||||
|
successMessage="探测 Token 已撤销"
|
||||||
|
errorMessage="撤销失败"
|
||||||
|
onConfirm={() => revokeAgentToken(node.id)}
|
||||||
|
>
|
||||||
|
撤销 Token
|
||||||
|
</ConfirmActionButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmActionButton
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
title="删除这个节点?"
|
||||||
|
description="节点、线路入口和相关探测数据会被清理。请确认没有套餐仍依赖它。"
|
||||||
|
confirmLabel="删除节点"
|
||||||
|
successMessage="节点已删除"
|
||||||
|
errorMessage="删除失败"
|
||||||
|
onConfirm={() => deleteNode(node.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ConfirmActionButton>
|
||||||
|
|
||||||
|
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-primary/15 bg-primary/10 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-primary">
|
||||||
|
<KeyRound className="size-3.5" /> PROBE TOKEN
|
||||||
|
</div>
|
||||||
|
<DialogTitle>探测 Token — {node.name}</DialogTitle>
|
||||||
|
<DialogDescription>请立即复制 Token 或一键安装命令,关闭后将无法再次查看。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground">探测 Token</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||||
|
{plainToken}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(plainToken);
|
||||||
|
toast.success("Token 已复制");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制 Token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||||
|
<Terminal className="size-3.5" /> 一键安装探测 Agent
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<code className="block w-full select-all break-all font-mono text-xs text-foreground">
|
||||||
|
{installCommand}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(installCommand);
|
||||||
|
toast.success("安装命令已复制");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制一键安装命令
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!siteUrl && (
|
||||||
|
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200">
|
||||||
|
建议先到系统设置填写站点域名,否则从本地地址打开后台时命令会带本机地址。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
此 Agent 仅用于 `/api/agent/latency` 和 `/api/agent/trace` 探测上报;节点客户端开通已改由 3x-ui 面板 API 处理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
108
src/app/(admin)/admin/nodes/node-form.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { createNode, updateNode } from "@/actions/admin/nodes";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface NodeFormValue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
panelUrl: string | null;
|
||||||
|
panelUsername: string | null;
|
||||||
|
panelPassword: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeForm({
|
||||||
|
node,
|
||||||
|
triggerLabel,
|
||||||
|
triggerVariant = "default",
|
||||||
|
}: {
|
||||||
|
node?: NodeFormValue;
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerVariant?: "default" | "outline" | "ghost";
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(node);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
async function handleCreate(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const result = await createNode(formData);
|
||||||
|
if (result.success) toast.success(result.message);
|
||||||
|
else toast.warning(result.message);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "创建失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(formData: FormData) {
|
||||||
|
try {
|
||||||
|
const result = await updateNode(node!.id, formData);
|
||||||
|
if (result.success) toast.success(result.message);
|
||||||
|
else toast.warning(result.message);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "更新失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||||
|
>
|
||||||
|
{triggerLabel || (isEdit ? "编辑" : "添加节点")}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
保存后会登录 3x-ui 并同步面板中的入站线路;入站请在 3x-ui 面板内维护。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>节点名称</Label>
|
||||||
|
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>3x-ui 面板地址</Label>
|
||||||
|
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>面板用户名</Label>
|
||||||
|
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>面板密码</Label>
|
||||||
|
<Input name="panelPassword" type="password" defaultValue={node?.panelPassword ?? ""} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
延迟和线路探测仍使用探测 Token;节点开通、暂停、删除客户端均回归 3x-ui 面板 API。
|
||||||
|
</p>
|
||||||
|
<Button type="submit" size="lg" className="w-full">
|
||||||
|
{isEdit ? "保存并同步入站" : "创建并同步入站"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
57
src/app/(admin)/admin/nodes/nodes-data.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parsePage } from "@/lib/utils";
|
||||||
|
import { getConfiguredSiteUrl } from "@/services/site-url";
|
||||||
|
|
||||||
|
const nodeInclude = {
|
||||||
|
_count: { select: { inbounds: true } },
|
||||||
|
inbounds: {
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
protocol: true,
|
||||||
|
port: true,
|
||||||
|
tag: true,
|
||||||
|
settings: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.NodeServerInclude;
|
||||||
|
|
||||||
|
export type NodeServerRow = Prisma.NodeServerGetPayload<{
|
||||||
|
include: typeof nodeInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function getNodeServers(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
|
) {
|
||||||
|
const { page, skip, pageSize } = parsePage(searchParams);
|
||||||
|
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||||
|
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" as const } },
|
||||||
|
{ panelUrl: { contains: q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies Prisma.NodeServerWhereInput;
|
||||||
|
|
||||||
|
const [nodes, total, siteUrl] = await Promise.all([
|
||||||
|
prisma.nodeServer.findMany({
|
||||||
|
where,
|
||||||
|
include: nodeInclude,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.nodeServer.count({ where }),
|
||||||
|
getConfiguredSiteUrl(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { nodes, total, page, pageSize, filters: { q, status }, siteUrl };
|
||||||
|
}
|
||||||
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
47
src/app/(admin)/admin/nodes/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { NodeForm } from "./node-form";
|
||||||
|
import { NodeCardList } from "./_components/node-card-list";
|
||||||
|
import { getNodeServers } from "./nodes-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "节点管理",
|
||||||
|
description: "维护节点面板连接与可售入站配置。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function NodesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { nodes, total, page, pageSize, filters, siteUrl } = await getNodeServers(await searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="基础设施"
|
||||||
|
title="节点管理"
|
||||||
|
actions={<NodeForm />}
|
||||||
|
/>
|
||||||
|
<AdminFilterBar
|
||||||
|
q={filters.q}
|
||||||
|
searchPlaceholder="搜索节点名、主机或面板地址"
|
||||||
|
selects={[
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
value: filters.status,
|
||||||
|
options: [
|
||||||
|
{ label: "全部状态", value: "" },
|
||||||
|
{ label: "active", value: "active" },
|
||||||
|
{ label: "inactive", value: "inactive" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<NodeCardList nodes={nodes} siteUrl={siteUrl} />
|
||||||
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/(admin)/admin/not-found.tsx
Normal file
22
src/app/(admin)/admin/not-found.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function AdminNotFound() {
|
||||||
|
return (
|
||||||
|
<section className="surface-card mx-auto w-full max-w-md space-y-4 rounded-xl p-6 text-center">
|
||||||
|
<p className="text-xs font-medium tracking-wide text-primary">404</p>
|
||||||
|
<h1 className="text-display text-2xl font-semibold">目标数据不存在</h1>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
记录可能已被删除,或当前管理账号没有读取权限。
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2.5">
|
||||||
|
<Link href="/admin/dashboard" className={buttonVariants({ size: "lg" })}>
|
||||||
|
返回仪表盘
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/support" className={buttonVariants({ variant: "outline", size: "lg" })}>
|
||||||
|
返回工单列表
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/(admin)/admin/orders/_components/orders-table.tsx
Normal file
128
src/app/(admin)/admin/orders/_components/orders-table.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { batchOrderOperation } from "@/actions/admin/orders";
|
||||||
|
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||||
|
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeadCell,
|
||||||
|
DataTableHeaderRow,
|
||||||
|
DataTableRow,
|
||||||
|
} from "@/components/shared/data-table";
|
||||||
|
import {
|
||||||
|
OrderReviewStatusBadge,
|
||||||
|
OrderStatusBadge,
|
||||||
|
orderKindLabels,
|
||||||
|
} from "@/components/shared/domain-badges";
|
||||||
|
import { formatDateShort } from "@/lib/utils";
|
||||||
|
import { OrderActions } from "../order-actions";
|
||||||
|
import { OrderReviewActions } from "../order-review-actions";
|
||||||
|
import type { AdminOrderRow } from "../orders-data";
|
||||||
|
|
||||||
|
interface OrdersTableProps {
|
||||||
|
orders: AdminOrderRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderAmount(amount: { toString(): string }) {
|
||||||
|
return `¥${Number(amount).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderTraffic(trafficGb: number | null) {
|
||||||
|
return trafficGb === null ? "—" : `${trafficGb} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersTable({ orders }: OrdersTableProps) {
|
||||||
|
return (
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={orders.length === 0}
|
||||||
|
emptyTitle="暂无订单"
|
||||||
|
emptyDescription="用户创建订单后,支付和审查状态会出现在这里。"
|
||||||
|
toolbar={
|
||||||
|
<BatchActionBar
|
||||||
|
id="order-batch-form"
|
||||||
|
action={batchOrderOperation}
|
||||||
|
className="rounded-none bg-transparent"
|
||||||
|
>
|
||||||
|
<BatchActionButton value="confirm">批量确认</BatchActionButton>
|
||||||
|
<BatchActionButton value="cancel" destructive>
|
||||||
|
批量取消
|
||||||
|
</BatchActionButton>
|
||||||
|
</BatchActionBar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataTable aria-label="订单列表" className="min-w-[1180px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>选择</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>金额</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>流量</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>支付</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>审查</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>备注</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell className="text-right">操作</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<DataTableRow key={order.id}>
|
||||||
|
<DataTableCell>
|
||||||
|
<input
|
||||||
|
form="order-batch-form"
|
||||||
|
type="checkbox"
|
||||||
|
name="orderIds"
|
||||||
|
value={order.id}
|
||||||
|
aria-label={`选择订单 ${order.id}`}
|
||||||
|
/>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-56 whitespace-normal break-all">
|
||||||
|
<p className="font-medium">{order.user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-52 whitespace-normal break-words font-medium">{order.plan.name}</DataTableCell>
|
||||||
|
<DataTableCell className="text-muted-foreground">{orderKindLabels[order.kind]}</DataTableCell>
|
||||||
|
<DataTableCell className="tabular-nums">{formatOrderAmount(order.amount)}</DataTableCell>
|
||||||
|
<DataTableCell className="text-muted-foreground">{formatOrderTraffic(order.trafficGb)}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{order.paymentMethod || "—"}</p>
|
||||||
|
<p className="max-w-48 break-all text-xs text-muted-foreground">
|
||||||
|
{order.tradeNo || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<OrderReviewStatusBadge status={order.reviewStatus} />
|
||||||
|
<OrderReviewActions orderId={order.id} reviewStatus={order.reviewStatus} />
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-64 text-xs text-muted-foreground">
|
||||||
|
<div className="space-y-1 whitespace-pre-wrap break-words">
|
||||||
|
<p>{order.note || "—"}</p>
|
||||||
|
{order.reviewNote && <p>审查备注:{order.reviewNote}</p>}
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateShort(order.createdAt)}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<OrderActions orderId={order.id} status={order.status} />
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/(admin)/admin/orders/order-actions.tsx
Normal file
49
src/app/(admin)/admin/orders/order-actions.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { confirmOrder, cancelOrder } from "@/actions/admin/orders";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
type AdminOrderActionStatus = "PENDING" | "PAID" | "CANCELLED" | "REFUNDED";
|
||||||
|
|
||||||
|
export function OrderActions({
|
||||||
|
orderId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
status: AdminOrderActionStatus;
|
||||||
|
}) {
|
||||||
|
if (status !== "PENDING") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await confirmOrder(orderId);
|
||||||
|
toast.success("订单已确认并已处理");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "确认失败"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await cancelOrder(orderId);
|
||||||
|
toast.success("已取消");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "取消失败"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/(admin)/admin/orders/order-review-actions.tsx
Normal file
48
src/app/(admin)/admin/orders/order-review-actions.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { updateOrderReview } from "@/actions/admin/orders";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function OrderReviewActions({
|
||||||
|
orderId,
|
||||||
|
reviewStatus,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED";
|
||||||
|
}) {
|
||||||
|
async function handle(status: "FLAGGED" | "RESOLVED" | "NORMAL") {
|
||||||
|
const note =
|
||||||
|
status === "NORMAL"
|
||||||
|
? ""
|
||||||
|
: prompt("请输入异常备注/处理备注(可留空)") ?? "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateOrderReview(orderId, status, note);
|
||||||
|
toast.success("订单审查状态已更新");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "更新失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{reviewStatus !== "FLAGGED" && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void handle("FLAGGED")}>
|
||||||
|
标记异常
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{reviewStatus !== "RESOLVED" && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void handle("RESOLVED")}>
|
||||||
|
标记解决
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{reviewStatus !== "NORMAL" && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void handle("NORMAL")}>
|
||||||
|
恢复正常
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/app/(admin)/admin/orders/orders-data.ts
Normal file
54
src/app/(admin)/admin/orders/orders-data.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parsePage } from "@/lib/utils";
|
||||||
|
|
||||||
|
const adminOrderInclude = {
|
||||||
|
user: true,
|
||||||
|
plan: true,
|
||||||
|
} satisfies Prisma.OrderInclude;
|
||||||
|
|
||||||
|
export type AdminOrderRow = Prisma.OrderGetPayload<{
|
||||||
|
include: typeof adminOrderInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function getAdminOrders(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
|
) {
|
||||||
|
const { page, skip, pageSize } = parsePage(searchParams);
|
||||||
|
const q = typeof searchParams.q === "string" ? searchParams.q.trim() : "";
|
||||||
|
const status = typeof searchParams.status === "string" ? searchParams.status : "";
|
||||||
|
const kind = typeof searchParams.kind === "string" ? searchParams.kind : "";
|
||||||
|
const reviewStatus =
|
||||||
|
typeof searchParams.reviewStatus === "string" ? searchParams.reviewStatus : "";
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(status ? { status: status as "PENDING" | "PAID" | "CANCELLED" | "REFUNDED" } : {}),
|
||||||
|
...(kind ? { kind: kind as "NEW_PURCHASE" | "RENEWAL" | "TRAFFIC_TOPUP" } : {}),
|
||||||
|
...(reviewStatus
|
||||||
|
? { reviewStatus: reviewStatus as "NORMAL" | "FLAGGED" | "RESOLVED" }
|
||||||
|
: {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||||
|
{ user: { name: { contains: q, mode: "insensitive" as const } } },
|
||||||
|
{ plan: { name: { contains: q, mode: "insensitive" as const } } },
|
||||||
|
{ tradeNo: { contains: q, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} satisfies Prisma.OrderWhereInput;
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: adminOrderInclude,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } };
|
||||||
|
}
|
||||||
67
src/app/(admin)/admin/orders/page.tsx
Normal file
67
src/app/(admin)/admin/orders/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { OrdersTable } from "./_components/orders-table";
|
||||||
|
import { getAdminOrders } from "./orders-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "订单管理",
|
||||||
|
description: "跟踪订单状态、审查结果与支付记录。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OrdersPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const { orders, total, page, pageSize, filters } = await getAdminOrders(await searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="商品与订单"
|
||||||
|
title="订单管理"
|
||||||
|
/>
|
||||||
|
<AdminFilterBar
|
||||||
|
q={filters.q}
|
||||||
|
searchPlaceholder="搜索邮箱、套餐、交易号"
|
||||||
|
selects={[
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
value: filters.status,
|
||||||
|
options: [
|
||||||
|
{ label: "全部状态", value: "" },
|
||||||
|
{ label: "待确认", value: "PENDING" },
|
||||||
|
{ label: "已支付", value: "PAID" },
|
||||||
|
{ label: "已取消", value: "CANCELLED" },
|
||||||
|
{ label: "已退款", value: "REFUNDED" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kind",
|
||||||
|
value: filters.kind,
|
||||||
|
options: [
|
||||||
|
{ label: "全部类型", value: "" },
|
||||||
|
{ label: "新购", value: "NEW_PURCHASE" },
|
||||||
|
{ label: "续费", value: "RENEWAL" },
|
||||||
|
{ label: "增流量", value: "TRAFFIC_TOPUP" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reviewStatus",
|
||||||
|
value: filters.reviewStatus,
|
||||||
|
options: [
|
||||||
|
{ label: "全部审查", value: "" },
|
||||||
|
{ label: "正常", value: "NORMAL" },
|
||||||
|
{ label: "异常", value: "FLAGGED" },
|
||||||
|
{ label: "已解决", value: "RESOLVED" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<OrdersTable orders={orders} />
|
||||||
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/(admin)/admin/page.tsx
Normal file
11
src/app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "管理后台",
|
||||||
|
description: "管理后台入口页。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminIndexPage() {
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
120
src/app/(admin)/admin/payments/config-form.tsx
Normal file
120
src/app/(admin)/admin/payments/config-form.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { savePaymentConfig } from "@/actions/admin/payments";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
type?: "text" | "checkboxes";
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
provider: string;
|
||||||
|
fields: Field[];
|
||||||
|
currentConfig?: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentConfigForm({ provider, fields, currentConfig, enabled: initialEnabled }: Props) {
|
||||||
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Track checkbox field values (comma-separated strings)
|
||||||
|
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() => {
|
||||||
|
const init: Record<string, Set<string>> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.type === "checkboxes") {
|
||||||
|
const raw = currentConfig?.[field.key] || "";
|
||||||
|
init[field.key] = new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleCheckbox(fieldKey: string, value: string) {
|
||||||
|
setCheckboxValues((prev) => {
|
||||||
|
const next = new Set(prev[fieldKey]);
|
||||||
|
if (next.has(value)) next.delete(value);
|
||||||
|
else next.add(value);
|
||||||
|
return { ...prev, [fieldKey]: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.type === "checkboxes") {
|
||||||
|
config[field.key] = Array.from(checkboxValues[field.key] ?? []).join(",");
|
||||||
|
} else {
|
||||||
|
config[field.key] = (formData.get(field.key) as string) || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await savePaymentConfig(provider, config, enabled);
|
||||||
|
toast.success("保存成功");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "保存失败"));
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="form-panel space-y-5">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{fields.map((field) =>
|
||||||
|
field.type === "checkboxes" ? (
|
||||||
|
<div key={field.key} className="sm:col-span-2">
|
||||||
|
<Label>{field.label}</Label>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3">
|
||||||
|
{field.options?.map((opt) => (
|
||||||
|
<label key={opt.value} className="choice-card flex cursor-pointer items-center gap-2 px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 rounded border-border accent-primary"
|
||||||
|
checked={checkboxValues[field.key]?.has(opt.value) ?? false}
|
||||||
|
onChange={() => toggleCheckbox(field.key, opt.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={field.key}>
|
||||||
|
<Label>{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
name={field.key}
|
||||||
|
type={field.secret ? "password" : "text"}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
defaultValue={currentConfig?.[field.key] || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<span className="text-sm">{enabled ? "已启用" : "未启用"}</span>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={saving}>
|
||||||
|
{saving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/(admin)/admin/payments/page.tsx
Normal file
48
src/app/(admin)/admin/payments/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { CreditCard } from "lucide-react";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { ActiveStatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { PaymentConfigForm } from "./config-form";
|
||||||
|
import { getPaymentProviderConfigs } from "./payments-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "支付配置",
|
||||||
|
description: "配置支付渠道、密钥与启用状态。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PaymentsPage() {
|
||||||
|
const providerConfigs = await getPaymentProviderConfigs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="系统"
|
||||||
|
title="支付配置"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{providerConfigs.map(({ provider, config }) => (
|
||||||
|
<section key={provider.id} className="surface-card overflow-hidden rounded-xl p-4">
|
||||||
|
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<CreditCard className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">{provider.name}</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-muted-foreground text-pretty">{provider.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActiveStatusBadge active={config?.enabled ?? false} activeLabel="已启用" inactiveLabel="未启用" />
|
||||||
|
</div>
|
||||||
|
<PaymentConfigForm
|
||||||
|
provider={provider.id}
|
||||||
|
fields={provider.fields}
|
||||||
|
currentConfig={config?.config as Record<string, string> | undefined}
|
||||||
|
enabled={config?.enabled ?? false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(admin)/admin/payments/payments-data.ts
Normal file
12
src/app/(admin)/admin/payments/payments-data.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { PAYMENT_PROVIDER_DEFINITIONS } from "@/services/payment/catalog";
|
||||||
|
|
||||||
|
export async function getPaymentProviderConfigs() {
|
||||||
|
const configs = await prisma.paymentConfig.findMany();
|
||||||
|
const configMap = new Map(configs.map((config) => [config.provider, config]));
|
||||||
|
|
||||||
|
return PAYMENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
||||||
|
provider,
|
||||||
|
config: configMap.get(provider.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
48
src/app/(admin)/admin/plans/_components/plans-list.tsx
Normal file
48
src/app/(admin)/admin/plans/_components/plans-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { batchPlanOperation } from "@/actions/admin/plans";
|
||||||
|
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
|
||||||
|
import { EmptyState } from "@/components/shared/page-shell";
|
||||||
|
import { PlanCard } from "../plan-card";
|
||||||
|
import { PlanForm, type StreamingServiceOption } from "../plan-form";
|
||||||
|
import type { AdminPlanRow } from "../plans-data";
|
||||||
|
|
||||||
|
export const PLAN_BATCH_FORM_ID = "plan-batch-form";
|
||||||
|
|
||||||
|
export function PlansList({
|
||||||
|
plans,
|
||||||
|
activeCountMap,
|
||||||
|
services,
|
||||||
|
}: {
|
||||||
|
plans: AdminPlanRow[];
|
||||||
|
activeCountMap: Map<string, number>;
|
||||||
|
services: StreamingServiceOption[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BatchActionBar id={PLAN_BATCH_FORM_ID} action={batchPlanOperation}>
|
||||||
|
<BatchActionButton value="enable">批量上架</BatchActionButton>
|
||||||
|
<BatchActionButton value="disable">批量下架</BatchActionButton>
|
||||||
|
<BatchActionButton value="delete" destructive>
|
||||||
|
批量彻底删除
|
||||||
|
</BatchActionButton>
|
||||||
|
</BatchActionBar>
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
activeCount={activeCountMap.get(plan.id) ?? 0}
|
||||||
|
services={services}
|
||||||
|
batchFormId={PLAN_BATCH_FORM_ID}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{plans.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无套餐"
|
||||||
|
description="创建第一个套餐后,用户就可以在商店中购买。"
|
||||||
|
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/app/(admin)/admin/plans/page.tsx
Normal file
68
src/app/(admin)/admin/plans/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { PlanForm } from "./plan-form";
|
||||||
|
import { PlansList } from "./_components/plans-list";
|
||||||
|
import { getAdminPlans } from "./plans-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "套餐管理",
|
||||||
|
description: "管理代理与流媒体套餐配置及上架状态。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PlansPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
plans,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
filters,
|
||||||
|
activeCountMap,
|
||||||
|
serviceOptions,
|
||||||
|
} = await getAdminPlans(await searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="商品与订单"
|
||||||
|
title="套餐管理"
|
||||||
|
actions={<PlanForm services={serviceOptions} />}
|
||||||
|
/>
|
||||||
|
<AdminFilterBar
|
||||||
|
q={filters.q}
|
||||||
|
searchPlaceholder="搜索套餐名或描述"
|
||||||
|
selects={[
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
value: filters.type,
|
||||||
|
options: [
|
||||||
|
{ label: "全部类型", value: "" },
|
||||||
|
{ label: "代理套餐", value: "PROXY" },
|
||||||
|
{ label: "流媒体套餐", value: "STREAMING" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
value: filters.status,
|
||||||
|
options: [
|
||||||
|
{ label: "全部状态", value: "" },
|
||||||
|
{ label: "上架中", value: "active" },
|
||||||
|
{ label: "已下架", value: "inactive" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PlansList
|
||||||
|
plans={plans}
|
||||||
|
activeCountMap={activeCountMap}
|
||||||
|
services={serviceOptions}
|
||||||
|
/>
|
||||||
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(admin)/admin/plans/plan-actions.tsx
Normal file
64
src/app/(admin)/admin/plans/plan-actions.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
|
||||||
|
import { deletePlanPermanently, togglePlan } from "@/actions/admin/plans";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
PlanForm,
|
||||||
|
type PlanFormValue,
|
||||||
|
type StreamingServiceOption,
|
||||||
|
} from "./plan-form";
|
||||||
|
|
||||||
|
export function PlanActions({
|
||||||
|
plan,
|
||||||
|
isActive,
|
||||||
|
services,
|
||||||
|
}: {
|
||||||
|
plan: PlanFormValue;
|
||||||
|
isActive: boolean;
|
||||||
|
services: StreamingServiceOption[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PlanForm
|
||||||
|
plan={plan}
|
||||||
|
services={services}
|
||||||
|
triggerLabel="编辑"
|
||||||
|
triggerVariant="outline"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await togglePlan(plan.id, !isActive);
|
||||||
|
toast.success(isActive ? "套餐已下架" : "套餐已上架");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "切换失败";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive ? "下架" : "上架"}
|
||||||
|
</Button>
|
||||||
|
<ConfirmActionButton
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
title="彻底删除套餐?"
|
||||||
|
description="关联订阅、本地订单记录和可同步的独占入口会一起处理。此操作无法恢复。"
|
||||||
|
confirmLabel="删除套餐"
|
||||||
|
successMessage="套餐已删除"
|
||||||
|
errorMessage="删除失败"
|
||||||
|
onConfirm={() => deletePlanPermanently(plan.id)}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ConfirmActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/app/(admin)/admin/plans/plan-basics-section.tsx
Normal file
166
src/app/(admin)/admin/plans/plan-basics-section.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type {
|
||||||
|
InboundOption,
|
||||||
|
PlanFormValue,
|
||||||
|
PlanType,
|
||||||
|
StreamingServiceOption,
|
||||||
|
} from "./plan-form-types";
|
||||||
|
|
||||||
|
type FieldId = (name: string) => string;
|
||||||
|
|
||||||
|
interface PlanBasicsFieldsProps {
|
||||||
|
fieldId: FieldId;
|
||||||
|
isEdit: boolean;
|
||||||
|
type: PlanType;
|
||||||
|
setType: Dispatch<SetStateAction<PlanType>>;
|
||||||
|
plan?: PlanFormValue;
|
||||||
|
services: StreamingServiceOption[];
|
||||||
|
streamingServiceId: string;
|
||||||
|
setStreamingServiceId: Dispatch<SetStateAction<string>>;
|
||||||
|
hasStreamingServices: boolean;
|
||||||
|
setInbounds: Dispatch<SetStateAction<InboundOption[]>>;
|
||||||
|
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
setAllowTrafficTopup: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanBasicsFields({
|
||||||
|
fieldId,
|
||||||
|
isEdit,
|
||||||
|
type,
|
||||||
|
setType,
|
||||||
|
plan,
|
||||||
|
services,
|
||||||
|
streamingServiceId,
|
||||||
|
setStreamingServiceId,
|
||||||
|
hasStreamingServices,
|
||||||
|
setInbounds,
|
||||||
|
setSelectedInboundIds,
|
||||||
|
setAllowTrafficTopup,
|
||||||
|
}: PlanBasicsFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("type")}>套餐类型</Label>
|
||||||
|
{isEdit ? (
|
||||||
|
<div id={fieldId("type")} className="premium-input flex h-11 items-center px-3 text-sm font-medium">
|
||||||
|
{type === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextType = value as PlanType;
|
||||||
|
setType(nextType);
|
||||||
|
if (nextType !== "PROXY") {
|
||||||
|
setInbounds([]);
|
||||||
|
setSelectedInboundIds([]);
|
||||||
|
setAllowTrafficTopup(false);
|
||||||
|
if (!streamingServiceId && hasStreamingServices) {
|
||||||
|
setStreamingServiceId(services[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={fieldId("type")}>
|
||||||
|
<SelectValue placeholder="选择类型">
|
||||||
|
{(value) => value === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PROXY">代理节点套餐</SelectItem>
|
||||||
|
<SelectItem value="STREAMING">流媒体套餐</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("name")}>套餐名称</Label>
|
||||||
|
<Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("durationDays")}>有效期(天)</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("durationDays")}
|
||||||
|
name="durationDays"
|
||||||
|
type="number"
|
||||||
|
defaultValue={plan?.durationDays ?? 30}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("sortOrder")}>排序</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("sortOrder")}
|
||||||
|
name="sortOrder"
|
||||||
|
type="number"
|
||||||
|
defaultValue={plan?.sortOrder ?? 100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("description")}>用户说明</Label>
|
||||||
|
<Textarea
|
||||||
|
id={fieldId("description")}
|
||||||
|
name="description"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={plan?.description ?? ""}
|
||||||
|
placeholder="适合的使用场景、交付方式与体验边界"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanLimitsFields({
|
||||||
|
fieldId,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
fieldId: FieldId;
|
||||||
|
plan?: PlanFormValue;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("totalLimit")}>总库存</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("totalLimit")}
|
||||||
|
name="totalLimit"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.totalLimit ?? ""}
|
||||||
|
placeholder="留空=不限量"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("perUserLimit")}>每用户限购</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("perUserLimit")}
|
||||||
|
name="perUserLimit"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.perUserLimit ?? ""}
|
||||||
|
placeholder="留空=不限购"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use PlanBasicsFields + PlanLimitsFields instead */
|
||||||
|
export const PlanBasicsSection = PlanBasicsFields;
|
||||||
231
src/app/(admin)/admin/plans/plan-card.tsx
Normal file
231
src/app/(admin)/admin/plans/plan-card.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Network, Tv } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||||
|
import { DetailItem, DetailList } from "@/components/admin/detail-list";
|
||||||
|
import {
|
||||||
|
PlanFormValue,
|
||||||
|
type StreamingServiceOption,
|
||||||
|
} from "./plan-form";
|
||||||
|
import { PlanActions } from "./plan-actions";
|
||||||
|
|
||||||
|
type NumericLike = number | { toString(): string } | null | undefined;
|
||||||
|
|
||||||
|
interface PlanListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "PROXY" | "STREAMING";
|
||||||
|
description: string | null;
|
||||||
|
durationDays: number;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
price: NumericLike;
|
||||||
|
nodeId: string | null;
|
||||||
|
inboundId: string | null;
|
||||||
|
streamingServiceId: string | null;
|
||||||
|
pricingMode: "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
|
||||||
|
fixedTrafficGb: number | null;
|
||||||
|
fixedPrice: NumericLike;
|
||||||
|
totalLimit: number | null;
|
||||||
|
perUserLimit: number | null;
|
||||||
|
totalTrafficGb: number | null;
|
||||||
|
allowRenewal: boolean;
|
||||||
|
allowTrafficTopup: boolean;
|
||||||
|
renewalPrice: NumericLike;
|
||||||
|
renewalPricingMode: string;
|
||||||
|
renewalDurationDays: number | null;
|
||||||
|
renewalMinDays: number | null;
|
||||||
|
renewalMaxDays: number | null;
|
||||||
|
renewalTrafficGb: number | null;
|
||||||
|
topupPricingMode: string;
|
||||||
|
topupPricePerGb: NumericLike;
|
||||||
|
topupFixedPrice: NumericLike;
|
||||||
|
minTopupGb: number | null;
|
||||||
|
maxTopupGb: number | null;
|
||||||
|
pricePerGb: NumericLike;
|
||||||
|
minTrafficGb: number | null;
|
||||||
|
maxTrafficGb: number | null;
|
||||||
|
node: { name: string } | null;
|
||||||
|
inbound: { protocol: string; port: number; tag: string } | null;
|
||||||
|
streamingService: { name: string; usedSlots: number; maxSlots: number } | null;
|
||||||
|
inboundOptions: Array<{
|
||||||
|
inboundId: string;
|
||||||
|
inbound: { protocol: string; port: number; tag: string };
|
||||||
|
}>;
|
||||||
|
_count: { subscriptions: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanCardProps {
|
||||||
|
plan: PlanListItem;
|
||||||
|
activeCount: number;
|
||||||
|
services: StreamingServiceOption[];
|
||||||
|
batchFormId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: NumericLike): number | null {
|
||||||
|
return value == null ? null : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: NumericLike): string {
|
||||||
|
return `¥${Number(value ?? 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renewalSummary(plan: PlanListItem) {
|
||||||
|
if (!plan.allowRenewal) return "续费关闭";
|
||||||
|
if (plan.renewalPricingMode === "PER_DAY") {
|
||||||
|
return `${money(plan.renewalPrice)}/天 · ${plan.renewalMinDays ?? 1}-${plan.renewalMaxDays ?? plan.durationDays} 天`;
|
||||||
|
}
|
||||||
|
return `${money(plan.renewalPrice)} / ${plan.renewalDurationDays ?? plan.durationDays} 天`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topupSummary(plan: PlanListItem) {
|
||||||
|
if (!plan.allowTrafficTopup) return "增流量关闭";
|
||||||
|
const range = plan.maxTopupGb == null
|
||||||
|
? `最少 ${plan.minTopupGb ?? 1} GB`
|
||||||
|
: `${plan.minTopupGb ?? 1}-${plan.maxTopupGb} GB`;
|
||||||
|
if (plan.topupPricingMode === "FIXED_AMOUNT") {
|
||||||
|
return `${money(plan.topupFixedPrice)} 固定 · ${range}`;
|
||||||
|
}
|
||||||
|
return `${money(plan.topupPricePerGb)}/GB · ${range}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
type: plan.type,
|
||||||
|
description: plan.description,
|
||||||
|
durationDays: plan.durationDays,
|
||||||
|
sortOrder: plan.sortOrder,
|
||||||
|
price: toNumber(plan.price),
|
||||||
|
nodeId: plan.nodeId,
|
||||||
|
inboundId: plan.inboundId,
|
||||||
|
inboundOptionIds: plan.inboundOptions.map((option) => option.inboundId),
|
||||||
|
streamingServiceId: plan.streamingServiceId,
|
||||||
|
pricingMode: plan.pricingMode,
|
||||||
|
fixedTrafficGb: plan.fixedTrafficGb,
|
||||||
|
fixedPrice: toNumber(plan.fixedPrice),
|
||||||
|
totalLimit: plan.totalLimit,
|
||||||
|
perUserLimit: plan.perUserLimit,
|
||||||
|
totalTrafficGb: plan.totalTrafficGb,
|
||||||
|
allowRenewal: plan.allowRenewal,
|
||||||
|
allowTrafficTopup: plan.allowTrafficTopup,
|
||||||
|
renewalPrice: toNumber(plan.renewalPrice),
|
||||||
|
renewalPricingMode: plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION",
|
||||||
|
renewalDurationDays: plan.renewalDurationDays,
|
||||||
|
renewalMinDays: plan.renewalMinDays,
|
||||||
|
renewalMaxDays: plan.renewalMaxDays,
|
||||||
|
renewalTrafficGb: plan.renewalTrafficGb,
|
||||||
|
topupPricingMode: plan.topupPricingMode === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PER_GB",
|
||||||
|
topupPricePerGb: toNumber(plan.topupPricePerGb),
|
||||||
|
topupFixedPrice: toNumber(plan.topupFixedPrice),
|
||||||
|
minTopupGb: plan.minTopupGb,
|
||||||
|
maxTopupGb: plan.maxTopupGb,
|
||||||
|
pricePerGb: toNumber(plan.pricePerGb),
|
||||||
|
minTrafficGb: plan.minTrafficGb,
|
||||||
|
maxTrafficGb: plan.maxTrafficGb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
|
||||||
|
const remaining = plan.totalLimit == null ? null : Math.max(0, plan.totalLimit - activeCount);
|
||||||
|
const planFormValue = buildPlanFormValue(plan);
|
||||||
|
const Icon = plan.type === "PROXY" ? Network : Tv;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-4">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<input
|
||||||
|
form={batchFormId}
|
||||||
|
type="checkbox"
|
||||||
|
name="planIds"
|
||||||
|
value={plan.id}
|
||||||
|
aria-label={`选择套餐 ${plan.name}`}
|
||||||
|
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
|
/>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
<CardTitle className="text-lg text-balance">{plan.name}</CardTitle>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
||||||
|
{plan.description || "无描述"} · 总订阅 {plan._count.subscriptions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlanActions
|
||||||
|
isActive={plan.isActive}
|
||||||
|
services={services}
|
||||||
|
plan={planFormValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
||||||
|
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
|
||||||
|
</StatusBadge>
|
||||||
|
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
||||||
|
<StatusBadge>{plan.durationDays} 天</StatusBadge>
|
||||||
|
<StatusBadge>
|
||||||
|
{plan.type === "PROXY"
|
||||||
|
? plan.pricingMode === "FIXED_PACKAGE"
|
||||||
|
? `${money(plan.fixedPrice)} / ${plan.fixedTrafficGb ?? 0}GB`
|
||||||
|
: `${money(plan.pricePerGb)}/GB`
|
||||||
|
: money(plan.price)}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{plan.type === "PROXY" ? (
|
||||||
|
<DetailList>
|
||||||
|
<DetailItem label="节点">{plan.node?.name ?? "未绑定"}</DetailItem>
|
||||||
|
<DetailItem label="入站">
|
||||||
|
{plan.inboundOptions.length > 0
|
||||||
|
? plan.inboundOptions
|
||||||
|
.map((option) => `${option.inbound.protocol}:${option.inbound.port}`)
|
||||||
|
.join(" / ")
|
||||||
|
: plan.inbound
|
||||||
|
? `${plan.inbound.protocol}:${plan.inbound.port}`
|
||||||
|
: "未绑定"}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="售卖方式">
|
||||||
|
{plan.pricingMode === "FIXED_PACKAGE"
|
||||||
|
? `固定 ${plan.fixedTrafficGb ?? 0} GB · ${money(plan.fixedPrice)}`
|
||||||
|
: `自选 ${plan.minTrafficGb ?? 0}-${plan.maxTrafficGb ?? 0} GB`}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="流量池">
|
||||||
|
{plan.totalTrafficGb == null ? "未配置" : `${plan.totalTrafficGb} GB`}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="库存">
|
||||||
|
{plan.totalLimit == null
|
||||||
|
? "不限量"
|
||||||
|
: `${activeCount}/${plan.totalLimit}${remaining === 0 ? " (已满)" : ""}`}
|
||||||
|
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="续费 / 增流量">
|
||||||
|
{renewalSummary(plan)} / {topupSummary(plan)}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailList>
|
||||||
|
) : (
|
||||||
|
<DetailList>
|
||||||
|
<DetailItem label="绑定服务">{plan.streamingService?.name ?? "未绑定"}</DetailItem>
|
||||||
|
<DetailItem label="服务占用">
|
||||||
|
{plan.streamingService
|
||||||
|
? `${plan.streamingService.usedSlots}/${plan.streamingService.maxSlots}`
|
||||||
|
: "-"}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="续费">
|
||||||
|
{renewalSummary(plan)}
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label="库存">
|
||||||
|
{plan.totalLimit == null ? "不限量" : `${activeCount}/${plan.totalLimit}`}
|
||||||
|
{plan.perUserLimit != null ? ` · 每人限 ${plan.perUserLimit}` : ""}
|
||||||
|
</DetailItem>
|
||||||
|
</DetailList>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/app/(admin)/admin/plans/plan-form-sections.tsx
Normal file
6
src/app/(admin)/admin/plans/plan-form-sections.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export { PlanBasicsFields, PlanLimitsFields, PlanBasicsSection } from "./plan-basics-section";
|
||||||
|
export { PlanPolicySection } from "./plan-policy-section";
|
||||||
|
export { ProxyNodeFields, ProxyPricingFields, ProxyConfigSection } from "./proxy-config-section";
|
||||||
|
export { StreamingConfigSection } from "./streaming-config-section";
|
||||||
57
src/app/(admin)/admin/plans/plan-form-types.ts
Normal file
57
src/app/(admin)/admin/plans/plan-form-types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export type PlanType = "STREAMING" | "PROXY";
|
||||||
|
export type PlanPricingMode = "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
|
||||||
|
|
||||||
|
export interface NodeOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboundOption {
|
||||||
|
id: string;
|
||||||
|
protocol: "VMESS" | "VLESS" | "TROJAN" | "SHADOWSOCKS" | "HYSTERIA2";
|
||||||
|
port: number;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanFormValue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: PlanType;
|
||||||
|
description: string | null;
|
||||||
|
durationDays: number;
|
||||||
|
sortOrder: number;
|
||||||
|
price: number | null;
|
||||||
|
nodeId: string | null;
|
||||||
|
inboundId: string | null;
|
||||||
|
inboundOptionIds: string[];
|
||||||
|
streamingServiceId: string | null;
|
||||||
|
pricingMode: PlanPricingMode;
|
||||||
|
fixedTrafficGb: number | null;
|
||||||
|
fixedPrice: number | null;
|
||||||
|
totalLimit: number | null;
|
||||||
|
perUserLimit: number | null;
|
||||||
|
totalTrafficGb: number | null;
|
||||||
|
allowRenewal: boolean;
|
||||||
|
allowTrafficTopup: boolean;
|
||||||
|
renewalPrice: number | null;
|
||||||
|
renewalPricingMode: "PER_DAY" | "FIXED_DURATION";
|
||||||
|
renewalDurationDays: number | null;
|
||||||
|
renewalMinDays: number | null;
|
||||||
|
renewalMaxDays: number | null;
|
||||||
|
renewalTrafficGb: number | null;
|
||||||
|
topupPricingMode: "PER_GB" | "FIXED_AMOUNT";
|
||||||
|
topupPricePerGb: number | null;
|
||||||
|
topupFixedPrice: number | null;
|
||||||
|
minTopupGb: number | null;
|
||||||
|
maxTopupGb: number | null;
|
||||||
|
pricePerGb: number | null;
|
||||||
|
minTrafficGb: number | null;
|
||||||
|
maxTrafficGb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamingServiceOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
usedSlots: number;
|
||||||
|
maxSlots: number;
|
||||||
|
}
|
||||||
265
src/app/(admin)/admin/plans/plan-form.tsx
Normal file
265
src/app/(admin)/admin/plans/plan-form.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FormEvent, ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { createPlan, updatePlan } from "@/actions/admin/plans";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
PlanBasicsFields,
|
||||||
|
PlanLimitsFields,
|
||||||
|
PlanPolicySection,
|
||||||
|
ProxyNodeFields,
|
||||||
|
ProxyPricingFields,
|
||||||
|
StreamingConfigSection,
|
||||||
|
} from "./plan-form-sections";
|
||||||
|
import type {
|
||||||
|
PlanFormValue,
|
||||||
|
StreamingServiceOption,
|
||||||
|
} from "./plan-form-types";
|
||||||
|
import { usePlanFormState } from "./use-plan-form-state";
|
||||||
|
|
||||||
|
export type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
|
||||||
|
|
||||||
|
function FormSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<fieldset className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
||||||
|
<legend className="px-1.5 text-sm font-semibold">{title}</legend>
|
||||||
|
{children}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanForm({
|
||||||
|
plan,
|
||||||
|
services,
|
||||||
|
triggerLabel,
|
||||||
|
triggerVariant = "default",
|
||||||
|
}: {
|
||||||
|
plan?: PlanFormValue;
|
||||||
|
services: StreamingServiceOption[];
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerVariant?: "default" | "outline" | "ghost";
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(plan);
|
||||||
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
handleOpenChange,
|
||||||
|
title,
|
||||||
|
fieldId,
|
||||||
|
type,
|
||||||
|
setType,
|
||||||
|
nodeId,
|
||||||
|
setNodeId,
|
||||||
|
selectedInboundIds,
|
||||||
|
setSelectedInboundIds,
|
||||||
|
streamingServiceId,
|
||||||
|
setStreamingServiceId,
|
||||||
|
pricingMode,
|
||||||
|
setPricingMode,
|
||||||
|
allowRenewal,
|
||||||
|
setAllowRenewal,
|
||||||
|
allowTrafficTopup,
|
||||||
|
setAllowTrafficTopup,
|
||||||
|
renewalPricingMode,
|
||||||
|
setRenewalPricingMode,
|
||||||
|
topupPricingMode,
|
||||||
|
setTopupPricingMode,
|
||||||
|
submitting,
|
||||||
|
startSubmitting,
|
||||||
|
finishSubmitting,
|
||||||
|
nodes,
|
||||||
|
inbounds,
|
||||||
|
setInbounds,
|
||||||
|
hasStreamingServices,
|
||||||
|
toggleInbound,
|
||||||
|
} = usePlanFormState({ plan, services, isEdit });
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
formData.set("type", type);
|
||||||
|
formData.set("allowRenewal", String(allowRenewal));
|
||||||
|
formData.set("allowTrafficTopup", String(type === "PROXY" ? allowTrafficTopup : false));
|
||||||
|
formData.set("pricingMode", type === "PROXY" ? pricingMode : "TRAFFIC_SLIDER");
|
||||||
|
|
||||||
|
if (!allowRenewal) {
|
||||||
|
formData.delete("renewalPrice");
|
||||||
|
formData.delete("renewalPricingMode");
|
||||||
|
formData.delete("renewalDurationDays");
|
||||||
|
formData.delete("renewalMinDays");
|
||||||
|
formData.delete("renewalMaxDays");
|
||||||
|
formData.delete("renewalTrafficGb");
|
||||||
|
} else if (renewalPricingMode === "FIXED_DURATION") {
|
||||||
|
formData.delete("renewalMinDays");
|
||||||
|
formData.delete("renewalMaxDays");
|
||||||
|
} else {
|
||||||
|
formData.delete("renewalDurationDays");
|
||||||
|
}
|
||||||
|
if (type !== "PROXY" || !allowTrafficTopup) {
|
||||||
|
formData.delete("topupPricingMode");
|
||||||
|
formData.delete("topupPricePerGb");
|
||||||
|
formData.delete("topupFixedPrice");
|
||||||
|
formData.delete("minTopupGb");
|
||||||
|
formData.delete("maxTopupGb");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "PROXY") {
|
||||||
|
if (!nodeId) {
|
||||||
|
toast.error("请先选择节点");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedInboundIds.length === 0) {
|
||||||
|
toast.error("请至少勾选一个可售入站");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.set("nodeId", nodeId);
|
||||||
|
formData.set("inboundId", selectedInboundIds[0]);
|
||||||
|
formData.set("inboundIds", selectedInboundIds.join(","));
|
||||||
|
formData.delete("streamingServiceId");
|
||||||
|
} else {
|
||||||
|
if (!streamingServiceId) {
|
||||||
|
toast.error("请先选择流媒体服务");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.set("streamingServiceId", streamingServiceId);
|
||||||
|
formData.delete("nodeId");
|
||||||
|
formData.delete("inboundId");
|
||||||
|
formData.delete("inboundIds");
|
||||||
|
formData.delete("totalTrafficGb");
|
||||||
|
formData.delete("topupPricingMode");
|
||||||
|
formData.delete("topupPricePerGb");
|
||||||
|
formData.delete("topupFixedPrice");
|
||||||
|
formData.delete("minTopupGb");
|
||||||
|
formData.delete("maxTopupGb");
|
||||||
|
formData.delete("renewalTrafficGb");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startSubmitting();
|
||||||
|
if (isEdit) {
|
||||||
|
await updatePlan(plan!.id, formData);
|
||||||
|
} else {
|
||||||
|
await createPlan(formData);
|
||||||
|
}
|
||||||
|
toast.success(isEdit ? "套餐更新成功" : "套餐创建成功");
|
||||||
|
handleOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, `${isEdit ? "更新" : "创建"}失败`));
|
||||||
|
} finally {
|
||||||
|
finishSubmitting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
|
||||||
|
>
|
||||||
|
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)} className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Left column: basics + resource config */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormSection title="基础信息">
|
||||||
|
<PlanBasicsFields
|
||||||
|
fieldId={fieldId}
|
||||||
|
isEdit={isEdit}
|
||||||
|
type={type}
|
||||||
|
setType={setType}
|
||||||
|
plan={plan}
|
||||||
|
services={services}
|
||||||
|
streamingServiceId={streamingServiceId}
|
||||||
|
setStreamingServiceId={setStreamingServiceId}
|
||||||
|
hasStreamingServices={hasStreamingServices}
|
||||||
|
setInbounds={setInbounds}
|
||||||
|
setSelectedInboundIds={setSelectedInboundIds}
|
||||||
|
setAllowTrafficTopup={setAllowTrafficTopup}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{type === "PROXY" ? (
|
||||||
|
<FormSection title="节点与线路">
|
||||||
|
<ProxyNodeFields
|
||||||
|
fieldId={fieldId}
|
||||||
|
nodes={nodes}
|
||||||
|
nodeId={nodeId}
|
||||||
|
setNodeId={setNodeId}
|
||||||
|
inbounds={inbounds}
|
||||||
|
setInbounds={setInbounds}
|
||||||
|
selectedInboundIds={selectedInboundIds}
|
||||||
|
setSelectedInboundIds={setSelectedInboundIds}
|
||||||
|
toggleInbound={toggleInbound}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
) : (
|
||||||
|
<FormSection title="服务与定价">
|
||||||
|
<StreamingConfigSection
|
||||||
|
fieldId={fieldId}
|
||||||
|
plan={plan}
|
||||||
|
services={services}
|
||||||
|
streamingServiceId={streamingServiceId}
|
||||||
|
setStreamingServiceId={setStreamingServiceId}
|
||||||
|
hasStreamingServices={hasStreamingServices}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: pricing (proxy only) + sales policy + submit */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{type === "PROXY" && (
|
||||||
|
<FormSection title="定价">
|
||||||
|
<ProxyPricingFields
|
||||||
|
fieldId={fieldId}
|
||||||
|
plan={plan}
|
||||||
|
pricingMode={pricingMode}
|
||||||
|
setPricingMode={setPricingMode}
|
||||||
|
allowTrafficTopup={allowTrafficTopup}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormSection title="销售策略">
|
||||||
|
<PlanLimitsFields fieldId={fieldId} plan={plan} />
|
||||||
|
<PlanPolicySection
|
||||||
|
fieldId={fieldId}
|
||||||
|
type={type}
|
||||||
|
plan={plan}
|
||||||
|
allowRenewal={allowRenewal}
|
||||||
|
setAllowRenewal={setAllowRenewal}
|
||||||
|
allowTrafficTopup={allowTrafficTopup}
|
||||||
|
setAllowTrafficTopup={setAllowTrafficTopup}
|
||||||
|
renewalPricingMode={renewalPricingMode}
|
||||||
|
setRenewalPricingMode={setRenewalPricingMode}
|
||||||
|
topupPricingMode={topupPricingMode}
|
||||||
|
setTopupPricingMode={setTopupPricingMode}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" className="w-full" disabled={submitting}>
|
||||||
|
{submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/app/(admin)/admin/plans/plan-policy-section.tsx
Normal file
224
src/app/(admin)/admin/plans/plan-policy-section.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { PlanFormValue, PlanType } from "./plan-form-types";
|
||||||
|
|
||||||
|
type FieldId = (name: string) => string;
|
||||||
|
type RenewalPricingMode = "PER_DAY" | "FIXED_DURATION";
|
||||||
|
type TopupPricingMode = "PER_GB" | "FIXED_AMOUNT";
|
||||||
|
|
||||||
|
interface PlanPolicySectionProps {
|
||||||
|
fieldId: FieldId;
|
||||||
|
type: PlanType;
|
||||||
|
plan?: PlanFormValue;
|
||||||
|
allowRenewal: boolean;
|
||||||
|
setAllowRenewal: Dispatch<SetStateAction<boolean>>;
|
||||||
|
allowTrafficTopup: boolean;
|
||||||
|
setAllowTrafficTopup: Dispatch<SetStateAction<boolean>>;
|
||||||
|
renewalPricingMode: RenewalPricingMode;
|
||||||
|
setRenewalPricingMode: Dispatch<SetStateAction<RenewalPricingMode>>;
|
||||||
|
topupPricingMode: TopupPricingMode;
|
||||||
|
setTopupPricingMode: Dispatch<SetStateAction<TopupPricingMode>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanPolicySection({
|
||||||
|
fieldId,
|
||||||
|
type,
|
||||||
|
plan,
|
||||||
|
allowRenewal,
|
||||||
|
setAllowRenewal,
|
||||||
|
allowTrafficTopup,
|
||||||
|
setAllowTrafficTopup,
|
||||||
|
renewalPricingMode,
|
||||||
|
setRenewalPricingMode,
|
||||||
|
topupPricingMode,
|
||||||
|
setTopupPricingMode,
|
||||||
|
}: PlanPolicySectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-panel grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
|
||||||
|
<div>
|
||||||
|
<p id={fieldId("allowRenewal-label")} className="text-sm font-medium">开放续费</p>
|
||||||
|
<p className="text-xs text-muted-foreground">用户可拖动选择续费时长</p>
|
||||||
|
</div>
|
||||||
|
<Switch aria-labelledby={fieldId("allowRenewal-label")} checked={allowRenewal} onCheckedChange={setAllowRenewal} />
|
||||||
|
</div>
|
||||||
|
{type === "PROXY" && (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
|
||||||
|
<div>
|
||||||
|
<p id={fieldId("allowTrafficTopup-label")} className="text-sm font-medium">开放增流量</p>
|
||||||
|
<p className="text-xs text-muted-foreground">用户可拖动选择加多少 GB</p>
|
||||||
|
</div>
|
||||||
|
<Switch aria-labelledby={fieldId("allowTrafficTopup-label")} checked={allowTrafficTopup} onCheckedChange={setAllowTrafficTopup} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowRenewal && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("renewalPricingMode")}>续费计价</Label>
|
||||||
|
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
|
||||||
|
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
|
||||||
|
<SelectTrigger id={fieldId("renewalPricingMode")} className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{(value) => value === "PER_DAY" ? "按天计费" : "固定周期计费"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PER_DAY">按天计费</SelectItem>
|
||||||
|
<SelectItem value="FIXED_DURATION">固定周期计费</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("renewalPrice")}>
|
||||||
|
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("renewalPrice")}
|
||||||
|
name="renewalPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0.01}
|
||||||
|
required
|
||||||
|
defaultValue={plan?.renewalPrice ?? ""}
|
||||||
|
placeholder={renewalPricingMode === "PER_DAY" ? "例如 1" : "例如 29.9"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{renewalPricingMode === "FIXED_DURATION" ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("renewalDurationDays")}>周期天数</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("renewalDurationDays")}
|
||||||
|
name="renewalDurationDays"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
defaultValue={plan?.renewalDurationDays ?? plan?.durationDays ?? ""}
|
||||||
|
placeholder="例如 30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("renewalMinDays")}>最小续费天数</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("renewalMinDays")}
|
||||||
|
name="renewalMinDays"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.renewalMinDays ?? ""}
|
||||||
|
placeholder="例如 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("renewalMaxDays")}>最大续费天数</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("renewalMaxDays")}
|
||||||
|
name="renewalMaxDays"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.renewalMaxDays ?? ""}
|
||||||
|
placeholder="例如 180"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "PROXY" && allowTrafficTopup && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("topupPricingMode")}>增流量计价</Label>
|
||||||
|
<input type="hidden" name="topupPricingMode" value={topupPricingMode} />
|
||||||
|
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
|
||||||
|
<SelectTrigger id={fieldId("topupPricingMode")} className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{(value) => value === "FIXED_AMOUNT" ? "固定金额" : "按 GB 计费"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PER_GB">按 GB 计费</SelectItem>
|
||||||
|
<SelectItem value="FIXED_AMOUNT">固定金额</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{topupPricingMode === "PER_GB" ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("topupPricePerGb")}>加流量价格(¥/GB)</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("topupPricePerGb")}
|
||||||
|
name="topupPricePerGb"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0.01}
|
||||||
|
required
|
||||||
|
defaultValue={plan?.topupPricePerGb ?? ""}
|
||||||
|
placeholder="例如 0.8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("topupFixedPrice")}>固定加流量金额(¥)</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("topupFixedPrice")}
|
||||||
|
name="topupFixedPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min={0.01}
|
||||||
|
required
|
||||||
|
defaultValue={plan?.topupFixedPrice ?? ""}
|
||||||
|
placeholder="例如 9.9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("minTopupGb")}>最小增流量(GB)</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("minTopupGb")}
|
||||||
|
name="minTopupGb"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.minTopupGb ?? ""}
|
||||||
|
placeholder="默认 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={fieldId("maxTopupGb")}>最大增流量(GB)</Label>
|
||||||
|
<Input
|
||||||
|
id={fieldId("maxTopupGb")}
|
||||||
|
name="maxTopupGb"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={plan?.maxTopupGb ?? ""}
|
||||||
|
placeholder="留空=按流量池剩余额度"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user