Compare commits

14 Commits
v3.1.1 ... main

Author SHA1 Message Date
JetSprow
1dd3157177 feat: add admin wallet recharge order actions 2026-05-01 05:28:20 +10:00
JetSprow
8034392408 docs: update lite deployment and feature docs 2026-05-01 04:56:06 +10:00
JetSprow
f4d71ca526 feat: show remaining subscription push count 2026-05-01 04:44:22 +10:00
JetSprow
e718d5edab feat: add subscription push transfers 2026-05-01 04:39:15 +10:00
JetSprow
b2a50514a4 fix: route wallet card redemption through api 2026-05-01 03:50:05 +10:00
JetSprow
0c8b402f3e feat: polish wallet recharge cards 2026-05-01 03:41:30 +10:00
JetSprow
035ac9266a fix: submit recharge card form 2026-05-01 03:16:11 +10:00
JetSprow
018bed3f36 feat: add wallet and recharge cards 2026-05-01 02:31:29 +10:00
JetSprow
6d6489817d feat: add jetboard deployment manager 2026-05-01 01:50:37 +10:00
JetSprow
4dd2f9280f polish: refine admin ui controls 2026-05-01 00:58:46 +10:00
JetSprow
b8a7cab1af polish: clarify record detail actions 2026-04-30 22:29:25 +10:00
JetSprow
157f3841f6 polish: redesign node admin UI 2026-04-30 22:09:21 +10:00
JetSprow
c5592621a4 polish: refine lite admin controls 2026-04-30 21:48:59 +10:00
JetSprow
6ee9cf2857 Polish admin list UI for lite 2026-04-30 20:49:03 +10:00
189 changed files with 9147 additions and 1267 deletions

View File

@@ -1,4 +1,4 @@
# J-Board panel # J-Board Lite panel
APP_PORT="3000" APP_PORT="3000"
SITE_NAME="J-Board Lite" SITE_NAME="J-Board Lite"

141
README.md
View File

@@ -1,6 +1,6 @@
# J-Board Lite # J-Board Lite
J-Board Lite 是 J-Board 的轻量版本,面向 1C1G 小机器和低资源 Docker 环境优化。它保留代理订阅售卖、流媒体共享、支付、订阅交付、工单、邮件、公告、审计、探测展示与订阅风控等核心功能,同时使用 SQLite 和进程内限流,避免 Postgres、Redis 等额外服务带来的部署负担。 J-Board Lite 是 J-Board 的轻量版本,面向 1C1G 小机器和低资源 Docker 环境优化。它保留代理订阅售卖、流媒体共享、钱包余额、充值卡、套餐 Push、支付、订阅交付、工单、邮件、公告、审计、探测展示与订阅风控等核心功能,同时使用 SQLite 和进程内限流,避免 Postgres、Redis 等额外服务带来的部署负担。
J-Board Lite 的定位很明确:它是 J-Board 的轻量化部署版本,不是新的节点控制面,也不替代 3x-ui。节点实际运行、入站协议、Xray 客户端配置仍由 3x-ui 维护;面板负责售卖、开通、订阅交付、售后和风险审查,并通过只读 Agent 从节点侧采集延迟、路由和 Xray access log 证据。 J-Board Lite 的定位很明确:它是 J-Board 的轻量化部署版本,不是新的节点控制面,也不替代 3x-ui。节点实际运行、入站协议、Xray 客户端配置仍由 3x-ui 维护;面板负责售卖、开通、订阅交付、售后和风险审查,并通过只读 Agent 从节点侧采集延迟、路由和 Xray access log 证据。
@@ -32,7 +32,9 @@ J-Board Lite 只保存售卖和展示需要的节点镜像数据。入站协议
- 支持 Cloudflare Turnstile 人机验证。 - 支持 Cloudflare Turnstile 人机验证。
- 代理套餐和流媒体套餐购买、续费、增流量。 - 代理套餐和流媒体套餐购买、续费、增流量。
- 购物车、订单、支付状态查询和支付方式切换。 - 购物车、订单、支付状态查询和支付方式切换。
- 钱包余额充值、余额支付、余额卡兑换和套餐卡兑换。
- 代理订阅查看、订阅链接下载、订阅访问重置。 - 代理订阅查看、订阅链接下载、订阅访问重置。
- 套餐 Push输入接收方邮箱和当前密码即可发起转让接收方需在 24 小时内确认。
- 线路体验展示:三网延迟、延迟历史、三网路由追踪。 - 线路体验展示:三网延迟、延迟历史、三网路由追踪。
- 流媒体订阅凭据展示。 - 流媒体订阅凭据展示。
- 通知中心、工单售后、账号资料、邀请码。 - 通知中心、工单售后、账号资料、邀请码。
@@ -43,7 +45,9 @@ J-Board Lite 只保存售卖和展示需要的节点镜像数据。入站协议
- 3x-ui 节点管理:保存面板地址、账号、密码,测试连接并同步入站。 - 3x-ui 节点管理:保存面板地址、账号、密码,测试连接并同步入站。
- 本地入站展示名维护,套餐绑定同步后的入站线路。 - 本地入站展示名维护,套餐绑定同步后的入站线路。
- 探测 Token 管理:用于 Agent 上报延迟、路由和节点日志。 - 探测 Token 管理:用于 Agent 上报延迟、路由和节点日志。
- 用户、订单、套餐、订阅、流媒体服务、支付配置。 - 用户、订单、充值订单、套餐、订阅、流媒体服务、支付配置。
- 钱包与卡密:生成余额充值卡、套餐充值卡,查看兑换详情,删除卡密;套餐卡会占用/释放套餐库存。
- 套餐 Push 管理:配置总开关、固定转让费、单周期次数、最低剩余天数和最低剩余流量,查看和删除历史记录。
- SMTP 邮件服务设置、注册邮箱验证开关、邮件模板发送。 - SMTP 邮件服务设置、注册邮箱验证开关、邮件模板发送。
- 公告、工单、系统设置、审计日志、任务中心、备份恢复。 - 公告、工单、系统设置、审计日志、任务中心、备份恢复。
- 日志清理:审计、任务、流量、延迟和风控日志支持手动删除与自动过期清理。 - 日志清理:审计、任务、流量、延迟和风控日志支持手动删除与自动过期清理。
@@ -99,8 +103,12 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。
| `prisma/schema.prisma` | 数据模型事实源。 | | `prisma/schema.prisma` | 数据模型事实源。 |
| `data/GeoLite2-City.mmdb` | 默认 GeoIP 城市库。 | | `data/GeoLite2-City.mmdb` | 默认 GeoIP 城市库。 |
| `agent/jboard-agent` | Go Agent 源码、构建脚本和 Agent 文档。 | | `agent/jboard-agent` | Go Agent 源码、构建脚本和 Agent 文档。 |
| `scripts/install-jboard-panel.sh` | 面板一键安装向导。 | | `scripts/install-jetboard-standalone.sh` | 不使用 Docker 的面板一键安装向导。 |
| `scripts/upgrade-jboard-panel.sh` | 面板升级脚本。 | | `scripts/uninstall-jetboard-standalone.sh` | standalone 部署一键卸载脚本。 |
| `scripts/jetboard.sh` | Docker / standalone 共用的 `jetboard` 管理命令入口。 |
| `scripts/install-jboard-panel.sh` | Docker 面板一键安装向导。 |
| `scripts/upgrade-jboard-panel.sh` | Docker 面板升级脚本。 |
| `scripts/uninstall-jboard-panel.sh` | Docker 面板一键卸载脚本。 |
| `scripts/install-jboard-agent.sh` | Agent 安装脚本。 | | `scripts/install-jboard-agent.sh` | Agent 安装脚本。 |
| `scripts/upgrade-jboard-agent.sh` | Agent 升级脚本。 | | `scripts/upgrade-jboard-agent.sh` | Agent 升级脚本。 |
| `docs/API.md` | HTTP 接口与 Server Actions 参考。 | | `docs/API.md` | HTTP 接口与 Server Actions 参考。 |
@@ -121,21 +129,23 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。
| `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 | | `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 |
| `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 | | `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 |
| `JBOARD_LOG_CLEANUP_SCHEDULER` | 日志清理定时器 | 默认启用。设为 `false` 可关闭进程内自动清理任务。 | | `JBOARD_LOG_CLEANUP_SCHEDULER` | 日志清理定时器 | 默认启用。设为 `false` 可关闭进程内自动清理任务。 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码。 | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码;忘记密码时执行 `jetboard reset`。 |
SMTP 邮件服务、注册邮箱验证开关、支付方式、3x-ui 节点等业务配置在管理后台填写,不建议写进 `.env` SMTP 邮件服务、注册邮箱验证开关、支付方式、3x-ui 节点等业务配置在管理后台填写,不建议写进 `.env`
日志清理在后台“系统设置 -> 日志清理”中配置。默认每天最多自动清理一次 30 天前日志,范围包含审计日志、任务记录、流量日志、节点延迟日志、风控访问日志和风控事件;正在生效的用户端风控限制不会被自动清理。管理员也可以在后台选择日志范围和天数,立即手动清理过期日志。 日志清理在后台“系统设置 -> 日志清理”中配置。默认每天最多自动清理一次 30 天前日志,范围包含审计日志、任务记录、流量日志、节点延迟日志、风控访问日志和风控事件;正在生效的用户端风控限制不会被自动清理。管理员也可以在后台选择日志范围和天数,立即手动清理过期日志。
## 一键部署 ## Standalone 一键部署
适合全新 Linux 服务器。脚本会安装基础依赖、安装 Docker 与 Compose 插件、拉取代码、生成 `.env`、初始化数据库并启动面板 适合不想使用 Docker 的全新 Linux 服务器。脚本会安装基础依赖、安装 Node.js、拉取代码、生成 `.env`、构建 Next.js standalone 产物、初始化 SQLite并注册 systemd 服务
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh) bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jetboard-standalone.sh)
``` ```
安装和更新脚本会自动检测 CPU、宿主机内存、Docker 可用内存和 Docker 数据盘空间。常规机器使用 Docker 默认构建策略1C、低于 2GB 内存或 Docker 可用空间低于 8GB 的小机器会自动进入低资源模式,降低 Compose 并发、npm 编译并发和 Next 构建堆内存,并把镜像分步构建,构建会更慢但峰值占用更低。 安装和更新脚本会自动检测 CPU、内存和安装目录可用空间。常规机器使用 npm/Next 默认构建策略1C、低于 2GB 内存或可用空间低于 8GB 的小机器会自动进入低资源模式,降低 npm 编译并发和 Next 构建堆内存,构建会更慢但峰值占用更低。
安装完成后会写入 `/etc/jetboard.conf` 并安装全局命令 `jetboard`。直接执行 `jetboard` 会打开 JetBoard 管理菜单,后续更新、状态查看、日志查看、管理员密码重置和卸载都可以从这里完成。
脚本会交互询问: 脚本会交互询问:
@@ -152,20 +162,20 @@ bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/s
也可以通过环境变量覆盖默认行为: 也可以通过环境变量覆盖默认行为:
```bash ```bash
APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board-Lite BRANCH=main bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh) APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board-Lite BRANCH=main bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jetboard-standalone.sh)
``` ```
构建资源策略也可以手动覆盖: 构建资源策略也可以手动覆盖:
```bash ```bash
# 自动判断,默认值 # 自动判断,默认值
JBOARD_BUILD_PROFILE=auto ./scripts/upgrade-jboard-panel.sh JBOARD_BUILD_PROFILE=auto jetboard update
# 强制低资源慢速构建 # 强制低资源慢速构建
JBOARD_BUILD_PROFILE=low ./scripts/upgrade-jboard-panel.sh JBOARD_BUILD_PROFILE=low jetboard update
# 强制常规构建,不额外限制并发或 Node heap # 强制常规构建,不额外限制并发或 Node heap
JBOARD_BUILD_PROFILE=normal ./scripts/upgrade-jboard-panel.sh JBOARD_BUILD_PROFILE=normal jetboard update
``` ```
脚本完成后会输出: 脚本完成后会输出:
@@ -180,6 +190,75 @@ JBOARD_BUILD_PROFILE=normal ./scripts/upgrade-jboard-panel.sh
请把管理员密码保存到密码管理器。已有数据库重复部署时,脚本会尽量沿用现有配置,不会随意重置管理员。 请把管理员密码保存到密码管理器。已有数据库重复部署时,脚本会尽量沿用现有配置,不会随意重置管理员。
## JetBoard 管理命令
Docker 和 standalone 一键脚本都会安装全局命令。安装后可在任意目录执行:
```bash
jetboard
```
常用命令:
```bash
jetboard status # 查看当前部署状态
jetboard update # 拉取最新代码、备份 SQLite、按机器配置更新
jetboard reset # 重置或创建管理员账号密码
jetboard logs # 查看服务日志
jetboard restart # 重启面板
jetboard uninstall # 完整卸载当前部署
```
`jetboard` 会读取 `/etc/jetboard.conf` 中的部署模式Docker 部署会调用 Docker Composestandalone 部署会调用 systemd 和本机构建。`jetboard update` 和安装脚本使用同一套资源检测逻辑强机器不额外限制构建1C1G、小内存或可用空间不足时自动进入低占用慢速构建。忘记管理员账号密码时执行 `jetboard reset`,按提示输入管理员邮箱和新密码;如果该管理员不存在,会自动创建并激活为管理员。
完整一键卸载 standalone 部署:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jetboard-standalone.sh)
```
非交互卸载:
```bash
UNINSTALL_CONFIRM=YES bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jetboard-standalone.sh)
```
卸载脚本会先备份 `.env``storage/``backups/``/root/jboard-uninstall-backup-时间.tar.gz`,再删除 systemd 服务、全局命令、`/etc/jetboard.conf` 和安装目录。
## Docker 一键部署
适合希望用 Docker Compose 隔离运行环境的服务器。脚本会安装基础依赖、安装 Docker 与 Compose 插件、拉取代码、生成 `.env`、初始化数据库并启动面板。
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh)
```
Docker 安装和更新脚本会自动检测 CPU、宿主机内存、Docker 可用内存和 Docker 数据盘空间。常规机器使用 Docker 默认构建策略1C、低于 2GB 内存或 Docker 可用空间低于 8GB 的小机器会自动进入低资源模式,降低 Compose 并发、npm 编译并发和 Next 构建堆内存,并把镜像分步构建,构建会更慢但峰值占用更低。
Docker 一键脚本也会安装 `jetboard` 全局命令,并写入 Docker 部署模式。常用操作:
```bash
jetboard status # docker compose ps
jetboard update # 拉取代码、备份 SQLite、按机器配置重建镜像
jetboard reset # 通过 init 容器重置或创建管理员
jetboard logs # docker compose logs -f app
jetboard uninstall # 备份后删除 Compose 服务、volume 和安装目录
```
完整一键卸载 Docker 部署:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jboard-panel.sh)
```
非交互卸载:
```bash
UNINSTALL_CONFIRM=YES bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jboard-panel.sh)
```
卸载脚本会先备份 `.env``backups/` 和 Docker SQLite volumeroot 执行时保存到 `/root/jboard-docker-uninstall-backup-时间.tar.gz`,非 root 直接执行时保存到当前用户 HOME随后执行 `docker compose down --volumes --remove-orphans`,最后删除全局命令、`/etc/jetboard.conf` 和安装目录。Docker 本身不会被删除。
## 手动 Docker 部署 ## 手动 Docker 部署
首次启动: 首次启动:
@@ -253,11 +332,13 @@ server {
2. 配置 SMTP 邮件服务并点击“测试发信”。 2. 配置 SMTP 邮件服务并点击“测试发信”。
3. 按需要开启注册邮箱验证。忘记密码和邮箱变更也会使用 SMTP。 3. 按需要开启注册邮箱验证。忘记密码和邮箱变更也会使用 SMTP。
4. 进入“支付配置”,填写并启用至少一种支付方式。 4. 进入“支付配置”,填写并启用至少一种支付方式。
5. 添加 3x-ui 节点,测试连接并同步入站 5. 如需钱包,确认余额支付开启,并用低金额测试余额充值和余额支付
6. 创建代理套餐,绑定入站;或创建流媒体服务和套餐 6. 添加 3x-ui 节点,测试连接并同步入站
7. 在节点页生成探测 Token安装 Agent 7. 创建代理套餐,绑定入站;或创建流媒体服务和套餐
8. 用普通用户注册、下单、支付、查看订阅,走一遍完整流程 8. 按需配置充值卡、套餐 Push、三网推荐和线路体验
9. 进入“订阅风控”确认地图、IP、分析日志、人工操作按钮都能正常展示 9. 在节点页生成探测 Token安装 Agent
10. 用普通用户注册、下单、支付、查看订阅,走一遍完整流程。
11. 进入“订阅风控”确认地图、IP、分析日志、人工操作按钮都能正常展示。
可以展示给用户的常用入口: 可以展示给用户的常用入口:
@@ -266,6 +347,8 @@ server {
- 套餐商店:`https://你的域名/store` - 套餐商店:`https://你的域名/store`
- 用户中心:`https://你的域名/dashboard` - 用户中心:`https://你的域名/dashboard`
- 订阅列表:`https://你的域名/subscriptions` - 订阅列表:`https://你的域名/subscriptions`
- 钱包:`https://你的域名/wallet`
- 套餐 Push`https://你的域名/subscriptions/push`
- 工单中心:`https://你的域名/support` - 工单中心:`https://你的域名/support`
## 邮件与邮箱验证 ## 邮件与邮箱验证
@@ -286,6 +369,7 @@ SMTP 配置在后台“系统设置”中完成,密码会加密保存在数据
| 支付方式 | 适用场景 | 必填信息 | 回调 / 查询说明 | | 支付方式 | 适用场景 | 必填信息 | 回调 / 查询说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 余额支付 | 默认启用,适合用户用钱包余额支付订单 | 可选显示名称 | 不走外部网关。关闭时后台会提示确认,普通订单不能再选择余额支付;余额充值本身不能使用余额支付。 |
| 易支付 | 第三方聚合支付,常用于支付宝/微信通道 | API 地址、商户 ID、商户密钥、启用渠道 | 通知地址为 `https://你的域名/api/payment/notify/epay`。支持 `alipay``wxpay` 渠道。 | | 易支付 | 第三方聚合支付,常用于支付宝/微信通道 | API 地址、商户 ID、商户密钥、启用渠道 | 通知地址为 `https://你的域名/api/payment/notify/epay`。支持 `alipay``wxpay` 渠道。 |
| 支付宝当面付 | 支付宝官方扫码支付 | App ID、应用私钥、支付宝公钥、网关地址 | 通知地址为 `https://你的域名/api/payment/notify/alipay_f2f`,也支持订单查询兜底。 | | 支付宝当面付 | 支付宝官方扫码支付 | App ID、应用私钥、支付宝公钥、网关地址 | 通知地址为 `https://你的域名/api/payment/notify/alipay_f2f`,也支持订单查询兜底。 |
| USDT TRC20 | 加密货币收款 | TRC20 钱包地址、汇率,可选 TronGrid API Key | 没有传统回调,系统按订单金额查询近期 TRC20 入账。建议配置 TronGrid API Key 提高稳定性。 | | USDT TRC20 | 加密货币收款 | TRC20 钱包地址、汇率,可选 TronGrid API Key | 没有传统回调,系统按订单金额查询近期 TRC20 入账。建议配置 TronGrid API Key 提高稳定性。 |
@@ -298,6 +382,27 @@ SMTP 配置在后台“系统设置”中完成,密码会加密保存在数据
- 支付宝密钥可以填写纯 key 内容或 PEM 格式;系统会自动补 PEM 包装。 - 支付宝密钥可以填写纯 key 内容或 PEM 格式;系统会自动补 PEM 包装。
- USDT TRC20 按金额匹配入账,测试时避免短时间出现多笔完全相同金额。 - USDT TRC20 按金额匹配入账,测试时避免短时间出现多笔完全相同金额。
## 钱包、充值卡与套餐 Push
钱包在用户端 `/wallet` 使用。用户可以创建余额充值订单,选择除余额支付外的外部支付方式完成充值;充值订单可取消,管理员可在订单页的“充值订单”标签查看、手动确认入账、取消或删除记录。普通商品订单支持余额支付,余额不足时会给出可读错误。
充值卡在后台“商业配置”中生成:
- 余额充值卡:兑换后立即入账用户余额。
- 套餐充值卡:只能绑定已有套餐,兑换后立即激活套餐。
- 套餐卡生成时会立即占用套餐库存;删除尚未兑换且未过期的套餐卡会释放库存。
- 卡密列表支持分页、每页数量选择、查看兑换人和兑换时间。
套餐 Push 在用户端 `/subscriptions/push` 使用,后台在“系统设置 -> 套餐 Push”配置
- 可单独开启或关闭套餐 Push。
- 可设置固定转让费,支持转出方支付或接收方支付。
- 可设置同一订阅周期内最多 Push 次数。续费后进入新周期,次数重新计算。
- 可设置低于多少天到期、剩余多少 GB 流量时不可 Push。
- 发起后套餐会暂停并禁用 3x-ui client接收方 24 小时内确认后才转入。
- 接收成功会删除旧 3x-ui client给接收方生成新的 client过期、拒收或取消会恢复原套餐并退回已扣手续费。
- 管理端 `/admin/subscription-transfers` 可查看详情并删除历史记录。待接收记录被管理员删除时,会先取消转让、恢复套餐并退费。
## 节点、3x-ui 与 Agent ## 节点、3x-ui 与 Agent
节点接入流程: 节点接入流程:

View File

@@ -158,14 +158,14 @@ Agent 会解析:
- `uniqueTargetCount`:聚合窗口内不同目标数。 - `uniqueTargetCount`:聚合窗口内不同目标数。
- `firstSeenAt` / `lastSeenAt`:窗口内首次和最近连接时间。 - `firstSeenAt` / `lastSeenAt`:窗口内首次和最近连接时间。
没有 `email:` 的日志会跳过,因为服务端无法把它归属到 J-Board 的 `NodeClient``[api -> api]` 这类 3x-ui 本地 API 通信通常也会跳过。 没有 `email:` 的日志会跳过,因为服务端无法把它归属到 J-Board Lite `NodeClient``[api -> api]` 这类 3x-ui 本地 API 通信通常也会跳过。
## Xray client email 与用户邮箱 ## Xray client email 与用户邮箱
J-Board 用户邮箱和 Xray client email 不一定完全相同。例如: J-Board Lite 用户邮箱和 Xray client email 不一定完全相同。例如:
```text ```text
J-Board 用户邮箱user@example.com J-Board Lite 用户邮箱user@example.com
Xray client emailuser@example.com-cmojtnp3 Xray client emailuser@example.com-cmojtnp3
``` ```
@@ -281,7 +281,7 @@ systemctl restart jboard-agent
- 后台系统设置是否开启订阅风控总控。 - 后台系统设置是否开启订阅风控总控。
- 后台是否开启节点日志风控。 - 后台是否开启节点日志风控。
- access log 是否包含 `email:` - access log 是否包含 `email:`
- `email:` 是否与 J-Board 数据库里的 `NodeClient.email` 一致。 - `email:` 是否与 J-Board Lite 数据库里的 `NodeClient.email` 一致。
- Agent Token 是否属于当前节点。 - Agent Token 是否属于当前节点。
- 面板日志是否有 `/api/agent/node-access` 的错误。 - 面板日志是否有 `/api/agent/node-access` 的错误。

View File

@@ -1,6 +1,6 @@
# J-Board API 与 Server Actions # J-Board Lite API 与 Server Actions
本文整理 J-Board 当前有效的 HTTP Route Handlers 和内部 Server Actions。HTTP 对外结构化描述见 `docs/openapi.yaml`;本文更偏向工程阅读和排障。 本文整理 J-Board Lite 当前有效的 HTTP Route Handlers 和内部 Server Actions。HTTP 对外结构化描述见 `docs/openapi.yaml`;本文更偏向工程阅读和排障。
## 通用约定 ## 通用约定
@@ -33,7 +33,8 @@
行为: 行为:
- 校验邮箱、密码、邀请码和 Turnstile。 - 校验邮箱、密码、邀请码和 Turnstile。
- 当注册邮箱验证开启时,用户会进`PENDING_EMAIL` 状态并收到验证邮件 - 当注册邮箱验证开启时,先写`PendingRegistration` 并发送验证邮件,用户不会立即创建
- 只有邮箱验证链接通过后,系统才创建 `User` 并激活。
- 当注册邮箱验证关闭时,用户直接成为可登录用户。 - 当注册邮箱验证关闭时,用户直接成为可登录用户。
### `GET|POST /api/auth/[...nextauth]` ### `GET|POST /api/auth/[...nextauth]`
@@ -66,7 +67,7 @@ NextAuth 内置登录、登出、会话接口。
### `GET /api/payment/providers` ### `GET /api/payment/providers`
返回当前启用的支付方式。普通用户创建订单或切换支付方式时使用。 返回当前启用的支付方式。普通用户创建订单或切换支付方式时使用。`?target=wallet` 会排除余额支付,避免用户用余额给余额充值。
### `POST /api/payment/create` ### `POST /api/payment/create`
@@ -109,6 +110,41 @@ NextAuth 内置登录、登出、会话接口。
- 标记订单已支付。 - 标记订单已支付。
- 调用 `src/services/provision.ts` 创建或续费订阅。 - 调用 `src/services/provision.ts` 创建或续费订阅。
- 代理订阅会同步 3x-ui client。 - 代理订阅会同步 3x-ui client。
- 充值订单回调会进入钱包入账流程,成功后写入 `WalletTransaction`
## 钱包接口
### `GET /api/wallet/recharge/{id}`
查询当前用户自己的余额充值订单。充值支付页会读取金额、状态、支付方式和流水号。
### `POST /api/wallet/recharge/payment/create`
为余额充值订单创建外部支付参数。
请求体:
```json
{
"rechargeId": "wallet-recharge-order-id",
"provider": "epay",
"channel": "alipay"
}
```
行为:
- 要求登录用户拥有该充值订单,且订单仍为 `PENDING`
- 不允许 `provider=balance`
- 调用对应支付适配器生成支付链接、二维码或链上收款信息。
### `GET /api/wallet/recharge/query/{tradeNo}`
按充值流水号主动查询支付状态。用于充值页轮询或回调异常时兜底。
### `POST /api/wallet/redeem-card`
兑换充值卡。余额卡立即入账余额;套餐卡立即激活对应套餐。已使用、过期或不存在的卡密会返回可展示错误。
## 订阅与用户资源 ## 订阅与用户资源
@@ -320,6 +356,10 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
自动清理由 `src/services/log-cleanup-scheduler.ts` 启动,默认每天最多执行一次,读取 `AppConfig.logCleanupEnabled``AppConfig.logRetentionDays`。自动清理和手动过期清理都会保留仍处于用户端限制中的风控事件。 自动清理由 `src/services/log-cleanup-scheduler.ts` 启动,默认每天最多执行一次,读取 `AppConfig.logCleanupEnabled``AppConfig.logRetentionDays`。自动清理和手动过期清理都会保留仍处于用户端限制中的风控事件。
#### 套餐 Push`src/actions/admin/subscription-transfers.ts`
- `deleteAdminSubscriptionTransfer(id)`:删除管理端 Push 历史记录。待接收记录会先取消转让、恢复套餐、退回已扣手续费,再删除记录;已完成记录只删除历史。
#### 订单:`src/actions/admin/orders.ts` #### 订单:`src/actions/admin/orders.ts`
- `confirmOrder(orderId)`:手动确认订单并触发开通。 - `confirmOrder(orderId)`:手动确认订单并触发开通。
@@ -327,6 +367,12 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- `updateOrderReview(...)`:更新风控/复核状态。 - `updateOrderReview(...)`:更新风控/复核状态。
- `batchOrderOperation(formData)`:批量操作订单。 - `batchOrderOperation(formData)`:批量操作订单。
#### 充值订单:`src/actions/admin/recharge-orders.ts`
- `confirmAdminWalletRecharge(id)`:手动确认待支付充值订单,立即给用户钱包入账。
- `cancelAdminWalletRecharge(id)`:取消待支付充值订单,不改变钱包余额。
- `deleteAdminWalletRecharge(id)`:删除充值订单记录;已入账订单只删除记录,不回滚余额,钱包流水保留。
#### 其他管理动作 #### 其他管理动作
- 用户:`src/actions/admin/users.ts` - 用户:`src/actions/admin/users.ts`
@@ -341,6 +387,7 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- 任务重试:`src/actions/admin/tasks.ts` - 任务重试:`src/actions/admin/tasks.ts`
- 流量视图刷新:`src/actions/admin/traffic.ts` - 流量视图刷新:`src/actions/admin/traffic.ts`
- 优惠券与促销:`src/actions/admin/commerce.ts` - 优惠券与促销:`src/actions/admin/commerce.ts`
- 充值卡:`src/actions/admin/recharge-cards.ts`,生成余额卡/套餐卡、删除卡密并在需要时释放套餐库存。
### 用户端 Actions ### 用户端 Actions
@@ -351,6 +398,8 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- `src/actions/user/notifications.ts`:已读、删除、清空。 - `src/actions/user/notifications.ts`:已读、删除、清空。
- `src/actions/user/support.ts`:创建、回复、关闭、删除工单。创建工单会受后台工单上限控制。 - `src/actions/user/support.ts`:创建、回复、关闭、删除工单。创建工单会受后台工单上限控制。
- `src/actions/user/orders.ts`:取消待支付订单、重新选择支付方式。 - `src/actions/user/orders.ts`:取消待支付订单、重新选择支付方式。
- `src/actions/user/wallet.ts`:创建余额充值订单、兑换充值卡、取消待支付充值订单。
- `src/actions/user/subscription-transfer.ts`:发起套餐 Push、接收、拒收、取消。发起时会暂停套餐并禁用 3x-ui client接收成功后会为接收方生成新的 client。
## 风控数据模型要点 ## 风控数据模型要点
@@ -359,6 +408,9 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- `SubscriptionRiskReason`:包含城市、省/地区、国家变化,以及节点高频、目标分散等原因。 - `SubscriptionRiskReason`:包含城市、省/地区、国家变化,以及节点高频、目标分散等原因。
- `AppConfig`:保存订阅风控总控、自动暂停开关、阈值、节点日志风控阈值,以及日志清理开关、保留天数和上次清理时间。 - `AppConfig`:保存订阅风控总控、自动暂停开关、阈值、节点日志风控阈值,以及日志清理开关、保留天数和上次清理时间。
- `NodeClient.email`:用于匹配 Xray access log 中的 `email:`。它可能形如 `user@example.com-cmojtnp3`,不要手动在 3x-ui 修改。 - `NodeClient.email`:用于匹配 Xray access log 中的 `email:`。它可能形如 `user@example.com-cmojtnp3`,不要手动在 3x-ui 修改。
- `WalletAccount` / `WalletTransaction` / `WalletRechargeOrder`:保存用户余额、余额流水和充值订单。
- `RechargeCard`:保存余额卡和套餐卡,套餐卡会参与套餐库存占用计算。
- `SubscriptionTransfer`:保存套餐 Push 全流程状态、费用承担方、扣费/退款时间、周期起点和 24 小时过期时间。
## 错误处理约定 ## 错误处理约定

View File

@@ -1,8 +1,8 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: J-Board API title: J-Board Lite API
version: 0.2.0 version: 0.3.0
description: Current J-Board Route Handlers. Node provisioning uses 3x-ui; probe API only accepts latency and trace uploads. description: Current J-Board Lite Route Handlers. Node provisioning uses 3x-ui; the Agent API accepts latency, route trace, and Xray access log uploads.
servers: servers:
- url: https://your-domain.com - url: https://your-domain.com
security: [] security: []
@@ -11,8 +11,10 @@ tags:
- name: Public - name: Public
- name: Payment - name: Payment
- name: Admin - name: Admin
- name: Probe - name: Agent
- name: Subscription - name: Subscription
- name: Wallet
- name: Support
paths: paths:
/api/auth/register: /api/auth/register:
post: post:
@@ -82,6 +84,14 @@ paths:
get: get:
tags: [Payment] tags: [Payment]
summary: List enabled payment providers summary: List enabled payment providers
parameters:
- name: target
in: query
required: false
schema:
type: string
enum: [order, wallet]
description: Use wallet to exclude balance payment from recharge flows.
responses: responses:
'200': '200':
description: Providers description: Providers
@@ -200,10 +210,10 @@ paths:
description: SQL file description: SQL file
/api/agent/latency: /api/agent/latency:
post: post:
tags: [Probe] tags: [Agent]
summary: Upload carrier latency probe results summary: Upload carrier latency results
security: security:
- probeToken: [] - agentToken: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -218,13 +228,13 @@ paths:
schema: schema:
$ref: '#/components/schemas/OkResponse' $ref: '#/components/schemas/OkResponse'
'401': '401':
description: Invalid probe token description: Invalid agent token
/api/agent/trace: /api/agent/trace:
post: post:
tags: [Probe] tags: [Agent]
summary: Upload carrier route trace results summary: Upload carrier route trace results
security: security:
- probeToken: [] - agentToken: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -239,7 +249,24 @@ paths:
schema: schema:
$ref: '#/components/schemas/OkResponse' $ref: '#/components/schemas/OkResponse'
'401': '401':
description: Invalid probe token description: Invalid agent token
/api/agent/node-access:
post:
tags: [Agent]
summary: Upload aggregated Xray access log events
security:
- agentToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NodeAccessUpload'
responses:
'200':
description: Accepted
'401':
description: Invalid agent token
/api/subscription/{id}: /api/subscription/{id}:
get: get:
tags: [Subscription] tags: [Subscription]
@@ -264,7 +291,7 @@ paths:
type: string type: string
/api/support/attachments/{id}: /api/support/attachments/{id}:
get: get:
tags: [Subscription] tags: [Support]
summary: Download or preview support attachment summary: Download or preview support attachment
security: security:
- cookieAuth: [] - cookieAuth: []
@@ -282,13 +309,99 @@ paths:
responses: responses:
'200': '200':
description: Attachment file description: Attachment file
/api/subscription/all:
get:
tags: [Subscription]
summary: Download aggregate subscription content
security:
- cookieAuth: []
responses:
'200':
description: Subscription text
content:
text/plain:
schema:
type: string
/api/notifications:
get:
tags: [Subscription]
summary: List current user notifications
security:
- cookieAuth: []
responses:
'200':
description: Notifications
/api/wallet/recharge/{id}:
get:
tags: [Wallet]
summary: Get current user's wallet recharge order
security:
- cookieAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Recharge order
/api/wallet/recharge/payment/create:
post:
tags: [Wallet]
summary: Create external payment parameters for a wallet recharge order
security:
- cookieAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateWalletRechargePaymentRequest'
responses:
'200':
description: Payment created
'400':
description: Invalid recharge or payment provider
/api/wallet/recharge/query/{tradeNo}:
get:
tags: [Wallet]
summary: Query wallet recharge payment by trade number
security:
- cookieAuth: []
parameters:
- name: tradeNo
in: path
required: true
schema:
type: string
responses:
'200':
description: Recharge payment state
/api/wallet/redeem-card:
post:
tags: [Wallet]
summary: Redeem a balance or subscription recharge card
security:
- cookieAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RedeemRechargeCardRequest'
responses:
'200':
description: Redeemed
'400':
description: Invalid, used, or expired card
components: components:
securitySchemes: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey
in: cookie in: cookie
name: next-auth.session-token name: next-auth.session-token
probeToken: agentToken:
type: http type: http
scheme: bearer scheme: bearer
parameters: parameters:
@@ -336,7 +449,25 @@ components:
type: string type: string
provider: provider:
type: string type: string
channel:
type: string
required: [orderId, provider] required: [orderId, provider]
CreateWalletRechargePaymentRequest:
type: object
properties:
rechargeId:
type: string
provider:
type: string
channel:
type: string
required: [rechargeId, provider]
RedeemRechargeCardRequest:
type: object
properties:
code:
type: string
required: [code]
LatencyUpload: LatencyUpload:
type: object type: object
properties: properties:
@@ -383,3 +514,38 @@ components:
type: integer type: integer
required: [carrier, hops] required: [carrier, hops]
required: [traces] required: [traces]
NodeAccessUpload:
type: object
properties:
events:
type: array
items:
type: object
properties:
clientEmail:
type: string
sourceIp:
type: string
inboundTag:
type: string
network:
type: string
enum: [tcp, udp]
targetHost:
type: string
targetPort:
type: integer
action:
type: string
connectionCount:
type: integer
uniqueTargetCount:
type: integer
firstSeenAt:
type: string
format: date-time
lastSeenAt:
type: string
format: date-time
required: [clientEmail, sourceIp, inboundTag, network, action, connectionCount, uniqueTargetCount]
required: [events]

