Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

17
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`:探测程序说明
## 请我喝杯咖啡
![JsPYre9xe7W1Ad6mwPgrfuXBYJ8iy6oC.webp](https://cdn.nodeimage.com/i/JsPYre9xe7W1Ad6mwPgrfuXBYJ8iy6oC.webp)
USDT-TRC20: TQfaGEBdnB89V4y6R6bypZXx7Za5QfXBCi
Telegram[@JetSprow](https://t.me/JetSprow)

3
agent/jboard-agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
jboard-agent
jboard-agent-linux-amd64
jboard-agent-linux-arm64

View 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

View 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 次并采用更低的有效结果。

View 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()
}

View File

@@ -0,0 +1,3 @@
module github.com/jboard/jboard-agent
go 1.22

View 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
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

11
prisma.config.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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."

View 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();
}

View 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");
}

View 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
View 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");
}

View 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("不支持的批量操作");
}
}
}

View 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
View 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");
}

View 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");
}

View 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");
}

View 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("不支持的批量操作");
}
}
}

View 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
View 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);
}
}

View 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
View 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");
}

View 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
View 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;
}

View 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");
}

View 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}`);
}

View 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,
};
}

View 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
View 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");
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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 } };
}

View 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>
);
}

View File

@@ -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>
);
}

View 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}` : ""}`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 } };
}

View 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>
);
}

View 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");
}

View 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>
);
}

View 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>
);
}

View 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),
}));
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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";

View 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;
}

View 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>
);
}

View 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