View File

@@ -8,7 +8,8 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:push": "prisma db push --accept-data-loss" "db:push": "prisma db push --accept-data-loss",
"admin:reset": "tsx scripts/reset-admin.ts"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",

View File

@@ -88,6 +88,29 @@ enum OrderKind {
TRAFFIC_TOPUP TRAFFIC_TOPUP
} }
enum WalletTransactionType {
BALANCE_RECHARGE
BALANCE_PAYMENT
CARD_REDEEM
ADMIN_ADJUST
REFUND
SUBSCRIPTION_TRANSFER_FEE
SUBSCRIPTION_TRANSFER_REFUND
}
enum SubscriptionTransferStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
EXPIRED
}
enum SubscriptionTransferFeePayer {
SENDER
RECIPIENT
}
enum Protocol { enum Protocol {
VMESS VMESS
VLESS VLESS
@@ -165,6 +188,18 @@ enum SupportTicketPriority {
URGENT URGENT
} }
enum RechargeCardType {
BALANCE
PLAN
}
enum RechargeCardStatus {
UNUSED
REDEEMED
DISABLED
EXPIRED
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
@@ -196,6 +231,13 @@ model User {
supportTickets SupportTicket[] supportTickets SupportTicket[]
supportReplies SupportTicketReply[] supportReplies SupportTicketReply[]
emailTokens EmailToken[] emailTokens EmailToken[]
walletAccount WalletAccount?
walletTransactions WalletTransaction[]
walletRechargeOrders WalletRechargeOrder[]
redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer")
createdRechargeCards RechargeCard[] @relation("RechargeCardCreator")
sentSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferSender")
receivedSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferRecipient")
} }
model EmailToken { model EmailToken {
@@ -301,6 +343,8 @@ model SubscriptionPlan {
orders Order[] orders Order[]
cartItems ShoppingCartItem[] cartItems ShoppingCartItem[]
orderItems OrderItem[] orderItems OrderItem[]
rechargeCards RechargeCard[]
subscriptionTransfers SubscriptionTransfer[]
@@index([type, isActive, isFeatured, sortOrder]) @@index([type, isActive, isFeatured, sortOrder])
@@index([inboundId]) @@index([inboundId])
@@ -327,11 +371,48 @@ model UserSubscription {
nodeClient NodeClient? nodeClient NodeClient?
createOrder Order? @relation("OrderCreatedSubscription") createOrder Order? @relation("OrderCreatedSubscription")
targetOrders Order[] @relation("OrderTargetSubscription") targetOrders Order[] @relation("OrderTargetSubscription")
transfers SubscriptionTransfer[]
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
} }
model SubscriptionTransfer {
id String @id @default(cuid())
subscriptionId String
planId String
senderId String
recipientId String
senderEmail String
recipientEmail String
status SubscriptionTransferStatus @default(PENDING)
feeAmount Decimal @default(0)
feePayer SubscriptionTransferFeePayer @default(SENDER)
feeChargedToId String?
feeChargedAt DateTime?
feeRefundedAt DateTime?
cycleStartedAt DateTime
expiresAt DateTime
acceptedAt DateTime?
rejectedAt DateTime?
cancelledAt DateTime?
expiredAt DateTime?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
sender User @relation("SubscriptionTransferSender", fields: [senderId], references: [id], onDelete: Cascade)
recipient User @relation("SubscriptionTransferRecipient", fields: [recipientId], references: [id], onDelete: Cascade)
@@index([subscriptionId, status, createdAt])
@@index([senderId, createdAt])
@@index([recipientId, createdAt])
@@index([status, expiresAt])
@@index([planId, cycleStartedAt])
}
model SubscriptionAccessLog { model SubscriptionAccessLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String? userId String?
@@ -482,6 +563,7 @@ model NodeInbound {
selectedByOrders Order[] selectedByOrders Order[]
cartItems ShoppingCartItem[] cartItems ShoppingCartItem[]
orderItems OrderItem[] orderItems OrderItem[]
rechargeCards RechargeCard[]
@@unique([serverId, tag]) @@unique([serverId, tag])
@@unique([serverId, panelInboundId]) @@unique([serverId, panelInboundId])
@@ -598,6 +680,7 @@ model Order {
items OrderItem[] items OrderItem[]
couponGrants CouponGrant[] @relation("CouponGrantUsedOrder") couponGrants CouponGrant[] @relation("CouponGrantUsedOrder")
inviteRewards InviteRewardLedger[] inviteRewards InviteRewardLedger[]
walletTransactions WalletTransaction[]
@@index([userId]) @@index([userId])
@@index([kind]) @@index([kind])
@@ -608,6 +691,98 @@ model Order {
@@index([couponId]) @@index([couponId])
} }
model WalletAccount {
id String @id @default(cuid())
userId String @unique
balance Decimal @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions WalletTransaction[]
@@index([updatedAt])
}
model WalletTransaction {
id String @id @default(cuid())
walletId String
userId String
type WalletTransactionType
amount Decimal
balanceAfter Decimal
description String?
orderId String?
rechargeOrderId String?
rechargeCardId String?
metadata Json?
createdAt DateTime @default(now())
wallet WalletAccount @relation(fields: [walletId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
rechargeOrder WalletRechargeOrder? @relation(fields: [rechargeOrderId], references: [id], onDelete: SetNull)
rechargeCard RechargeCard? @relation(fields: [rechargeCardId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([walletId, createdAt])
@@index([orderId])
@@index([rechargeOrderId])
@@index([rechargeCardId])
}
model WalletRechargeOrder {
id String @id @default(cuid())
userId String
amount Decimal
status OrderStatus @default(PENDING)
paymentMethod String?
paymentRef String?
paymentUrl String?
tradeNo String? @unique
expireAt DateTime?
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions WalletTransaction[]
@@index([userId, createdAt])
@@index([status])
@@index([tradeNo])
}
model RechargeCard {
id String @id @default(cuid())
code String @unique
type RechargeCardType
status RechargeCardStatus @default(UNUSED)
balanceAmount Decimal?
planId String?
selectedInboundId String?
trafficGb Int?
batchName String?
note String?
expiresAt DateTime?
redeemedById String?
redeemedAt DateTime?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plan SubscriptionPlan? @relation(fields: [planId], references: [id], onDelete: SetNull)
selectedInbound NodeInbound? @relation(fields: [selectedInboundId], references: [id], onDelete: SetNull)
redeemedBy User? @relation("RechargeCardRedeemer", fields: [redeemedById], references: [id], onDelete: SetNull)
createdBy User? @relation("RechargeCardCreator", fields: [createdById], references: [id], onDelete: SetNull)
transactions WalletTransaction[]
@@index([type, status, createdAt])
@@index([planId])
@@index([redeemedById, redeemedAt])
@@index([expiresAt])
}
model NodeLatency { model NodeLatency {
id String @id @default(cuid()) id String @id @default(cuid())
nodeId String nodeId String
@@ -810,6 +985,11 @@ model AppConfig {
inviteRewardCouponId String? inviteRewardCouponId String?
inviteRewardRate Decimal @default(0) inviteRewardRate Decimal @default(0)
inviteRewardEnabled Boolean @default(false) inviteRewardEnabled Boolean @default(false)
subscriptionTransferEnabled Boolean @default(true)
subscriptionTransferFee Decimal @default(0)
subscriptionTransferLimitPerCycle Int @default(1)
subscriptionTransferMinRemainingDays Int @default(0)
subscriptionTransferMinRemainingTrafficGb Int @default(0)
turnstileSiteKey String? turnstileSiteKey String?
turnstileSecretKey String? turnstileSecretKey String?
smtpEnabled Boolean @default(false) smtpEnabled Boolean @default(false)

View File

@@ -190,7 +190,7 @@ write_systemd_service() {
local service_tmp="$TMP_DIR/${SERVICE_NAME}.service" local service_tmp="$TMP_DIR/${SERVICE_NAME}.service"
cat > "$service_tmp" <<SERVICE cat > "$service_tmp" <<SERVICE
[Unit] [Unit]
Description=J-Board Probe Agent Description=J-Board Lite Agent
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -221,7 +221,7 @@ DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS" CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
echo "[1/10] Release tag: ${RESOLVED_TAG}" echo "[1/10] Release tag: ${RESOLVED_TAG}"
echo "[2/10] Downloading probe agent binary: ${ASSET}" echo "[2/10] Downloading agent binary: ${ASSET}"
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET" curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then

View File

@@ -239,7 +239,7 @@ load_resource_helpers() {
} }
prepare_repo() { prepare_repo() {
section "准备 J-Board 代码" section "准备 J-Board Lite 代码"
local default_dir local default_dir
default_dir="$(resolve_default_app_dir)" default_dir="$(resolve_default_app_dir)"
@@ -253,7 +253,7 @@ prepare_repo() {
git_in_repo pull --ff-only origin "$BRANCH" git_in_repo pull --ff-only origin "$BRANCH"
elif [ -e "$APP_DIR" ] && [ "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')" != "0" ]; then elif [ -e "$APP_DIR" ] && [ "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')" != "0" ]; then
echo "安装目录已存在且不为空:$APP_DIR" >&2 echo "安装目录已存在且不为空:$APP_DIR" >&2
echo "请换一个目录,或设置 APP_DIR 指向空目录/已有 J-Board 仓库。" >&2 echo "请换一个目录,或设置 APP_DIR 指向空目录/已有 J-Board Lite 仓库。" >&2
exit 1 exit 1
else else
run_as_root mkdir -p "$(dirname "$APP_DIR")" run_as_root mkdir -p "$(dirname "$APP_DIR")"
@@ -291,7 +291,7 @@ write_env() {
tmp="$(mktemp)" tmp="$(mktemp)"
{ {
printf '# J-Board panel\n' printf '# J-Board Lite Docker panel\n'
printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")" printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")"
printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")" printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")"
printf '\n# SQLite for local tools and Docker\n' printf '\n# SQLite for local tools and Docker\n'
@@ -315,6 +315,49 @@ write_env() {
rm -f "$tmp" rm -f "$tmp"
} }
install_jetboard_command() {
section "安装 JetBoard 管理命令"
if [ ! -f "$APP_DIR/scripts/jetboard.sh" ]; then
echo "未找到 $APP_DIR/scripts/jetboard.sh跳过管理命令安装。"
return
fi
local config_tmp wrapper_tmp
config_tmp="$(mktemp)"
wrapper_tmp="$(mktemp)"
{
printf '# JetBoard Docker command configuration\n'
printf 'JBOARD_DEPLOY_MODE="docker"\n'
printf 'JBOARD_APP_DIR="%s"\n' "$(env_escape "$APP_DIR")"
printf 'GH_REPO="%s"\n' "$(env_escape "$GH_REPO")"
printf 'BRANCH="%s"\n' "$(env_escape "$BRANCH")"
printf 'COMPOSE="docker compose"\n'
} > "$config_tmp"
{
printf '#!/usr/bin/env bash\n'
printf 'set -euo pipefail\n'
printf 'CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"\n'
printf 'if [ -r "$CONFIG_FILE" ]; then\n'
printf ' # shellcheck disable=SC1090\n'
printf ' . "$CONFIG_FILE"\n'
printf 'fi\n'
printf 'APP_DIR="${JBOARD_APP_DIR:-/opt/jboard}"\n'
printf 'exec "$APP_DIR/scripts/jetboard.sh" "$@"\n'
} > "$wrapper_tmp"
run_as_root mkdir -p /usr/local/bin
run_as_root install -m 0644 "$config_tmp" /etc/jetboard.conf
run_as_root install -m 0755 "$wrapper_tmp" /usr/local/bin/jetboard
run_as_root ln -sf /usr/local/bin/jetboard /usr/local/bin/JetBoard || true
run_as_root chmod +x "$APP_DIR/scripts/jetboard.sh" || true
rm -f "$config_tmp" "$wrapper_tmp"
echo "已安装jetboard也可使用 JetBoard"
}
configure_env() { configure_env() {
section "生成 .env 配置" section "生成 .env 配置"
@@ -416,12 +459,12 @@ print_summary() {
local proxy_target="http://127.0.0.1:${APP_PORT:-3000}" local proxy_target="http://127.0.0.1:${APP_PORT:-3000}"
local shown_password="$ADMIN_PASSWORD" local shown_password="$ADMIN_PASSWORD"
if [ "$ENV_REUSED" = "1" ] && [ -z "$shown_password" ]; then if [ "$ENV_REUSED" = "1" ] && [ -z "$shown_password" ]; then
shown_password="沿用已有数据库账号;如忘记请在数据库中重置" shown_password="沿用已有数据库账号;如忘记可执行 jetboard reset"
fi fi
echo echo
printf '%s\n' "============================================================" printf '%s\n' "============================================================"
printf '%s\n' "J-Board 部署完成" printf '%s\n' "J-Board Lite Docker 部署完成"
printf '%s\n' "============================================================" printf '%s\n' "============================================================"
printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}" printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}"
printf '订阅地址:%s\n' "${SUBSCRIPTION_PUBLIC_URL:-${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}}" printf '订阅地址:%s\n' "${SUBSCRIPTION_PUBLIC_URL:-${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}}"
@@ -446,15 +489,17 @@ print_summary() {
printf ' 4. 后台 /admin/plans创建套餐并绑定入站或流媒体服务。\n' printf ' 4. 后台 /admin/plans创建套餐并绑定入站或流媒体服务。\n'
echo echo
printf '%s\n' "常用命令" printf '%s\n' "常用命令"
printf ' cd %s\n' "$APP_DIR" printf ' jetboard # 打开 JetBoard 管理菜单\n'
printf ' sudo docker compose logs -f app\n' printf ' jetboard status # 查看 Docker 服务状态\n'
printf ' sudo docker compose ps\n' printf ' jetboard update # 拉取代码、备份数据库并按机器配置重建\n'
printf ' sudo ./scripts/upgrade-jboard-panel.sh\n' printf ' jetboard reset # 重置或创建管理员账号密码\n'
printf ' jetboard logs # 查看 app 日志\n'
printf ' jetboard uninstall # 完整卸载 Docker 部署\n'
printf '%s\n' "============================================================" printf '%s\n' "============================================================"
} }
main() { main() {
section "J-Board 一键部署向导" section "J-Board Lite Docker 一键部署向导"
echo "这个脚本会安装 Docker、准备配置、初始化数据库并启动面板。" echo "这个脚本会安装 Docker、准备配置、初始化数据库并启动面板。"
echo "适合全新 Linux 服务器;已有环境会尽量保留现有 .env。" echo "适合全新 Linux 服务器;已有环境会尽量保留现有 .env。"
@@ -462,6 +507,7 @@ main() {
prepare_repo prepare_repo
load_resource_helpers load_resource_helpers
configure_env configure_env
install_jetboard_command
install_docker install_docker
start_panel start_panel
wait_for_app wait_for_app

View File

@@ -0,0 +1,603 @@
#!/usr/bin/env bash
set -euo pipefail
GH_REPO="${GH_REPO:-JetSprow/J-Board-Lite}"
BRANCH="${BRANCH:-main}"
APP_DIR="${APP_DIR:-}"
REWRITE_ENV="${REWRITE_ENV:-}"
SKIP_NODE_INSTALL="${SKIP_NODE_INSTALL:-0}"
NODE_MAJOR="${NODE_MAJOR:-20}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}"
SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}"
CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"
APP_PORT=""
PUBLIC_URL=""
SUBSCRIPTION_PUBLIC_URL=""
SITE_NAME=""
ADMIN_EMAIL=""
ADMIN_PASSWORD=""
ADMIN_NAME=""
NEXTAUTH_SECRET=""
ENCRYPTION_KEY=""
ENV_REUSED="0"
RUNTIME_NODE_OPTIONS=""
is_interactive() {
[ -r /dev/tty ] && [ -w /dev/tty ]
}
prompt_print() {
if is_interactive; then
printf '%b' "$*" > /dev/tty
else
printf '%b' "$*" >&2
fi
}
prompt_read() {
local __var="$1"
if is_interactive; then
IFS= read -r "$__var" < /dev/tty || true
else
return 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "需要 root 权限。请使用 root 用户运行,或先安装 sudo。" >&2
exit 1
fi
}
line() {
printf '%s\n' "------------------------------------------------------------"
}
section() {
echo
line
printf '%s\n' "$1"
line
}
need_command() {
command -v "$1" >/dev/null 2>&1
}
install_base_packages() {
section "安装基础依赖"
if need_command apt-get; then
run_as_root apt-get update
run_as_root apt-get install -y ca-certificates curl git openssl python3 make g++ build-essential
elif need_command dnf; then
run_as_root dnf install -y ca-certificates curl git openssl python3 make gcc gcc-c++
elif need_command yum; then
run_as_root yum install -y ca-certificates curl git openssl python3 make gcc gcc-c++
elif need_command apk; then
run_as_root apk add --no-cache ca-certificates curl git openssl python3 make g++
else
echo "无法识别包管理器请先手动安装curl git openssl nodejs npm python3 make g++" >&2
exit 1
fi
}
node_major_version() {
if ! need_command node; then
echo 0
return
fi
local version
version="$(node -v 2>/dev/null | sed 's/^v//; s/\..*$//' || true)"
case "$version" in
''|*[!0-9]*) echo 0 ;;
*) echo "$version" ;;
esac
}
install_node() {
if [ "$SKIP_NODE_INSTALL" = "1" ]; then
return
fi
local current_major
current_major="$(node_major_version)"
if [ "$current_major" -ge "$NODE_MAJOR" ] && need_command npm; then
echo "Node.js $(node -v) 已满足要求。"
return
fi
section "安装 Node.js ${NODE_MAJOR}"
local setup_tmp
setup_tmp="$(mktemp)"
if need_command apt-get; then
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root apt-get install -y nodejs
elif need_command dnf; then
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root dnf install -y nodejs
elif need_command yum; then
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root yum install -y nodejs
elif need_command apk; then
run_as_root apk add --no-cache nodejs npm
else
echo "无法自动安装 Node.js请先手动安装 Node.js ${NODE_MAJOR}+ 和 npm。" >&2
exit 1
fi
rm -f "$setup_tmp"
current_major="$(node_major_version)"
if [ "$current_major" -lt "$NODE_MAJOR" ] || ! need_command npm; then
echo "Node.js 安装后仍不满足要求,请检查 node/npm。" >&2
exit 1
fi
}
random_hex() {
openssl rand -hex "$1"
}
random_password() {
openssl rand -hex 12
}
server_ip() {
curl -fsS --max-time 3 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1"
}
normalize_url() {
local value="$1"
value="${value%/}"
if [ -z "$value" ]; then
printf '%s' "$value"
return
fi
case "$value" in
http://*|https://*) printf '%s' "$value" ;;
*) printf 'https://%s' "$value" ;;
esac
}
prompt_value() {
local label="$1"
local default="$2"
local help="${3:-}"
local value=""
if [ -n "$help" ]; then
prompt_print "$help\n"
fi
if is_interactive; then
prompt_print "$label [$default]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
prompt_generated() {
local label="$1"
local default="$2"
local help="${3:-}"
local value=""
if [ -n "$help" ]; then
prompt_print "$help\n"
fi
if is_interactive; then
prompt_print "$label [回车自动生成]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
prompt_yes_no() {
local label="$1"
local default="$2"
local value=""
if ! is_interactive; then
printf '%s' "$default"
return
fi
prompt_print "$label [$default]: "
prompt_read value || true
value="${value:-$default}"
case "$value" in
y|Y|yes|YES|Yes) printf 'y' ;;
*) printf 'n' ;;
esac
}
resolve_default_app_dir() {
local source="${BASH_SOURCE[0]:-}"
local dir=""
if [ -n "$source" ] && [ -f "$source" ]; then
dir="$(cd -- "$(dirname -- "$source")" && pwd)"
if [ -f "$dir/../package.json" ]; then
cd -- "$dir/.." && pwd
return
fi
fi
echo "/opt/jboard"
}
git_in_repo() {
if [ -w "$APP_DIR/.git" ] || [ -w "$APP_DIR" ]; then
git -c safe.directory="$APP_DIR" "$@"
else
run_as_root git -c safe.directory="$APP_DIR" "$@"
fi
}
run_in_app_dir() {
if [ -w "$APP_DIR" ] && { [ ! -f "$APP_DIR/.env" ] || [ -r "$APP_DIR/.env" ]; } && [ "$(id -u)" -ne 0 ]; then
"$@"
else
run_as_root "$@"
fi
}
load_resource_helpers() {
local helper="$APP_DIR/scripts/lib-standalone-profile.sh"
if [ -f "$helper" ]; then
# shellcheck disable=SC1090
. "$helper"
else
echo "未找到本机资源检测脚本:$helper,将使用默认构建策略。"
fi
}
prepare_repo() {
section "准备 J-Board Lite 代码"
local default_dir
default_dir="$(resolve_default_app_dir)"
APP_DIR="$(prompt_value "安装目录" "${APP_DIR:-$default_dir}" "如果你已经在仓库目录里运行脚本,直接回车即可。")"
if [ -d "$APP_DIR/.git" ]; then
echo "检测到已有仓库:$APP_DIR"
cd "$APP_DIR"
git_in_repo fetch origin "$BRANCH"
git_in_repo checkout "$BRANCH"
git_in_repo pull --ff-only origin "$BRANCH"
elif [ -e "$APP_DIR" ] && [ "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')" != "0" ]; then
echo "安装目录已存在且不为空:$APP_DIR" >&2
echo "请换一个目录,或设置 APP_DIR 指向空目录/已有 J-Board Lite 仓库。" >&2
exit 1
else
run_as_root mkdir -p "$(dirname "$APP_DIR")"
run_as_root git clone --branch "$BRANCH" "https://github.com/${GH_REPO}.git" "$APP_DIR"
cd "$APP_DIR"
fi
}
load_existing_env() {
if [ -f .env ]; then
if [ -r .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
else
echo ".env 存在但当前用户不可读取;如需显示管理员密码,请使用 root 重新运行或查看 .env。"
fi
APP_PORT="${APP_PORT:-3000}"
PUBLIC_URL="${NEXTAUTH_URL:-}"
SUBSCRIPTION_PUBLIC_URL="${SUBSCRIPTION_URL:-}"
SITE_NAME="${SITE_NAME:-J-Board Lite}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
ADMIN_NAME="${ADMIN_NAME:-Admin}"
fi
}
env_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\$/\\$/g'
}
write_env() {
local tmp
tmp="$(mktemp)"
{
printf '# J-Board Lite standalone panel\n'
printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")"
printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")"
printf '\n# SQLite for standalone runtime\n'
printf 'DATABASE_URL="file:%s/storage/jboard.db"\n' "$(env_escape "$APP_DIR")"
printf '\n# NextAuth\n'
printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")"
printf 'NEXTAUTH_URL="%s"\n' "$(env_escape "$PUBLIC_URL")"
printf 'SUBSCRIPTION_URL="%s"\n' "$(env_escape "$SUBSCRIPTION_PUBLIC_URL")"
printf '\n# Must be at least 32 bytes, used for AES-256-GCM encryption\n'
printf 'ENCRYPTION_KEY="%s"\n' "$(env_escape "$ENCRYPTION_KEY")"
printf '\n# Initial admin account, used by npm run db:seed on first install\n'
printf 'ADMIN_EMAIL="%s"\n' "$(env_escape "$ADMIN_EMAIL")"
printf 'ADMIN_PASSWORD="%s"\n' "$(env_escape "$ADMIN_PASSWORD")"
printf 'ADMIN_NAME="%s"\n' "$(env_escape "$ADMIN_NAME")"
} > "$tmp"
if [ -f .env ]; then
run_as_root cp .env ".env.backup.$(date +%Y%m%d%H%M%S)"
fi
run_as_root install -m 0600 "$tmp" .env
rm -f "$tmp"
}
configure_env() {
section "生成 .env 配置"
if [ -f .env ] && [ -z "$REWRITE_ENV" ]; then
local answer
answer="$(prompt_yes_no "检测到已有 .env是否重新生成" "n")"
if [ "$answer" = "n" ]; then
ENV_REUSED="1"
load_existing_env
echo "沿用现有 .env。"
return
fi
elif [ "${REWRITE_ENV:-0}" = "0" ] && [ -f .env ]; then
ENV_REUSED="1"
load_existing_env
echo "沿用现有 .env。"
return
fi
local ip default_url
ip="$(server_ip)"
default_url="http://${ip}:3000"
SITE_NAME="$(prompt_value "站点名称" "J-Board Lite")"
PUBLIC_URL="$(prompt_value "网站访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的面板域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")"
PUBLIC_URL="$(normalize_url "$PUBLIC_URL")"
SUBSCRIPTION_PUBLIC_URL="$(prompt_value "订阅访问地址" "$PUBLIC_URL" "用于生成客户端订阅链接。可以和网站地址相同,也可以填单独反代到本面板的订阅域名,例如 https://sub.example.com。")"
SUBSCRIPTION_PUBLIC_URL="$(normalize_url "$SUBSCRIPTION_PUBLIC_URL")"
APP_PORT="$(prompt_value "本机监听端口" "3000" "反向代理目标会是 http://127.0.0.1:端口,默认 3000。")"
ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")"
ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")"
ADMIN_NAME="$(prompt_value "管理员昵称" "Admin")"
NEXTAUTH_SECRET="$(prompt_generated "NEXTAUTH_SECRET" "$(random_hex 32)")"
ENCRYPTION_KEY="$(prompt_generated "ENCRYPTION_KEY" "$(random_hex 32)" "生产使用后不要更换 ENCRYPTION_KEY否则已加密的面板密码、Token、凭据会无法解密。")"
write_env
echo ".env 已写入:$APP_DIR/.env"
}
ensure_service_user() {
if id "$SERVICE_USER" >/dev/null 2>&1; then
return
fi
section "创建运行用户:$SERVICE_USER"
if need_command useradd; then
run_as_root useradd --system --user-group --home "$APP_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
elif need_command adduser; then
run_as_root adduser -S -D -H -h "$APP_DIR" -s /sbin/nologin "$SERVICE_USER"
else
echo "无法创建系统用户,请手动创建 $SERVICE_USER" >&2
exit 1
fi
}
prepare_runtime_tree() {
[ -f "$APP_DIR/.next/standalone/server.js" ] || {
echo "未找到 standalone 构建产物:$APP_DIR/.next/standalone/server.js" >&2
exit 1
}
run_in_app_dir mkdir -p "$APP_DIR/.next/standalone/.next"
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/.next/static"
run_in_app_dir cp -R "$APP_DIR/.next/static" "$APP_DIR/.next/standalone/.next/"
if [ -d "$APP_DIR/public" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/public"
run_in_app_dir cp -R "$APP_DIR/public" "$APP_DIR/.next/standalone/public"
fi
if [ -d "$APP_DIR/data" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/data"
run_in_app_dir cp -R "$APP_DIR/data" "$APP_DIR/.next/standalone/data"
fi
}
install_dependencies_and_build() {
section "安装依赖并构建"
if command -v jboard_prepare_standalone_build_env >/dev/null 2>&1; then
jboard_prepare_standalone_build_env "$APP_DIR"
jboard_print_standalone_profile
RUNTIME_NODE_OPTIONS="${JBOARD_RUNTIME_NODE_OPTIONS:-}"
else
export NEXT_TELEMETRY_DISABLED=1
fi
run_as_root mkdir -p "$APP_DIR/storage" "$APP_DIR/backups"
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm ci --no-audit --no-fund
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm run build
prepare_runtime_tree
run_in_app_dir npm run db:push
run_in_app_dir npm run db:seed
run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" "$APP_DIR/backups"
}
write_service() {
section "安装 systemd 服务"
if ! need_command systemctl; then
echo "当前系统没有 systemd无法自动安装服务。请使用支持 systemd 的 Linux 发行版。" >&2
exit 1
fi
local tmp
tmp="$(mktemp)"
{
printf '[Unit]\n'
printf 'Description=J-Board Lite standalone panel\n'
printf 'After=network-online.target\n'
printf 'Wants=network-online.target\n'
printf '\n[Service]\n'
printf 'Type=simple\n'
printf 'User=%s\n' "$SERVICE_USER"
printf 'WorkingDirectory=%s/.next/standalone\n' "$APP_DIR"
printf 'EnvironmentFile=%s/.env\n' "$APP_DIR"
printf 'Environment=NODE_ENV=production\n'
printf 'Environment=PORT=%s\n' "${APP_PORT:-3000}"
printf 'Environment=HOSTNAME=0.0.0.0\n'
if [ -n "$RUNTIME_NODE_OPTIONS" ]; then
printf 'Environment=NODE_OPTIONS=%s\n' "$RUNTIME_NODE_OPTIONS"
fi
printf 'ExecStart=/usr/bin/env node server.js\n'
printf 'Restart=always\n'
printf 'RestartSec=3\n'
printf 'NoNewPrivileges=true\n'
printf 'PrivateTmp=true\n'
printf '\n[Install]\n'
printf 'WantedBy=multi-user.target\n'
} > "$tmp"
run_as_root install -m 0644 "$tmp" "/etc/systemd/system/${SERVICE_NAME}.service"
rm -f "$tmp"
run_as_root systemctl daemon-reload
run_as_root systemctl enable --now "$SERVICE_NAME"
}
install_jetboard_command() {
section "安装 JetBoard 管理命令"
local config_tmp wrapper_tmp
config_tmp="$(mktemp)"
wrapper_tmp="$(mktemp)"
{
printf '# JetBoard standalone command configuration\n'
printf 'JBOARD_DEPLOY_MODE="standalone"\n'
printf 'JBOARD_APP_DIR="%s"\n' "$(env_escape "$APP_DIR")"
printf 'JBOARD_SERVICE_NAME="%s"\n' "$(env_escape "$SERVICE_NAME")"
printf 'JBOARD_SERVICE_USER="%s"\n' "$(env_escape "$SERVICE_USER")"
printf 'GH_REPO="%s"\n' "$(env_escape "$GH_REPO")"
printf 'BRANCH="%s"\n' "$(env_escape "$BRANCH")"
} > "$config_tmp"
{
printf '#!/usr/bin/env bash\n'
printf 'set -euo pipefail\n'
printf 'CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"\n'
printf 'if [ -r "$CONFIG_FILE" ]; then\n'
printf ' # shellcheck disable=SC1090\n'
printf ' . "$CONFIG_FILE"\n'
printf 'fi\n'
printf 'APP_DIR="${JBOARD_APP_DIR:-/opt/jboard}"\n'
printf 'exec "$APP_DIR/scripts/jetboard.sh" "$@"\n'
} > "$wrapper_tmp"
run_as_root mkdir -p /usr/local/bin
run_as_root install -m 0644 "$config_tmp" "$CONFIG_FILE"
run_as_root install -m 0755 "$wrapper_tmp" /usr/local/bin/jetboard
run_as_root ln -sf /usr/local/bin/jetboard /usr/local/bin/JetBoard || true
run_as_root chmod +x "$APP_DIR/scripts/jetboard.sh" || true
rm -f "$config_tmp" "$wrapper_tmp"
echo "已安装jetboard也可使用 JetBoard"
}
wait_for_app() {
section "检查启动状态"
local url="http://127.0.0.1:${APP_PORT:-3000}/api/public/app-info"
local ok="0"
for _ in $(seq 1 30); do
if curl -fsS "$url" >/dev/null 2>&1; then
ok="1"
break
fi
sleep 2
done
run_as_root systemctl --no-pager status "$SERVICE_NAME" || true
echo
if [ "$ok" = "1" ]; then
echo "健康检查通过:$url"
else
echo "健康检查暂未通过请查看日志jetboard logs"
fi
}
print_summary() {
local proxy_target="http://127.0.0.1:${APP_PORT:-3000}"
local shown_password="$ADMIN_PASSWORD"
if [ "$ENV_REUSED" = "1" ] && [ -z "$shown_password" ]; then
shown_password="沿用已有数据库账号;如忘记可执行 jetboard reset"
fi
echo
printf '%s\n' "============================================================"
printf '%s\n' "J-Board Lite standalone 部署完成"
printf '%s\n' "============================================================"
printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}"
printf '订阅地址:%s\n' "${SUBSCRIPTION_PUBLIC_URL:-${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}}"
printf '反代目标:%s\n' "$proxy_target"
printf '管理员邮箱:%s\n' "${ADMIN_EMAIL:-admin@jboard.local}"
printf '管理员密码:%s\n' "$shown_password"
echo
printf '%s\n' "常用命令"
printf ' jetboard # 打开 JetBoard 管理菜单\n'
printf ' jetboard status # 查看服务状态\n'
printf ' jetboard update # 拉取代码、备份数据库并本机构建\n'
printf ' jetboard reset # 重置或创建管理员账号密码\n'
printf ' jetboard logs # 查看服务日志\n'
printf ' jetboard uninstall # 完整卸载 standalone 部署\n'
printf '%s\n' "============================================================"
}
main() {
section "J-Board Lite standalone 一键部署向导"
echo "这个脚本不会安装或使用 Docker。"
echo "它会安装 Node.js、拉取代码、生成 .env、构建 standalone 产物、初始化 SQLite 并注册 systemd 服务。"
install_base_packages
install_node
prepare_repo
load_resource_helpers
configure_env
ensure_service_user
install_dependencies_and_build
write_service
install_jetboard_command
wait_for_app
print_summary
}
main "$@"

531
scripts/jetboard.sh Executable file
View File

@@ -0,0 +1,531 @@
#!/usr/bin/env bash
set -euo pipefail
BRAND="JetBoard"
CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"
DEPLOY_MODE="${JBOARD_DEPLOY_MODE:-auto}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}"
SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
COMPOSE="${COMPOSE:-docker compose}"
line() {
printf '%s\n' "------------------------------------------------------------"
}
section() {
echo
line
printf '%s\n' "$1"
line
}
fail() {
echo "$1" >&2
exit 1
}
is_interactive() {
[ -r /dev/tty ] && [ -w /dev/tty ]
}
prompt_print() {
if is_interactive; then
printf '%b' "$*" > /dev/tty
else
printf '%b' "$*" >&2
fi
}
prompt_read() {
local __var="$1"
if is_interactive; then
IFS= read -r "$__var" < /dev/tty || true
else
return 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
fail "需要 root 权限。请使用 root 用户运行,或先安装 sudo。"
fi
}
load_config() {
if [ -r "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
DEPLOY_MODE="${JBOARD_DEPLOY_MODE:-${DEPLOY_MODE:-auto}}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-${SERVICE_NAME:-jetboard}}"
SERVICE_USER="${JBOARD_SERVICE_USER:-${SERVICE_USER:-jetboard}}"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
COMPOSE="${COMPOSE:-docker compose}"
fi
}
resolve_app_dir() {
if [ -n "$APP_DIR" ]; then
printf '%s' "$APP_DIR"
return
fi
local source="${BASH_SOURCE[0]:-}"
local dir=""
if [ -n "$source" ] && [ -f "$source" ]; then
dir="$(cd -- "$(dirname -- "$source")" && pwd)"
if [ -f "$dir/../package.json" ]; then
cd -- "$dir/.." && pwd
return
fi
fi
printf '%s' "/opt/jboard"
}
detect_deploy_mode() {
case "$DEPLOY_MODE" in
docker|standalone)
return
;;
auto|'')
;;
*)
echo "未知 JBOARD_DEPLOY_MODE=$DEPLOY_MODE,回退为 auto。" >&2
;;
esac
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] && [ -f "$APP_DIR/.next/standalone/server.js" ]; then
DEPLOY_MODE="standalone"
elif [ -f "$APP_DIR/docker-compose.yml" ]; then
DEPLOY_MODE="docker"
else
DEPLOY_MODE="standalone"
fi
}
load_config
APP_DIR="$(resolve_app_dir)"
[ -d "$APP_DIR" ] || fail "未找到 J-Board Lite 安装目录:$APP_DIR"
[ -f "$APP_DIR/package.json" ] || fail "安装目录不是有效的 J-Board Lite 仓库:$APP_DIR"
cd "$APP_DIR"
detect_deploy_mode
load_env_for_prompts() {
if [ -r .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
elif [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
local env_dump
env_dump="$(sudo sh -c "set -a; . '$APP_DIR/.env' 2>/dev/null; env" || true)"
if [ -n "$env_dump" ]; then
while IFS='=' read -r key value; do
case "$key" in
APP_PORT|SITE_NAME|NEXTAUTH_URL|SUBSCRIPTION_URL|ADMIN_EMAIL|ADMIN_NAME)
export "$key=$value"
;;
esac
done <<EOF
$env_dump
EOF
fi
fi
}
load_standalone_resource_helpers() {
if [ -f "$APP_DIR/scripts/lib-standalone-profile.sh" ]; then
# shellcheck disable=SC1091
. "$APP_DIR/scripts/lib-standalone-profile.sh"
fi
}
random_password() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 12
else
date +%s | cksum | awk '{print "jb" $1 $2}'
fi
}
prompt_value() {
local label="$1"
local default="$2"
local value=""
if is_interactive; then
prompt_print "$label [$default]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
prompt_secret_or_generated() {
local label="$1"
local default="$2"
local value=""
if is_interactive; then
prompt_print "$label [回车自动生成]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
run_in_app_dir() {
if [ -w "$APP_DIR" ] && { [ ! -f "$APP_DIR/.env" ] || [ -r "$APP_DIR/.env" ]; } && [ "$(id -u)" -ne 0 ]; then
"$@"
else
run_as_root "$@"
fi
}
docker_compose() {
local -a compose_cmd
local -a env_args=()
read -r -a compose_cmd <<< "$COMPOSE"
if [ "${#compose_cmd[@]}" -eq 0 ]; then
compose_cmd=(docker compose)
fi
if [ -n "${COMPOSE_PARALLEL_LIMIT:-}" ]; then
env_args+=("COMPOSE_PARALLEL_LIMIT=$COMPOSE_PARALLEL_LIMIT")
fi
if [ "$(id -u)" -eq 0 ]; then
env "${env_args[@]}" "${compose_cmd[@]}" "$@"
else
run_as_root env "${env_args[@]}" "${compose_cmd[@]}" "$@"
fi
}
standalone_service_ctl() {
run_as_root systemctl "$@" "$SERVICE_NAME"
}
standalone_logs() {
run_as_root journalctl -u "$SERVICE_NAME" -f
}
standalone_backup_sqlite_database() {
run_as_root mkdir -p "$APP_DIR/backups"
local backup_path="$APP_DIR/backups/jboard-standalone-$(date +%F-%H%M%S).tar.gz"
if [ -f "$APP_DIR/storage/jboard.db" ]; then
run_as_root tar -C "$APP_DIR/storage" -czf "$backup_path" \
jboard.db \
$( [ -f "$APP_DIR/storage/jboard.db-wal" ] && printf '%s' "jboard.db-wal" || true ) \
$( [ -f "$APP_DIR/storage/jboard.db-shm" ] && printf '%s' "jboard.db-shm" || true )
echo "SQLite backup saved: $backup_path"
else
echo "No existing SQLite database found; skipping database backup."
fi
}
prepare_runtime_tree() {
[ -f "$APP_DIR/.next/standalone/server.js" ] || fail "未找到 standalone 构建产物,请先执行 jetboard update 或重新安装。"
run_in_app_dir mkdir -p "$APP_DIR/.next/standalone/.next"
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/.next/static"
run_in_app_dir cp -R "$APP_DIR/.next/static" "$APP_DIR/.next/standalone/.next/"
if [ -d "$APP_DIR/public" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/public"
run_in_app_dir cp -R "$APP_DIR/public" "$APP_DIR/.next/standalone/public"
fi
if [ -d "$APP_DIR/data" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/data"
run_in_app_dir cp -R "$APP_DIR/data" "$APP_DIR/.next/standalone/data"
fi
}
standalone_build_app() {
section "$BRAND 构建"
load_standalone_resource_helpers
if command -v jboard_prepare_standalone_build_env >/dev/null 2>&1; then
jboard_prepare_standalone_build_env "$APP_DIR"
jboard_print_standalone_profile
else
export NEXT_TELEMETRY_DISABLED=1
fi
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm ci --no-audit --no-fund
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm run build
prepare_runtime_tree
run_in_app_dir npm run db:push
run_in_app_dir npm run db:seed
run_as_root mkdir -p "$APP_DIR/storage" "$APP_DIR/backups"
if id "$SERVICE_USER" >/dev/null 2>&1; then
run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" "$APP_DIR/backups"
fi
}
print_usage() {
cat <<EOF
$BRAND 管理命令
当前模式:$DEPLOY_MODE
用法:
jetboard 打开交互菜单
jetboard status 查看服务状态
jetboard update 按当前部署模式更新
jetboard reset 重置或创建管理员账号密码
jetboard logs 查看服务日志
jetboard restart 重启面板
jetboard stop 停止面板
jetboard start 启动面板
jetboard uninstall 完整卸载当前部署
可选环境变量:
JBOARD_APP_DIR=/opt/jboard
JBOARD_DEPLOY_MODE=docker|standalone
JBOARD_BUILD_PROFILE=auto|low|normal
EOF
}
status_cmd() {
section "$BRAND 状态"
printf '部署模式:%s\n' "$DEPLOY_MODE"
printf '安装目录:%s\n' "$APP_DIR"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
printf '当前版本:%s\n' "$(git rev-parse --short HEAD 2>/dev/null || true)"
printf '当前分支:%s\n' "$(git branch --show-current 2>/dev/null || true)"
fi
echo
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose ps
else
printf '服务名称:%s\n\n' "$SERVICE_NAME"
run_as_root systemctl --no-pager status "$SERVICE_NAME" || true
fi
}
docker_update_cmd() {
[ -f "$APP_DIR/scripts/upgrade-jboard-panel.sh" ] || fail "未找到更新脚本:$APP_DIR/scripts/upgrade-jboard-panel.sh"
run_as_root env \
APP_DIR="$APP_DIR" \
COMPOSE="$COMPOSE" \
BACKUP="${BACKUP:-1}" \
JBOARD_BUILD_PROFILE="${JBOARD_BUILD_PROFILE:-auto}" \
bash "$APP_DIR/scripts/upgrade-jboard-panel.sh"
}
standalone_update_cmd() {
standalone_backup_sqlite_database
echo "[1/5] Pulling latest code..."
run_in_app_dir git -c safe.directory="$APP_DIR" pull --ff-only
echo "[2/5] Installing dependencies and building..."
standalone_build_app
echo "[3/5] Restarting service..."
standalone_service_ctl restart
echo "[4/5] Waiting for app..."
sleep 5
echo "[5/5] Service status:"
status_cmd
}
update_cmd() {
section "$BRAND 更新"
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_update_cmd
else
standalone_update_cmd
fi
}
reset_admin_cmd() {
section "重置管理员账号"
load_env_for_prompts
local email="${1:-${ADMIN_RESET_EMAIL:-}}"
local password="${2:-${ADMIN_RESET_PASSWORD:-}}"
local name="${3:-${ADMIN_RESET_NAME:-}}"
email="${email:-$(prompt_value "管理员邮箱" "${ADMIN_EMAIL:-admin@jboard.local}")}"
password="${password:-$(prompt_secret_or_generated "新管理员密码" "$(random_password)")}"
name="${name:-$(prompt_value "管理员昵称" "${ADMIN_NAME:-Admin}")}"
[ -n "$email" ] || fail "管理员邮箱不能为空。"
[ -n "$password" ] || fail "管理员密码不能为空。"
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose --profile setup run --rm \
-e "ADMIN_RESET_EMAIL=$email" \
-e "ADMIN_RESET_PASSWORD=$password" \
-e "ADMIN_RESET_NAME=$name" \
init sh -lc 'npm run db:push && npm run admin:reset && chown -R 1001:1001 /app/storage'
else
run_in_app_dir npm run db:push
run_in_app_dir env \
ADMIN_RESET_EMAIL="$email" \
ADMIN_RESET_PASSWORD="$password" \
ADMIN_RESET_NAME="$name" \
npm run admin:reset
run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" || true
fi
echo
printf '管理员邮箱:%s\n' "$email"
printf '管理员密码:%s\n' "$password"
}
logs_cmd() {
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose logs -f app
else
standalone_logs
fi
}
restart_cmd() {
section "$BRAND 重启"
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose up -d app
else
standalone_service_ctl restart
fi
status_cmd
}
stop_cmd() {
section "$BRAND 停止"
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose stop app
else
standalone_service_ctl stop
fi
}
start_cmd() {
section "$BRAND 启动"
if [ "$DEPLOY_MODE" = "docker" ]; then
docker_compose up -d app
else
standalone_service_ctl start
fi
status_cmd
}
uninstall_cmd() {
local uninstall_script=""
if [ "$DEPLOY_MODE" = "docker" ]; then
uninstall_script="$APP_DIR/scripts/uninstall-jboard-panel.sh"
else
uninstall_script="$APP_DIR/scripts/uninstall-jetboard-standalone.sh"
fi
[ -f "$uninstall_script" ] || fail "未找到卸载脚本:$uninstall_script"
local tmp
tmp="$(mktemp)"
cp "$uninstall_script" "$tmp"
chmod +x "$tmp"
if [ "$DEPLOY_MODE" = "docker" ]; then
run_as_root env \
JBOARD_APP_DIR="$APP_DIR" \
COMPOSE="$COMPOSE" \
JETBOARD_CONFIG="$CONFIG_FILE" \
bash "$tmp"
else
run_as_root env \
JBOARD_APP_DIR="$APP_DIR" \
JBOARD_SERVICE_NAME="$SERVICE_NAME" \
JBOARD_SERVICE_USER="$SERVICE_USER" \
JETBOARD_CONFIG="$CONFIG_FILE" \
bash "$tmp"
fi
rm -f "$tmp"
}
menu_cmd() {
if ! is_interactive; then
print_usage
return
fi
while true; do
section "$BRAND 管理菜单"
printf '部署模式:%s\n' "$DEPLOY_MODE"
printf '安装目录:%s\n\n' "$APP_DIR"
printf ' 1. 查看状态\n'
printf ' 2. 更新面板\n'
printf ' 3. 重置管理员账号密码\n'
printf ' 4. 查看日志\n'
printf ' 5. 重启面板\n'
printf ' 6. 停止面板\n'
printf ' 7. 启动面板\n'
printf ' 8. 卸载面板\n'
printf ' 0. 退出\n'
echo
prompt_print "请选择操作 [1]: "
local choice=""
prompt_read choice || true
choice="${choice:-1}"
case "$choice" in
1) status_cmd ;;
2) update_cmd ;;
3) reset_admin_cmd ;;
4) logs_cmd ;;
5) restart_cmd ;;
6) stop_cmd ;;
7) start_cmd ;;
8) uninstall_cmd; break ;;
0|q|Q|exit) break ;;
*) echo "未知选项:$choice" >&2 ;;
esac
done
}
main() {
local command="${1:-menu}"
if [ "$#" -gt 0 ]; then
shift
fi
case "$command" in
menu) menu_cmd ;;
status|ps) status_cmd ;;
update|upgrade) update_cmd ;;
reset|reset-admin) reset_admin_cmd "$@" ;;
logs|log) logs_cmd ;;
restart) restart_cmd ;;
stop) stop_cmd ;;
start) start_cmd ;;
uninstall|remove) uninstall_cmd ;;
help|-h|--help) print_usage ;;
*) print_usage; exit 1 ;;
esac
}
main "$@"

191
scripts/lib-standalone-profile.sh Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Shared profile detection for non-Docker installs and updates.
# Strong machines use the default npm/Next behavior. Small machines trade time
# for lower peak CPU and memory usage.
JBOARD_STANDALONE_PROFILE_RESOLVED=""
JBOARD_CPU_COUNT=""
JBOARD_HOST_MEM_MB=""
JBOARD_APP_DISK_AVAIL_MB=""
JBOARD_BUILD_NODE_HEAP_MB=""
JBOARD_RUNTIME_NODE_HEAP_MB=""
JBOARD_RUNTIME_NODE_OPTIONS=""
jboard_cpu_count() {
local value=""
if command -v nproc >/dev/null 2>&1; then
value="$(nproc 2>/dev/null || true)"
elif command -v getconf >/dev/null 2>&1; then
value="$(getconf _NPROCESSORS_ONLN 2>/dev/null || true)"
elif command -v sysctl >/dev/null 2>&1; then
value="$(sysctl -n hw.ncpu 2>/dev/null || true)"
fi
case "$value" in
''|*[!0-9]*) echo 1 ;;
*) echo "$value" ;;
esac
}
jboard_host_mem_mb() {
local value=""
if [ -r /proc/meminfo ]; then
value="$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null || true)"
elif command -v getconf >/dev/null 2>&1; then
local pages page_size
pages="$(getconf _PHYS_PAGES 2>/dev/null || true)"
page_size="$(getconf PAGE_SIZE 2>/dev/null || true)"
if [ -n "$pages" ] && [ -n "$page_size" ]; then
value="$((pages * page_size / 1024 / 1024))"
fi
elif command -v sysctl >/dev/null 2>&1; then
local bytes
bytes="$(sysctl -n hw.memsize 2>/dev/null || true)"
if [ -n "$bytes" ]; then
value="$((bytes / 1024 / 1024))"
fi
fi
case "$value" in
''|*[!0-9]*) echo 0 ;;
*) echo "$value" ;;
esac
}
jboard_path_avail_mb() {
local path="$1"
local value=""
if [ -n "$path" ]; then
value="$(df -Pm "$path" 2>/dev/null | awk 'NR == 2 {print $4}' || true)"
fi
case "$value" in
''|*[!0-9]*) echo 0 ;;
*) echo "$value" ;;
esac
}
jboard_standalone_build_heap_mb() {
local mem_mb="$1"
if [ -n "${JBOARD_LOW_RESOURCE_NODE_MB:-}" ]; then
echo "$JBOARD_LOW_RESOURCE_NODE_MB"
return
fi
if [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1200 ]; then
echo 640
elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1700 ]; then
echo 768
elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 2400 ]; then
echo 1024
else
echo 1536
fi
}
jboard_standalone_runtime_heap_mb() {
local mem_mb="$1"
if [ -n "${JBOARD_RUNTIME_NODE_MB:-}" ]; then
echo "$JBOARD_RUNTIME_NODE_MB"
return
fi
if [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1200 ]; then
echo 512
elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1700 ]; then
echo 768
elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 2400 ]; then
echo 1024
else
echo ""
fi
}
jboard_resolve_standalone_profile() {
local requested="${JBOARD_BUILD_PROFILE:-auto}"
local cpu="$1"
local mem_mb="$2"
local disk_mb="$3"
case "$requested" in
low|slow|small)
echo low
return
;;
normal|fast|strong)
echo normal
return
;;
auto|'')
;;
*)
echo "未知 JBOARD_BUILD_PROFILE=$requested,回退为 auto。" >&2
;;
esac
if [ "$cpu" -le 1 ]; then
echo low
elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -lt 2048 ]; then
echo low
elif [ "$disk_mb" -gt 0 ] && [ "$disk_mb" -lt 8192 ]; then
echo low
else
echo normal
fi
}
jboard_prepare_standalone_build_env() {
local app_dir="${1:-.}"
JBOARD_CPU_COUNT="$(jboard_cpu_count)"
JBOARD_HOST_MEM_MB="$(jboard_host_mem_mb)"
JBOARD_APP_DISK_AVAIL_MB="$(jboard_path_avail_mb "$app_dir")"
JBOARD_STANDALONE_PROFILE_RESOLVED="$(jboard_resolve_standalone_profile "$JBOARD_CPU_COUNT" "$JBOARD_HOST_MEM_MB" "$JBOARD_APP_DISK_AVAIL_MB")"
export NEXT_TELEMETRY_DISABLED=1
if [ "$JBOARD_STANDALONE_PROFILE_RESOLVED" = "low" ]; then
JBOARD_BUILD_NODE_HEAP_MB="$(jboard_standalone_build_heap_mb "$JBOARD_HOST_MEM_MB")"
JBOARD_RUNTIME_NODE_HEAP_MB="$(jboard_standalone_runtime_heap_mb "$JBOARD_HOST_MEM_MB")"
JBOARD_RUNTIME_NODE_OPTIONS="--max-old-space-size=${JBOARD_RUNTIME_NODE_HEAP_MB}"
export NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-1}"
export npm_config_jobs="${npm_config_jobs:-$NPM_CONFIG_JOBS}"
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=${JBOARD_BUILD_NODE_HEAP_MB}}"
else
JBOARD_BUILD_NODE_HEAP_MB=""
JBOARD_RUNTIME_NODE_HEAP_MB=""
JBOARD_RUNTIME_NODE_OPTIONS="${JBOARD_RUNTIME_NODE_OPTIONS:-}"
export NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}"
fi
}
jboard_is_low_resource_standalone() {
[ "${JBOARD_STANDALONE_PROFILE_RESOLVED:-}" = "low" ]
}
jboard_print_standalone_profile() {
local disk="${JBOARD_APP_DISK_AVAIL_MB:-0}"
local disk_text="unknown"
if [ "$disk" -gt 0 ]; then
disk_text="${disk}MB"
fi
if jboard_is_low_resource_standalone; then
echo "检测到低资源本机环境CPU=${JBOARD_CPU_COUNT:-?},内存=${JBOARD_HOST_MEM_MB:-0}MB可用空间=${disk_text}"
echo "启用慢速低占用模式npm jobs=${NPM_CONFIG_JOBS:-1},构建 Node heap=${JBOARD_BUILD_NODE_HEAP_MB:-?}MB运行 Node heap=${JBOARD_RUNTIME_NODE_HEAP_MB:-?}MB。"
else
echo "检测到常规本机环境CPU=${JBOARD_CPU_COUNT:-?},内存=${JBOARD_HOST_MEM_MB:-0}MB可用空间=${disk_text}"
echo "使用 npm/Next 默认构建策略,不额外限制并发或 Node heap。"
fi
if [ "$disk" -gt 0 ] && [ "$disk" -lt 8192 ]; then
echo "提示:可用空间低于 8GB建议扩容或清理缓存后再构建。"
fi
}

57
scripts/reset-admin.ts Normal file
View File

@@ -0,0 +1,57 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import bcrypt from "bcryptjs";
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL || "file:./storage/jboard.db",
});
const prisma = new PrismaClient({ adapter });
function envValue(key: string, fallback = "") {
return process.env[key]?.trim() || fallback;
}
async function main() {
const email = envValue("ADMIN_RESET_EMAIL", envValue("ADMIN_EMAIL", "admin@jboard.local")).toLowerCase();
const password = envValue("ADMIN_RESET_PASSWORD", envValue("ADMIN_PASSWORD"));
const name = envValue("ADMIN_RESET_NAME", envValue("ADMIN_NAME", "Admin"));
if (!email) {
throw new Error("ADMIN_RESET_EMAIL is required.");
}
if (!password || password.length < 6) {
throw new Error("ADMIN_RESET_PASSWORD must be at least 6 characters.");
}
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.upsert({
where: { email },
update: {
password: hashedPassword,
name,
role: "ADMIN",
status: "ACTIVE",
emailVerifiedAt: new Date(),
},
create: {
email,
password: hashedPassword,
name,
role: "ADMIN",
status: "ACTIVE",
emailVerifiedAt: new Date(),
},
});
console.log(`Admin reset completed: ${email}`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(() => prisma.$disconnect());

174
scripts/uninstall-jboard-panel.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
COMPOSE="${COMPOSE:-docker compose}"
if [ -z "${BACKUP_ROOT:-}" ]; then
if [ "$(id -u)" -eq 0 ]; then
BACKUP_ROOT="/root"
else
BACKUP_ROOT="${HOME:-/tmp}"
fi
fi
UNINSTALL_CONFIRM="${UNINSTALL_CONFIRM:-}"
if [ -z "$APP_DIR" ] && [ -r "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
COMPOSE="${COMPOSE:-docker compose}"
fi
APP_DIR="${APP_DIR:-/opt/jboard}"
is_interactive() {
[ -r /dev/tty ] && [ -w /dev/tty ]
}
prompt_print() {
if is_interactive; then
printf '%b' "$*" > /dev/tty
else
printf '%b' "$*" >&2
fi
}
prompt_read() {
local __var="$1"
if is_interactive; then
IFS= read -r "$__var" < /dev/tty || true
else
return 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "需要 root 权限。请使用 root 用户运行,或先安装 sudo。" >&2
exit 1
fi
}
compose() {
if [ "$(id -u)" -eq 0 ]; then
$COMPOSE "$@"
else
run_as_root $COMPOSE "$@"
fi
}
line() {
printf '%s\n' "------------------------------------------------------------"
}
section() {
echo
line
printf '%s\n' "$1"
line
}
confirm_uninstall() {
if [ "$UNINSTALL_CONFIRM" = "YES" ]; then
return
fi
if ! is_interactive; then
echo "非交互卸载请设置 UNINSTALL_CONFIRM=YES。" >&2
exit 1
fi
section "确认卸载 J-Board Lite Docker 部署"
printf '将停止并删除 Docker Compose 服务和 SQLite volume。\n'
printf '将删除安装目录:%s\n' "$APP_DIR"
printf '将删除命令:/usr/local/bin/jetboard、/usr/local/bin/JetBoard\n'
printf '将删除配置:%s\n' "$CONFIG_FILE"
echo "卸载前会尝试备份 .env、backups 和 SQLite 数据库到 ${BACKUP_ROOT}"
echo
prompt_print "请输入 YES 确认卸载: "
local answer=""
prompt_read answer || true
if [ "$answer" != "YES" ]; then
echo "已取消卸载。"
exit 0
fi
}
backup_before_remove() {
if [ ! -d "$APP_DIR" ]; then
echo "安装目录不存在,跳过备份:$APP_DIR"
return
fi
local backup_dir backup_file sqlite_backup
backup_dir="${BACKUP_ROOT%/}/jboard-docker-uninstall-backup-$(date +%F-%H%M%S)"
backup_file="${backup_dir}.tar.gz"
sqlite_backup="$backup_dir/sqlite-storage.tar.gz"
run_as_root mkdir -p "$backup_dir"
if [ "$(id -u)" -ne 0 ]; then
run_as_root chown "$(id -u):$(id -g)" "$backup_dir"
fi
if [ -f "$APP_DIR/.env" ]; then
run_as_root cp "$APP_DIR/.env" "$backup_dir/.env"
fi
if [ -d "$APP_DIR/backups" ]; then
run_as_root cp -R "$APP_DIR/backups" "$backup_dir/backups"
fi
if [ -f "$APP_DIR/docker-compose.yml" ]; then
local backup_cmd='cd /app/storage 2>/dev/null && if [ -f jboard.db ]; then set -- jboard.db; [ -f jboard.db-wal ] && set -- "$@" jboard.db-wal; [ -f jboard.db-shm ] && set -- "$@" jboard.db-shm; tar -czf - "$@"; fi'
(
cd "$APP_DIR"
if compose ps --services --filter status=running 2>/dev/null | grep -qx app; then
compose exec -T app sh -lc "$backup_cmd" > "$sqlite_backup" || true
fi
if [ ! -s "$sqlite_backup" ]; then
compose --profile setup run --rm -T --entrypoint sh init -lc "$backup_cmd" > "$sqlite_backup" || true
fi
)
if [ ! -s "$sqlite_backup" ]; then
rm -f "$sqlite_backup"
echo "未找到可备份的 SQLite volume跳过数据库备份。"
fi
fi
run_as_root tar -C "$(dirname "$backup_dir")" -czf "$backup_file" "$(basename "$backup_dir")"
run_as_root rm -rf "$backup_dir"
echo "卸载备份已保存:$backup_file"
}
remove_compose_stack() {
section "删除 Docker Compose 服务"
if [ -f "$APP_DIR/docker-compose.yml" ]; then
(
cd "$APP_DIR"
compose down --volumes --remove-orphans || true
)
fi
}
remove_files() {
section "删除文件"
cd /
run_as_root rm -f /usr/local/bin/jetboard /usr/local/bin/JetBoard
run_as_root rm -f "$CONFIG_FILE"
run_as_root rm -rf "$APP_DIR"
}
main() {
confirm_uninstall
backup_before_remove
remove_compose_stack
remove_files
echo
echo "J-Board Lite Docker 部署已卸载。Docker 本身不会被删除。"
}
main "$@"

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}"
SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}"
BACKUP_ROOT="${BACKUP_ROOT:-/root}"
UNINSTALL_CONFIRM="${UNINSTALL_CONFIRM:-}"
if [ -z "$APP_DIR" ] && [ -r "$CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$CONFIG_FILE"
APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-${SERVICE_NAME:-jetboard}}"
SERVICE_USER="${JBOARD_SERVICE_USER:-${SERVICE_USER:-jetboard}}"
fi
APP_DIR="${APP_DIR:-/opt/jboard}"
is_interactive() {
[ -r /dev/tty ] && [ -w /dev/tty ]
}
prompt_print() {
if is_interactive; then
printf '%b' "$*" > /dev/tty
else
printf '%b' "$*" >&2
fi
}
prompt_read() {
local __var="$1"
if is_interactive; then
IFS= read -r "$__var" < /dev/tty || true
else
return 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "需要 root 权限。请使用 root 用户运行,或先安装 sudo。" >&2
exit 1
fi
}
line() {
printf '%s\n' "------------------------------------------------------------"
}
section() {
echo
line
printf '%s\n' "$1"
line
}
confirm_uninstall() {
if [ "$UNINSTALL_CONFIRM" = "YES" ]; then
return
fi
if ! is_interactive; then
echo "非交互卸载请设置 UNINSTALL_CONFIRM=YES。" >&2
exit 1
fi
section "确认卸载 J-Board Lite standalone"
printf '将停止并删除服务:%s\n' "$SERVICE_NAME"
printf '将删除安装目录:%s\n' "$APP_DIR"
printf '将删除命令:/usr/local/bin/jetboard、/usr/local/bin/JetBoard\n'
printf '将删除配置:%s\n' "$CONFIG_FILE"
echo "卸载前会尝试备份 .env 和 storage 到 ${BACKUP_ROOT}"
echo
prompt_print "请输入 YES 确认卸载: "
local answer=""
prompt_read answer || true
if [ "$answer" != "YES" ]; then
echo "已取消卸载。"
exit 0
fi
}
backup_before_remove() {
if [ ! -d "$APP_DIR" ]; then
echo "安装目录不存在,跳过备份:$APP_DIR"
return
fi
local backup_dir backup_file
backup_dir="${BACKUP_ROOT%/}/jboard-uninstall-backup-$(date +%F-%H%M%S)"
backup_file="${backup_dir}.tar.gz"
run_as_root mkdir -p "$backup_dir"
if [ -f "$APP_DIR/.env" ]; then
run_as_root cp "$APP_DIR/.env" "$backup_dir/.env"
fi
if [ -d "$APP_DIR/storage" ]; then
run_as_root cp -R "$APP_DIR/storage" "$backup_dir/storage"
fi
if [ -d "$APP_DIR/backups" ]; then
run_as_root cp -R "$APP_DIR/backups" "$backup_dir/backups"
fi
run_as_root tar -C "$(dirname "$backup_dir")" -czf "$backup_file" "$(basename "$backup_dir")"
run_as_root rm -rf "$backup_dir"
echo "卸载备份已保存:$backup_file"
}
remove_service() {
section "删除 systemd 服务"
if command -v systemctl >/dev/null 2>&1; then
run_as_root systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
run_as_root systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
run_as_root rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
if command -v systemctl >/dev/null 2>&1; then
run_as_root systemctl daemon-reload || true
run_as_root systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
}
remove_files() {
section "删除文件"
run_as_root rm -f /usr/local/bin/jetboard /usr/local/bin/JetBoard
run_as_root rm -f "$CONFIG_FILE"
run_as_root rm -rf "$APP_DIR"
}
remove_user() {
section "删除运行用户"
if id "$SERVICE_USER" >/dev/null 2>&1; then
if command -v userdel >/dev/null 2>&1; then
run_as_root userdel "$SERVICE_USER" >/dev/null 2>&1 || true
elif command -v deluser >/dev/null 2>&1; then
run_as_root deluser "$SERVICE_USER" >/dev/null 2>&1 || true
fi
fi
}
main() {
confirm_uninstall
backup_before_remove
remove_service
remove_files
remove_user
echo
echo "J-Board Lite standalone 已卸载。"
}
main "$@"

View File

@@ -222,7 +222,7 @@ write_systemd_service() {
local service_tmp="$TMP_DIR/${SERVICE_NAME}.service" local service_tmp="$TMP_DIR/${SERVICE_NAME}.service"
cat > "$service_tmp" <<SERVICE cat > "$service_tmp" <<SERVICE
[Unit] [Unit]
Description=J-Board Probe Agent Description=J-Board Lite Agent
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -253,7 +253,7 @@ DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS" CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
echo "[1/7] Release tag: ${RESOLVED_TAG}" echo "[1/7] Release tag: ${RESOLVED_TAG}"
echo "[2/7] Downloading probe agent binary: ${ASSET}" echo "[2/7] Downloading agent binary: ${ASSET}"
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET" curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then

View File

@@ -105,6 +105,7 @@ export async function updateNode(id: string, formData: FormData) {
message: `更新 3x-ui 节点 ${node.name}`, message: `更新 3x-ui 节点 ${node.name}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${id}`);
return { return {
...result, ...result,
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`, message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
@@ -140,6 +141,7 @@ export async function testNodeConnection(id: string) {
message: `测试 3x-ui 节点 ${server.name}${result.message}`, message: `测试 3x-ui 节点 ${server.name}${result.message}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${id}`);
return result; return result;
} }
@@ -172,7 +174,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData)); const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
const inbound = await prisma.nodeInbound.findUniqueOrThrow({ const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id }, where: { id },
include: { server: { select: { name: true } } }, include: { server: { select: { id: true, name: true } } },
}); });
await prisma.nodeInbound.update({ await prisma.nodeInbound.update({
@@ -190,6 +192,7 @@ export async function updateInboundDisplayName(id: string, formData: FormData) {
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${inbound.server.id}`);
revalidatePath("/store"); revalidatePath("/store");
} }
@@ -210,6 +213,7 @@ export async function deleteInbound(id: string) {
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`, message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${inbound.server.id}`);
} }
export async function generateAgentToken(nodeId: string) { export async function generateAgentToken(nodeId: string) {
@@ -237,6 +241,7 @@ export async function generateAgentToken(nodeId: string) {
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${nodeId}`);
return plainToken; return plainToken;
} }
@@ -262,4 +267,5 @@ export async function revokeAgentToken(nodeId: string) {
}); });
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath(`/admin/nodes/${nodeId}`);
} }

View File

@@ -78,6 +78,30 @@ export async function setPaymentConfigEnabled(
): Promise<PaymentActionResult> { ): Promise<PaymentActionResult> {
try { try {
const session = await requireAdmin(); const session = await requireAdmin();
if (provider === "balance") {
const current = await prisma.paymentConfig.findUnique({
where: { provider },
select: { enabled: true, config: true },
});
if (current?.enabled !== enabled) {
await prisma.paymentConfig.upsert({
where: { provider },
create: { provider, enabled, config: current?.config ?? {} },
update: { enabled },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "payment.toggle",
targetType: "PaymentConfig",
targetId: provider,
targetLabel: getPaymentProviderName(provider),
message: `${enabled ? "启用" : "停用"}支付方式 ${getPaymentProviderName(provider)}`,
});
}
revalidatePath("/admin/payments");
return { ok: true };
}
const current = await prisma.paymentConfig.findUnique({ const current = await prisma.paymentConfig.findUnique({
where: { provider }, where: { provider },
select: { config: true, enabled: true }, select: { config: true, enabled: true },

View File

@@ -389,9 +389,6 @@ export async function updatePlan(id: string, formData: FormData) {
const nodeId = data.nodeId ?? existing.nodeId; const nodeId = data.nodeId ?? existing.nodeId;
if (!nodeId) throw new Error("代理套餐必须选择节点"); if (!nodeId) throw new Error("代理套餐必须选择节点");
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
throw new Error("代理套餐必须填写总流量池,且大于 0");
}
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId); const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
if (inboundIds.length === 0) { if (inboundIds.length === 0) {

View File

@@ -0,0 +1,115 @@
"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 { createRechargeCards } from "@/services/wallet";
const optionalDate = z.preprocess(
(value) => (value === "" || value == null ? undefined : new Date(String(value))),
z.date().optional(),
);
const createRechargeCardsSchema = z.object({
type: z.enum(["BALANCE", "PLAN"]),
quantity: z.coerce.number().int().min(1).max(200).default(1),
balanceAmount: z.preprocess(
(value) => (value === "" || value == null ? undefined : Number(value)),
z.number().positive().optional(),
),
planId: z.string().trim().optional(),
batchName: z.string().trim().optional(),
expiresAt: optionalDate,
});
export async function createAdminRechargeCards(formData: FormData) {
const session = await requireAdmin();
const data = createRechargeCardsSchema.parse(Object.fromEntries(formData));
if (data.type === "BALANCE" && !data.balanceAmount) {
throw new Error("请输入余额卡金额");
}
if (data.type === "PLAN" && !data.planId) {
throw new Error("请选择套餐");
}
const cards = await createRechargeCards({
createdById: session.user.id,
type: data.type,
quantity: data.quantity,
balanceAmount: data.balanceAmount,
planId: data.planId,
batchName: data.batchName || null,
expiresAt: data.expiresAt ?? null,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "recharge_card.create",
targetType: "RechargeCard",
targetLabel: data.type === "BALANCE" ? "余额充值卡" : "套餐充值卡",
message: `生成 ${cards.length}${data.type === "BALANCE" ? "余额充值卡" : "套餐充值卡"}`,
metadata: {
type: data.type,
quantity: cards.length,
batchName: data.batchName || null,
planId: data.planId || null,
},
});
revalidatePath("/admin/commerce");
revalidatePath("/admin/plans");
revalidatePath("/store");
}
export async function deleteAdminRechargeCard(cardId: string) {
const session = await requireAdmin();
const id = z.string().min(1, "充值卡不存在").parse(cardId);
const card = await prisma.rechargeCard.findUnique({
where: { id },
include: {
plan: { select: { id: true, name: true } },
redeemedBy: { select: { email: true, name: true } },
},
});
if (!card) {
throw new Error("充值卡不存在或已被删除");
}
const releasesPlanStock =
card.type === "PLAN"
&& card.status === "UNUSED"
&& (!card.expiresAt || card.expiresAt > new Date());
await prisma.rechargeCard.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "recharge_card.delete",
targetType: "RechargeCard",
targetId: card.id,
targetLabel: card.code,
message: releasesPlanStock
? `删除套餐充值卡 ${card.code},已释放套餐库存`
: `删除充值卡 ${card.code}`,
metadata: {
code: card.code,
type: card.type,
status: card.status,
planId: card.planId,
planName: card.plan?.name ?? null,
redeemedBy: card.redeemedBy?.email ?? null,
redeemedAt: card.redeemedAt?.toISOString() ?? null,
releasesPlanStock,
},
});
revalidatePath("/admin/commerce");
revalidatePath("/admin/plans");
revalidatePath("/store");
return { releasesPlanStock };
}

View File

@@ -0,0 +1,153 @@
"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 { processWalletRechargeOrderSuccess } from "@/services/wallet";
const rechargeOrderIdSchema = z.string().trim().min(1, "充值订单不存在");
function manualTradeNo(orderId: string) {
return `WR-MANUAL-${Date.now()}-${orderId.slice(0, 8)}`;
}
function revalidateRechargeOrderViews(orderId: string) {
revalidatePath("/admin/orders");
revalidatePath("/wallet");
revalidatePath(`/wallet/recharge/${orderId}`);
}
export async function confirmAdminWalletRecharge(rechargeOrderId: string) {
const session = await requireAdmin();
const id = rechargeOrderIdSchema.parse(rechargeOrderId);
const recharge = await prisma.walletRechargeOrder.findUnique({
where: { id },
include: { user: { select: { email: true, name: true } } },
});
if (!recharge) {
throw new Error("充值订单不存在或已被删除");
}
if (recharge.status !== "PENDING") {
throw new Error("只能确认待支付充值订单");
}
const tradeNo = recharge.tradeNo ?? manualTradeNo(recharge.id);
const paymentRef = recharge.paymentRef ?? `manual:${session.user.id}:${Date.now()}`;
const result = await processWalletRechargeOrderSuccess({
rechargeOrderId: recharge.id,
paidAmount: Number(recharge.amount),
paymentMethod: recharge.paymentMethod ?? "manual",
paymentRef,
tradeNo,
description: "管理员手动确认余额充值",
metadata: {
manual: true,
adminUserId: session.user.id,
adminEmail: session.user.email ?? null,
},
});
if (result.finalStatus !== "PAID") {
throw new Error("充值订单状态已变化,请刷新后重试");
}
await recordAuditLog({
actor: actorFromSession(session),
action: "wallet_recharge.confirm",
targetType: "WalletRechargeOrder",
targetId: recharge.id,
targetLabel: recharge.user.email,
message: `手动确认充值订单 ${recharge.id},入账 ¥${Number(recharge.amount).toFixed(2)}`,
metadata: {
userEmail: recharge.user.email,
amount: Number(recharge.amount),
tradeNo,
paymentRef,
},
});
revalidateRechargeOrderViews(recharge.id);
}
export async function cancelAdminWalletRecharge(rechargeOrderId: string) {
const session = await requireAdmin();
const id = rechargeOrderIdSchema.parse(rechargeOrderId);
const recharge = await prisma.walletRechargeOrder.findUnique({
where: { id },
include: { user: { select: { email: true, name: true } } },
});
if (!recharge) {
throw new Error("充值订单不存在或已被删除");
}
if (recharge.status !== "PENDING") {
throw new Error("只能取消待支付充值订单");
}
await prisma.walletRechargeOrder.update({
where: { id: recharge.id },
data: {
status: "CANCELLED",
paymentUrl: null,
expireAt: null,
note: "管理员手动取消",
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "wallet_recharge.cancel",
targetType: "WalletRechargeOrder",
targetId: recharge.id,
targetLabel: recharge.user.email,
message: `取消充值订单 ${recharge.id}`,
metadata: {
userEmail: recharge.user.email,
amount: Number(recharge.amount),
tradeNo: recharge.tradeNo,
paymentMethod: recharge.paymentMethod,
},
});
revalidateRechargeOrderViews(recharge.id);
}
export async function deleteAdminWalletRecharge(rechargeOrderId: string) {
const session = await requireAdmin();
const id = rechargeOrderIdSchema.parse(rechargeOrderId);
const recharge = await prisma.walletRechargeOrder.findUnique({
where: { id },
include: {
user: { select: { email: true, name: true } },
transactions: { select: { id: true }, take: 5 },
},
});
if (!recharge) {
throw new Error("充值订单不存在或已被删除");
}
await prisma.walletRechargeOrder.delete({ where: { id: recharge.id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "wallet_recharge.delete",
targetType: "WalletRechargeOrder",
targetId: recharge.id,
targetLabel: recharge.user.email,
message: `删除充值订单 ${recharge.id}`,
metadata: {
userEmail: recharge.user.email,
amount: Number(recharge.amount),
status: recharge.status,
tradeNo: recharge.tradeNo,
paymentMethod: recharge.paymentMethod,
keptWalletTransactions: recharge.transactions.length,
},
});
revalidateRechargeOrderViews(recharge.id);
}

View File

@@ -59,6 +59,11 @@ const settingsSchema = z.object({
inviteRewardEnabled: z.string().optional(), inviteRewardEnabled: z.string().optional(),
inviteRewardRate: z.coerce.number().min(0).max(100).optional(), inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
inviteRewardCouponId: z.string().trim().optional(), inviteRewardCouponId: z.string().trim().optional(),
subscriptionTransferEnabled: z.string().optional(),
subscriptionTransferFee: z.coerce.number().min(0).max(100000).optional(),
subscriptionTransferLimitPerCycle: z.coerce.number().int().min(0).max(100).optional(),
subscriptionTransferMinRemainingDays: z.coerce.number().int().min(0).max(3650).optional(),
subscriptionTransferMinRemainingTrafficGb: z.coerce.number().int().min(0).max(1000000).optional(),
turnstileSiteKey: z.string().trim().optional(), turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: z.string().trim().optional(), turnstileSecretKey: z.string().trim().optional(),
smtpEnabled: z.string().optional(), smtpEnabled: z.string().optional(),
@@ -128,6 +133,7 @@ function booleanSettingData(field: BooleanSettingField, value: boolean) {
subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value }, subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value },
nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value }, nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value },
inviteRewardEnabled: { inviteRewardEnabled: value }, inviteRewardEnabled: { inviteRewardEnabled: value },
subscriptionTransferEnabled: { subscriptionTransferEnabled: value },
smtpEnabled: { smtpEnabled: value }, smtpEnabled: { smtpEnabled: value },
smtpSecure: { smtpSecure: value }, smtpSecure: { smtpSecure: value },
}[field]; }[field];
@@ -243,6 +249,18 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled), inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate), inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
inviteRewardCouponId: parsed.inviteRewardCouponId || null, inviteRewardCouponId: parsed.inviteRewardCouponId || null,
subscriptionTransferEnabled: optionalBoolean(
parsed.subscriptionTransferEnabled,
current.subscriptionTransferEnabled,
),
subscriptionTransferFee:
parsed.subscriptionTransferFee ?? Number(current.subscriptionTransferFee),
subscriptionTransferLimitPerCycle:
parsed.subscriptionTransferLimitPerCycle ?? current.subscriptionTransferLimitPerCycle,
subscriptionTransferMinRemainingDays:
parsed.subscriptionTransferMinRemainingDays ?? current.subscriptionTransferMinRemainingDays,
subscriptionTransferMinRemainingTrafficGb:
parsed.subscriptionTransferMinRemainingTrafficGb ?? current.subscriptionTransferMinRemainingTrafficGb,
turnstileSiteKey, turnstileSiteKey,
turnstileSecretKey, turnstileSecretKey,
smtpEnabled, smtpEnabled,
@@ -290,6 +308,7 @@ function revalidateSettingsViews() {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/store"); revalidatePath("/store");
revalidatePath("/subscriptions"); revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath("/account"); revalidatePath("/account");
revalidatePath("/support"); revalidatePath("/support");

View File

@@ -0,0 +1,25 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession } from "@/services/audit";
import { deleteSubscriptionTransferAsAdmin } from "@/services/subscription-transfer";
const transferIdSchema = z.string().trim().min(1, "套餐 Push 记录不存在");
export async function deleteAdminSubscriptionTransfer(transferId: string) {
const session = await requireAdmin();
const id = transferIdSchema.parse(transferId);
const result = await deleteSubscriptionTransferAsAdmin({
transferId: id,
actor: actorFromSession(session),
});
revalidatePath("/admin/subscription-transfers");
revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
return result;
}

View File

@@ -0,0 +1,97 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAuth } from "@/lib/require-auth";
import { getErrorMessage } from "@/lib/errors";
import { actorFromSession } from "@/services/audit";
import {
acceptSubscriptionTransfer,
cancelSubscriptionTransfer,
createSubscriptionTransfer,
rejectSubscriptionTransfer,
} from "@/services/subscription-transfer";
const createTransferSchema = z.object({
subscriptionId: z.string().trim().min(1, "请选择要 Push 的套餐"),
recipientEmail: z.string().trim().email("请输入正确的接收方邮箱"),
password: z.string().min(1, "请输入当前账户密码"),
feePayer: z.enum(["SENDER", "RECIPIENT"]),
});
const transferIdSchema = z.string().trim().min(1, "套餐 Push 不存在");
type UserTransferActionResult = { ok: true; id?: string } | { ok: false; error: string };
function revalidateTransferViews(subscriptionId?: string) {
revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
revalidatePath("/wallet");
if (subscriptionId) revalidatePath(`/subscriptions/${subscriptionId}`);
}
export async function createUserSubscriptionTransfer(formData: FormData): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const data = createTransferSchema.parse(Object.fromEntries(formData));
const transfer = await createSubscriptionTransfer({
senderId: session.user.id,
recipientEmail: data.recipientEmail,
subscriptionId: data.subscriptionId,
password: data.password,
feePayer: data.feePayer,
actor: actorFromSession(session),
});
revalidateTransferViews(data.subscriptionId);
return { ok: true, id: transfer.id };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "发起套餐 Push 失败") };
}
}
export async function acceptUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await acceptSubscriptionTransfer({
transferId: id,
recipientId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "接收套餐 Push 失败") };
}
}
export async function rejectUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await rejectSubscriptionTransfer({
transferId: id,
recipientId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "拒收套餐 Push 失败") };
}
}
export async function cancelUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await cancelSubscriptionTransfer({
transferId: id,
senderId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "取消套餐 Push 失败") };
}
}

View File

@@ -0,0 +1,63 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/require-auth";
import { createWalletRechargeOrder, redeemRechargeCard } from "@/services/wallet";
const rechargeSchema = z.object({
amount: z.coerce.number().min(1, "充值金额不能低于 1 元").max(100000, "单次充值金额过大"),
});
const redeemSchema = z.object({
code: z.string().trim().min(4, "请输入充值卡卡密"),
});
export async function createWalletRecharge(formData: FormData) {
const session = await requireAuth();
const data = rechargeSchema.parse(Object.fromEntries(formData));
const order = await createWalletRechargeOrder(session.user.id, data.amount);
return { id: order.id };
}
export async function redeemWalletCard(formData: FormData) {
const session = await requireAuth();
const data = redeemSchema.parse(Object.fromEntries(formData));
const result = await redeemRechargeCard(session.user.id, data.code);
revalidatePath("/wallet");
revalidatePath("/subscriptions");
revalidatePath("/dashboard");
return result;
}
export async function cancelWalletRecharge(rechargeId: string) {
const session = await requireAuth();
const recharge = await prisma.walletRechargeOrder.findFirst({
where: { id: rechargeId, userId: session.user.id },
select: { id: true, status: true },
});
if (!recharge) {
throw new Error("充值订单不存在");
}
if (recharge.status !== "PENDING") {
throw new Error("这笔充值订单已经不在待支付状态");
}
await prisma.walletRechargeOrder.update({
where: { id: rechargeId },
data: {
status: "CANCELLED",
paymentMethod: null,
paymentRef: null,
paymentUrl: null,
tradeNo: null,
expireAt: null,
note: null,
},
});
revalidatePath("/wallet");
revalidatePath(`/wallet/recharge/${rechargeId}`);
}

View File

@@ -32,7 +32,7 @@ export function AnnouncementsTable({ announcements, users }: AnnouncementsTableP
<DataTableShell <DataTableShell
isEmpty={announcements.length === 0} isEmpty={announcements.length === 0}
emptyTitle="暂无公告或消息" emptyTitle="暂无公告或消息"
emptyDescription="发布公告后,会显示展示范围、时间窗口和启用状态。" emptyDescription="发布后显示范围、时间和状态。"
mobileCards={announcements.map((announcement) => ( mobileCards={announcements.map((announcement) => (
<article key={announcement.id} className="space-y-3 p-4"> <article key={announcement.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">

View File

@@ -63,7 +63,7 @@ export function AnnouncementActions({
size="sm" size="sm"
variant="destructive" variant="destructive"
title="删除这条公告?" title="删除这条公告?"
description="公告本体和已经同步的站内通知会一起删除,此操作无法恢复。" description="会删除公告和同步通知,无法恢复。"
confirmLabel="删除公告" confirmLabel="删除公告"
successMessage="公告已删除" successMessage="公告已删除"
errorMessage="删除失败" errorMessage="删除失败"

View File

@@ -15,6 +15,7 @@ import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -22,6 +23,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
@@ -43,6 +51,32 @@ interface AnnouncementFormData {
endAt: Date | string | null; endAt: Date | string | null;
} }
const audienceLabels: Record<AnnouncementAudience, string> = {
PUBLIC: "公开",
USERS: "全部用户",
ADMINS: "全部管理员",
SPECIFIC_USER: "指定用户",
};
const displayTypeLabels: Record<AnnouncementDisplayType, string> = {
INLINE: "普通公告",
BIG: "大公告",
POPUP: "弹窗公告",
};
function getAudienceLabel(value: unknown) {
return audienceLabels[value as AnnouncementAudience] ?? "选择范围";
}
function getDisplayTypeLabel(value: unknown) {
return displayTypeLabels[value as AnnouncementDisplayType] ?? "选择展示方式";
}
function getTargetUserLabel(users: AnnouncementOptionUser[], value: unknown) {
if (!value) return "不指定";
return users.find((user) => user.id === value)?.email ?? "选择用户";
}
function toDateTimeLocalValue(value: Date | string | null) { function toDateTimeLocalValue(value: Date | string | null) {
if (!value) { if (!value) {
return ""; return "";
@@ -94,48 +128,55 @@ export function AnnouncementForm({
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}> <DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
{triggerLabel ?? "编辑"} {triggerLabel ?? "编辑"}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-4"> <DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-4 md:grid-cols-2"> <form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
<div className="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`title-${announcement.id}`}></Label> <Label htmlFor={`title-${announcement.id}`}></Label>
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required /> <Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`audience-${announcement.id}`}></Label> <Label htmlFor={`audience-${announcement.id}`}></Label>
<select <Select
id={`audience-${announcement.id}`}
name="audience" name="audience"
defaultValue={announcement.audience} value={audience}
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)} onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="PUBLIC">/</option> <SelectTrigger id={`audience-${announcement.id}`} className="w-full">
<option value="USERS"></option> <SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
<option value="ADMINS"></option> </SelectTrigger>
<option value="SPECIFIC_USER"></option> <SelectContent align="start">
</select> <SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`displayType-${announcement.id}`}></Label> <Label htmlFor={`displayType-${announcement.id}`}></Label>
<select <Select
id={`displayType-${announcement.id}`}
name="displayType" name="displayType"
defaultValue={announcement.displayType} defaultValue={announcement.displayType}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="INLINE"></option> <SelectTrigger id={`displayType-${announcement.id}`} className="w-full">
<option value="BIG"></option> <SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
<option value="POPUP"></option> </SelectTrigger>
</select> <SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`dismissible-${announcement.id}`}></Label> <Label htmlFor={`dismissible-${announcement.id}`}></Label>
<BooleanToggle <BooleanToggle
id={`dismissible-${announcement.id}`} id={`dismissible-${announcement.id}`}
@@ -144,29 +185,33 @@ export function AnnouncementForm({
trueLabel="允许" trueLabel="允许"
falseLabel="不允许" falseLabel="不允许"
ariaLabel="允许用户关闭" ariaLabel="允许用户关闭"
size="compact"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`targetUserId-${announcement.id}`}></Label> <Label htmlFor={`targetUserId-${announcement.id}`}></Label>
<select <Select
id={`targetUserId-${announcement.id}`}
name="targetUserId" name="targetUserId"
defaultValue={announcement.targetUserId ?? ""} defaultValue={announcement.targetUserId ?? ""}
disabled={audience !== "SPECIFIC_USER"} 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> <SelectTrigger id={`targetUserId-${announcement.id}`} className="w-full">
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{users.map((user) => ( {users.map((user) => (
<option key={user.id} value={user.id}> <SelectItem key={user.id} value={user.id}>
{user.email} {user.email}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`body-${announcement.id}`}></Label> <Label htmlFor={`body-${announcement.id}`}></Label>
<Textarea <Textarea
id={`body-${announcement.id}`} id={`body-${announcement.id}`}
@@ -177,8 +222,8 @@ export function AnnouncementForm({
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`startAt-${announcement.id}`}></Label> <Label htmlFor={`startAt-${announcement.id}`}></Label>
<Input <Input
id={`startAt-${announcement.id}`} id={`startAt-${announcement.id}`}
@@ -187,7 +232,7 @@ export function AnnouncementForm({
defaultValue={toDateTimeLocalValue(announcement.startAt)} defaultValue={toDateTimeLocalValue(announcement.startAt)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`endAt-${announcement.id}`}></Label> <Label htmlFor={`endAt-${announcement.id}`}></Label>
<Input <Input
id={`endAt-${announcement.id}`} id={`endAt-${announcement.id}`}
@@ -198,7 +243,7 @@ export function AnnouncementForm({
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor={`sendNotification-${announcement.id}`}></Label> <Label htmlFor={`sendNotification-${announcement.id}`}></Label>
<BooleanToggle <BooleanToggle
id={`sendNotification-${announcement.id}`} id={`sendNotification-${announcement.id}`}
@@ -207,13 +252,15 @@ export function AnnouncementForm({
trueLabel="发送" trueLabel="发送"
falseLabel="不发送" falseLabel="不发送"
ariaLabel="同步发送站内通知" ariaLabel="同步发送站内通知"
size="compact"
/> />
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel="保存中..."> <PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
</PendingSubmitButton> </PendingSubmitButton>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -239,50 +286,65 @@ export function CreateAnnouncementButton({
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setAudience("USERS");
}
setOpen(nextOpen);
}}
>
<DialogTrigger render={<Button />}></DialogTrigger> <DialogTrigger render={<Button />}></DialogTrigger>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-4"> <DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-4 md:grid-cols-2"> <form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
<div className="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-title"></Label> <Label htmlFor="create-announcement-title"></Label>
<Input id="create-announcement-title" name="title" required /> <Input id="create-announcement-title" name="title" required />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-audience"></Label> <Label htmlFor="create-announcement-audience"></Label>
<select <Select
id="create-announcement-audience"
name="audience" name="audience"
defaultValue="USERS" value={audience}
onChange={(event) => setAudience(event.target.value as AnnouncementAudience)} onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="PUBLIC">/</option> <SelectTrigger id="create-announcement-audience" className="w-full">
<option value="USERS"></option> <SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
<option value="ADMINS"></option> </SelectTrigger>
<option value="SPECIFIC_USER"></option> <SelectContent align="start">
</select> <SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-displayType"></Label> <Label htmlFor="create-announcement-displayType"></Label>
<select <Select
id="create-announcement-displayType"
name="displayType" name="displayType"
defaultValue="INLINE" defaultValue="INLINE"
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="INLINE"></option> <SelectTrigger id="create-announcement-displayType" className="w-full">
<option value="BIG"></option> <SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
<option value="POPUP"></option> </SelectTrigger>
</select> <SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-dismissible"></Label> <Label htmlFor="create-announcement-dismissible"></Label>
<BooleanToggle <BooleanToggle
id="create-announcement-dismissible" id="create-announcement-dismissible"
@@ -291,45 +353,49 @@ export function CreateAnnouncementButton({
trueLabel="允许" trueLabel="允许"
falseLabel="不允许" falseLabel="不允许"
ariaLabel="允许用户关闭" ariaLabel="允许用户关闭"
size="compact"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-targetUserId"></Label> <Label htmlFor="create-announcement-targetUserId"></Label>
<select <Select
id="create-announcement-targetUserId"
name="targetUserId" name="targetUserId"
defaultValue="" defaultValue=""
disabled={audience !== "SPECIFIC_USER"} 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> <SelectTrigger id="create-announcement-targetUserId" className="w-full">
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{users.map((user) => ( {users.map((user) => (
<option key={user.id} value={user.id}> <SelectItem key={user.id} value={user.id}>
{user.email} {user.email}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-body"></Label> <Label htmlFor="create-announcement-body"></Label>
<Textarea id="create-announcement-body" name="body" rows={5} required /> <Textarea id="create-announcement-body" name="body" rows={5} required />
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-startAt"></Label> <Label htmlFor="create-announcement-startAt"></Label>
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" /> <Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-endAt"></Label> <Label htmlFor="create-announcement-endAt"></Label>
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" /> <Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="create-announcement-sendNotification"></Label> <Label htmlFor="create-announcement-sendNotification"></Label>
<BooleanToggle <BooleanToggle
id="create-announcement-sendNotification" id="create-announcement-sendNotification"
@@ -338,13 +404,15 @@ export function CreateAnnouncementButton({
trueLabel="发送" trueLabel="发送"
falseLabel="不发送" falseLabel="不发送"
ariaLabel="同步发送站内通知" ariaLabel="同步发送站内通知"
size="compact"
/> />
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel="发布中..."> <PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
</PendingSubmitButton> </PendingSubmitButton>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -24,7 +24,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableShell <DataTableShell
isEmpty={logs.length === 0} isEmpty={logs.length === 0}
emptyTitle="暂无审计日志" emptyTitle="暂无审计日志"
emptyDescription="后台关键操作发生后,会记录在这里。" emptyDescription="后台关键操作会记录在这里。"
mobileCards={logs.map((log) => ( mobileCards={logs.map((log) => (
<article key={log.id} className="space-y-3 p-4"> <article key={log.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -36,7 +36,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
id={log.id} id={log.id}
target="AUDIT_LOGS" target="AUDIT_LOGS"
title="删除这条审计日志?" title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。" description="会新增一条删除审计记录。"
successMessage="审计日志已删除" successMessage="审计日志已删除"
/> />
</div> </div>
@@ -99,7 +99,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
id={log.id} id={log.id}
target="AUDIT_LOGS" target="AUDIT_LOGS"
title="删除这条审计日志?" title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。" description="会新增一条删除审计记录。"
successMessage="审计日志已删除" successMessage="审计日志已删除"
/> />
</div> </div>

View File

@@ -26,7 +26,7 @@ export default function BackupsPage() {
<div> <div>
<h3 className="text-lg font-semibold tracking-tight"></h3> <h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground"> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
SQL SQL
</p> </p>
</div> </div>
</div> </div>

View File

@@ -33,9 +33,7 @@ export function RestoreBackupForm() {
</span> </span>
<div> <div>
<h3 className="text-lg font-semibold tracking-tight"></h3> <h3 className="text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <p className="mt-1 text-sm leading-6 text-muted-foreground"> SQL</p>
SQL SQL
</p>
</div> </div>
</div> </div>
@@ -46,13 +44,13 @@ export function RestoreBackupForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmation"></Label> <Label htmlFor="confirmation"></Label>
<Input id="confirmation" name="confirmation" placeholder="请输入 RESTORE" /> <Input id="confirmation" name="confirmation" placeholder="RESTORE" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sqlText"> SQL </Label> <Label htmlFor="sqlText"> SQL </Label>
<Textarea id="sqlText" name="sqlText" rows={8} placeholder="-- paste sql backup here" /> <Textarea id="sqlText" name="sqlText" rows={8} placeholder="粘贴 SQL 内容" />
</div> </div>
<Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto"> <Button type="submit" size="lg" variant="destructive" disabled={loading} className="w-full sm:w-auto">

View File

@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type DiscountType = "AMOUNT_OFF" | "PERCENT_OFF";
const discountTypeLabels: Record<DiscountType, string> = {
AMOUNT_OFF: "立减金额",
PERCENT_OFF: "折扣百分比",
};
function getDiscountTypeLabel(value: unknown) {
return discountTypeLabels[value as DiscountType] ?? "选择优惠类型";
}
export function DiscountTypeSelect({
defaultValue = "AMOUNT_OFF",
}: {
defaultValue?: DiscountType;
}) {
const [value, setValue] = useState<DiscountType>(defaultValue);
return (
<Select
name="discountType"
value={value}
onValueChange={(nextValue) => setValue((nextValue ?? "AMOUNT_OFF") as DiscountType)}
>
<SelectTrigger id="coupon-type" className="w-full">
<SelectValue>{(selectedValue) => getDiscountTypeLabel(selectedValue)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="AMOUNT_OFF"></SelectItem>
<SelectItem value="PERCENT_OFF"></SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useRouter } from "next/navigation";
import { Eye, Trash2 } from "lucide-react";
import { deleteAdminRechargeCard } from "@/actions/admin/recharge-cards";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { CopyButton } from "@/components/shared/copy-button";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface RechargeCardActionItem {
id: string;
code: string;
type: "BALANCE" | "PLAN";
typeLabel: string;
status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED";
statusLabel: string;
statusTone: StatusTone;
valueLabel: string;
batchName: string | null;
createdByLabel: string;
redeemedByLabel: string;
createdAtLabel: string;
updatedAtLabel: string;
expiresAtLabel: string;
redeemedAtLabel: string;
transactionLabel: string | null;
releasesPlanStock: boolean;
}
function DetailItem({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p className={cn("mt-1 break-words text-sm font-medium leading-5", mono && "font-mono text-xs")}>{value}</p>
</div>
);
}
export function RechargeCardActions({ card }: { card: RechargeCardActionItem }) {
const router = useRouter();
const deleteDescription = card.releasesPlanStock
? "这张套餐卡尚未兑换,删除后会释放 1 个套餐库存。已兑换记录不会受影响。"
: card.status === "REDEEMED"
? "删除只移除卡密记录,不会回滚已兑换的余额或套餐。"
: "删除后卡密不可恢复,请确认不再需要保留。";
const details = [
{ label: "卡密", value: card.code, mono: true },
{ label: "类型", value: card.typeLabel },
{ label: card.type === "BALANCE" ? "充值金额" : "绑定套餐", value: card.valueLabel },
{ label: "批次", value: card.batchName ?? "未设置" },
{ label: "创建人", value: card.createdByLabel },
{ label: "创建时间", value: card.createdAtLabel },
{ label: "有效期", value: card.expiresAtLabel },
{ label: "更新时间", value: card.updatedAtLabel },
{ label: "兑换人", value: card.redeemedByLabel },
{ label: "兑换时间", value: card.redeemedAtLabel },
];
return (
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
<CopyButton text={card.code} />
<Dialog>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
<Eye className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[36rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle></DialogTitle>
<StatusBadge tone={card.statusTone}>{card.statusLabel}</StatusBadge>
</div>
<DialogDescription>{card.valueLabel}</DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-2 sm:grid-cols-2">
{details.map((item) => (
<DetailItem key={item.label} {...item} />
))}
</div>
{card.transactionLabel && (
<div className="mt-3 rounded-lg border border-primary/15 bg-primary/5 px-3 py-2 text-sm text-primary">
{card.transactionLabel}
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
<ConfirmActionButton
title={`删除充值卡 ${card.code}`}
description={deleteDescription}
confirmLabel="删除卡密"
successMessage={card.releasesPlanStock ? "充值卡已删除,套餐库存已释放" : "充值卡已删除"}
errorMessage="删除充值卡失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await deleteAdminRechargeCard(card.id);
}}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
</ConfirmActionButton>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { createAdminRechargeCards } from "@/actions/admin/recharge-cards";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getErrorMessage } from "@/lib/errors";
interface PlanOption {
id: string;
name: string;
type: "PROXY" | "STREAMING";
remainingCount: number | null;
}
export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
const router = useRouter();
const [type, setType] = useState<"BALANCE" | "PLAN">("BALANCE");
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
const [hasExpiry, setHasExpiry] = useState(false);
const [pending, startTransition] = useTransition();
const selectedPlan = plans.find((plan) => plan.id === planId) ?? null;
const planSoldOut = type === "PLAN" && selectedPlan?.remainingCount === 0;
return (
<form
className="form-panel space-y-4"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
formData.set("type", type);
if (type === "PLAN") formData.set("planId", planId);
startTransition(async () => {
try {
await createAdminRechargeCards(formData);
toast.success("充值卡已生成");
form.reset();
setHasExpiry(false);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成充值卡失败"));
}
});
}}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<BooleanToggle
value={type === "PLAN"}
onChange={(value) => setType(value ? "PLAN" : "BALANCE")}
trueLabel="套餐卡"
falseLabel="余额卡"
ariaLabel="充值卡类型"
className="w-36"
/>
</div>
<input type="hidden" name="type" value={type} />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recharge-card-quantity"></Label>
<Input
id="recharge-card-quantity"
name="quantity"
type="number"
min="1"
max={type === "PLAN" && selectedPlan?.remainingCount != null ? Math.min(200, selectedPlan.remainingCount) : 200}
defaultValue={1}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="recharge-card-batch"></Label>
<Input id="recharge-card-batch" name="batchName" placeholder="五月活动" />
</div>
</div>
{type === "BALANCE" ? (
<div className="space-y-2">
<Label htmlFor="recharge-card-amount"></Label>
<Input id="recharge-card-amount" name="balanceAmount" type="number" min="0.01" step="0.01" placeholder="100.00" required />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Select value={planId} onValueChange={(value) => setPlanId(value ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择套餐">
{(value) => {
const plan = plans.find((item) => item.id === value);
return plan
? `${plan.name} · ${plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}`
: "选择套餐";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} · {plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"} · {plan.remainingCount ?? "不限"}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPlan && (
<p className="text-xs text-muted-foreground">
{selectedPlan.remainingCount == null ? "库存不限" : `当前可生成 ${selectedPlan.remainingCount}`}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="recharge-card-expiry-mode"></Label>
<BooleanToggle
id="recharge-card-expiry-mode"
value={hasExpiry}
onChange={setHasExpiry}
trueLabel="设置到期"
falseLabel="永不过期"
ariaLabel="充值卡有效期"
/>
</div>
{hasExpiry && (
<div className="space-y-2">
<Label htmlFor="recharge-card-expires"></Label>
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" required />
</div>
)}
{!hasExpiry && (
<input type="hidden" name="expiresAt" value="" />
)}
{type === "PLAN" && plans.length === 0 && (
<div className="rounded-lg border border-destructive/15 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
</div>
)}
<Button type="submit" className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
{pending ? "生成中..." : planSoldOut ? "套餐已售罄" : "生成充值卡"}
</Button>
</form>
);
}

View File

@@ -1,7 +1,26 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import { getPlanAvailability } from "@/services/plan-availability";
export async function getCommerceData() { function getRechargeCardPlanRemaining(
const [coupons, promotions] = await Promise.all([ planType: "PROXY" | "STREAMING",
availability: Awaited<ReturnType<typeof getPlanAvailability>>,
) {
const limits: number[] = [];
if (availability.remainingByPlanLimit != null) {
limits.push(availability.remainingByPlanLimit);
}
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
limits.push(availability.remainingByServiceCapacity);
}
return limits.length > 0 ? Math.min(...limits) : null;
}
export async function getCommerceData(
searchParams: Record<string, string | string[] | undefined> = {},
) {
const { page, skip, pageSize } = parsePage(searchParams, 20);
const [coupons, promotions, rechargeCards, rechargeCardTotal, planRows] = await Promise.all([
prisma.coupon.findMany({ prisma.coupon.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { _count: { select: { orders: true, grants: true } } }, include: { _count: { select: { orders: true, grants: true } } },
@@ -11,7 +30,57 @@ export async function getCommerceData() {
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }], orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
take: 30, take: 30,
}), }),
prisma.rechargeCard.findMany({
orderBy: { createdAt: "desc" },
include: {
plan: { select: { name: true, type: true } },
redeemedBy: { select: { email: true, name: true } },
createdBy: { select: { email: true, name: true } },
transactions: {
orderBy: { createdAt: "desc" },
take: 1,
select: {
amount: true,
balanceAfter: true,
createdAt: true,
},
},
},
skip,
take: pageSize,
}),
prisma.rechargeCard.count(),
prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
select: {
id: true,
name: true,
type: true,
totalLimit: true,
perUserLimit: true,
streamingServiceId: true,
},
}),
]); ]);
return { coupons, promotions }; const plans = await Promise.all(
planRows.map(async (plan) => {
const availability = await getPlanAvailability(plan);
return {
id: plan.id,
name: plan.name,
type: plan.type,
remainingCount: getRechargeCardPlanRemaining(plan.type, availability),
};
}),
);
return {
coupons,
promotions,
rechargeCards,
rechargeCardPagination: { total: rechargeCardTotal, page, pageSize },
plans,
};
} }

View File

@@ -1,26 +1,124 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Gift, Sparkles } from "lucide-react"; import { Gift, Sparkles, WalletCards } from "lucide-react";
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce"; import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
import { DetailItem, DetailList } from "@/components/admin/detail-list";
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import type { StatusTone } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDate } from "@/lib/utils";
import { getCommerceData } from "./commerce-data"; import { getCommerceData } from "./commerce-data";
import { CommerceToggleButton } from "./_components/commerce-actions"; import { CommerceToggleButton } from "./_components/commerce-actions";
import { DiscountTypeSelect } from "./_components/discount-type-select";
import { RechargeCardActions, type RechargeCardActionItem } from "./_components/recharge-card-actions";
import { RechargeCardForm } from "./_components/recharge-card-form";
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none"; function formatCouponDiscount(type: string, value: unknown) {
const numericValue = Number(value);
if (type === "PERCENT_OFF") {
return `折扣 ${numericValue}%`;
}
return `立减 ¥${numericValue.toFixed(2)}`;
}
export const metadata: Metadata = { export const metadata: Metadata = {
title: "商业配置", title: "商业配置",
description: "管理优惠券与满减规则。", description: "管理优惠券与满减规则。",
}; };
export default async function AdminCommercePage() { const rechargeCardStatusLabels: Record<string, string> = {
const { coupons, promotions } = await getCommerceData(); UNUSED: "未使用",
REDEEMED: "已兑换",
EXPIRED: "已过期",
DISABLED: "已停用",
};
const rechargeCardStatusTones: Record<string, StatusTone> = {
UNUSED: "success",
REDEEMED: "info",
EXPIRED: "warning",
DISABLED: "danger",
};
function userLabel(user: { name: string | null; email: string } | null | undefined, fallback = "系统") {
if (!user) return fallback;
return user.name ? `${user.name} · ${user.email}` : user.email;
}
function formatRechargeCardValue(card: {
type: "BALANCE" | "PLAN";
balanceAmount: unknown;
plan: { name: string } | null;
}) {
return card.type === "BALANCE"
? `¥${Number(card.balanceAmount ?? 0).toFixed(2)}`
: card.plan?.name ?? "套餐已删除";
}
type RechargeCardRow = {
id: string;
code: string;
type: "BALANCE" | "PLAN";
status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED";
balanceAmount: unknown;
plan: { name: string } | null;
batchName: string | null;
expiresAt: Date | null;
redeemedAt: Date | null;
createdAt: Date;
updatedAt: Date;
createdBy: { name: string | null; email: string } | null;
redeemedBy: { name: string | null; email: string } | null;
transactions: Array<{
amount: unknown;
balanceAfter: unknown;
createdAt: Date;
}>;
};
function formatRechargeCardAction(card: RechargeCardRow): RechargeCardActionItem {
const transaction = card.transactions[0] ?? null;
return {
id: card.id,
code: card.code,
type: card.type,
typeLabel: card.type === "BALANCE" ? "余额卡" : "套餐卡",
status: card.status,
statusLabel: rechargeCardStatusLabels[card.status] ?? "未知状态",
statusTone: rechargeCardStatusTones[card.status] ?? "neutral",
valueLabel: formatRechargeCardValue(card),
batchName: card.batchName,
createdByLabel: userLabel(card.createdBy),
redeemedByLabel: userLabel(card.redeemedBy, "未兑换"),
createdAtLabel: formatDate(card.createdAt),
updatedAtLabel: formatDate(card.updatedAt),
expiresAtLabel: card.expiresAt ? formatDate(card.expiresAt) : "永不过期",
redeemedAtLabel: card.redeemedAt ? formatDate(card.redeemedAt) : "未兑换",
transactionLabel: transaction
? `余额入账 ¥${Number(transaction.amount).toFixed(2)} · 兑换后余额 ¥${Number(transaction.balanceAfter).toFixed(2)} · ${formatDate(transaction.createdAt)}`
: null,
releasesPlanStock: card.type === "PLAN"
&& card.status === "UNUSED"
&& (!card.expiresAt || card.expiresAt > new Date()),
};
}
function normalizeCommerceTab(value: string | string[] | undefined) {
return value === "manage" || value === "cards" ? value : "create";
}
export default async function AdminCommercePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const activeTab = normalizeCommerceTab(params.tab);
const { coupons, promotions, rechargeCards, rechargeCardPagination, plans } = await getCommerceData(params);
return ( return (
<PageShell> <PageShell>
@@ -29,10 +127,11 @@ export default async function AdminCommercePage() {
title="优惠与奖励" title="优惠与奖励"
/> />
<Tabs defaultValue="create" className="space-y-6"> <Tabs defaultValue={activeTab} className="space-y-6">
<TabsList variant="line" className="surface-card p-1"> <TabsList variant="line" className="surface-card p-1">
<TabsTrigger value="create"></TabsTrigger> <TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="manage"></TabsTrigger> <TabsTrigger value="manage"></TabsTrigger>
<TabsTrigger value="cards"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="create"> <TabsContent value="create">
@@ -52,10 +151,7 @@ export default async function AdminCommercePage() {
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="coupon-type"></Label> <Label htmlFor="coupon-type"></Label>
<select id="coupon-type" name="discountType" className={selectClassName} defaultValue="AMOUNT_OFF"> <DiscountTypeSelect />
<option value="AMOUNT_OFF"></option>
<option value="PERCENT_OFF"></option>
</select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="coupon-value"></Label> <Label htmlFor="coupon-value"></Label>
@@ -109,51 +205,116 @@ export default async function AdminCommercePage() {
<TabsContent value="manage" className="space-y-6"> <TabsContent value="manage" className="space-y-6">
<section className="space-y-4"> <section className="space-y-4">
<SectionHeader title="优惠券" /> <SectionHeader title="优惠券" />
<div className="grid gap-4 lg:grid-cols-2"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{coupons.map((coupon) => ( {coupons.map((coupon) => (
<article key={coupon.id} className="surface-card rounded-xl p-4"> <article key={coupon.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.9fr)_auto] lg:items-center">
<div className="flex items-start justify-between gap-4"> <div className="flex min-w-0 items-center gap-3">
<div className="flex items-start gap-3"> <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
<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 className="min-w-0">
<div> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="font-semibold">{coupon.name}</h3> <h3 className="min-w-0 truncate font-semibold leading-6">{coupon.name}</h3>
<p className="mt-1 font-mono text-sm text-primary">{coupon.code}</p> <ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div>
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2">
<StatusBadge tone="warning">
{formatCouponDiscount(coupon.discountType, coupon.discountValue)}
</StatusBadge>
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>
<StatusBadge> {coupon._count.orders} · {coupon._count.grants}</StatusBadge>
</div>
<div className="flex justify-start lg:justify-end">
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} /> <CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
</div> </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> </article>
))} ))}
{coupons.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</div> </div>
</section> </section>
<section className="space-y-4"> <section className="space-y-4">
<SectionHeader title="满减规则" /> <SectionHeader title="满减规则" />
<div className="grid gap-4 lg:grid-cols-2"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{promotions.map((rule) => ( {promotions.map((rule) => (
<article key={rule.id} className="surface-card rounded-xl p-4"> <article key={rule.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] lg:items-center">
<div className="flex items-start justify-between gap-4"> <div className="flex min-w-0 items-center gap-3">
<div className="flex items-start gap-3"> <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"><Sparkles className="size-4" /></span>
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-primary/10 text-primary"><Sparkles className="size-4" /></span> <div className="min-w-0">
<div> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="font-semibold">{rule.name}</h3> <h3 className="min-w-0 truncate font-semibold leading-6">{rule.name}</h3>
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
</div>
<p className="mt-1 text-sm text-muted-foreground"> ¥{Number(rule.thresholdAmount).toFixed(2)} ¥{Number(rule.discountAmount).toFixed(2)}</p> <p className="mt-1 text-sm text-muted-foreground"> ¥{Number(rule.thresholdAmount).toFixed(2)} ¥{Number(rule.discountAmount).toFixed(2)}</p>
</div> </div>
</div> </div>
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} /> <div className="flex flex-wrap gap-2">
</div> <StatusBadge tone="info"> ¥{Number(rule.discountAmount).toFixed(2)}</StatusBadge>
<div className="mt-4 flex flex-wrap gap-2"> <StatusBadge> ¥{Number(rule.thresholdAmount).toFixed(2)}</StatusBadge>
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
<StatusBadge> {rule.sortOrder}</StatusBadge> <StatusBadge> {rule.sortOrder}</StatusBadge>
</div> </div>
<div className="flex justify-start lg:justify-end">
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
</div>
</article> </article>
))} ))}
{promotions.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</div>
</section>
</TabsContent>
<TabsContent value="cards" className="space-y-6">
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
<RechargeCardForm plans={plans} />
<div className="space-y-4">
<SectionHeader title="充值卡列表" />
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{rechargeCards.map((card) => {
const actionCard = formatRechargeCardAction(card);
return (
<article key={card.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<WalletCards className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-mono font-semibold">{card.code}</h3>
<StatusBadge tone={actionCard.statusTone}>
{actionCard.statusLabel}
</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{actionCard.typeLabel} · {actionCard.valueLabel}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
{card.redeemedBy && <StatusBadge tone="info">{userLabel(card.redeemedBy, "已兑换")}</StatusBadge>}
<StatusBadge>{card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"}</StatusBadge>
</div>
<RechargeCardActions card={actionCard} />
</article>
);
})}
{rechargeCards.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground"></p>
)}
</div>
<Pagination
total={rechargeCardPagination.total}
pageSize={rechargeCardPagination.pageSize}
page={rechargeCardPagination.page}
fixedParams={{ tab: "cards" }}
/>
</div> </div>
</section> </section>
</TabsContent> </TabsContent>

View File

@@ -23,7 +23,7 @@ export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps)
{recentOrders.length === 0 ? ( {recentOrders.length === 0 ? (
<EmptyState <EmptyState
title="还没有订单" title="还没有订单"
description="用户创建订单后,这里会显示最新购买和支付状态。" description="新订单会显示购买和支付状态。"
className="border-0 bg-transparent py-8" className="border-0 bg-transparent py-8"
/> />
) : ( ) : (
@@ -62,7 +62,7 @@ export function RecentSection({ recentOrders, recentUsers }: RecentSectionProps)
{recentUsers.length === 0 ? ( {recentUsers.length === 0 ? (
<EmptyState <EmptyState
title="还没有新用户" title="还没有新用户"
description="新用户注册后,这里会显示最近加入的账户。" description="新注册账户会显示在这里。"
className="border-0 bg-transparent py-8" className="border-0 bg-transparent py-8"
/> />
) : ( ) : (

View File

@@ -1,20 +1,22 @@
"use client"; "use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { StatusBadge } from "@/components/shared/status-badge";
import type { NodeDetail } from "../node-detail-data"; import type { NodeDetail } from "../node-detail-data";
import { InboundsTab } from "./tabs/inbounds-tab"; import { InboundsTab } from "./tabs/inbounds-tab";
export function NodeDetailTabs({ node }: { node: NodeDetail }) { export function NodeDetailTabs({ node }: { node: NodeDetail }) {
return ( return (
<Tabs defaultValue="inbounds"> <section className="space-y-4">
<TabsList variant="line" className="w-full overflow-x-auto"> <div className="flex flex-wrap items-center justify-between gap-3">
<TabsTrigger value="inbounds"> <div>
3x-ui ({node.inbounds.length}) <p className="text-xs font-medium tracking-wide text-muted-foreground">线</p>
</TabsTrigger> <h2 className="text-lg font-semibold tracking-tight">3x-ui </h2>
</TabsList> </div>
<TabsContent value="inbounds"> <StatusBadge tone={node.inbounds.length > 0 ? "info" : "neutral"}>
{node.inbounds.length}
</StatusBadge>
</div>
<InboundsTab node={node} /> <InboundsTab node={node} />
</TabsContent> </section>
</Tabs>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { Waypoints } from "lucide-react"; import { Waypoints } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { InboundDeleteButton } from "../../../inbound-delete-button"; import { InboundDeleteButton } from "../../../inbound-delete-button";
import { InboundDisplayNameForm } from "../../../inbound-display-name-form"; import { InboundDisplayNameForm } from "../../../inbound-display-name-form";
import type { NodeDetail } from "../../node-detail-data"; import type { NodeDetail } from "../../node-detail-data";
@@ -17,58 +17,77 @@ function getDisplayName(inbound: { tag: string; settings: unknown }) {
return inbound.tag; return inbound.tag;
} }
function streamValue(settings: unknown, key: string) {
if (!settings || typeof settings !== "object") return null;
const value = (settings as Record<string, unknown>)[key];
if (typeof value !== "string" || !value.trim()) return null;
return value;
}
function InboundMeta({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<span className="inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-border bg-muted/25 px-2.5 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold text-foreground">{value}</span>
</span>
);
}
export function InboundsTab({ node }: { node: NodeDetail }) { export function InboundsTab({ node }: { node: NodeDetail }) {
if (node.inbounds.length === 0) { if (node.inbounds.length === 0) {
return ( return (
<div className="surface-card rounded-xl p-5">
<EmptyState <EmptyState
title="暂无已同步入站" title="暂无已同步入站"
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。" description="同步节点后会显示可售入口。"
/> />
</div>
); );
} }
return ( return (
<div className="space-y-4 pt-4"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
<p className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground"> {node.inbounds.map((inbound) => {
3x-ui 线 const network = streamValue(inbound.streamSettings, "network");
</p> const security = streamValue(inbound.streamSettings, "security");
<div className="grid gap-3">
{node.inbounds.map((inbound) => ( return (
<Card key={inbound.id}> <article key={inbound.id} className="grid gap-4 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] xl:items-center">
<CardHeader className="flex flex-row items-center justify-between gap-3 pb-2"> <div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Waypoints className="size-4 shrink-0 text-primary" /> <Waypoints className="size-4" />
<CardTitle className="text-sm"> </span>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<StatusBadge tone="info">{inbound.protocol}</StatusBadge>
<Badge variant="outline">:{inbound.port}</Badge>
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">{inbound.tag}</span>
</div>
<InboundDisplayNameForm <InboundDisplayNameForm
inboundId={inbound.id} inboundId={inbound.id}
defaultValue={getDisplayName(inbound)} defaultValue={getDisplayName(inbound)}
/> />
</CardTitle>
</div> </div>
<div className="flex items-center gap-2"> </div>
<Badge variant="secondary">{inbound.protocol}</Badge>
<Badge variant="outline">:{inbound.port}</Badge> <div className="flex flex-wrap gap-2">
<InboundMeta label="客户端" value={inbound.clients.length} />
{network && <InboundMeta label="传输" value={network} />}
{security && <InboundMeta label="安全" value={security} />}
</div>
<div className="flex justify-start xl:justify-end">
<InboundDeleteButton inboundId={inbound.id} /> <InboundDeleteButton inboundId={inbound.id} />
</div> </div>
</CardHeader> </article>
<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> </div>
); );
} }

View File

@@ -7,7 +7,9 @@ const nodeDetailSelect = {
id: true, id: true,
name: true, name: true,
panelUrl: true, panelUrl: true,
panelUsername: true,
status: true, status: true,
agentToken: true,
inbounds: { inbounds: {
where: { isActive: true }, where: { isActive: true },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
@@ -32,6 +34,7 @@ export async function getNodeDetail(id: string): Promise<NodeDetail> {
return { return {
...node, ...node,
agentToken: node.agentToken ? "configured" : null,
inbounds: node.inbounds.map((inbound) => ({ inbounds: node.inbounds.map((inbound) => ({
...inbound, ...inbound,
settings: sanitizeInboundSettings(inbound.settings), settings: sanitizeInboundSettings(inbound.settings),

View File

@@ -1,46 +1,122 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, KeyRound, Server, UserRound, Waypoints } from "lucide-react";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageShell } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { getNodeStatusLabel } from "@/lib/domain-labels"; import { getNodeStatusLabel } from "@/lib/domain-labels";
import { getConfiguredSiteUrl } from "@/services/site-url";
import { getNodeDetail } from "./node-detail-data"; import { getNodeDetail } from "./node-detail-data";
import { NodeDetailTabs } from "./_components/node-detail-tabs"; import { NodeDetailTabs } from "./_components/node-detail-tabs";
import { NodeActions } from "../node-actions";
import { NodeForm } from "../node-form";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "节点详情", title: "节点详情",
}; };
function DetailMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: ReactNode;
}) {
return (
<div className="flex min-w-0 items-center gap-3 bg-muted/20 px-4 py-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background/70 text-primary">
{icon}
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="mt-0.5 truncate text-sm font-semibold">{value}</div>
</div>
</div>
);
}
export default async function NodeDetailPage({ export default async function NodeDetailPage({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const node = await getNodeDetail(id); const [node, siteUrl] = await Promise.all([getNodeDetail(id), getConfiguredSiteUrl()]);
return ( return (
<PageShell> <PageShell>
<div className="flex items-center gap-2">
<Link <Link
href="/admin/nodes" href="/admin/nodes"
className={buttonVariants({ variant: "ghost", size: "icon" })} className={buttonVariants({ variant: "ghost", size: "sm", className: "w-fit" })}
> >
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
</Link> </Link>
<PageHeader
eyebrow="基础设施" <section className="surface-card overflow-hidden rounded-xl">
title={node.name} <div className="flex flex-col gap-5 p-5 xl:flex-row xl:items-start xl:justify-between">
description={`3x-ui · ${node.panelUrl || "未配置面板"}`} <div className="flex min-w-0 items-start gap-4">
actions={ <span className="flex size-12 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Server className="size-5" />
</span>
<div className="min-w-0">
<div className="flex min-h-7 flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-2xl font-semibold leading-8 tracking-tight">{node.name}</h1>
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}> <StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{getNodeStatusLabel(node.status)} {getNodeStatusLabel(node.status)}
</StatusBadge> </StatusBadge>
} </div>
className="flex-1" <p className="mt-1 break-all text-sm text-muted-foreground">
{node.panelUrl || "未配置面板"}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<NodeForm
node={{
id: node.id,
name: node.name,
panelUrl: node.panelUrl,
panelUsername: node.panelUsername,
}}
triggerLabel="编辑"
triggerVariant="outline"
/>
<NodeActions
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
siteUrl={siteUrl}
deleteRedirectHref="/admin/nodes"
/> />
</div> </div>
</div>
<div className="grid gap-px border-t border-border/60 bg-border/60 sm:grid-cols-2 xl:grid-cols-4">
<DetailMetric
icon={<Server className="size-4" />}
label="面板类型"
value="3x-ui"
/>
<DetailMetric
icon={<UserRound className="size-4" />}
label="面板账号"
value={node.panelUsername || "未配置"}
/>
<DetailMetric
icon={<Waypoints className="size-4" />}
label="已同步入站"
value={`${node.inbounds.length}`}
/>
<DetailMetric
icon={<KeyRound className="size-4" />}
label="探测 Token"
value={node.agentToken ? "已启用" : "未生成"}
/>
</div>
</section>
<NodeDetailTabs node={node} /> <NodeDetailTabs node={node} />
</PageShell> </PageShell>

View File

@@ -1,60 +1,109 @@
import { Server, Waypoints } from "lucide-react"; import { Eye, KeyRound, Server, UserRound, Waypoints } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { batchTestNodeConnections } from "@/actions/admin/nodes"; import { batchTestNodeConnections } from "@/actions/admin/nodes";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar"; import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button";
import { getNodeStatusLabel } from "@/lib/domain-labels"; import { getNodeStatusLabel } from "@/lib/domain-labels";
import { InboundDeleteButton } from "../inbound-delete-button";
import { InboundDisplayNameForm } from "../inbound-display-name-form";
import { NodeActions } from "../node-actions"; import { NodeActions } from "../node-actions";
import { NodeForm } from "../node-form"; import { NodeForm } from "../node-form";
import type { NodeServerRow } from "../nodes-data"; import type { NodeServerRow } from "../nodes-data";
const NODE_BATCH_FORM_ID = "node-batch-form"; const NODE_BATCH_FORM_ID = "node-batch-form";
function PanelInfoBar({ node }: { node: NodeServerRow }) { function NodeInlineMeta({ node }: { node: NodeServerRow }) {
return ( return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-muted/25 px-4 py-3 text-xs text-muted-foreground"> <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium text-foreground">3x-ui</span> <span className="min-w-0 max-w-full truncate">{node.panelUrl || "未配置面板"}</span>
<span>{node.panelUrl || "未配置面板"}</span> <span className="inline-flex items-center gap-1">
{node.agentToken && <span> Token: 已启用</span>} <UserRound className="size-3" />
{node.panelUsername || "未配置账号"}
</span>
<span className="inline-flex items-center gap-1">
<KeyRound className="size-3" />
{node.agentToken ? "Token 已启用" : "未生成 Token"}
</span>
</div>
);
}
function InboundPreview({ node }: { node: NodeServerRow }) {
const preview = node.inbounds.slice(0, 3);
const hiddenCount = Math.max(0, node.inbounds.length - preview.length);
if (node.inbounds.length === 0) {
return (
<div className="flex min-h-12 items-center rounded-lg border border-dashed border-border bg-muted/20 px-3 text-xs text-muted-foreground">
</div>
);
}
return (
<div className="space-y-2">
<div className="flex min-h-6 items-center justify-between gap-3">
<p className="text-xs font-medium text-muted-foreground"></p>
</div>
<div className="flex flex-wrap gap-2">
{preview.map((inbound) => (
<span
key={inbound.id}
className="inline-flex min-h-8 max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-2.5 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>
<span className="min-w-0 truncate">{getInboundDisplayName(inbound)}</span>
</span>
))}
{hiddenCount > 0 && (
<span className="inline-flex min-h-8 items-center rounded-lg border border-border bg-muted/25 px-2.5 text-xs font-medium text-muted-foreground">
</span>
)}
</div>
</div> </div>
); );
} }
function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) { function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | null }) {
return ( return (
<Card> <article className="grid gap-4 px-4 py-4 transition-colors duration-200 hover:bg-muted/15 xl:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)_auto] xl:items-center">
<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-center gap-3">
<div className="flex min-w-0 items-start gap-3">
<input <input
form={NODE_BATCH_FORM_ID} form={NODE_BATCH_FORM_ID}
type="checkbox" type="checkbox"
name="nodeIds" name="nodeIds"
value={node.id} value={node.id}
aria-label={`选择节点 ${node.name}`} aria-label={`选择节点 ${node.name}`}
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm" className="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"> <span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Server className="size-5" /> <Server className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-lg"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<Link href={`/admin/nodes/${node.id}`} className="hover:underline"> <h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">
{node.name} {node.name}
</Link> </h3>
</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"}> <StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
{getNodeStatusLabel(node.status)} {getNodeStatusLabel(node.status)}
</StatusBadge> </StatusBadge>
</div>
<NodeInlineMeta node={node} />
</div>
</div>
<InboundPreview node={node} />
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Link
href={`/admin/nodes/${node.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
<NodeForm <NodeForm
node={{ node={{
id: node.id, id: node.id,
@@ -70,31 +119,7 @@ function NodeCard({ node, siteUrl }: { node: NodeServerRow; siteUrl: string | nu
siteUrl={siteUrl} siteUrl={siteUrl}
/> />
</div> </div>
</CardHeader> </article>
<CardContent className="space-y-4">
<PanelInfoBar node={node} />
{node.inbounds.length > 0 ? (
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
{node.inbounds.map((inbound) => (
<div
key={inbound.id}
className="flex min-w-0 flex-wrap items-center gap-2 border-b border-border/50 pb-2 text-xs font-medium last:border-b-0 last:pb-0"
>
<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>
); );
} }
@@ -104,16 +129,18 @@ export function NodeCardList({ nodes, siteUrl }: { nodes: NodeServerRow[]; siteU
<BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}> <BatchActionBar id={NODE_BATCH_FORM_ID} action={batchTestNodeConnections}>
<BatchActionButton></BatchActionButton> <BatchActionButton></BatchActionButton>
</BatchActionBar> </BatchActionBar>
<div className="grid gap-5"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{nodes.map((node) => ( {nodes.map((node) => (
<NodeCard key={node.id} node={node} siteUrl={siteUrl} /> <NodeCard key={node.id} node={node} siteUrl={siteUrl} />
))} ))}
{nodes.length === 0 && ( {nodes.length === 0 && (
<div className="p-5">
<EmptyState <EmptyState
title="暂无节点" title="暂无节点"
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。" description="添加节点后同步入站并绑定套餐。"
action={<NodeForm triggerLabel="添加节点" />} action={<NodeForm triggerLabel="添加节点" />}
/> />
</div>
)} )}
</div> </div>
</> </>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Trash2 } from "lucide-react";
import { deleteInbound } from "@/actions/admin/nodes"; import { deleteInbound } from "@/actions/admin/nodes";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
@@ -14,12 +15,13 @@ export function InboundDeleteButton({
variant="ghost" variant="ghost"
className="h-7 px-2 text-destructive hover:text-destructive" className="h-7 px-2 text-destructive hover:text-destructive"
title="删除这个线路入口?" title="删除这个线路入口?"
description="这里只会移除本地同步记录,不删除 3x-ui 面板中的入站。请确认没有套餐仍依赖它。" description="移除本地记录,不删除 3x-ui 入站。"
confirmLabel="删除入口" confirmLabel="删除入口"
successMessage="线路入口已删除" successMessage="线路入口已删除"
errorMessage="删除线路入口失败" errorMessage="删除线路入口失败"
onConfirm={() => deleteInbound(inboundId)} onConfirm={() => deleteInbound(inboundId)}
> >
<Trash2 className="size-3" />
</ConfirmActionButton> </ConfirmActionButton>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useId, useState } from "react";
import { Save } from "lucide-react";
import { updateInboundDisplayName } from "@/actions/admin/nodes"; import { updateInboundDisplayName } from "@/actions/admin/nodes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -14,6 +15,7 @@ export function InboundDisplayNameForm({
inboundId: string; inboundId: string;
defaultValue: string; defaultValue: string;
}) { }) {
const inputId = useId();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function handleSubmit(formData: FormData) { async function handleSubmit(formData: FormData) {
@@ -30,13 +32,18 @@ export function InboundDisplayNameForm({
return ( return (
<form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2"> <form action={handleSubmit} className="flex min-w-0 flex-1 items-center gap-2">
<label htmlFor={inputId} className="sr-only">
</label>
<Input <Input
id={inputId}
name="displayName" name="displayName"
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder="例如 悉尼 · 日常优选" placeholder="例如 悉尼 · 日常优选"
className="h-8 min-h-8 rounded-xl px-3 text-xs" className="h-9 min-h-9 rounded-lg px-3 text-sm"
/> />
<Button type="submit" size="xs" variant="outline" disabled={saving}> <Button type="submit" size="xs" variant="outline" disabled={saving}>
<Save className="size-3" />
{saving ? "保存中" : "保存"} {saving ? "保存中" : "保存"}
</Button> </Button>
</form> </form>

View File

@@ -1,15 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { KeyRound, Terminal } from "lucide-react"; import { useRouter } from "next/navigation";
import { KeyRound, RefreshCw, ShieldOff, Terminal, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,6 +14,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes"; import { deleteNode, generateAgentToken, revokeAgentToken, testNodeConnection } from "@/actions/admin/nodes";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
interface NodeActionValue { interface NodeActionValue {
@@ -27,7 +23,7 @@ interface NodeActionValue {
agentToken: string | null; agentToken: string | null;
} }
const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board/lite/scripts/install-jboard-agent.sh"; const INSTALL_SCRIPT_URL = "https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-agent.sh";
function shellQuote(value: string) { function shellQuote(value: string) {
return `'${value.replaceAll("'", `'"'"'`)}'`; return `'${value.replaceAll("'", `'"'"'`)}'`;
@@ -45,85 +41,127 @@ function buildInstallCommand(token: string, siteUrl: string | null) {
return `curl -fsSL ${INSTALL_SCRIPT_URL} | SERVER_URL=${shellQuote(serverUrl)} AUTH_TOKEN=${shellQuote(token)} bash`; 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 }) { export function NodeActions({
node,
siteUrl,
deleteRedirectHref,
}: {
node: NodeActionValue;
siteUrl: string | null;
deleteRedirectHref?: string;
}) {
const router = useRouter();
const [tokenDialogOpen, setTokenDialogOpen] = useState(false); const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
const [plainToken, setPlainToken] = useState(""); const [plainToken, setPlainToken] = useState("");
const [installCommand, setInstallCommand] = useState(""); const [installCommand, setInstallCommand] = useState("");
const hasToken = !!node.agentToken; const [hasToken, setHasToken] = useState(!!node.agentToken);
const [syncing, setSyncing] = useState(false);
const [tokenLoading, setTokenLoading] = useState(false);
async function handleGenerateToken() { async function handleSync() {
try { setSyncing(true);
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 { try {
const res = await testNodeConnection(node.id); const res = await testNodeConnection(node.id);
if (res.success) toast.success(res.message); if (res.success) toast.success(res.message);
else toast.error(getErrorMessage(res.message, "节点测试失败")); else toast.error(getErrorMessage(res.message, "节点测试失败"));
router.refresh();
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, "测试失败")); toast.error(getErrorMessage(error, "测试失败"));
} finally {
setSyncing(false);
} }
}} }
async function handleGenerateToken() {
setTokenLoading(true);
try {
const token = await generateAgentToken(node.id);
setHasToken(true);
setPlainToken(token);
setInstallCommand(buildInstallCommand(token, siteUrl));
setTokenDialogOpen(true);
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成 Token 失败"));
} finally {
setTokenLoading(false);
}
}
return (
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleSync()}
disabled={syncing}
> >
<RefreshCw className={cn("size-3.5", syncing && "animate-spin")} />
</DropdownMenuItem> {syncing ? "同步中" : "同步"}
<DropdownMenuItem onClick={handleGenerateToken}> </Button>
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
</DropdownMenuItem> <Button
</DropdownMenuContent> type="button"
</DropdownMenu> variant="outline"
size="sm"
onClick={() => void handleGenerateToken()}
disabled={tokenLoading}
>
<KeyRound className="size-3.5" />
{hasToken ? "重置 Token" : "Token"}
</Button>
{hasToken && ( {hasToken && (
<ConfirmActionButton <ConfirmActionButton
size="sm" size="sm"
variant="outline" variant="outline"
className="border-amber-500/25 text-amber-700 hover:bg-amber-500/10 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200"
title="撤销这个探测 Token" title="撤销这个探测 Token"
description="撤销后,延迟、线路探测和节点日志风控程序将无法继续上报数据。" description="撤销后探测 Agent 停止上报。"
confirmLabel="撤销 Token" confirmLabel="撤销 Token"
successMessage="探测 Token 已撤销" successMessage="探测 Token 已撤销"
errorMessage="撤销失败" errorMessage="撤销失败"
onConfirm={() => revokeAgentToken(node.id)} onConfirm={() => revokeAgentToken(node.id)}
onSuccess={() => {
setHasToken(false);
router.refresh();
}}
> >
<ShieldOff className="size-3.5" />
Token Token
</ConfirmActionButton> </ConfirmActionButton>
)} )}
<ConfirmActionButton <ConfirmActionButton
size="sm" size="sm"
variant="destructive" variant="outline"
className="border-destructive/25 text-destructive hover:bg-destructive/10 hover:text-destructive"
title="删除这个节点?" title="删除这个节点?"
description="节点、线路入口和相关探测数据会被清理。请确认没有套餐仍依赖它。" description="会清理节点、入口和探测数据。"
confirmLabel="删除节点" confirmLabel="删除节点"
successMessage="节点已删除" successMessage="节点已删除"
errorMessage="删除失败" errorMessage="删除失败"
onConfirm={() => deleteNode(node.id)} onConfirm={() => deleteNode(node.id)}
onSuccess={() => {
if (deleteRedirectHref) router.push(deleteRedirectHref);
else router.refresh();
}}
> >
<Trash2 className="size-3.5" />
</ConfirmActionButton> </ConfirmActionButton>
<Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}> <Dialog open={tokenDialogOpen} onOpenChange={setTokenDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-[30rem]">
<DialogHeader> <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"> <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 <KeyRound className="size-3.5" /> PROBE TOKEN
</div> </div>
<DialogTitle> Token {node.name}</DialogTitle> <DialogTitle> Token {node.name}</DialogTitle>
<DialogDescription> Token </DialogDescription> <DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground"> Token</div> <div className="text-xs font-semibold text-muted-foreground"> Token</div>
<div className="rounded-lg border border-border bg-muted/30 p-3"> <div className="rounded-lg border border-border bg-muted/30 p-3">
@@ -166,15 +204,15 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
{!siteUrl && ( {!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 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">
URL URL
</p> </p>
)} )}
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
Agent `/api/agent/latency``/api/agent/trace` 3x-ui/Xray access logAgent 3x-ui Agent
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </div>
); );
} }

View File

@@ -1,14 +1,17 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Plus, Server, Pencil } from "lucide-react";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -35,6 +38,10 @@ export function NodeForm({
}) { }) {
const isEdit = Boolean(node); const isEdit = Boolean(node);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const nameId = isEdit ? `node-name-${node!.id}` : "node-name";
const panelUrlId = isEdit ? `node-panel-url-${node!.id}` : "node-panel-url";
const panelUsernameId = isEdit ? `node-panel-username-${node!.id}` : "node-panel-username";
const panelPasswordId = isEdit ? `node-panel-password-${node!.id}` : "node-panel-password";
async function handleCreate(formData: FormData) { async function handleCreate(formData: FormData) {
try { try {
@@ -63,51 +70,71 @@ export function NodeForm({
<DialogTrigger <DialogTrigger
render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />} render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}
> >
{isEdit ? <Pencil className="size-3.5" /> : <Plus className="size-4" />}
{triggerLabel || (isEdit ? "编辑" : "添加节点")} {triggerLabel || (isEdit ? "编辑" : "添加节点")}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[34rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle>{isEdit ? "编辑 3x-ui 节点" : "添加 3x-ui 节点"}</DialogTitle> <div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<DialogDescription> <Server className="size-4" />
3x-ui 线 3x-ui </div>
</DialogDescription> <DialogTitle>{isEdit ? "编辑节点" : "添加节点"}</DialogTitle>
</DialogHeader> <DialogDescription> 3x-ui </DialogDescription>
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5"> </DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<div> <DialogBody className="flex-1 px-4 py-3">
<Label></Label> <form action={isEdit ? handleEdit : handleCreate} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" /> <div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 text-xs font-semibold"></div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={nameId}></Label>
<Input id={nameId} name="name" defaultValue={node?.name ?? ""} placeholder="HK-01" />
</div>
<div className="space-y-2">
<Label htmlFor={panelUrlId}>3x-ui </Label>
<Input
id={panelUrlId}
name="panelUrl"
defaultValue={node?.panelUrl ?? ""}
placeholder="http://1.2.3.4:2053"
required
/>
</div> </div>
<div>
<Label>3x-ui </Label>
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
</div> </div>
</div> </div>
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 text-xs font-semibold"></div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-2">
<Label></Label> <Label htmlFor={panelUsernameId}></Label>
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required /> <Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
</div> </div>
<div> <div className="space-y-2">
<Label></Label> <Label htmlFor={panelPasswordId}></Label>
<Input <Input
id={panelPasswordId}
name="panelPassword" name="panelPassword"
type="password" type="password"
placeholder={isEdit ? "留空则沿用当前密码" : "请输入面板密码"} placeholder={isEdit ? "留空不变" : "面板密码"}
required={!isEdit} required={!isEdit}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
</div> </div>
</div>
<p className="text-xs leading-5 text-muted-foreground"> <DialogFooter className="-mx-4 -mb-3">
线使 Token 3x-ui API <Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)}>
</p>
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}> </Button>
{isEdit ? "保存并同步入站" : "创建并同步入站"} <PendingSubmitButton className="h-8" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存并同步" : "创建并同步"}
</PendingSubmitButton> </PendingSubmitButton>
</DialogFooter>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -37,7 +37,7 @@ export function OrdersTable({ orders }: OrdersTableProps) {
<DataTableShell <DataTableShell
isEmpty={orders.length === 0} isEmpty={orders.length === 0}
emptyTitle="暂无订单" emptyTitle="暂无订单"
emptyDescription="用户创建订单后,支付审查状态会出现在这里。" emptyDescription="订单创建后显示支付审查状态。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="order-batch-form" id="order-batch-form"

View File

@@ -0,0 +1,123 @@
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { getPaymentProviderName } from "@/services/payment/catalog";
import { formatDateShort } from "@/lib/utils";
import { RechargeOrderActions } from "../recharge-order-actions";
import type { AdminRechargeOrderRow } from "../orders-data";
interface RechargeOrdersTableProps {
rechargeOrders: AdminRechargeOrderRow[];
}
function formatAmount(amount: { toString(): string }) {
return `¥${Number(amount).toFixed(2)}`;
}
function getPaymentLabel(provider: string | null) {
if (provider === "manual") return "手动确认";
return provider ? getPaymentProviderName(provider) : "未选择支付";
}
const rechargeStatusLabels: Record<AdminRechargeOrderRow["status"], string> = {
PENDING: "待支付",
PAID: "已入账",
CANCELLED: "已取消",
REFUNDED: "已退款",
};
function getRechargeStatusTone(status: AdminRechargeOrderRow["status"]): StatusTone {
if (status === "PAID") return "success";
if (status === "PENDING") return "warning";
if (status === "CANCELLED") return "neutral";
return "danger";
}
function RechargeStatusBadge({ status }: { status: AdminRechargeOrderRow["status"] }) {
return <StatusBadge tone={getRechargeStatusTone(status)}>{rechargeStatusLabels[status]}</StatusBadge>;
}
export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps) {
return (
<DataTableShell
isEmpty={rechargeOrders.length === 0}
emptyTitle="暂无充值订单"
emptyDescription="用户发起钱包充值后,会在这里显示支付与入账状态。"
mobileCards={rechargeOrders.map((order) => (
<article key={order.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-semibold">{order.user.email}</p>
<p className="mt-1 text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
</div>
<RechargeStatusBadge status={order.status} />
</div>
<div className="rounded-lg bg-muted/25 p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground"></p>
<p className="font-semibold tabular-nums">{formatAmount(order.amount)}</p>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{getPaymentLabel(order.paymentMethod)} · {order.tradeNo || "无交易号"}
</p>
<p className="mt-1 text-xs text-muted-foreground">{formatDateShort(order.createdAt)}</p>
</div>
<RechargeOrderActions orderId={order.id} status={order.status} />
</article>
))}
>
<DataTable aria-label="充值订单列表" className="min-w-[1120px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{rechargeOrders.map((order) => (
<DataTableRow key={order.id}>
<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="font-semibold tabular-nums">{formatAmount(order.amount)}</DataTableCell>
<DataTableCell>
<div className="space-y-1">
<p>{getPaymentLabel(order.paymentMethod)}</p>
<p className="max-w-56 break-all text-xs text-muted-foreground">
{order.tradeNo || "—"}
</p>
</div>
</DataTableCell>
<DataTableCell>
<RechargeStatusBadge status={order.status} />
</DataTableCell>
<DataTableCell className="max-w-64 whitespace-normal break-words text-xs text-muted-foreground">
{order.note || order.paymentRef || "—"}
</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDateShort(order.createdAt)}
</DataTableCell>
<DataTableCell>
<RechargeOrderActions orderId={order.id} status={order.status} />
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
);
}

View File

@@ -11,6 +11,14 @@ export type AdminOrderRow = Prisma.OrderGetPayload<{
include: typeof adminOrderInclude; include: typeof adminOrderInclude;
}>; }>;
const adminRechargeOrderInclude = {
user: true,
} satisfies Prisma.WalletRechargeOrderInclude;
export type AdminRechargeOrderRow = Prisma.WalletRechargeOrderGetPayload<{
include: typeof adminRechargeOrderInclude;
}>;
export async function getAdminOrders( export async function getAdminOrders(
searchParams: Record<string, string | string[] | undefined>, searchParams: Record<string, string | string[] | undefined>,
) { ) {
@@ -52,3 +60,38 @@ export async function getAdminOrders(
return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } }; return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } };
} }
export async function getAdminRechargeOrders(
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: status as "PENDING" | "PAID" | "CANCELLED" | "REFUNDED" } : {}),
...(q
? {
OR: [
{ user: { email: { contains: q } } },
{ user: { name: { contains: q } } },
{ tradeNo: { contains: q } },
{ paymentRef: { contains: q } },
],
}
: {}),
} satisfies Prisma.WalletRechargeOrderWhereInput;
const [rechargeOrders, total] = await Promise.all([
prisma.walletRechargeOrder.findMany({
where,
include: adminRechargeOrderInclude,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.walletRechargeOrder.count({ where }),
]);
return { rechargeOrders, total, page, pageSize, filters: { q, status } };
}

View File

@@ -1,21 +1,56 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react";
import Link from "next/link";
import { AdminFilterBar } from "@/components/admin/filter-bar"; import { AdminFilterBar } from "@/components/admin/filter-bar";
import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { PageHeader, PageShell } from "@/components/shared/page-shell";
import { Pagination } from "@/components/shared/pagination"; import { Pagination } from "@/components/shared/pagination";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { OrdersTable } from "./_components/orders-table"; import { OrdersTable } from "./_components/orders-table";
import { getAdminOrders } from "./orders-data"; import { RechargeOrdersTable } from "./_components/recharge-orders-table";
import { getAdminOrders, getAdminRechargeOrders } from "./orders-data";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "订单管理", title: "订单管理",
description: "跟踪订单状态、审查结果与支付记录。", description: "跟踪订单状态、审查结果与支付记录。",
}; };
function normalizeOrdersTab(value: string | string[] | undefined) {
return value === "recharge" ? "recharge" : "orders";
}
function OrdersTabLink({
href,
active,
children,
}: {
href: string;
active: boolean;
children: ReactNode;
}) {
return (
<Link
href={href}
className={cn(
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
"min-w-28",
)}
>
{children}
</Link>
);
}
export default async function OrdersPage({ export default async function OrdersPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const { orders, total, page, pageSize, filters } = await getAdminOrders(await searchParams); const params = await searchParams;
const activeTab = normalizeOrdersTab(params.tab);
const orderData = activeTab === "orders" ? await getAdminOrders(params) : null;
const rechargeData = activeTab === "recharge" ? await getAdminRechargeOrders(params) : null;
const filters = activeTab === "orders" ? orderData!.filters : rechargeData!.filters;
return ( return (
<PageShell> <PageShell>
@@ -23,24 +58,34 @@ export default async function OrdersPage({
eyebrow="商品与订单" eyebrow="商品与订单"
title="订单管理" title="订单管理"
/> />
<div className="surface-card flex w-fit flex-wrap gap-2 rounded-xl p-1">
<OrdersTabLink href="/admin/orders?tab=orders" active={activeTab === "orders"}>
</OrdersTabLink>
<OrdersTabLink href="/admin/orders?tab=recharge" active={activeTab === "recharge"}>
</OrdersTabLink>
</div>
<AdminFilterBar <AdminFilterBar
q={filters.q} q={filters.q}
searchPlaceholder="搜索邮箱、套餐、交易号" searchPlaceholder={activeTab === "orders" ? "搜索邮箱、套餐、交易号" : "搜索邮箱、交易号"}
selects={[ selects={[
{ {
name: "status", name: "status",
value: filters.status, value: filters.status,
options: [ options: [
{ label: "全部状态", value: "" }, { label: "全部状态", value: "" },
{ label: "待确认", value: "PENDING" }, { label: activeTab === "orders" ? "待确认" : "待支付", value: "PENDING" },
{ label: "已支付", value: "PAID" }, { label: activeTab === "orders" ? "已支付" : "已入账", value: "PAID" },
{ label: "已取消", value: "CANCELLED" }, { label: "已取消", value: "CANCELLED" },
{ label: "已退款", value: "REFUNDED" }, { label: "已退款", value: "REFUNDED" },
], ],
}, },
...(activeTab === "orders"
? [
{ {
name: "kind", name: "kind",
value: filters.kind, value: orderData!.filters.kind,
options: [ options: [
{ label: "全部类型", value: "" }, { label: "全部类型", value: "" },
{ label: "新购", value: "NEW_PURCHASE" }, { label: "新购", value: "NEW_PURCHASE" },
@@ -50,7 +95,7 @@ export default async function OrdersPage({
}, },
{ {
name: "reviewStatus", name: "reviewStatus",
value: filters.reviewStatus, value: orderData!.filters.reviewStatus,
options: [ options: [
{ label: "全部审查", value: "" }, { label: "全部审查", value: "" },
{ label: "正常", value: "NORMAL" }, { label: "正常", value: "NORMAL" },
@@ -58,10 +103,33 @@ export default async function OrdersPage({
{ label: "已解决", value: "RESOLVED" }, { label: "已解决", value: "RESOLVED" },
], ],
}, },
]
: []),
]} ]}
>
<input type="hidden" name="tab" value={activeTab} />
</AdminFilterBar>
{activeTab === "orders" ? (
<>
<OrdersTable orders={orderData!.orders} />
<Pagination
total={orderData!.total}
pageSize={orderData!.pageSize}
page={orderData!.page}
fixedParams={{ tab: "orders" }}
/> />
<OrdersTable orders={orders} /> </>
<Pagination total={total} pageSize={pageSize} page={page} /> ) : (
<>
<RechargeOrdersTable rechargeOrders={rechargeData!.rechargeOrders} />
<Pagination
total={rechargeData!.total}
pageSize={rechargeData!.pageSize}
page={rechargeData!.page}
fixedParams={{ tab: "recharge" }}
/>
</>
)}
</PageShell> </PageShell>
); );
} }

View File

@@ -0,0 +1,84 @@
"use client";
import { useRouter } from "next/navigation";
import { CheckCircle2, Trash2, XCircle } from "lucide-react";
import {
cancelAdminWalletRecharge,
confirmAdminWalletRecharge,
deleteAdminWalletRecharge,
} from "@/actions/admin/recharge-orders";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
type RechargeOrderStatus = "PENDING" | "PAID" | "CANCELLED" | "REFUNDED";
export function RechargeOrderActions({
orderId,
status,
}: {
orderId: string;
status: RechargeOrderStatus;
}) {
const router = useRouter();
const isPending = status === "PENDING";
const isPaid = status === "PAID";
return (
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
{isPending && (
<>
<ConfirmActionButton
title="确认这笔充值?"
description="会立即把充值金额入账到用户钱包,并将订单标记为已入账。"
confirmLabel="确认入账"
successMessage="充值订单已确认入账"
errorMessage="确认充值订单失败"
size="sm"
onConfirm={async () => {
await confirmAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<CheckCircle2 className="size-3.5" />
</ConfirmActionButton>
<ConfirmActionButton
title="取消这笔充值?"
description="取消后用户不能继续支付这笔充值订单,钱包余额不会变化。"
confirmLabel="取消充值"
successMessage="充值订单已取消"
errorMessage="取消充值订单失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await cancelAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<XCircle className="size-3.5" />
</ConfirmActionButton>
</>
)}
<ConfirmActionButton
title="删除这条充值记录?"
description={
isPaid
? "只删除充值订单记录,不回滚已入账余额,钱包流水会保留。"
: "删除后这条充值记录不可恢复,不会改变用户钱包余额。"
}
confirmLabel="删除记录"
successMessage="充值记录已删除"
errorMessage="删除充值记录失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await deleteAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
</ConfirmActionButton>
</div>
);
}

View File

@@ -2,13 +2,15 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react"; import { AlertTriangle, Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments"; import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
@@ -78,6 +80,7 @@ export function PaymentConfigItem({
const [enabled, setEnabled] = useState(initialEnabled); const [enabled, setEnabled] = useState(initialEnabled);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [balanceDisableOpen, setBalanceDisableOpen] = useState(false);
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() => const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
buildInitialCheckboxValues(fields, currentConfig), buildInitialCheckboxValues(fields, currentConfig),
); );
@@ -98,7 +101,7 @@ export function PaymentConfigItem({
}); });
} }
async function handleStatusToggle(nextEnabled: boolean) { async function commitStatusToggle(nextEnabled: boolean) {
if (statusSaving || enabled === nextEnabled) return; if (statusSaving || enabled === nextEnabled) return;
const previousEnabled = enabled; const previousEnabled = enabled;
@@ -121,6 +124,15 @@ export function PaymentConfigItem({
} }
} }
async function handleStatusToggle(nextEnabled: boolean) {
if (statusSaving || enabled === nextEnabled) return;
if (provider === "balance" && !nextEnabled) {
setBalanceDisableOpen(true);
return;
}
await commitStatusToggle(nextEnabled);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (saving || statusSaving) return; if (saving || statusSaving) return;
@@ -158,17 +170,18 @@ export function PaymentConfigItem({
} }
return ( return (
<>
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center"> <section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" /> <CreditCard className="size-4" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3> <h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>} {displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div> </div>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p> <p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && ( {checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => ( {checkboxSummaries.slice(0, 2).map((label) => (
@@ -196,22 +209,23 @@ export function PaymentConfigItem({
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto bg-card sm:max-w-3xl"> <DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary"> <div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
</div> </div>
<DialogTitle>{providerName}</DialogTitle> <DialogTitle>{providerName}</DialogTitle>
<DialogDescription>{providerDescription}</DialogDescription> <DialogDescription></DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5"> <DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-4 sm:grid-cols-2"> <form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) => {fields.map((field) =>
field.type === "checkboxes" ? ( field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2"> <div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label> <Label>{field.label}</Label>
<div className="mt-3 grid gap-2 sm:grid-cols-2"> <div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => { {field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false; const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return ( return (
@@ -221,13 +235,13 @@ export function PaymentConfigItem({
aria-pressed={selected} aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)} onClick={() => toggleCheckbox(field.key, option.value)}
className={cn( className={cn(
"flex min-h-10 items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20", "flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected selected
? "border-primary/35 bg-primary/10 text-primary" ? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground", : "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)} )}
> >
<span className="truncate">{option.label}</span> <span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />} {selected && <Check className="size-4 shrink-0" />}
</button> </button>
); );
@@ -235,28 +249,30 @@ export function PaymentConfigItem({
</div> </div>
</div> </div>
) : ( ) : (
<div key={field.key} className="space-y-2"> <div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label> <Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input <Input
id={`${provider}-${field.key}`} id={`${provider}-${field.key}`}
name={field.key} name={field.key}
type={field.secret ? "password" : "text"} type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空保持不变" : field.placeholder} placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""} defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/> />
{field.secret && secretConfigured[field.key] && ( {field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
)} )}
</div> </div>
), ),
)} )}
</div> </div>
<div className="rounded-lg border border-border bg-muted/20 p-3"> <div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<Label className="text-sm font-semibold"></Label> <div className="flex items-center gap-1.5">
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p> <Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
<div className="flex justify-start sm:justify-end"> <div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}> <StatusBadge tone={enabled ? "success" : "neutral"}>
@@ -266,17 +282,56 @@ export function PaymentConfigItem({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={saving}> <Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
</Button> </Button>
<Button type="submit" disabled={saving}> <Button type="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"} {saving ? "保存中..." : "保存配置"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</section> </section>
{provider === "balance" && (
<Dialog open={balanceDisableOpen} onOpenChange={(nextOpen) => !statusSaving && setBalanceDisableOpen(nextOpen)}>
<DialogContent className="max-w-[22rem]">
<DialogHeader>
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setBalanceDisableOpen(false)}
disabled={statusSaving}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
setBalanceDisableOpen(false);
void commitStatusToggle(false);
}}
disabled={statusSaving}
>
{statusSaving ? "处理中..." : "确认关闭"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
); );
} }

View File

@@ -20,6 +20,8 @@ export async function getPaymentProviderConfigs() {
enabled: config.enabled, enabled: config.enabled,
config: redactPaymentConfigForClient(provider.id, configValue ?? {}), config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
} }
: provider.id === "balance"
? { enabled: true, config: {} }
: null, : null,
secretConfigured: configValue secretConfigured: configValue
? getPaymentSecretConfiguredState(provider.id, configValue) ? getPaymentSecretConfiguredState(provider.id, configValue)

View File

@@ -10,10 +10,12 @@ export const PLAN_BATCH_FORM_ID = "plan-batch-form";
export function PlansList({ export function PlansList({
plans, plans,
activeCountMap, activeCountMap,
reservedCardCountMap,
services, services,
}: { }: {
plans: AdminPlanRow[]; plans: AdminPlanRow[];
activeCountMap: Map<string, number>; activeCountMap: Map<string, number>;
reservedCardCountMap: Map<string, number>;
services: StreamingServiceOption[]; services: StreamingServiceOption[];
}) { }) {
return ( return (
@@ -25,22 +27,25 @@ export function PlansList({
</BatchActionButton> </BatchActionButton>
</BatchActionBar> </BatchActionBar>
<div className="grid gap-5"> <div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{plans.map((plan) => ( {plans.map((plan) => (
<PlanCard <PlanCard
key={plan.id} key={plan.id}
plan={plan} plan={plan}
activeCount={activeCountMap.get(plan.id) ?? 0} activeCount={activeCountMap.get(plan.id) ?? 0}
reservedCardCount={reservedCardCountMap.get(plan.id) ?? 0}
services={services} services={services}
batchFormId={PLAN_BATCH_FORM_ID} batchFormId={PLAN_BATCH_FORM_ID}
/> />
))} ))}
{plans.length === 0 && ( {plans.length === 0 && (
<div className="p-5">
<EmptyState <EmptyState
title="暂无套餐" title="暂无套餐"
description="创建第一个套餐后,用户就可以在商店中购买。" description="创建第一个套餐后,用户就可以在商店中购买。"
action={<PlanForm services={services} triggerLabel="创建套餐" />} action={<PlanForm services={services} triggerLabel="创建套餐" />}
/> />
</div>
)} )}
</div> </div>
</> </>

View File

@@ -23,6 +23,7 @@ export default async function PlansPage({
pageSize, pageSize,
filters, filters,
activeCountMap, activeCountMap,
reservedCardCountMap,
serviceOptions, serviceOptions,
} = await getAdminPlans(await searchParams); } = await getAdminPlans(await searchParams);
@@ -60,6 +61,7 @@ export default async function PlansPage({
<PlansList <PlansList
plans={plans} plans={plans}
activeCountMap={activeCountMap} activeCountMap={activeCountMap}
reservedCardCountMap={reservedCardCountMap}
services={serviceOptions} services={serviceOptions}
/> />
<Pagination total={total} pageSize={pageSize} page={page} /> <Pagination total={total} pageSize={pageSize} page={page} />

View File

@@ -50,7 +50,7 @@ export function PlanActions({
variant="destructive" variant="destructive"
size="sm" size="sm"
title="彻底删除套餐?" title="彻底删除套餐?"
description="关联订阅、本地订单记录和可同步的独占入口会一起处理。此操作无法恢复。" description="会清理关联订阅和订单,无法恢复。"
confirmLabel="删除套餐" confirmLabel="删除套餐"
successMessage="套餐已删除" successMessage="套餐已删除"
errorMessage="删除失败" errorMessage="删除失败"

View File

@@ -51,10 +51,13 @@ export function PlanBasicsFields({
}: PlanBasicsFieldsProps) { }: PlanBasicsFieldsProps) {
return ( return (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("type")}></Label> <Label htmlFor={fieldId("type")}></Label>
{isEdit ? ( {isEdit ? (
<div id={fieldId("type")} className="premium-input flex h-11 items-center px-3 text-sm font-medium"> <div
id={fieldId("type")}
className="premium-input flex h-8 min-h-8 items-center px-2.5 text-xs font-medium"
>
{type === "PROXY" ? "代理节点套餐" : "流媒体套餐"} {type === "PROXY" ? "代理节点套餐" : "流媒体套餐"}
</div> </div>
) : ( ) : (
@@ -86,12 +89,12 @@ export function PlanBasicsFields({
)} )}
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("name")}></Label> <Label htmlFor={fieldId("name")}></Label>
<Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required /> <Input id={fieldId("name")} name="name" defaultValue={plan?.name ?? ""} required />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("durationDays")}></Label> <Label htmlFor={fieldId("durationDays")}></Label>
<Input <Input
id={fieldId("durationDays")} id={fieldId("durationDays")}
@@ -101,7 +104,7 @@ export function PlanBasicsFields({
required required
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("sortOrder")}></Label> <Label htmlFor={fieldId("sortOrder")}></Label>
<Input <Input
id={fieldId("sortOrder")} id={fieldId("sortOrder")}
@@ -113,14 +116,15 @@ export function PlanBasicsFields({
</div> </div>
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("description")}></Label> <Label htmlFor={fieldId("description")}></Label>
<Textarea <Textarea
id={fieldId("description")} id={fieldId("description")}
name="description" name="description"
rows={2} rows={2}
defaultValue={plan?.description ?? ""} defaultValue={plan?.description ?? ""}
placeholder="适合的使用场景、交付方式与体验边界" placeholder="展示给用户"
className="min-h-16 px-2.5 py-2 text-xs leading-5"
/> />
</div> </div>
</> </>
@@ -135,8 +139,8 @@ export function PlanLimitsFields({
plan?: PlanFormValue; plan?: PlanFormValue;
}) { }) {
return ( return (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("totalLimit")}></Label> <Label htmlFor={fieldId("totalLimit")}></Label>
<Input <Input
id={fieldId("totalLimit")} id={fieldId("totalLimit")}
@@ -144,10 +148,10 @@ export function PlanLimitsFields({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.totalLimit ?? ""} defaultValue={plan?.totalLimit ?? ""}
placeholder="空=不限" placeholder="空=不限"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("perUserLimit")}></Label> <Label htmlFor={fieldId("perUserLimit")}></Label>
<Input <Input
id={fieldId("perUserLimit")} id={fieldId("perUserLimit")}
@@ -155,7 +159,7 @@ export function PlanLimitsFields({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.perUserLimit ?? ""} defaultValue={plan?.perUserLimit ?? ""}
placeholder="空=不限" placeholder="空=不限"
/> />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,5 @@
import { Network, Tv } from "lucide-react"; import { Network, Tv } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import { DetailItem, DetailList } from "@/components/admin/detail-list";
import { import {
PlanFormValue, PlanFormValue,
type StreamingServiceOption, type StreamingServiceOption,
@@ -57,6 +55,7 @@ interface PlanListItem {
interface PlanCardProps { interface PlanCardProps {
plan: PlanListItem; plan: PlanListItem;
activeCount: number; activeCount: number;
reservedCardCount: number;
services: StreamingServiceOption[]; services: StreamingServiceOption[];
batchFormId: string; batchFormId: string;
} }
@@ -65,27 +64,15 @@ function toNumber(value: NumericLike): number | null {
return value == null ? null : Number(value); return value == null ? null : Number(value);
} }
function money(value: NumericLike): string { function remainingStockSummary(plan: PlanListItem, activeCount: number, reservedCardCount: number) {
return `¥${Number(value ?? 0).toFixed(2)}`; if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
}
function renewalSummary(plan: PlanListItem) { const remaining = Math.max(0, plan.totalLimit - activeCount - reservedCardCount);
if (!plan.allowRenewal) return "续费关闭"; return {
if (plan.renewalPricingMode === "PER_DAY") { value: remaining.toString(),
return `${money(plan.renewalPrice)}/天 · ${plan.renewalMinDays ?? 1}-${plan.renewalMaxDays ?? plan.durationDays}`; hint: reservedCardCount > 0 ? `预占 ${reservedCardCount}` : remaining === 0 ? "已售罄" : "剩余库存",
} empty: remaining === 0,
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 { function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
@@ -126,106 +113,50 @@ function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
}; };
} }
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) { export function PlanCard({ plan, activeCount, reservedCardCount, services, batchFormId }: PlanCardProps) {
const remaining = plan.totalLimit == null ? null : Math.max(0, plan.totalLimit - activeCount);
const planFormValue = buildPlanFormValue(plan); const planFormValue = buildPlanFormValue(plan);
const stock = remainingStockSummary(plan, activeCount, reservedCardCount);
const Icon = plan.type === "PROXY" ? Network : Tv; const Icon = plan.type === "PROXY" ? Network : Tv;
return ( return (
<Card> <section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<CardHeader className="gap-4"> <div className="flex min-w-0 items-center gap-3">
<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 <input
form={batchFormId} form={batchFormId}
type="checkbox" type="checkbox"
name="planIds" name="planIds"
value={plan.id} value={plan.id}
aria-label={`选择套餐 ${plan.name}`} aria-label={`选择套餐 ${plan.name}`}
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm" className="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"> <span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Icon className="size-5" /> <Icon className="size-4" />
</span> </span>
<div className="min-w-0 space-y-1.5"> <div className="min-w-0">
<CardTitle className="text-lg text-balance">{plan.name}</CardTitle> <div className="flex min-h-6 flex-wrap items-center gap-2">
<p className="text-sm leading-6 text-muted-foreground text-pretty"> <h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">{plan.name}</h3>
{plan.description || "无描述"} · {plan._count.subscriptions} <StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
</p> {plan.type === "PROXY" ? "代理" : "流媒体"}
</StatusBadge>
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
</div> </div>
</div> </div>
</div>
<div className="inline-flex w-fit items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 lg:justify-self-end">
<span className={`text-lg font-semibold tabular-nums ${stock.empty ? "text-destructive" : "text-foreground"}`}>
{stock.value}
</span>
<span className="text-xs font-medium text-muted-foreground">{stock.hint}</span>
</div>
<div className="flex justify-start lg:justify-end">
<PlanActions <PlanActions
isActive={plan.isActive} isActive={plan.isActive}
services={services} services={services}
plan={planFormValue} plan={planFormValue}
/> />
</div> </div>
</section>
<div className="flex flex-wrap items-center gap-2">
<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

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -31,8 +32,8 @@ export type { PlanFormValue, StreamingServiceOption } from "./plan-form-types";
function FormSection({ title, children }: { title: string; children: ReactNode }) { function FormSection({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<fieldset className="space-y-4 rounded-lg border border-border bg-muted/20 p-4"> <fieldset className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
<legend className="px-1.5 text-sm font-semibold">{title}</legend> <legend className="px-1.5 text-xs font-semibold">{title}</legend>
{children} {children}
</fieldset> </fieldset>
); );
@@ -171,13 +172,17 @@ export function PlanForm({
> >
{triggerLabel ?? (isEdit ? "编辑" : "创建套餐")} {triggerLabel ?? (isEdit ? "编辑" : "创建套餐")}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-5xl"> <DialogContent className="flex max-h-[min(90dvh,42rem)] flex-col overflow-hidden p-0 sm:max-w-[56rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="grid gap-4 lg:grid-cols-2"> <DialogBody className="flex-1 px-4 py-3">
<form
onSubmit={(event) => void handleSubmit(event)}
className="grid gap-3 text-[12px] leading-5 lg:grid-cols-2 [&_[data-slot=button]]:text-xs [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs"
>
{/* Left column: basics + resource config */} {/* Left column: basics + resource config */}
<div className="space-y-4"> <div className="space-y-3">
<FormSection title="基础信息"> <FormSection title="基础信息">
<PlanBasicsFields <PlanBasicsFields
fieldId={fieldId} fieldId={fieldId}
@@ -224,7 +229,7 @@ export function PlanForm({
</div> </div>
{/* Right column: pricing (proxy only) + sales policy + submit */} {/* Right column: pricing (proxy only) + sales policy + submit */}
<div className="space-y-4"> <div className="space-y-3">
{type === "PROXY" && ( {type === "PROXY" && (
<FormSection title="定价"> <FormSection title="定价">
<ProxyPricingFields <ProxyPricingFields
@@ -232,7 +237,6 @@ export function PlanForm({
plan={plan} plan={plan}
pricingMode={pricingMode} pricingMode={pricingMode}
setPricingMode={setPricingMode} setPricingMode={setPricingMode}
allowTrafficTopup={allowTrafficTopup}
/> />
</FormSection> </FormSection>
)} )}
@@ -254,11 +258,12 @@ export function PlanForm({
/> />
</FormSection> </FormSection>
<Button type="submit" size="lg" className="w-full" disabled={submitting}> <Button type="submit" className="h-8 w-full" disabled={submitting}>
{submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")} {submitting ? "提交中..." : (isEdit ? "保存套餐" : "创建套餐")}
</Button> </Button>
</div> </div>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, ReactNode, SetStateAction } from "react";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { InlineHelp } from "@/components/ui/inline-help";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -31,6 +32,30 @@ interface PlanPolicySectionProps {
setTopupPricingMode: Dispatch<SetStateAction<TopupPricingMode>>; setTopupPricingMode: Dispatch<SetStateAction<TopupPricingMode>>;
} }
function PolicyToggleRow({
labelId,
label,
help,
children,
}: {
labelId: string;
label: string;
help: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-2 rounded-md bg-muted/20 p-2 sm:min-h-10 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-fit shrink-0 items-center gap-1.5">
<p id={labelId} className="whitespace-nowrap text-xs font-medium">
{label}
</p>
<InlineHelp align="start">{help}</InlineHelp>
</div>
<div className="w-full sm:w-32 sm:shrink-0">{children}</div>
</div>
);
}
export function PlanPolicySection({ export function PlanPolicySection({
fieldId, fieldId,
type, type,
@@ -46,45 +71,43 @@ export function PlanPolicySection({
}: PlanPolicySectionProps) { }: PlanPolicySectionProps) {
return ( return (
<> <>
<div className="form-panel grid gap-4 sm:grid-cols-2"> <div className="grid gap-2 rounded-lg border border-border bg-card/75 p-2 shadow-[var(--shadow-soft)] xl:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3"> <PolicyToggleRow
<div> labelId={fieldId("allowRenewal-label")}
<p id={fieldId("allowRenewal-label")} className="text-sm font-medium"></p> label="开放续费"
<p className="text-xs text-muted-foreground"></p> help="用户可在订阅页自助续费。"
</div> >
<div className="w-40">
<BooleanToggle <BooleanToggle
value={allowRenewal} value={allowRenewal}
onChange={setAllowRenewal} onChange={setAllowRenewal}
trueLabel="开放" trueLabel="开放"
falseLabel="关闭" falseLabel="关闭"
ariaLabel="开放续费" ariaLabel="开放续费"
size="compact"
/> />
</div> </PolicyToggleRow>
</div>
{type === "PROXY" && ( {type === "PROXY" && (
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3"> <PolicyToggleRow
<div> labelId={fieldId("allowTrafficTopup-label")}
<p id={fieldId("allowTrafficTopup-label")} className="text-sm font-medium"></p> label="开放增流量"
<p className="text-xs text-muted-foreground"> GB</p> help="用户可在订阅页自助购买额外流量。"
</div> >
<div className="w-40">
<BooleanToggle <BooleanToggle
value={allowTrafficTopup} value={allowTrafficTopup}
onChange={setAllowTrafficTopup} onChange={setAllowTrafficTopup}
trueLabel="开放" trueLabel="开放"
falseLabel="关闭" falseLabel="关闭"
ariaLabel="开放增流量" ariaLabel="开放增流量"
size="compact"
/> />
</div> </PolicyToggleRow>
</div>
)} )}
</div> </div>
{allowRenewal && ( {allowRenewal && (
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4"> <div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPricingMode")}></Label> <Label htmlFor={fieldId("renewalPricingMode")}></Label>
<input type="hidden" name="renewalPricingMode" value={renewalPricingMode} /> <input type="hidden" name="renewalPricingMode" value={renewalPricingMode} />
<Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}> <Select value={renewalPricingMode} onValueChange={(value) => setRenewalPricingMode(value as RenewalPricingMode)}>
@@ -99,7 +122,7 @@ export function PlanPolicySection({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalPrice")}> <Label htmlFor={fieldId("renewalPrice")}>
{renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"} {renewalPricingMode === "PER_DAY" ? "续费价格(¥/天)" : "续费价格(¥/周期)"}
</Label> </Label>
@@ -111,14 +134,14 @@ export function PlanPolicySection({
min={0.01} min={0.01}
required required
defaultValue={plan?.renewalPrice ?? ""} defaultValue={plan?.renewalPrice ?? ""}
placeholder={renewalPricingMode === "PER_DAY" ? "例如 1" : "例如 29.9"} placeholder={renewalPricingMode === "PER_DAY" ? "1" : "29.9"}
/> />
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{renewalPricingMode === "FIXED_DURATION" ? ( {renewalPricingMode === "FIXED_DURATION" ? (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalDurationDays")}></Label> <Label htmlFor={fieldId("renewalDurationDays")}></Label>
<Input <Input
id={fieldId("renewalDurationDays")} id={fieldId("renewalDurationDays")}
@@ -127,12 +150,12 @@ export function PlanPolicySection({
min={1} min={1}
required required
defaultValue={plan?.renewalDurationDays ?? plan?.durationDays ?? ""} defaultValue={plan?.renewalDurationDays ?? plan?.durationDays ?? ""}
placeholder="例如 30" placeholder="30"
/> />
</div> </div>
) : ( ) : (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMinDays")}></Label> <Label htmlFor={fieldId("renewalMinDays")}></Label>
<Input <Input
id={fieldId("renewalMinDays")} id={fieldId("renewalMinDays")}
@@ -140,10 +163,10 @@ export function PlanPolicySection({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.renewalMinDays ?? ""} defaultValue={plan?.renewalMinDays ?? ""}
placeholder="例如 1" placeholder="1"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("renewalMaxDays")}></Label> <Label htmlFor={fieldId("renewalMaxDays")}></Label>
<Input <Input
id={fieldId("renewalMaxDays")} id={fieldId("renewalMaxDays")}
@@ -151,7 +174,7 @@ export function PlanPolicySection({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.renewalMaxDays ?? ""} defaultValue={plan?.renewalMaxDays ?? ""}
placeholder="例如 180" placeholder="180"
/> />
</div> </div>
</> </>
@@ -161,9 +184,9 @@ export function PlanPolicySection({
)} )}
{type === "PROXY" && allowTrafficTopup && ( {type === "PROXY" && allowTrafficTopup && (
<div className="space-y-3 rounded-xl border border-border bg-muted/20 p-4"> <div className="space-y-2 rounded-lg border border-border bg-muted/15 p-3">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricingMode")}></Label> <Label htmlFor={fieldId("topupPricingMode")}></Label>
<input type="hidden" name="topupPricingMode" value={topupPricingMode} /> <input type="hidden" name="topupPricingMode" value={topupPricingMode} />
<Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}> <Select value={topupPricingMode} onValueChange={(value) => setTopupPricingMode(value as TopupPricingMode)}>
@@ -179,7 +202,7 @@ export function PlanPolicySection({
</Select> </Select>
</div> </div>
{topupPricingMode === "PER_GB" ? ( {topupPricingMode === "PER_GB" ? (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupPricePerGb")}>¥/GB</Label> <Label htmlFor={fieldId("topupPricePerGb")}>¥/GB</Label>
<Input <Input
id={fieldId("topupPricePerGb")} id={fieldId("topupPricePerGb")}
@@ -189,11 +212,11 @@ export function PlanPolicySection({
min={0.01} min={0.01}
required required
defaultValue={plan?.topupPricePerGb ?? ""} defaultValue={plan?.topupPricePerGb ?? ""}
placeholder="例如 0.8" placeholder="0.8"
/> />
</div> </div>
) : ( ) : (
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("topupFixedPrice")}>¥</Label> <Label htmlFor={fieldId("topupFixedPrice")}>¥</Label>
<Input <Input
id={fieldId("topupFixedPrice")} id={fieldId("topupFixedPrice")}
@@ -203,14 +226,14 @@ export function PlanPolicySection({
min={0.01} min={0.01}
required required
defaultValue={plan?.topupFixedPrice ?? ""} defaultValue={plan?.topupFixedPrice ?? ""}
placeholder="例如 9.9" placeholder="9.9"
/> />
</div> </div>
)} )}
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("minTopupGb")}>GB</Label> <Label htmlFor={fieldId("minTopupGb")}>GB</Label>
<Input <Input
id={fieldId("minTopupGb")} id={fieldId("minTopupGb")}
@@ -218,10 +241,10 @@ export function PlanPolicySection({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.minTopupGb ?? ""} defaultValue={plan?.minTopupGb ?? ""}
placeholder="默认 1" placeholder="1"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("maxTopupGb")}>GB</Label> <Label htmlFor={fieldId("maxTopupGb")}>GB</Label>
<Input <Input
id={fieldId("maxTopupGb")} id={fieldId("maxTopupGb")}
@@ -229,7 +252,7 @@ export function PlanPolicySection({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.maxTopupGb ?? ""} defaultValue={plan?.maxTopupGb ?? ""}
placeholder="空=按流量池剩余额度" placeholder="空=余量"
/> />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils"; import { parsePage } from "@/lib/utils";
import { activePlanCardReservationWhere } from "@/services/plan-availability";
import type { StreamingServiceOption } from "./plan-form"; import type { StreamingServiceOption } from "./plan-form";
const planInclude = { const planInclude = {
@@ -43,7 +44,7 @@ export async function getAdminPlans(
: {}), : {}),
} satisfies Prisma.SubscriptionPlanWhereInput; } satisfies Prisma.SubscriptionPlanWhereInput;
const [plans, total, services, activeGroups] = await Promise.all([ const [plans, total, services, activeGroups, reservedCardGroups] = await Promise.all([
prisma.subscriptionPlan.findMany({ prisma.subscriptionPlan.findMany({
where, where,
include: planInclude, include: planInclude,
@@ -62,11 +63,22 @@ export async function getAdminPlans(
where: { status: "ACTIVE" }, where: { status: "ACTIVE" },
_count: { _all: true }, _count: { _all: true },
}), }),
prisma.rechargeCard.groupBy({
by: ["planId"],
where: {
...activePlanCardReservationWhere(),
planId: { not: null },
},
_count: { _all: true },
}),
]); ]);
const activeCountMap = new Map( const activeCountMap = new Map(
activeGroups.map((item) => [item.planId, item._count._all]), activeGroups.map((item) => [item.planId, item._count._all]),
); );
const reservedCardCountMap = new Map(
reservedCardGroups.map((item) => [item.planId ?? "", item._count._all]),
);
const serviceOptions: StreamingServiceOption[] = services.map((service) => ({ const serviceOptions: StreamingServiceOption[] = services.map((service) => ({
id: service.id, id: service.id,
name: service.name, name: service.name,
@@ -80,6 +92,7 @@ export async function getAdminPlans(
pageSize, pageSize,
filters: { q, type, status }, filters: { q, type, status },
activeCountMap, activeCountMap,
reservedCardCountMap,
serviceOptions, serviceOptions,
}; };
} }

View File

@@ -43,7 +43,7 @@ export function ProxyNodeFields({
}) { }) {
return ( return (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("nodeId")}></Label> <Label htmlFor={fieldId("nodeId")}></Label>
<Select <Select
value={nodeId} value={nodeId}
@@ -71,7 +71,7 @@ export function ProxyNodeFields({
</Select> </Select>
</div> </div>
<div> <div className="space-y-1.5">
<Label id={fieldId("inboundIds-label")}></Label> <Label id={fieldId("inboundIds-label")}></Label>
<input type="hidden" name="inboundIds" value={selectedInboundIds.join(",")} /> <input type="hidden" name="inboundIds" value={selectedInboundIds.join(",")} />
<div className="grid gap-2 sm:grid-cols-2" role="group" aria-labelledby={fieldId("inboundIds-label")}> <div className="grid gap-2 sm:grid-cols-2" role="group" aria-labelledby={fieldId("inboundIds-label")}>
@@ -82,17 +82,17 @@ export function ProxyNodeFields({
key={inbound.id} key={inbound.id}
type="button" type="button"
className={cn( className={cn(
"choice-card text-left px-3 py-2.5 text-sm", "choice-card flex min-h-8 items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs leading-4",
selected selected
? "border-primary/30 bg-primary/10 text-primary" ? "border-primary/30 bg-primary/10 text-primary"
: "hover:bg-muted/45", : "hover:bg-muted/45",
)} )}
onClick={() => toggleInbound(inbound.id)} onClick={() => toggleInbound(inbound.id)}
> >
<p className="font-medium"> <span className="shrink-0 font-medium leading-4">
{inbound.protocol} · {inbound.port} {inbound.protocol} · {inbound.port}
</p> </span>
<p className="text-xs text-muted-foreground">{inbound.tag}</p> <span className="min-w-0 truncate text-[11px] leading-4 text-muted-foreground">{inbound.tag}</span>
</button> </button>
); );
})} })}
@@ -110,13 +110,11 @@ export function ProxyPricingFields({
plan, plan,
pricingMode, pricingMode,
setPricingMode, setPricingMode,
allowTrafficTopup,
}: { }: {
fieldId: FieldId; fieldId: FieldId;
plan?: PlanFormValue; plan?: PlanFormValue;
pricingMode: PlanPricingMode; pricingMode: PlanPricingMode;
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>; setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
allowTrafficTopup: boolean;
}) { }) {
const pricingModeLabels: Record<string, string> = { const pricingModeLabels: Record<string, string> = {
TRAFFIC_SLIDER: "用户自选流量", TRAFFIC_SLIDER: "用户自选流量",
@@ -125,7 +123,7 @@ export function ProxyPricingFields({
return ( return (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("pricingMode")}></Label> <Label htmlFor={fieldId("pricingMode")}></Label>
<Select value={pricingMode} onValueChange={(value) => setPricingMode(value as PlanPricingMode)}> <Select value={pricingMode} onValueChange={(value) => setPricingMode(value as PlanPricingMode)}>
<SelectTrigger id={fieldId("pricingMode")}> <SelectTrigger id={fieldId("pricingMode")}>
@@ -141,8 +139,8 @@ export function ProxyPricingFields({
</div> </div>
{pricingMode === "TRAFFIC_SLIDER" ? ( {pricingMode === "TRAFFIC_SLIDER" ? (
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("pricePerGb")}>¥/GB</Label> <Label htmlFor={fieldId("pricePerGb")}>¥/GB</Label>
<Input <Input
id={fieldId("pricePerGb")} id={fieldId("pricePerGb")}
@@ -150,33 +148,33 @@ export function ProxyPricingFields({
type="number" type="number"
step="0.01" step="0.01"
defaultValue={plan?.pricePerGb ?? ""} defaultValue={plan?.pricePerGb ?? ""}
placeholder="例如 0.5" placeholder="0.5"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("minTrafficGb")}> GB</Label> <Label htmlFor={fieldId("minTrafficGb")}> GB</Label>
<Input <Input
id={fieldId("minTrafficGb")} id={fieldId("minTrafficGb")}
name="minTrafficGb" name="minTrafficGb"
type="number" type="number"
defaultValue={plan?.minTrafficGb ?? ""} defaultValue={plan?.minTrafficGb ?? ""}
placeholder="例如 10" placeholder="10"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("maxTrafficGb")}> GB</Label> <Label htmlFor={fieldId("maxTrafficGb")}> GB</Label>
<Input <Input
id={fieldId("maxTrafficGb")} id={fieldId("maxTrafficGb")}
name="maxTrafficGb" name="maxTrafficGb"
type="number" type="number"
defaultValue={plan?.maxTrafficGb ?? ""} defaultValue={plan?.maxTrafficGb ?? ""}
placeholder="例如 1000" placeholder="1000"
/> />
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("fixedTrafficGb")}>GB</Label> <Label htmlFor={fieldId("fixedTrafficGb")}>GB</Label>
<Input <Input
id={fieldId("fixedTrafficGb")} id={fieldId("fixedTrafficGb")}
@@ -184,10 +182,10 @@ export function ProxyPricingFields({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.fixedTrafficGb ?? plan?.minTrafficGb ?? ""} defaultValue={plan?.fixedTrafficGb ?? plan?.minTrafficGb ?? ""}
placeholder="例如 200" placeholder="200"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("fixedPrice")}>¥</Label> <Label htmlFor={fieldId("fixedPrice")}>¥</Label>
<Input <Input
id={fieldId("fixedPrice")} id={fieldId("fixedPrice")}
@@ -196,13 +194,13 @@ export function ProxyPricingFields({
step="0.01" step="0.01"
min={0.01} min={0.01}
defaultValue={plan?.fixedPrice ?? ""} defaultValue={plan?.fixedPrice ?? ""}
placeholder="例如 29.9" placeholder="29.9"
/> />
</div> </div>
</div> </div>
)} )}
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("totalTrafficGb")}>GB</Label> <Label htmlFor={fieldId("totalTrafficGb")}>GB</Label>
<Input <Input
id={fieldId("totalTrafficGb")} id={fieldId("totalTrafficGb")}
@@ -210,13 +208,8 @@ export function ProxyPricingFields({
type="number" type="number"
min={1} min={1}
defaultValue={plan?.totalTrafficGb ?? ""} defaultValue={plan?.totalTrafficGb ?? ""}
placeholder="空=无限流量" placeholder="空=不限"
/> />
{allowTrafficTopup && (
<p className="mt-1.5 text-xs text-muted-foreground">
</p>
)}
</div> </div>
</> </>
); );
@@ -234,7 +227,6 @@ export function ProxyConfigSection(props: {
selectedInboundIds: string[]; selectedInboundIds: string[];
setSelectedInboundIds: Dispatch<SetStateAction<string[]>>; setSelectedInboundIds: Dispatch<SetStateAction<string[]>>;
toggleInbound: (inboundId: string) => void; toggleInbound: (inboundId: string) => void;
allowTrafficTopup: boolean;
pricingMode: PlanPricingMode; pricingMode: PlanPricingMode;
setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>; setPricingMode: Dispatch<SetStateAction<PlanPricingMode>>;
}) { }) {

View File

@@ -34,7 +34,7 @@ export function StreamingConfigSection({
}: StreamingConfigSectionProps) { }: StreamingConfigSectionProps) {
return ( return (
<> <>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("streamingServiceId")}></Label> <Label htmlFor={fieldId("streamingServiceId")}></Label>
<Select <Select
value={streamingServiceId} value={streamingServiceId}
@@ -72,7 +72,7 @@ export function StreamingConfigSection({
)} )}
</div> </div>
<div> <div className="space-y-1.5">
<Label htmlFor={fieldId("price")}>¥</Label> <Label htmlFor={fieldId("price")}>¥</Label>
<Input <Input
id={fieldId("price")} id={fieldId("price")}

View File

@@ -20,7 +20,7 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
<DataTableShell <DataTableShell
isEmpty={services.length === 0} isEmpty={services.length === 0}
emptyTitle="暂无流媒体服务" emptyTitle="暂无流媒体服务"
emptyDescription="添加服务后,流媒体套餐才能分配共享槽位。" emptyDescription="添加后可分配共享槽位。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="service-batch-form" id="service-batch-form"
@@ -44,7 +44,7 @@ export function ServicesTable({ services }: { services: StreamingServiceRow[] })
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="break-words text-sm font-semibold">{service.name}</p> <p className="break-words text-sm font-semibold">{service.name}</p>
<p className="mt-1 line-clamp-2 break-words text-xs text-muted-foreground">{service.description || "无描述"}</p> <p className="mt-1 break-words text-xs text-muted-foreground">{service.description || "未填写说明"}</p>
</div> </div>
<ActiveStatusBadge active={service.isActive} /> <ActiveStatusBadge active={service.isActive} />
</div> </div>

View File

@@ -32,7 +32,7 @@ export function ServiceActions({ service }: { service: StreamingService }) {
size="sm" size="sm"
variant="destructive" variant="destructive"
title="删除这个服务?" title="删除这个服务?"
description="删除后无法恢复。请确认没有正在使用这个服务的共享名额。" description="请确认没有名额正在使用。"
confirmLabel="删除服务" confirmLabel="删除服务"
successMessage="服务已删除" successMessage="服务已删除"
errorMessage="删除失败" errorMessage="删除失败"

View File

@@ -9,7 +9,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
Dialog, Dialog,
DialogBody,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@@ -50,17 +52,18 @@ export function ServiceForm({
<DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}> <DialogTrigger render={<Button variant={triggerVariant} size={isEdit ? "sm" : "default"} />}>
{triggerLabel ?? (isEdit ? "编辑" : "添加服务")} {triggerLabel ?? (isEdit ? "编辑" : "添加服务")}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader> <DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle> <DialogTitle>{isEdit ? "编辑流媒体服务" : "添加流媒体服务"}</DialogTitle>
<p className="text-sm leading-6 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="form-panel space-y-5"> <DialogBody className="flex-1 px-4 py-3">
<div> <form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_textarea]:!text-xs">
<div className="space-y-1.5">
<Label> ( Netflix)</Label> <Label> ( Netflix)</Label>
<Input name="name" defaultValue={service?.name} required /> <Input name="name" defaultValue={service?.name} required />
</div> </div>
<div> <div className="space-y-1.5">
<Label> ()</Label> <Label> ()</Label>
<Textarea <Textarea
name="credentials" name="credentials"
@@ -68,23 +71,27 @@ export function ServiceForm({
defaultValue="" defaultValue=""
placeholder={ placeholder={
isEdit isEdit
? "重新输入最新凭据,不留空" ? "重新输入凭据"
: "email: xxx&#10;password: xxx" : "email: xxx&#10;password: xxx"
} }
className="min-h-20 px-2.5 py-2 text-xs leading-5"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label></Label> <Label></Label>
<Input name="maxSlots" type="number" defaultValue={service?.maxSlots ?? 5} required /> <Input name="maxSlots" type="number" defaultValue={service?.maxSlots ?? 5} required />
</div> </div>
<div> <div className="space-y-1.5">
<Label></Label> <Label></Label>
<Input name="description" defaultValue={service?.description ?? ""} /> <Input name="description" defaultValue={service?.description ?? ""} />
</div> </div>
<PendingSubmitButton size="lg" className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}> <DialogFooter className="-mx-4 -mb-3">
<PendingSubmitButton className="h-8 w-full sm:w-auto" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"} {isEdit ? "保存" : "创建"}
</PendingSubmitButton> </PendingSubmitButton>
</DialogFooter>
</form> </form>
</DialogBody>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -62,6 +62,11 @@ export default async function AdminSettingsPage() {
inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardEnabled: config.inviteRewardEnabled,
inviteRewardRate: Number(config.inviteRewardRate), inviteRewardRate: Number(config.inviteRewardRate),
inviteRewardCouponId: config.inviteRewardCouponId, inviteRewardCouponId: config.inviteRewardCouponId,
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
subscriptionTransferFee: Number(config.subscriptionTransferFee),
subscriptionTransferLimitPerCycle: config.subscriptionTransferLimitPerCycle,
subscriptionTransferMinRemainingDays: config.subscriptionTransferMinRemainingDays,
subscriptionTransferMinRemainingTrafficGb: config.subscriptionTransferMinRemainingTrafficGb,
turnstileSiteKey: config.turnstileSiteKey, turnstileSiteKey: config.turnstileSiteKey,
turnstileSecretConfigured: Boolean(config.turnstileSecretKey), turnstileSecretConfigured: Boolean(config.turnstileSecretKey),
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,

View File

@@ -1,14 +1,22 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react"; import { ArrowRightLeft, Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs"; import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { InlineHelp } from "@/components/ui/inline-help";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
saveAppSettings, saveAppSettings,
@@ -64,6 +72,11 @@ interface AppConfig {
inviteRewardEnabled: boolean; inviteRewardEnabled: boolean;
inviteRewardRate: number; inviteRewardRate: number;
inviteRewardCouponId: string | null; inviteRewardCouponId: string | null;
subscriptionTransferEnabled: boolean;
subscriptionTransferFee: number;
subscriptionTransferLimitPerCycle: number;
subscriptionTransferMinRemainingDays: number;
subscriptionTransferMinRemainingTrafficGb: number;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
turnstileSecretConfigured: boolean; turnstileSecretConfigured: boolean;
smtpEnabled: boolean; smtpEnabled: boolean;
@@ -81,7 +94,6 @@ interface CouponOption {
name: string; name: string;
} }
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
const sectionClassName = "surface-card scroll-mt-24 space-y-4 rounded-xl p-4"; const sectionClassName = "surface-card scroll-mt-24 space-y-4 rounded-xl p-4";
const sectionHeadingClassName = "flex items-center gap-2 text-sm font-semibold"; const sectionHeadingClassName = "flex items-center gap-2 text-sm font-semibold";
@@ -95,6 +107,7 @@ type SettingsSectionValue =
| "auth" | "auth"
| "email" | "email"
| "invite" | "invite"
| "transfer"
| "turnstile" | "turnstile"
| "notices"; | "notices";
@@ -108,6 +121,7 @@ const settingsNavItems = [
{ value: "auth", label: "注册" }, { value: "auth", label: "注册" },
{ value: "email", label: "邮件" }, { value: "email", label: "邮件" },
{ value: "invite", label: "邀请" }, { value: "invite", label: "邀请" },
{ value: "transfer", label: "转让" },
{ value: "turnstile", label: "验证" }, { value: "turnstile", label: "验证" },
{ value: "notices", label: "公告" }, { value: "notices", label: "公告" },
] satisfies Array<{ value: SettingsSectionValue; label: string }>; ] satisfies Array<{ value: SettingsSectionValue; label: string }>;
@@ -126,6 +140,27 @@ type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels = booleanAppSettingLabels; const booleanSettingLabels = booleanAppSettingLabels;
function LabelWithHelp({
htmlFor,
children,
help,
align = "start",
}: {
htmlFor?: string;
children: ReactNode;
help: ReactNode;
align?: "start" | "center" | "end";
}) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Label htmlFor={htmlFor} className="whitespace-nowrap">
{children}
</Label>
<InlineHelp align={align}>{help}</InlineHelp>
</div>
);
}
function initialToggleValues(config: AppConfig): ToggleValues { function initialToggleValues(config: AppConfig): ToggleValues {
return { return {
allowRegistration: config.allowRegistration, allowRegistration: config.allowRegistration,
@@ -140,6 +175,7 @@ function initialToggleValues(config: AppConfig): ToggleValues {
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled, nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardEnabled: config.inviteRewardEnabled,
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,
smtpSecure: config.smtpSecure, smtpSecure: config.smtpSecure,
}; };
@@ -317,8 +353,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Settings2 className="size-5" /> <Settings2 className="size-5" />
</span> </span>
<div> <div>
<div className="flex items-center gap-1.5">
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p> <InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</div> </div>
<nav className="flex gap-2 overflow-x-auto pb-1" aria-label="设置分组"> <nav className="flex gap-2 overflow-x-auto pb-1" aria-label="设置分组">
@@ -349,14 +387,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Input id="siteName" name="siteName" defaultValue={config.siteName} required /> <Input id="siteName" name="siteName" defaultValue={config.siteName} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="siteUrl"> URL</Label> <LabelWithHelp htmlFor="siteUrl" help="登录、邮件和支付回跳使用。">
URL
</LabelWithHelp>
<Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://panel.example.com" /> <Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://panel.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> Agent </p>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="subscriptionUrl"> URL</Label> <LabelWithHelp htmlFor="subscriptionUrl" help="客户端订阅链接使用。">
URL
</LabelWithHelp>
<Input id="subscriptionUrl" name="subscriptionUrl" defaultValue={config.subscriptionUrl ?? ""} placeholder="https://sub.example.com" /> <Input id="subscriptionUrl" name="subscriptionUrl" defaultValue={config.subscriptionUrl ?? ""} placeholder="https://sub.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> URL 使 sub 便 Cloudflare/WAF 访</p>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="supportContact"></Label> <Label htmlFor="supportContact"></Label>
@@ -371,7 +411,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="supportOpenTicketLimit"></Label> <LabelWithHelp htmlFor="supportOpenTicketLimit" help="同一用户未关闭工单数。">
</LabelWithHelp>
<Input <Input
id="supportOpenTicketLimit" id="supportOpenTicketLimit"
name="supportOpenTicketLimit" name="supportOpenTicketLimit"
@@ -381,9 +423,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
step={1} step={1}
defaultValue={config.supportOpenTicketLimit} defaultValue={config.supportOpenTicketLimit}
/> />
<p className="text-xs leading-5 text-muted-foreground">
2
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -406,7 +445,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })} {renderImmediateToggle("trafficSyncEnabled", { id: "trafficSyncEnabled" })}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="trafficSyncIntervalSeconds"></Label> <LabelWithHelp htmlFor="trafficSyncIntervalSeconds" help="最低 10 秒。">
</LabelWithHelp>
<Input <Input
id="trafficSyncIntervalSeconds" id="trafficSyncIntervalSeconds"
name="trafficSyncIntervalSeconds" name="trafficSyncIntervalSeconds"
@@ -416,7 +457,6 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
defaultValue={config.trafficSyncIntervalSeconds} defaultValue={config.trafficSyncIntervalSeconds}
placeholder="60" placeholder="60"
/> />
<p className="text-xs leading-5 text-muted-foreground"> 60 10 </p>
</div> </div>
</div> </div>
</section> </section>
@@ -424,10 +464,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-logs" className={sectionClass("logs")}> <section id="settings-logs" className={sectionClass("logs")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Trash2 className="size-4 text-primary" /> <Trash2 className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
30
</p>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="logCleanupEnabled"></Label> <Label htmlFor="logCleanupEnabled"></Label>
@@ -456,18 +494,21 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end"> <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="manualCleanupTarget"></Label> <Label htmlFor="manualCleanupTarget"></Label>
<select <Select
id="manualCleanupTarget"
value={cleanupTarget} value={cleanupTarget}
onChange={(event) => setCleanupTarget(event.target.value as LogCleanupTarget)} onValueChange={(value) => setCleanupTarget((value ?? "ALL") as LogCleanupTarget)}
className={selectClassName}
> >
<SelectTrigger id="manualCleanupTarget" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
{logCleanupTargetOptions.map((option) => ( {logCleanupTargetOptions.map((option) => (
<option key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="manualCleanupDays"></Label> <Label htmlFor="manualCleanupDays"></Label>
@@ -483,9 +524,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<ConfirmActionButton <ConfirmActionButton
title="清理过期日志?" title="清理过期日志?"
description={`将删除 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"}。删除后无法恢复。`} description={`清理 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"}无法恢复。`}
confirmLabel="开始清理" confirmLabel="开始清理"
errorMessage="清理日志失败" errorMessage="清理日志失败"
size="lg"
disabled={saving || hasPendingToggle || cleaningLogs} disabled={saving || hasPendingToggle || cleaningLogs}
onConfirm={handleCleanupExpiredLogs} onConfirm={handleCleanupExpiredLogs}
> >
@@ -493,9 +535,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
{cleaningLogs ? "清理中..." : "清理过期日志"} {cleaningLogs ? "清理中..." : "清理过期日志"}
</ConfirmActionButton> </ConfirmActionButton>
</div> </div>
<p className="mt-3 text-xs leading-5 text-muted-foreground"> <div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
</p> <InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</section> </section>
@@ -505,18 +548,16 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkRecommendationsEnabled"></Label> <LabelWithHelp htmlFor="networkRecommendationsEnabled" help="商城显示低延迟推荐。">
</LabelWithHelp>
{renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })} {renderImmediateToggle("networkRecommendationsEnabled", { id: "networkRecommendationsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label> <LabelWithHelp htmlFor="networkInsightsEnabled" help="套餐详情显示延迟与路径。">
线
</LabelWithHelp>
{renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })} {renderImmediateToggle("networkInsightsEnabled", { id: "networkInsightsEnabled" })}
<p className="text-xs leading-5 text-muted-foreground">
访线
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -524,10 +565,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-risk" className={sectionClass("risk")}> <section id="settings-risk" className={sectionClass("risk")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> 访 <ShieldAlert className="size-4 text-primary" /> 访
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
访{toggleValues.subscriptionRiskEnabled ? "已开启" : "已关闭"}
</p>
<div id="subscription-risk-settings" className="space-y-4"> <div id="subscription-risk-settings" className="space-y-4">
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -668,9 +707,10 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} /> <Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
24 4 5 2 /3 /2 3 IP 180 / 60 / Agent XRAY_ACCESS_LOG_PATH Agent
</p> <InlineHelp align="start"></InlineHelp>
</div>
</div> </div>
</section> </section>
@@ -698,14 +738,15 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
})} })}
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label> <LabelWithHelp htmlFor="emailVerificationRequired" help="验证后创建账户。">
</LabelWithHelp>
{renderImmediateToggle("emailVerificationRequired", { {renderImmediateToggle("emailVerificationRequired", {
id: "emailVerificationRequired", id: "emailVerificationRequired",
trueLabel: "开启验证", trueLabel: "开启验证",
falseLabel: "关闭", falseLabel: "关闭",
ariaLabel: "注册邮箱验证", ariaLabel: "注册邮箱验证",
})} })}
<p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
</div> </div>
</section> </section>
@@ -713,10 +754,8 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-email" className={sectionClass("email")}> <section id="settings-email" className={sectionClass("email")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Mail className="size-4 text-primary" /> SMTP <Mail className="size-4 text-primary" /> SMTP
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
SMTP
</p>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label> <Label htmlFor="smtpEnabled"></Label>
@@ -745,7 +784,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label> <Label htmlFor="smtpPassword">SMTP </Label>
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" /> <Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空不变" autoComplete="new-password" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="smtpFromName"></Label> <Label htmlFor="smtpFromName"></Label>
@@ -771,6 +810,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<section id="settings-invite" className={sectionClass("invite")}> <section id="settings-invite" className={sectionClass("invite")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<Gift className="size-4 text-primary" /> <Gift className="size-4 text-primary" />
<InlineHelp align="start"></InlineHelp>
</div> </div>
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -783,33 +823,105 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inviteRewardCouponId"></Label> <Label htmlFor="inviteRewardCouponId"></Label>
<select <Select
id="inviteRewardCouponId"
name="inviteRewardCouponId" name="inviteRewardCouponId"
defaultValue={config.inviteRewardCouponId ?? ""} defaultValue={config.inviteRewardCouponId ?? ""}
className={selectClassName}
> >
<option value=""></option> <SelectTrigger id="inviteRewardCouponId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{coupons.map((coupon) => ( {coupons.map((coupon) => (
<option key={coupon.id} value={coupon.id}> <SelectItem key={coupon.id} value={coupon.id}>
{coupon.name} · {coupon.code} {coupon.name} · {coupon.code}
</option> </SelectItem>
))} ))}
</select> </SelectContent>
</Select>
</div>
</div>
</section>
<section id="settings-transfer" className={sectionClass("transfer")}>
<div className={sectionHeadingClassName}>
<ArrowRightLeft className="size-4 text-primary" /> Push
<InlineHelp align="start"></InlineHelp>
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="subscriptionTransferEnabled"> Push</Label>
{renderImmediateToggle("subscriptionTransferEnabled", {
id: "subscriptionTransferEnabled",
trueLabel: "允许",
falseLabel: "关闭",
ariaLabel: "允许套餐 Push",
})}
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferFee" help="固定手续费,可填 0。">
</LabelWithHelp>
<Input
id="subscriptionTransferFee"
name="subscriptionTransferFee"
type="number"
min={0}
max={100000}
step="0.01"
defaultValue={config.subscriptionTransferFee}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferLimitPerCycle" help="同一订阅周期内最多成功转让次数0 表示禁止。">
</LabelWithHelp>
<Input
id="subscriptionTransferLimitPerCycle"
name="subscriptionTransferLimitPerCycle"
type="number"
min={0}
max={100}
step={1}
defaultValue={config.subscriptionTransferLimitPerCycle}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingDays" help="低于这个剩余天数时不能转让0 表示不限制。">
</LabelWithHelp>
<Input
id="subscriptionTransferMinRemainingDays"
name="subscriptionTransferMinRemainingDays"
type="number"
min={0}
max={3650}
step={1}
defaultValue={config.subscriptionTransferMinRemainingDays}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingTrafficGb" help="代理套餐剩余流量低于此值时不能转让0 表示不限制。">
GB
</LabelWithHelp>
<Input
id="subscriptionTransferMinRemainingTrafficGb"
name="subscriptionTransferMinRemainingTrafficGb"
type="number"
min={0}
max={1000000}
step={1}
defaultValue={config.subscriptionTransferMinRemainingTrafficGb}
/>
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
</section> </section>
<section id="settings-turnstile" className={sectionClass("turnstile")}> <section id="settings-turnstile" className={sectionClass("turnstile")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile <ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile
<InlineHelp align="start"></InlineHelp>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground">
</p>
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="turnstileSiteKey">Site Key</Label> <Label htmlFor="turnstileSiteKey">Site Key</Label>
@@ -821,11 +933,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
id="turnstileSecretKey" id="turnstileSecretKey"
name="turnstileSecretKey" name="turnstileSecretKey"
type="password" type="password"
placeholder={config.turnstileSecretConfigured ? "留空保持不变" : "0x4AAAAAAA..."} placeholder={config.turnstileSecretConfigured ? "留空不变" : "0x4AAAAAAA..."}
autoComplete="new-password" autoComplete="new-password"
/> />
{config.turnstileSecretConfigured && ( {config.turnstileSecretConfigured && (
<p className="text-xs leading-5 text-muted-foreground">Secret Key Site Key Turnstile</p> <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<InlineHelp align="start"></InlineHelp>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -111,7 +111,7 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden"> <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<ScrollText className="size-4 shrink-0 text-primary" /> <ScrollText className="size-4 shrink-0 text-primary" />
<span className="truncate"></span> <span className="whitespace-nowrap"></span>
</span> </span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground"> <span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{logs.length} {logs.length}
@@ -137,7 +137,7 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
id={log.id} id={log.id}
target="SUBSCRIPTION_ACCESS_LOGS" target="SUBSCRIPTION_ACCESS_LOGS"
title="删除这条风控访问日志?" title="删除这条风控访问日志?"
description="删除后无法恢复,只会移除这条访问或节点连接证据,不会删除用户订阅或风控事件。" description="删除这条证据,不影响用户订阅。"
successMessage="风控访问日志已删除" successMessage="风控访问日志已删除"
/> />
</div> </div>
@@ -176,7 +176,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-semibold"></h3> <h3 className="text-sm font-semibold"></h3>
<p className="truncate text-xs text-muted-foreground"> IP </p> <p className="whitespace-nowrap text-xs text-muted-foreground"> IP </p>
</div> </div>
</div> </div>
<StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}> <StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}>

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client"; import type { SubscriptionRiskEvent } from "@prisma/client";
import { ChevronDown } from "lucide-react"; import { ChevronDown, Eye } from "lucide-react";
import { LogDeleteButton } from "@/components/admin/log-delete-button"; import { LogDeleteButton } from "@/components/admin/log-delete-button";
import { import {
SubscriptionStatusBadge, SubscriptionStatusBadge,
@@ -10,6 +10,7 @@ import {
import { EmptyState } from "@/components/shared/page-shell"; import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions"; import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { buttonVariants } from "@/components/ui/button";
import { formatDate, formatDateShort } from "@/lib/utils"; import { formatDate, formatDateShort } from "@/lib/utils";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details"; import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import type { SubscriptionRiskEventRow } from "../risk-data"; import type { SubscriptionRiskEventRow } from "../risk-data";
@@ -114,14 +115,19 @@ function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Link href={"/admin/subscriptions/" + event.subscription.id} className="break-words font-medium hover:underline"> <p className="break-words font-medium">{event.subscription.plan.name}</p>
{event.subscription.plan.name}
</Link>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SubscriptionTypeBadge type={event.subscription.plan.type} /> <SubscriptionTypeBadge type={event.subscription.plan.type} />
<SubscriptionStatusBadge status={event.subscription.status} /> <SubscriptionStatusBadge status={event.subscription.status} />
</div> </div>
<p className="text-xs text-muted-foreground">{formatDateShort(event.subscription.endDate)}</p> <p className="text-xs text-muted-foreground">{formatDateShort(event.subscription.endDate)}</p>
<Link
href={"/admin/subscriptions/" + event.subscription.id}
className={buttonVariants({ variant: "outline", size: "xs" })}
>
<Eye className="size-3" />
</Link>
</div> </div>
); );
} }
@@ -133,11 +139,18 @@ function UserBlock({ event }: { event: SubscriptionRiskEventRow }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Link href={"/admin/users/" + event.user.id} className="block break-all font-medium hover:underline"> <p className="break-all font-medium">{event.user.email}</p>
{event.user.email}
</Link>
<p className="break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p> <p className="break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<div className="flex flex-wrap items-center gap-2">
<UserStatusBadge status={event.user.status} /> <UserStatusBadge status={event.user.status} />
<Link
href={"/admin/users/" + event.user.id}
className={buttonVariants({ variant: "outline", size: "xs" })}
>
<Eye className="size-3" />
</Link>
</div>
</div> </div>
); );
} }
@@ -260,8 +273,8 @@ function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
title="删除这条风控事件?" title="删除这条风控事件?"
description={ description={
event.userRestrictionActive event.userRestrictionActive
? "删除后无法恢复。此事件当前仍有用户端限制标记,请先确认是否需要在处理动作里解除限制。" ? "当前仍有限制标记,请先确认是否解除。"
: "删除后无法恢复,只会移除这条风控事件,不会删除用户、订阅或访问日志。" : "删除事件,不影响用户、订阅或日志。"
} }
successMessage="风控事件已删除" successMessage="风控事件已删除"
className="w-full justify-center text-destructive hover:text-destructive" className="w-full justify-center text-destructive hover:text-destructive"
@@ -280,7 +293,7 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
return ( return (
<EmptyState <EmptyState
title="暂无订阅风控事件" title="暂无订阅风控事件"
description="订阅链接或节点真实连接出现跨城市、跨省份或跨国家异常后,会在这里进入人工跟进队列。" description="跨地区访问异常会进入人工队列。"
/> />
); );
} }

View File

@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
<PageHeader <PageHeader
eyebrow="商品与订单" eyebrow="商品与订单"
title="订阅风控" title="订阅风控"
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。" description="集中处理跨城市、跨省份访问异常。"
/> />
<AdminFilterBar <AdminFilterBar

View File

@@ -0,0 +1,155 @@
"use client";
import { useRouter } from "next/navigation";
import { Eye, Trash2 } from "lucide-react";
import { deleteAdminSubscriptionTransfer } from "@/actions/admin/subscription-transfers";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export interface AdminSubscriptionTransferItem {
id: string;
status: string;
statusLabel: string;
statusTone: StatusTone;
planName: string;
planTypeLabel: string;
senderLabel: string;
recipientLabel: string;
feeLabel: string;
feePayerLabel: string;
feeChargedLabel: string;
feeRefundedLabel: string;
cycleStartedAtLabel: string;
createdAtLabel: string;
expiresAtLabel: string;
acceptedAtLabel: string;
endDateLabel: string;
trafficLabel: string;
nodeLabel: string;
}
function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p className="mt-1 break-words text-sm font-medium leading-5">{value}</p>
</div>
);
}
function TransferDetailDialog({ item }: { item: AdminSubscriptionTransferItem }) {
const details = [
{ label: "套餐", value: `${item.planName} · ${item.planTypeLabel}` },
{ label: "状态", value: item.statusLabel },
{ label: "转出方", value: item.senderLabel },
{ label: "接收方", value: item.recipientLabel },
{ label: "费用", value: item.feeLabel },
{ label: "承担方", value: item.feePayerLabel },
{ label: "扣费", value: item.feeChargedLabel },
{ label: "退款", value: item.feeRefundedLabel },
{ label: "周期起点", value: item.cycleStartedAtLabel },
{ label: "发起时间", value: item.createdAtLabel },
{ label: "确认截止", value: item.expiresAtLabel },
{ label: "接收时间", value: item.acceptedAtLabel },
{ label: "套餐到期", value: item.endDateLabel },
{ label: "剩余流量", value: item.trafficLabel },
{ label: "节点", value: item.nodeLabel },
];
return (
<Dialog>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
<Eye className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[40rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle>Push </DialogTitle>
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
</div>
<DialogDescription>{item.senderLabel} {"->"} {item.recipientLabel}</DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-2 sm:grid-cols-2">
{details.map((detail) => (
<DetailItem key={detail.label} {...detail} />
))}
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
export function SubscriptionTransfersTable({ transfers }: { transfers: AdminSubscriptionTransferItem[] }) {
const router = useRouter();
if (transfers.length === 0) {
return (
<div className="surface-card rounded-xl px-4 py-8 text-center text-sm text-muted-foreground">
Push
</div>
);
}
return (
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{transfers.map((item) => {
const deletingPending = item.status === "PENDING";
return (
<article
key={item.id}
className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)_auto] lg:items-center"
>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-semibold">{item.planName}</h3>
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{item.senderLabel} {"->"} {item.recipientLabel}</p>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge>{item.feeLabel}</StatusBadge>
<StatusBadge>{item.feePayerLabel}</StatusBadge>
<StatusBadge>{item.createdAtLabel}</StatusBadge>
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
<TransferDetailDialog item={item} />
<ConfirmActionButton
title="删除这条 Push 记录?"
description={
deletingPending
? "这条 Push 仍在待接收状态。删除会取消本次 Push、恢复转出方套餐并退回已扣手续费。"
: "只删除管理端历史记录,不会回滚已完成的套餐转移。"
}
confirmLabel="删除记录"
successMessage={deletingPending ? "Push 已取消并删除" : "Push 记录已删除"}
errorMessage="删除 Push 记录失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await deleteAdminSubscriptionTransfer(item.id);
}}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
</ConfirmActionButton>
</div>
</article>
);
})}
</div>
);
}

View File

@@ -0,0 +1,113 @@
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 type { StatusTone } from "@/components/shared/status-badge";
import { formatBytes, formatDate } from "@/lib/utils";
import {
getSubscriptionTransferFeePayerLabel,
getSubscriptionTransferStatusLabel,
} from "@/lib/domain-labels";
import { getAdminSubscriptionTransfers, type AdminSubscriptionTransferRow } from "./transfers-data";
import {
SubscriptionTransfersTable,
type AdminSubscriptionTransferItem,
} from "./_components/subscription-transfers-table";
export const metadata: Metadata = {
title: "套餐 Push",
description: "查看用户间套餐转让记录。",
};
const statusTones: Record<string, StatusTone> = {
PENDING: "warning",
ACCEPTED: "success",
REJECTED: "neutral",
CANCELLED: "neutral",
EXPIRED: "danger",
};
function money(value: unknown) {
return `¥${Number(value).toFixed(2)}`;
}
function userLabel(user: { name: string | null; email: string }) {
return user.name ? `${user.name} · ${user.email}` : user.email;
}
function trafficLabel(row: AdminSubscriptionTransferRow) {
const sub = row.subscription;
if (sub.plan.type !== "PROXY") return "不涉及流量限制";
if (!sub.trafficLimit) return "不限流量";
const remaining = sub.trafficLimit > sub.trafficUsed ? sub.trafficLimit - sub.trafficUsed : BigInt(0);
return `${formatBytes(remaining)} / ${formatBytes(sub.trafficLimit)}`;
}
function nodeLabel(row: AdminSubscriptionTransferRow) {
const client = row.subscription.nodeClient;
if (!client) return row.subscription.streamingSlot?.service.name ?? "无节点";
return `${client.inbound.server.name} · ${client.inbound.tag}`;
}
function toItem(row: AdminSubscriptionTransferRow): AdminSubscriptionTransferItem {
return {
id: row.id,
status: row.status,
statusLabel: getSubscriptionTransferStatusLabel(row.status),
statusTone: statusTones[row.status] ?? "neutral",
planName: row.plan.name,
planTypeLabel: row.plan.type === "PROXY" ? "代理" : "流媒体",
senderLabel: userLabel(row.sender),
recipientLabel: userLabel(row.recipient),
feeLabel: money(row.feeAmount),
feePayerLabel: getSubscriptionTransferFeePayerLabel(row.feePayer),
feeChargedLabel: row.feeChargedAt ? formatDate(row.feeChargedAt) : "未扣费",
feeRefundedLabel: row.feeRefundedAt ? formatDate(row.feeRefundedAt) : "未退款",
cycleStartedAtLabel: formatDate(row.cycleStartedAt),
createdAtLabel: formatDate(row.createdAt),
expiresAtLabel: formatDate(row.expiresAt),
acceptedAtLabel: row.acceptedAt ? formatDate(row.acceptedAt) : "未接收",
endDateLabel: formatDate(row.subscription.endDate),
trafficLabel: trafficLabel(row),
nodeLabel: nodeLabel(row),
};
}
export default async function AdminSubscriptionTransfersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { transfers, total, page, pageSize, filters } = await getAdminSubscriptionTransfers(await searchParams);
return (
<PageShell>
<PageHeader
eyebrow="商品与订单"
title="套餐 Push"
/>
<AdminFilterBar
q={filters.q}
searchPlaceholder="搜索邮箱、昵称、套餐名"
selects={[
{
name: "status",
value: filters.status,
options: [
{ label: "全部状态", value: "" },
{ label: "待接收", value: "PENDING" },
{ label: "已接收", value: "ACCEPTED" },
{ label: "已拒收", value: "REJECTED" },
{ label: "已取消", value: "CANCELLED" },
{ label: "已过期", value: "EXPIRED" },
],
},
]}
/>
<SubscriptionTransfersTable transfers={transfers.map(toItem)} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

View File

@@ -0,0 +1,77 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import { processExpiredSubscriptionTransfers } from "@/services/subscription-transfer";
const adminTransferInclude = {
plan: true,
subscription: {
include: {
plan: true,
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
streamingSlot: {
include: {
service: true,
},
},
},
},
sender: { select: { id: true, email: true, name: true } },
recipient: { select: { id: true, email: true, name: true } },
} satisfies Prisma.SubscriptionTransferInclude;
export type AdminSubscriptionTransferRow = Prisma.SubscriptionTransferGetPayload<{
include: typeof adminTransferInclude;
}>;
export async function getAdminSubscriptionTransfers(
searchParams: Record<string, string | string[] | undefined>,
) {
await processExpiredSubscriptionTransfers();
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: status as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED" } : {}),
...(q
? {
OR: [
{ senderEmail: { contains: q } },
{ recipientEmail: { contains: q } },
{ plan: { name: { contains: q } } },
{ sender: { name: { contains: q } } },
{ recipient: { name: { contains: q } } },
],
}
: {}),
} satisfies Prisma.SubscriptionTransferWhereInput;
const [transfers, total] = await Promise.all([
prisma.subscriptionTransfer.findMany({
where,
include: adminTransferInclude,
orderBy: { createdAt: "desc" },
skip,
take: pageSize,
}),
prisma.subscriptionTransfer.count({ where }),
]);
return {
transfers,
total,
page,
pageSize,
filters: { q, status },
};
}

View File

@@ -6,7 +6,7 @@ import type {
SubscriptionType, SubscriptionType,
UserStatus, UserStatus,
} from "@prisma/client"; } from "@prisma/client";
import { AlertTriangle, ShieldCheck, UserRound } from "lucide-react"; import { AlertTriangle, Eye, ShieldCheck, UserRound } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell"; import { DataTableShell } from "@/components/admin/data-table-shell";
import { import {
DataTable, DataTable,
@@ -121,7 +121,7 @@ export function SubscriptionAccessRiskSection({
</span> </span>
<div> <div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3> <h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p> <p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div> </div>
</div> </div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}> <Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
@@ -137,8 +137,15 @@ export function SubscriptionAccessRiskSection({
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link href={"/admin/users/" + owner.id} className="break-all font-medium hover:underline">{owner.email}</Link> <span className="break-all font-medium">{owner.email}</span>
<UserStatusBadge status={owner.status} /> <UserStatusBadge status={owner.status} />
<Link
href={"/admin/users/" + owner.id}
className={buttonVariants({ variant: "outline", size: "xs" })}
>
<Eye className="size-3" />
</Link>
</div> </div>
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p> <p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{owner.id}</p> <p className="break-all font-mono text-xs text-muted-foreground">{owner.id}</p>
@@ -203,7 +210,7 @@ export function SubscriptionAccessRiskSection({
<DataTableShell <DataTableShell
isEmpty={accessLogs.length === 0} isEmpty={accessLogs.length === 0}
emptyTitle="暂无订阅访问记录" emptyTitle="暂无订阅访问记录"
emptyDescription="用户客户端拉取订阅后,这里会显示最近访问 IP 与地区。" emptyDescription="客户端拉取订阅后显示 IP 与地区。"
> >
<DataTable aria-label="订阅访问记录" className="min-w-[980px]"> <DataTable aria-label="订阅访问记录" className="min-w-[980px]">
<DataTableHead> <DataTableHead>

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Eye } from "lucide-react";
import { batchSubscriptionOperation } from "@/actions/admin/subscriptions"; import { batchSubscriptionOperation } from "@/actions/admin/subscriptions";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar"; import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { DataTableShell } from "@/components/admin/data-table-shell"; import { DataTableShell } from "@/components/admin/data-table-shell";
@@ -15,6 +16,7 @@ import {
SubscriptionStatusBadge, SubscriptionStatusBadge,
SubscriptionTypeBadge, SubscriptionTypeBadge,
} from "@/components/shared/domain-badges"; } from "@/components/shared/domain-badges";
import { buttonVariants } from "@/components/ui/button";
import { formatBytes, formatDateShort } from "@/lib/utils"; import { formatBytes, formatDateShort } from "@/lib/utils";
import { AdminSubscriptionActions } from "../subscription-actions"; import { AdminSubscriptionActions } from "../subscription-actions";
import type { StreamingServiceOption } from "../streaming-slot-dialog"; import type { StreamingServiceOption } from "../streaming-slot-dialog";
@@ -66,7 +68,7 @@ export function SubscriptionsTable({
<DataTableShell <DataTableShell
isEmpty={subscriptions.length === 0} isEmpty={subscriptions.length === 0}
emptyTitle="暂无订阅记录" emptyTitle="暂无订阅记录"
emptyDescription="用户完成购买开通后订阅会出现在这里。" emptyDescription="购买开通后订阅会显示在这里。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="subscription-batch-form" id="subscription-batch-form"
@@ -92,12 +94,7 @@ export function SubscriptionsTable({
className="mt-1 size-4 rounded border-border accent-primary" className="mt-1 size-4 rounded border-border accent-primary"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <p className="break-words text-sm font-semibold">{subscription.plan.name}</p>
href={`/admin/subscriptions/${subscription.id}`}
className="break-words text-sm font-semibold hover:underline"
>
{subscription.plan.name}
</Link>
<p className="mt-1 break-all text-xs text-muted-foreground">{subscription.user.email}</p> <p className="mt-1 break-all text-xs text-muted-foreground">{subscription.user.email}</p>
</div> </div>
<SubscriptionStatusBadge status={subscription.status} /> <SubscriptionStatusBadge status={subscription.status} />
@@ -118,7 +115,14 @@ export function SubscriptionsTable({
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex flex-wrap justify-end gap-2">
<Link
href={`/admin/subscriptions/${subscription.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
<AdminSubscriptionActions <AdminSubscriptionActions
subscriptionId={subscription.id} subscriptionId={subscription.id}
status={subscription.status} status={subscription.status}
@@ -162,12 +166,7 @@ export function SubscriptionsTable({
</p> </p>
</DataTableCell> </DataTableCell>
<DataTableCell className="max-w-52 whitespace-normal break-words"> <DataTableCell className="max-w-52 whitespace-normal break-words">
<Link <p className="font-medium">{subscription.plan.name}</p>
href={`/admin/subscriptions/${subscription.id}`}
className="font-medium hover:underline"
>
{subscription.plan.name}
</Link>
</DataTableCell> </DataTableCell>
<DataTableCell> <DataTableCell>
<SubscriptionTypeBadge type={subscription.plan.type} /> <SubscriptionTypeBadge type={subscription.plan.type} />
@@ -188,7 +187,14 @@ export function SubscriptionsTable({
<SubscriptionStatusBadge status={subscription.status} /> <SubscriptionStatusBadge status={subscription.status} />
</DataTableCell> </DataTableCell>
<DataTableCell> <DataTableCell>
<div className="flex justify-end"> <div className="flex flex-wrap justify-end gap-2">
<Link
href={`/admin/subscriptions/${subscription.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
<AdminSubscriptionActions <AdminSubscriptionActions
subscriptionId={subscription.id} subscriptionId={subscription.id}
status={subscription.status} status={subscription.status}

View File

@@ -75,7 +75,7 @@ export function AdminSubscriptionActions({
size="sm" size="sm"
variant="destructive" variant="destructive"
title="彻底删除这个订阅?" title="彻底删除这个订阅?"
description="会同步删除远端客户端,并清理本地记录与相关订单。此操作无法恢复。" description="会删除远端客户端和本地记录,无法恢复。"
confirmLabel="删除订阅" confirmLabel="删除订阅"
successMessage="订阅已删除" successMessage="订阅已删除"
errorMessage="删除失败" errorMessage="删除失败"

View File

@@ -20,12 +20,12 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
</span> </span>
<div> <div>
<h3 className="font-heading text-lg font-semibold tracking-tight"></h3> <h3 className="font-heading text-lg font-semibold tracking-tight"></h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p> <p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="body"></Label> <Label htmlFor="body"></Label>
<Textarea id="body" name="body" rows={4} placeholder="输入给用户的回复" required /> <Textarea id="body" name="body" rows={4} placeholder="回复内容" required />
</div> </div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4"> <div className="space-y-2 rounded-lg border border-border bg-muted/30 p-4">
<Label htmlFor="admin-reply-attachments" className="inline-flex items-center gap-2"> <Label htmlFor="admin-reply-attachments" className="inline-flex items-center gap-2">
@@ -38,9 +38,7 @@ export function AdminSupportReplyForm({ ticketId }: { ticketId: string }) {
multiple multiple
accept={SUPPORT_ATTACHMENT_ACCEPT} accept={SUPPORT_ATTACHMENT_ACCEPT}
/> />
<p className="field-note"> <p className="field-note"> 3 3MB</p>
JPGPNGWEBPGIFAVIF 3 3MB
</p>
</div> </div>
<PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton> <PendingSubmitButton size="lg" className="w-full sm:w-auto" pendingLabel="发送中..."></PendingSubmitButton>
</form> </form>

View File

@@ -28,13 +28,11 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
<DataTableShell <DataTableShell
isEmpty={tickets.length === 0} isEmpty={tickets.length === 0}
emptyTitle="暂无工单" emptyTitle="暂无工单"
emptyDescription="用户提交售后问题后,会显示在这里。" emptyDescription="用户提交后工单会显示在这里。"
mobileCards={tickets.map((ticket) => ( mobileCards={tickets.map((ticket) => (
<article key={ticket.id} className="space-y-3 p-4"> <article key={ticket.id} className="space-y-3 p-4">
<div className="min-w-0"> <div className="min-w-0">
<Link href={`/admin/support/${ticket.id}`} className="break-words text-sm font-semibold hover:underline"> <p className="break-words text-sm font-semibold">{ticket.subject}</p>
{ticket.subject}
</Link>
<p className="mt-1 break-all text-xs text-muted-foreground">{ticket.user.email}</p> <p className="mt-1 break-all text-xs text-muted-foreground">{ticket.user.email}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -72,9 +70,7 @@ export function AdminSupportTable({ tickets }: AdminSupportTableProps) {
{tickets.map((ticket) => ( {tickets.map((ticket) => (
<DataTableRow key={ticket.id}> <DataTableRow key={ticket.id}>
<DataTableCell className="max-w-64 whitespace-normal break-words"> <DataTableCell className="max-w-64 whitespace-normal break-words">
<Link href={`/admin/support/${ticket.id}`} className="font-medium hover:underline"> <p className="font-medium">{ticket.subject}</p>
{ticket.subject}
</Link>
{ticket.category && ( {ticket.category && (
<p className="mt-1 text-xs text-muted-foreground">{ticket.category}</p> <p className="mt-1 text-xs text-muted-foreground">{ticket.category}</p>
)} )}

View File

@@ -1,44 +1,17 @@
import { updateSupportTicketMeta } from "@/actions/admin/support"; import { updateSupportTicketMeta } from "@/actions/admin/support";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Label } from "@/components/ui/label";
import type { AdminSupportTicketDetail } from "../support-data"; import type { AdminSupportTicketDetail } from "../support-data";
import { SupportTicketMetaSelects } from "./support-ticket-meta-selects";
export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) { export function SupportTicketMetaForm({ ticket }: { ticket: AdminSupportTicketDetail }) {
return ( return (
<form <form
action={updateSupportTicketMeta} action={updateSupportTicketMeta}
className="surface-card flex flex-wrap items-end gap-3 rounded-xl p-4" className="surface-card grid gap-3 rounded-xl p-4 sm:grid-cols-[minmax(10rem,12rem)_minmax(10rem,12rem)_auto] sm:items-end"
> >
<input type="hidden" name="ticketId" value={ticket.id} /> <input type="hidden" name="ticketId" value={ticket.id} />
<div className="space-y-2"> <SupportTicketMetaSelects status={ticket.status} priority={ticket.priority} />
<Label htmlFor="status"></Label> <PendingSubmitButton variant="outline" size="lg" className="w-full sm:w-auto" pendingLabel="更新中..."></PendingSubmitButton>
<select
id="status"
name="status"
defaultValue={ticket.status}
className="h-11 px-3 text-sm outline-none"
>
<option value="OPEN"></option>
<option value="USER_REPLIED"></option>
<option value="ADMIN_REPLIED"></option>
<option value="CLOSED"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="priority"></Label>
<select
id="priority"
name="priority"
defaultValue={ticket.priority}
className="h-11 px-3 text-sm outline-none"
>
<option value="LOW"></option>
<option value="NORMAL"></option>
<option value="HIGH"></option>
<option value="URGENT"></option>
</select>
</div>
<PendingSubmitButton variant="outline" size="lg" pendingLabel="更新中..."></PendingSubmitButton>
</form> </form>
); );
} }

View File

@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import type { SupportTicketPriority, SupportTicketStatus } from "@prisma/client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
supportTicketPriorityLabels,
supportTicketStatusLabels,
} from "@/services/support-labels";
function getStatusLabel(value: unknown) {
return supportTicketStatusLabels[value as SupportTicketStatus] ?? "选择状态";
}
function getPriorityLabel(value: unknown) {
return supportTicketPriorityLabels[value as SupportTicketPriority] ?? "选择优先级";
}
export function SupportTicketMetaSelects({
status,
priority,
}: {
status: SupportTicketStatus;
priority: SupportTicketPriority;
}) {
const [selectedStatus, setSelectedStatus] = useState(status);
const [selectedPriority, setSelectedPriority] = useState(priority);
return (
<>
<div className="min-w-44 space-y-2">
<Label htmlFor="status"></Label>
<Select
name="status"
value={selectedStatus}
onValueChange={(value) => setSelectedStatus((value ?? "OPEN") as SupportTicketStatus)}
>
<SelectTrigger id="status" className="w-full">
<SelectValue>{(value) => getStatusLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="OPEN"></SelectItem>
<SelectItem value="USER_REPLIED"></SelectItem>
<SelectItem value="ADMIN_REPLIED"></SelectItem>
<SelectItem value="CLOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-40 space-y-2">
<Label htmlFor="priority"></Label>
<Select
name="priority"
value={selectedPriority}
onValueChange={(value) => setSelectedPriority((value ?? "NORMAL") as SupportTicketPriority)}
>
<SelectTrigger id="priority" className="w-full">
<SelectValue>{(value) => getPriorityLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="LOW"></SelectItem>
<SelectItem value="NORMAL"></SelectItem>
<SelectItem value="HIGH"></SelectItem>
<SelectItem value="URGENT"></SelectItem>
</SelectContent>
</Select>
</div>
</>
);
}

View File

@@ -11,7 +11,7 @@ export function TaskLaunchPanel() {
</span> </span>
<div> <div>
<p className="font-semibold"></p> <p className="font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground"></p> <p className="mt-1 text-xs leading-5 text-muted-foreground"></p>
</div> </div>
<PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton> <PendingSubmitButton size="sm" variant="outline" className="mt-auto w-full" pendingLabel="派发中..."></PendingSubmitButton>
</form> </form>

View File

@@ -25,7 +25,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
<DataTableShell <DataTableShell
isEmpty={tasks.length === 0} isEmpty={tasks.length === 0}
emptyTitle="暂无任务记录" emptyTitle="暂无任务记录"
emptyDescription="手动或定时任务执行后,会显示运行状态与错误信息。" emptyDescription="任务执行后显示状态与错误。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="task-batch-form" id="task-batch-form"
@@ -79,7 +79,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
id={task.id} id={task.id}
target="TASK_RUNS" target="TASK_RUNS"
title="删除这条任务记录?" title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不撤销任务已经产生的业务结果。" description="删除记录,不撤销业务结果。"
successMessage="任务记录已删除" successMessage="任务记录已删除"
/> />
</div> </div>
@@ -142,7 +142,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
id={task.id} id={task.id}
target="TASK_RUNS" target="TASK_RUNS"
title="删除这条任务记录?" title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不撤销任务已经产生的业务结果。" description="删除记录,不撤销业务结果。"
successMessage="任务记录已删除" successMessage="任务记录已删除"
/> />
</div> </div>

View File

@@ -40,7 +40,7 @@ export function TrafficClientsTable({ clients }: TrafficClientsTableProps) {
<DataTableShell <DataTableShell
isEmpty={visibleClients.length === 0} isEmpty={visibleClients.length === 0}
emptyTitle="暂无流量数据" emptyTitle="暂无流量数据"
emptyDescription="客户端绑定订阅并同步流量后会显示在这里。" emptyDescription="同步流量后客户端会显示在这里。"
mobileCards={visibleClients.map((client) => { mobileCards={visibleClients.map((client) => {
const subscription = client.subscription!; const subscription = client.subscription!;
const used = Number(subscription.trafficUsed); const used = Number(subscription.trafficUsed);

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Eye } from "lucide-react";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { DataTableShell } from "@/components/admin/data-table-shell"; import { DataTableShell } from "@/components/admin/data-table-shell";
import { import {
@@ -22,6 +23,7 @@ import {
orderKindLabels, orderKindLabels,
} from "@/components/shared/domain-badges"; } from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button";
import { import {
SupportTicketPriorityBadge, SupportTicketPriorityBadge,
SupportTicketStatusBadge, SupportTicketStatusBadge,
@@ -102,7 +104,15 @@ export default async function AdminUserDetailPage({
<SectionHeader <SectionHeader
title="账号资料" title="账号资料"
description="用于风控判断时快速确认用户基础信息。" description="用于风控判断时快速确认用户基础信息。"
actions={<Link href={"/admin/subscription-risk?q=" + encodeURIComponent(user.email)} className="text-sm font-medium text-primary hover:underline"></Link>} actions={
<Link
href={"/admin/subscription-risk?q=" + encodeURIComponent(user.email)}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
}
/> />
<div className="mt-4 grid gap-3 md:grid-cols-2"> <div className="mt-4 grid gap-3 md:grid-cols-2">
<div> <div>
@@ -136,15 +146,14 @@ export default async function AdminUserDetailPage({
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow> </DataTableHeaderRow>
</DataTableHead> </DataTableHead>
<DataTableBody> <DataTableBody>
{subscriptions.map((subscription) => ( {subscriptions.map((subscription) => (
<DataTableRow key={subscription.id}> <DataTableRow key={subscription.id}>
<DataTableCell> <DataTableCell>
<Link href={"/admin/subscriptions/" + subscription.id} className="font-medium hover:underline"> <p className="font-medium">{subscription.plan.name}</p>
{subscription.plan.name}
</Link>
</DataTableCell> </DataTableCell>
<DataTableCell><SubscriptionTypeBadge type={subscription.plan.type} /></DataTableCell> <DataTableCell><SubscriptionTypeBadge type={subscription.plan.type} /></DataTableCell>
<DataTableCell><SubscriptionStatusBadge status={subscription.status} /></DataTableCell> <DataTableCell><SubscriptionStatusBadge status={subscription.status} /></DataTableCell>
@@ -153,6 +162,17 @@ export default async function AdminUserDetailPage({
</DataTableCell> </DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.endDate)}</DataTableCell> <DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.endDate)}</DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.createdAt)}</DataTableCell> <DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.createdAt)}</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<Link
href={"/admin/subscriptions/" + subscription.id}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
</div>
</DataTableCell>
</DataTableRow> </DataTableRow>
))} ))}
</DataTableBody> </DataTableBody>
@@ -235,19 +255,29 @@ export default async function AdminUserDetailPage({
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow> </DataTableHeaderRow>
</DataTableHead> </DataTableHead>
<DataTableBody> <DataTableBody>
{supportTickets.map((ticket) => ( {supportTickets.map((ticket) => (
<DataTableRow key={ticket.id}> <DataTableRow key={ticket.id}>
<DataTableCell> <DataTableCell>
<Link href={"/admin/support/" + ticket.id} className="max-w-72 truncate font-medium hover:underline"> <p className="max-w-72 truncate font-medium">{ticket.subject}</p>
{ticket.subject}
</Link>
</DataTableCell> </DataTableCell>
<DataTableCell><SupportTicketStatusBadge status={ticket.status} /></DataTableCell> <DataTableCell><SupportTicketStatusBadge status={ticket.status} /></DataTableCell>
<DataTableCell><SupportTicketPriorityBadge priority={ticket.priority} /></DataTableCell> <DataTableCell><SupportTicketPriorityBadge priority={ticket.priority} /></DataTableCell>
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(ticket.updatedAt)}</DataTableCell> <DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(ticket.updatedAt)}</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<Link
href={"/admin/support/" + ticket.id}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<Eye className="size-3.5" />
</Link>
</div>
</DataTableCell>
</DataTableRow> </DataTableRow>
))} ))}
</DataTableBody> </DataTableBody>

View File

@@ -24,7 +24,7 @@ export function UsersTable({ users }: UsersTableProps) {
<DataTableShell <DataTableShell
isEmpty={users.length === 0} isEmpty={users.length === 0}
emptyTitle="暂无用户" emptyTitle="暂无用户"
emptyDescription="创建用户或等待新用户注册后会显示在这里。" emptyDescription="创建注册后用户会显示在这里。"
toolbar={ toolbar={
<BatchActionBar <BatchActionBar
id="user-batch-form" id="user-batch-form"

View File

@@ -63,7 +63,7 @@ export function UserActions({ user }: { user: User }) {
size="sm" size="sm"
variant="destructive" variant="destructive"
title="强制删除这个用户?" title="强制删除这个用户?"
description="将同步删除该用户在节点面板中的客户端,并永久清理名下订单、订阅、工单、通知、访问日志等数据。此操作不可恢复。" description="会清理节点客户端、订单、订阅、工单和日志,无法恢复。"
confirmLabel="强制删除" confirmLabel="强制删除"
successMessage="用户已删除" successMessage="用户已删除"
errorMessage="删除失败" errorMessage="删除失败"

View File

@@ -6,6 +6,13 @@ import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -54,11 +61,11 @@ export function UserForm({
<DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle> <DialogTitle>{isEdit ? "编辑用户" : "创建用户"}</DialogTitle>
</DialogHeader> </DialogHeader>
<form action={handleSubmit} className="space-y-5"> <form action={handleSubmit} className="space-y-5">
<div> <div className="space-y-2">
<Label></Label> <Label></Label>
<Input name="email" type="email" defaultValue={user?.email} required /> <Input name="email" type="email" defaultValue={user?.email} required />
</div> </div>
<div> <div className="space-y-2">
<Label>{isEdit ? "新密码(可留空)" : "密码"}</Label> <Label>{isEdit ? "新密码(可留空)" : "密码"}</Label>
<Input <Input
name="password" name="password"
@@ -68,20 +75,24 @@ export function UserForm({
placeholder={isEdit ? "留空则保持不变" : undefined} placeholder={isEdit ? "留空则保持不变" : undefined}
/> />
</div> </div>
<div> <div className="space-y-2">
<Label></Label> <Label></Label>
<Input name="name" defaultValue={user?.name ?? ""} /> <Input name="name" defaultValue={user?.name ?? ""} />
</div> </div>
<div> <div className="space-y-2">
<Label></Label> <Label htmlFor="user-role"></Label>
<select <Select
name="role" name="role"
defaultValue={user?.role ?? "USER"} defaultValue={user?.role ?? "USER"}
className="h-10 w-full px-3 text-sm outline-none"
> >
<option value="USER"></option> <SelectTrigger id="user-role" className="w-full">
<option value="ADMIN"></option> <SelectValue />
</select> </SelectTrigger>
<SelectContent align="start">
<SelectItem value="USER"></SelectItem>
<SelectItem value="ADMIN"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}> <PendingSubmitButton className="w-full" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
{isEdit ? "保存" : "创建"} {isEdit ? "保存" : "创建"}

View File

@@ -40,7 +40,7 @@ export function AuthCard({
{PRODUCT_EDITION.slice(0, 1)} {PRODUCT_EDITION.slice(0, 1)}
</div> </div>
{title && <h1 className="text-display text-2xl font-semibold">{title}</h1>} {title && <h1 className="text-display text-2xl font-semibold">{title}</h1>}
{description && <p className="text-sm leading-6 text-muted-foreground">{description}</p>} {description && <p className="mx-auto max-w-xs text-sm leading-6 text-muted-foreground text-pretty">{description}</p>}
</CardHeader> </CardHeader>
)} )}
<CardContent className="pb-6">{children}</CardContent> <CardContent className="pb-6">{children}</CardContent>

View File

@@ -30,13 +30,13 @@ export function ForgotPasswordClient() {
return ( return (
<AuthShell> <AuthShell>
<AuthCard title="找回密码" description="输入注册邮箱,我们会发送一封密码重设邮件。"> <AuthCard title="找回密码" description="输入注册邮箱接收重设邮件。">
{sent ? ( {sent ? (
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Mail className="size-5" /> <Mail className="size-5" />
</div> </div>
<p className="text-sm leading-6 text-muted-foreground"> 20 </p> <p className="text-sm leading-6 text-muted-foreground">20 </p>
<Link href="/login" className="text-sm font-medium text-primary hover:underline"></Link> <Link href="/login" className="text-sm font-medium text-primary hover:underline"></Link>
</div> </div>
) : ( ) : (

View File

@@ -30,13 +30,13 @@ export function VerifyEmailRequestClient() {
return ( return (
<AuthShell> <AuthShell>
<AuthCard title="重新发送验证邮件" description="没有收到邮件时,可以重新发送一次。"> <AuthCard title="重新发送验证邮件" description="未收到时可重新发送。">
{sent ? ( {sent ? (
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<MailCheck className="size-5" /> <MailCheck className="size-5" />
</div> </div>
<p className="text-sm leading-6 text-muted-foreground"></p> <p className="text-sm leading-6 text-muted-foreground"></p>
<Link href="/login" className="font-medium text-primary hover:underline"></Link> <Link href="/login" className="font-medium text-primary hover:underline"></Link>
</div> </div>
) : ( ) : (

View File

@@ -8,7 +8,7 @@ import type { PaymentInfo } from "../payment-types";
export function AlipayQrView({ qrCode }: { qrCode: string }) { export function AlipayQrView({ qrCode }: { qrCode: string }) {
return ( return (
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/20 p-4"> <div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/20 p-4">
<p className="font-medium">使</p> <p className="font-medium"></p>
<div className="rounded-xl border border-border bg-white p-4"> <div className="rounded-xl border border-border bg-white p-4">
<QRCodeSVG value={qrCode} size={220} /> <QRCodeSVG value={qrCode} size={220} />
</div> </div>
@@ -25,7 +25,7 @@ export function UsdtView({ raw }: { raw: NonNullable<PaymentInfo["raw"]> }) {
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums"> <p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-primary tabular-nums">
{raw.usdtAmount} USDT {raw.usdtAmount} USDT
</p> </p>
<p className="mt-1 text-sm text-muted-foreground"></p> <p className="mt-1 text-sm text-muted-foreground"></p>
</div> </div>
<div className="space-y-3 rounded-xl border border-border bg-muted/25 p-4 text-sm"> <div className="space-y-3 rounded-xl border border-border bg-muted/25 p-4 text-sm">

View File

@@ -24,9 +24,7 @@ export function PaymentCard({ title, children }: { title: string; children: Reac
<ShieldCheck className="size-5" /> <ShieldCheck className="size-5" />
</div> </div>
<h1 className="text-display text-2xl font-semibold">{title}</h1> <h1 className="text-display text-2xl font-semibold">{title}</h1>
<p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground"> <p className="mx-auto max-w-md text-sm leading-6 text-muted-foreground"></p>
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pb-6">{children}</CardContent> <CardContent className="space-y-4 pb-6">{children}</CardContent>
</Card> </Card>

View File

@@ -42,17 +42,22 @@ export function PaymentMethodSelector({
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn("flex size-9 items-center justify-center rounded-lg transition-colors duration-200", selected ? "bg-primary text-primary-foreground" : "bg-muted text-primary")}> <div className={cn("flex size-9 items-center justify-center rounded-lg transition-colors duration-200", selected ? "bg-primary text-primary-foreground" : "bg-muted text-primary")}>
{provider.provider === "usdt_trc20" ? <Coins className="size-5" /> : <CreditCard className="size-5" />} {provider.provider === "usdt_trc20" || provider.provider === "balance" ? <Coins className="size-5" /> : <CreditCard className="size-5" />}
</div> </div>
<div> <div>
<span className="font-semibold">{provider.name}</span> <span className="font-semibold">{provider.name}</span>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{provider.provider === "usdt_trc20" ? "适合使用稳定币付款" : "根据页面提示完成确认"} {provider.provider === "balance"
? "从账户余额扣款"
: provider.provider === "usdt_trc20"
? "稳定币付款"
: "按提示完成"}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{provider.provider === "usdt_trc20" && <StatusBadge tone="info">Crypto</StatusBadge>} {provider.provider === "balance" && <StatusBadge tone="success"></StatusBadge>}
{provider.provider === "usdt_trc20" && <StatusBadge tone="info"></StatusBadge>}
{selected && <CheckCircle2 className="size-5 text-primary" />} {selected && <CheckCircle2 className="size-5 text-primary" />}
</div> </div>
</button> </button>

View File

@@ -13,9 +13,7 @@ export function PaymentSuccessCard({ onDashboard }: { onDashboard: () => void })
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<h1 className="text-display text-2xl font-semibold"></h1> <h1 className="text-display text-2xl font-semibold"></h1>
<p className="mx-auto max-w-sm text-sm leading-6 text-muted-foreground"> <p className="mx-auto max-w-sm text-sm leading-6 text-muted-foreground"></p>
</p>
</div> </div>
<Button size="lg" onClick={onDashboard}></Button> <Button size="lg" onClick={onDashboard}></Button>
</CardContent> </CardContent>

View File

@@ -49,7 +49,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
)} )}
{status !== "booting" && providers.length === 0 && ( {status !== "booting" && providers.length === 0 && (
<p className="py-4 text-center text-muted-foreground"></p> <p className="py-4 text-center text-muted-foreground"></p>
)} )}
{!payment && status !== "booting" && providers.length > 0 && ( {!payment && status !== "booting" && providers.length > 0 && (
@@ -74,7 +74,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
{payment && status === "waiting" && ( {payment && status === "waiting" && (
<div className="space-y-3 rounded-lg border border-primary/15 bg-primary/10 px-4 py-3 text-center text-sm text-primary"> <div className="space-y-3 rounded-lg border border-primary/15 bg-primary/10 px-4 py-3 text-center text-sm text-primary">
<p className="animate-pulse font-semibold"></p> <p className="animate-pulse font-semibold"></p>
<p className="text-xs leading-5 text-muted-foreground"></p> <p className="text-xs leading-5 text-muted-foreground"></p>
</div> </div>
)} )}
@@ -101,7 +101,7 @@ export function PayPageClient({ orderId }: { orderId: string }) {
size="lg" size="lg"
variant="destructive" variant="destructive"
title="取消这笔订单?" title="取消这笔订单?"
description="取消会释放本次保留名额,你可以重新选择套餐并创建新的订单。" description="取消会释放本次保留名额。"
confirmLabel="取消订单" confirmLabel="取消订单"
successMessage="订单已取消" successMessage="订单已取消"
errorMessage="取消订单失败" errorMessage="取消订单失败"

View File

@@ -9,6 +9,7 @@ export interface PaymentInfo {
paymentUrl?: string; paymentUrl?: string;
qrCode?: string; qrCode?: string;
raw?: { raw?: {
status?: string;
walletAddress?: string; walletAddress?: string;
usdtAmount?: string; usdtAmount?: string;
cnyAmount?: string; cnyAmount?: string;

View File

@@ -63,6 +63,10 @@ export function usePaymentFlow(orderId: string) {
}); });
setPayment(data); setPayment(data);
if (provider === "balance" || data.raw?.status === "paid") {
setStatus("paid");
return;
}
setStatus("waiting"); setStatus("waiting");
if ( if (

View File

@@ -38,7 +38,7 @@ export function AccountInviteCard({
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -28,7 +28,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription>使 6 </CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -51,7 +51,7 @@ export function AccountPasswordCard({ email, isSaving, onSubmit }: AccountPasswo
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="inline-flex items-center gap-2 text-xs leading-5 text-muted-foreground"> <p className="inline-flex items-center gap-2 text-xs leading-5 text-muted-foreground">
<ShieldCheck className="size-3.5 text-primary" /> <ShieldCheck className="size-3.5 text-primary" />
</p> </p>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto"> <Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "更新中..." : "更新密码"} {isSaving ? "更新中..." : "更新密码"}

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