mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd3157177 | ||
|
|
8034392408 | ||
|
|
f4d71ca526 | ||
|
|
e718d5edab | ||
|
|
b2a50514a4 | ||
|
|
0c8b402f3e | ||
|
|
035ac9266a | ||
|
|
018bed3f36 | ||
|
|
6d6489817d | ||
|
|
4dd2f9280f | ||
|
|
b8a7cab1af | ||
|
|
157f3841f6 | ||
|
|
c5592621a4 | ||
|
|
6ee9cf2857 |
@@ -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
141
README.md
@@ -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 Compose,standalone 部署会调用 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 volume,root 执行时保存到 `/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
|
||||||
|
|
||||||
节点接入流程:
|
节点接入流程:
|
||||||
|
|||||||
@@ -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 email:user@example.com-cmojtnp3
|
Xray client email:user@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` 的错误。
|
||||||
|
|
||||||
|
|||||||
60
docs/API.md
60
docs/API.md
@@ -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 小时过期时间。
|
||||||
|
|
||||||
## 错误处理约定
|
## 错误处理约定
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
603
scripts/install-jetboard-standalone.sh
Executable file
603
scripts/install-jetboard-standalone.sh
Executable 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
531
scripts/jetboard.sh
Executable 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
191
scripts/lib-standalone-profile.sh
Executable 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
57
scripts/reset-admin.ts
Normal 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
174
scripts/uninstall-jboard-panel.sh
Executable 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 "$@"
|
||||||
158
scripts/uninstall-jetboard-standalone.sh
Executable file
158
scripts/uninstall-jetboard-standalone.sh
Executable 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 "$@"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
115
src/actions/admin/recharge-cards.ts
Normal file
115
src/actions/admin/recharge-cards.ts
Normal 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 };
|
||||||
|
}
|
||||||
153
src/actions/admin/recharge-orders.ts
Normal file
153
src/actions/admin/recharge-orders.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
25
src/actions/admin/subscription-transfers.ts
Normal file
25
src/actions/admin/subscription-transfers.ts
Normal 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;
|
||||||
|
}
|
||||||
97
src/actions/user/subscription-transfer.ts
Normal file
97
src/actions/user/subscription-transfer.ts
Normal 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 失败") };
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/actions/user/wallet.ts
Normal file
63
src/actions/user/wallet.ts
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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="删除失败"
|
||||||
|
|||||||
@@ -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">
|
||||||
{users.map((user) => (
|
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||||||
<option key={user.id} value={user.id}>
|
</SelectTrigger>
|
||||||
{user.email}
|
<SelectContent align="start">
|
||||||
</option>
|
<SelectItem value="">不指定</SelectItem>
|
||||||
))}
|
{users.map((user) => (
|
||||||
</select>
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
{users.map((user) => (
|
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
|
||||||
<option key={user.id} value={user.id}>
|
</SelectTrigger>
|
||||||
{user.email}
|
<SelectContent align="start">
|
||||||
</option>
|
<SelectItem value="">不指定</SelectItem>
|
||||||
))}
|
{users.map((user) => (
|
||||||
</select>
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
|
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p>
|
||||||
</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>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
||||||
</div>
|
</div>
|
||||||
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
<StatusBadge tone="info">减 ¥{Number(rule.discountAmount).toFixed(2)}</StatusBadge>
|
||||||
|
<StatusBadge>门槛 ¥{Number(rule.thresholdAmount).toFixed(2)}</StatusBadge>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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"}>
|
||||||
<InboundsTab node={node} />
|
{node.inbounds.length} 个
|
||||||
</TabsContent>
|
</StatusBadge>
|
||||||
</Tabs>
|
</div>
|
||||||
|
<InboundsTab node={node} />
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<EmptyState
|
<div className="surface-card rounded-xl p-5">
|
||||||
title="暂无已同步入站"
|
<EmptyState
|
||||||
description="请先在 3x-ui 面板创建入站,然后回到节点列表点击测试并同步入站。"
|
title="暂无已同步入站"
|
||||||
/>
|
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>
|
||||||
<InboundDisplayNameForm
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
inboundId={inbound.id}
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
defaultValue={getDisplayName(inbound)}
|
<StatusBadge tone="info">{inbound.protocol}</StatusBadge>
|
||||||
/>
|
<Badge variant="outline">:{inbound.port}</Badge>
|
||||||
</CardTitle>
|
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">{inbound.tag}</span>
|
||||||
|
</div>
|
||||||
|
<InboundDisplayNameForm
|
||||||
|
inboundId={inbound.id}
|
||||||
|
defaultValue={getDisplayName(inbound)}
|
||||||
|
/>
|
||||||
</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">
|
||||||
<InboundDeleteButton inboundId={inbound.id} />
|
<InboundMeta label="客户端" value={inbound.clients.length} />
|
||||||
</div>
|
{network && <InboundMeta label="传输" value={network} />}
|
||||||
</CardHeader>
|
{security && <InboundMeta label="安全" value={security} />}
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span>客户端: {inbound.clients.length}</span>
|
<div className="flex justify-start xl:justify-end">
|
||||||
{inbound.streamSettings && typeof inbound.streamSettings === "object" && (
|
<InboundDeleteButton inboundId={inbound.id} />
|
||||||
<>
|
</div>
|
||||||
{(inbound.streamSettings as Record<string, unknown>).network && (
|
</article>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: "sm", className: "w-fit" })}
|
||||||
className={buttonVariants({ variant: "ghost", size: "icon" })}
|
>
|
||||||
>
|
<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">
|
||||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
<Server className="size-5" />
|
||||||
{getNodeStatusLabel(node.status)}
|
</span>
|
||||||
</StatusBadge>
|
<div className="min-w-0">
|
||||||
}
|
<div className="flex min-h-7 flex-wrap items-center gap-2">
|
||||||
className="flex-1"
|
<h1 className="min-w-0 truncate text-2xl font-semibold leading-8 tracking-tight">{node.name}</h1>
|
||||||
/>
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
</div>
|
{getNodeStatusLabel(node.status)}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<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 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>
|
||||||
|
|||||||
@@ -1,100 +1,125 @@
|
|||||||
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="size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
/>
|
||||||
/>
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<Server className="size-4" />
|
||||||
<Server className="size-5" />
|
</span>
|
||||||
</span>
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
<CardTitle className="text-lg">
|
<h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">
|
||||||
<Link href={`/admin/nodes/${node.id}`} className="hover:underline">
|
{node.name}
|
||||||
{node.name}
|
</h3>
|
||||||
</Link>
|
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
||||||
</CardTitle>
|
{getNodeStatusLabel(node.status)}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
</StatusBadge>
|
||||||
{node.panelUrl || "未配置面板"} · {node._count.inbounds} 个入站
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<NodeInlineMeta node={node} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<StatusBadge tone={node.status === "active" ? "success" : "neutral"}>
|
|
||||||
{getNodeStatusLabel(node.status)}
|
<InboundPreview node={node} />
|
||||||
</StatusBadge>
|
|
||||||
<NodeForm
|
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||||
node={{
|
<Link
|
||||||
id: node.id,
|
href={`/admin/nodes/${node.id}`}
|
||||||
name: node.name,
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
panelUrl: node.panelUrl,
|
>
|
||||||
panelUsername: node.panelUsername,
|
<Eye className="size-3.5" />
|
||||||
}}
|
详情
|
||||||
triggerLabel="编辑"
|
</Link>
|
||||||
triggerVariant="outline"
|
<NodeForm
|
||||||
/>
|
node={{
|
||||||
<NodeActions
|
id: node.id,
|
||||||
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
name: node.name,
|
||||||
siteUrl={siteUrl}
|
panelUrl: node.panelUrl,
|
||||||
/>
|
panelUsername: node.panelUsername,
|
||||||
</div>
|
}}
|
||||||
</CardHeader>
|
triggerLabel="编辑"
|
||||||
<CardContent className="space-y-4">
|
triggerVariant="outline"
|
||||||
<PanelInfoBar node={node} />
|
/>
|
||||||
{node.inbounds.length > 0 ? (
|
<NodeActions
|
||||||
<div className="grid gap-2 rounded-lg bg-muted/20 p-3">
|
node={{ id: node.id, name: node.name, agentToken: node.agentToken }}
|
||||||
{node.inbounds.map((inbound) => (
|
siteUrl={siteUrl}
|
||||||
<div
|
/>
|
||||||
key={inbound.id}
|
</div>
|
||||||
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"
|
</article>
|
||||||
>
|
|
||||||
<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 && (
|
||||||
<EmptyState
|
<div className="p-5">
|
||||||
title="暂无节点"
|
<EmptyState
|
||||||
description="添加 3x-ui 节点后,可以同步入站并绑定到代理套餐。"
|
title="暂无节点"
|
||||||
action={<NodeForm triggerLabel="添加节点" />}
|
description="添加节点后同步入站并绑定套餐。"
|
||||||
/>
|
action={<NodeForm triggerLabel="添加节点" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 handleSync() {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await testNodeConnection(node.id);
|
||||||
|
if (res.success) toast.success(res.message);
|
||||||
|
else toast.error(getErrorMessage(res.message, "节点测试失败"));
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "测试失败"));
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGenerateToken() {
|
async function handleGenerateToken() {
|
||||||
|
setTokenLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = await generateAgentToken(node.id);
|
const token = await generateAgentToken(node.id);
|
||||||
|
setHasToken(true);
|
||||||
setPlainToken(token);
|
setPlainToken(token);
|
||||||
setInstallCommand(buildInstallCommand(token, siteUrl));
|
setInstallCommand(buildInstallCommand(token, siteUrl));
|
||||||
setTokenDialogOpen(true);
|
setTokenDialogOpen(true);
|
||||||
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "生成 Token 失败"));
|
toast.error(getErrorMessage(error, "生成 Token 失败"));
|
||||||
|
} finally {
|
||||||
|
setTokenLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>...</DropdownMenuTrigger>
|
type="button"
|
||||||
<DropdownMenuContent>
|
variant="outline"
|
||||||
<DropdownMenuItem
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={() => void handleSync()}
|
||||||
try {
|
disabled={syncing}
|
||||||
const res = await testNodeConnection(node.id);
|
>
|
||||||
if (res.success) toast.success(res.message);
|
<RefreshCw className={cn("size-3.5", syncing && "animate-spin")} />
|
||||||
else toast.error(getErrorMessage(res.message, "节点测试失败"));
|
{syncing ? "同步中" : "同步"}
|
||||||
} catch (error) {
|
</Button>
|
||||||
toast.error(getErrorMessage(error, "测试失败"));
|
|
||||||
}
|
<Button
|
||||||
}}
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
测试并同步入站
|
size="sm"
|
||||||
</DropdownMenuItem>
|
onClick={() => void handleGenerateToken()}
|
||||||
<DropdownMenuItem onClick={handleGenerateToken}>
|
disabled={tokenLoading}
|
||||||
{hasToken ? "重新生成探测 Token" : "生成探测 Token"}
|
>
|
||||||
</DropdownMenuItem>
|
<KeyRound className="size-3.5" />
|
||||||
</DropdownMenuContent>
|
{hasToken ? "重置 Token" : "Token"}
|
||||||
</DropdownMenu>
|
</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 log,找到后启用节点日志风控。Agent 只读日志,不修改 3x-ui 配置。
|
Agent 只读日志,用于延迟、路径和风控上报。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<DialogDescription>连接 3x-ui 面板并同步可售入站。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={isEdit ? handleEdit : handleCreate} className="form-panel space-y-5">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<DialogBody className="flex-1 px-4 py-3">
|
||||||
<div>
|
<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">
|
||||||
<Label>节点名称</Label>
|
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
||||||
<Input name="name" defaultValue={node?.name ?? ""} placeholder="如 HK-01" />
|
<div className="mb-2 text-xs font-semibold">基础信息</div>
|
||||||
</div>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>3x-ui 面板地址</Label>
|
<Label htmlFor={nameId}>节点名称</Label>
|
||||||
<Input name="panelUrl" defaultValue={node?.panelUrl ?? ""} placeholder="http://1.2.3.4:2053" required />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
||||||
<div>
|
<div className="mb-2 text-xs font-semibold">面板凭据</div>
|
||||||
<Label>面板用户名</Label>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Input name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor={panelUsernameId}>面板用户名</Label>
|
||||||
<div>
|
<Input id={panelUsernameId} name="panelUsername" defaultValue={node?.panelUsername ?? ""} required />
|
||||||
<Label>面板密码</Label>
|
</div>
|
||||||
<Input
|
<div className="space-y-2">
|
||||||
name="panelPassword"
|
<Label htmlFor={panelPasswordId}>面板密码</Label>
|
||||||
type="password"
|
<Input
|
||||||
placeholder={isEdit ? "留空则沿用当前密码" : "请输入面板密码"}
|
id={panelPasswordId}
|
||||||
required={!isEdit}
|
name="panelPassword"
|
||||||
autoComplete="new-password"
|
type="password"
|
||||||
/>
|
placeholder={isEdit ? "留空不变" : "面板密码"}
|
||||||
|
required={!isEdit}
|
||||||
|
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 ? "保存中..." : "创建中..."}>
|
||||||
</PendingSubmitButton>
|
{isEdit ? "保存并同步" : "创建并同步"}
|
||||||
|
</PendingSubmitButton>
|
||||||
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</DialogBody>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,45 +58,78 @@ 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",
|
? [
|
||||||
value: filters.kind,
|
{
|
||||||
options: [
|
name: "kind",
|
||||||
{ label: "全部类型", value: "" },
|
value: orderData!.filters.kind,
|
||||||
{ label: "新购", value: "NEW_PURCHASE" },
|
options: [
|
||||||
{ label: "续费", value: "RENEWAL" },
|
{ label: "全部类型", value: "" },
|
||||||
{ label: "增流量", value: "TRAFFIC_TOPUP" },
|
{ label: "新购", value: "NEW_PURCHASE" },
|
||||||
],
|
{ label: "续费", value: "RENEWAL" },
|
||||||
},
|
{ label: "增流量", value: "TRAFFIC_TOPUP" },
|
||||||
{
|
],
|
||||||
name: "reviewStatus",
|
},
|
||||||
value: filters.reviewStatus,
|
{
|
||||||
options: [
|
name: "reviewStatus",
|
||||||
{ label: "全部审查", value: "" },
|
value: orderData!.filters.reviewStatus,
|
||||||
{ label: "正常", value: "NORMAL" },
|
options: [
|
||||||
{ label: "异常", value: "FLAGGED" },
|
{ label: "全部审查", value: "" },
|
||||||
{ label: "已解决", value: "RESOLVED" },
|
{ label: "正常", value: "NORMAL" },
|
||||||
],
|
{ label: "异常", value: "FLAGGED" },
|
||||||
},
|
{ label: "已解决", value: "RESOLVED" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
>
|
||||||
<OrdersTable orders={orders} />
|
<input type="hidden" name="tab" value={activeTab} />
|
||||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
</AdminFilterBar>
|
||||||
|
{activeTab === "orders" ? (
|
||||||
|
<>
|
||||||
|
<OrdersTable orders={orderData!.orders} />
|
||||||
|
<Pagination
|
||||||
|
total={orderData!.total}
|
||||||
|
pageSize={orderData!.pageSize}
|
||||||
|
page={orderData!.page}
|
||||||
|
fixedParams={{ tab: "orders" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RechargeOrdersTable rechargeOrders={rechargeData!.rechargeOrders} />
|
||||||
|
<Pagination
|
||||||
|
total={rechargeData!.total}
|
||||||
|
pageSize={rechargeData!.pageSize}
|
||||||
|
page={rechargeData!.page}
|
||||||
|
fixedParams={{ tab: "recharge" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/app/(admin)/admin/orders/recharge-order-actions.tsx
Normal file
84
src/app/(admin)/admin/orders/recharge-order-actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,125 +170,168 @@ 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">
|
<>
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<CreditCard className="size-4" />
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
</span>
|
<CreditCard className="size-4" />
|
||||||
<div className="min-w-0">
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="min-w-0">
|
||||||
<h3 className="text-base font-semibold tracking-tight">{providerName}</h3>
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
|
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
|
||||||
|
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
|
||||||
|
{checkboxSummaries.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{checkboxSummaries.slice(0, 2).map((label) => (
|
||||||
|
<StatusBadge key={label} tone="info">{label}</StatusBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">{providerDescription}</p>
|
|
||||||
{checkboxSummaries.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{checkboxSummaries.slice(0, 2).map((label) => (
|
|
||||||
<StatusBadge key={label} tone="info">{label}</StatusBadge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-start lg:justify-end">
|
<div className="flex items-center justify-start lg:justify-end">
|
||||||
<BooleanToggle
|
<BooleanToggle
|
||||||
className="w-full lg:w-40"
|
className="w-full lg:w-40"
|
||||||
value={enabled}
|
value={enabled}
|
||||||
onChange={(value) => void handleStatusToggle(value)}
|
onChange={(value) => void handleStatusToggle(value)}
|
||||||
trueLabel="启用"
|
trueLabel="启用"
|
||||||
falseLabel="停用"
|
falseLabel="停用"
|
||||||
ariaLabel={`${providerName}状态`}
|
ariaLabel={`${providerName}状态`}
|
||||||
disabled={saving || statusSaving}
|
disabled={saving || statusSaving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
|
||||||
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
|
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
|
||||||
<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">
|
||||||
{fields.map((field) =>
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
field.type === "checkboxes" ? (
|
{fields.map((field) =>
|
||||||
<div key={field.key} className="sm:col-span-2">
|
field.type === "checkboxes" ? (
|
||||||
<Label>{field.label}</Label>
|
<div key={field.key} className="sm:col-span-2">
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
<Label>{field.label}</Label>
|
||||||
{field.options?.map((option) => {
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
|
{field.options?.map((option) => {
|
||||||
return (
|
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
|
||||||
<button
|
return (
|
||||||
key={option.value}
|
<button
|
||||||
type="button"
|
key={option.value}
|
||||||
aria-pressed={selected}
|
type="button"
|
||||||
onClick={() => toggleCheckbox(field.key, option.value)}
|
aria-pressed={selected}
|
||||||
className={cn(
|
onClick={() => toggleCheckbox(field.key, option.value)}
|
||||||
"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",
|
className={cn(
|
||||||
selected
|
"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",
|
||||||
? "border-primary/35 bg-primary/10 text-primary"
|
selected
|
||||||
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
|
? "border-primary/35 bg-primary/10 text-primary"
|
||||||
)}
|
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
|
||||||
>
|
)}
|
||||||
<span className="truncate">{option.label}</span>
|
>
|
||||||
{selected && <Check className="size-4 shrink-0" />}
|
<span className="whitespace-nowrap">{option.label}</span>
|
||||||
</button>
|
{selected && <Check className="size-4 shrink-0" />}
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={field.key} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
id={`${provider}-${field.key}`}
|
||||||
|
name={field.key}
|
||||||
|
type={field.secret ? "password" : "text"}
|
||||||
|
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
|
||||||
|
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
|
||||||
|
/>
|
||||||
|
{field.secret && secretConfigured[field.key] && (
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">已保存,留空不变。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Label className="whitespace-nowrap text-xs font-semibold">支付通道状态</Label>
|
||||||
|
<InlineHelp align="start">开关即时生效。</InlineHelp>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex justify-start sm:justify-end">
|
||||||
<div key={field.key} className="space-y-2">
|
<StatusBadge tone={enabled ? "success" : "neutral"}>
|
||||||
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
|
{enabled ? "已启用" : "已停用"}
|
||||||
<Input
|
</StatusBadge>
|
||||||
id={`${provider}-${field.key}`}
|
|
||||||
name={field.key}
|
|
||||||
type={field.secret ? "password" : "text"}
|
|
||||||
placeholder={field.secret && secretConfigured[field.key] ? "留空保持不变" : field.placeholder}
|
|
||||||
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
|
|
||||||
/>
|
|
||||||
{field.secret && secretConfigured[field.key] && (
|
|
||||||
<p className="text-xs leading-5 text-muted-foreground">当前密钥已保存,重新填写才会覆盖。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-muted/20 p-3">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-semibold">支付通道状态</Label>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">启停在列表行即时生效;启用前必须保证必填项完整。</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-start sm:justify-end">
|
|
||||||
<StatusBadge tone={enabled ? "success" : "neutral"}>
|
|
||||||
{enabled ? "已启用" : "已停用"}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<DialogFooter className="-mx-4 -mb-3">
|
||||||
|
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="h-8" disabled={saving}>
|
||||||
|
{saving ? "保存中..." : "保存配置"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={saving}>
|
<Button
|
||||||
取消
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBalanceDisableOpen(false)}
|
||||||
|
disabled={statusSaving}
|
||||||
|
>
|
||||||
|
保持开启
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={saving}>
|
<Button
|
||||||
{saving ? "保存中..." : "保存配置"}
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setBalanceDisableOpen(false);
|
||||||
|
void commitStatusToggle(false);
|
||||||
|
}}
|
||||||
|
disabled={statusSaving}
|
||||||
|
>
|
||||||
|
{statusSaving ? "处理中..." : "确认关闭"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
)}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export async function getPaymentProviderConfigs() {
|
|||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
|
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
|
||||||
}
|
}
|
||||||
: null,
|
: provider.id === "balance"
|
||||||
|
? { enabled: true, config: {} }
|
||||||
|
: null,
|
||||||
secretConfigured: configValue
|
secretConfigured: configValue
|
||||||
? getPaymentSecretConfiguredState(provider.id, configValue)
|
? getPaymentSecretConfiguredState(provider.id, configValue)
|
||||||
: {},
|
: {},
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<EmptyState
|
<div className="p-5">
|
||||||
title="暂无套餐"
|
<EmptyState
|
||||||
description="创建第一个套餐后,用户就可以在商店中购买。"
|
title="暂无套餐"
|
||||||
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
description="创建第一个套餐后,用户就可以在商店中购买。"
|
||||||
/>
|
action={<PlanForm services={services} triggerLabel="创建套餐" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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="删除失败"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
<input
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
form={batchFormId}
|
||||||
<input
|
type="checkbox"
|
||||||
form={batchFormId}
|
name="planIds"
|
||||||
type="checkbox"
|
value={plan.id}
|
||||||
name="planIds"
|
aria-label={`选择套餐 ${plan.name}`}
|
||||||
value={plan.id}
|
className="size-5 rounded-lg border-border accent-primary shadow-sm"
|
||||||
aria-label={`选择套餐 ${plan.name}`}
|
/>
|
||||||
className="mt-3 size-5 rounded-lg border-border accent-primary shadow-sm"
|
<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-4" />
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
</span>
|
||||||
<Icon className="size-5" />
|
<div className="min-w-0">
|
||||||
</span>
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
<div className="min-w-0 space-y-1.5">
|
<h3 className="min-w-0 truncate text-base font-semibold leading-6 tracking-tight">{plan.name}</h3>
|
||||||
<CardTitle className="text-lg text-balance">{plan.name}</CardTitle>
|
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
||||||
<p className="text-sm leading-6 text-muted-foreground text-pretty">
|
{plan.type === "PROXY" ? "代理" : "流媒体"}
|
||||||
{plan.description || "无描述"} · 总订阅 {plan._count.subscriptions}
|
</StatusBadge>
|
||||||
</p>
|
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PlanActions
|
|
||||||
isActive={plan.isActive}
|
|
||||||
services={services}
|
|
||||||
plan={planFormValue}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
||||||
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
|
<span className={`text-lg font-semibold tabular-nums ${stock.empty ? "text-destructive" : "text-foreground"}`}>
|
||||||
{plan.type === "PROXY" ? "代理套餐" : "流媒体套餐"}
|
{stock.value}
|
||||||
</StatusBadge>
|
</span>
|
||||||
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
|
<span className="text-xs font-medium text-muted-foreground">{stock.hint}</span>
|
||||||
<StatusBadge>{plan.durationDays} 天</StatusBadge>
|
</div>
|
||||||
<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>
|
<div className="flex justify-start lg:justify-end">
|
||||||
{plan.type === "PROXY" ? (
|
<PlanActions
|
||||||
<DetailList>
|
isActive={plan.isActive}
|
||||||
<DetailItem label="节点">{plan.node?.name ?? "未绑定"}</DetailItem>
|
services={services}
|
||||||
<DetailItem label="入站">
|
plan={planFormValue}
|
||||||
{plan.inboundOptions.length > 0
|
/>
|
||||||
? plan.inboundOptions
|
</div>
|
||||||
.map((option) => `${option.inbound.protocol}:${option.inbound.port}`)
|
</section>
|
||||||
.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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
value={allowRenewal}
|
||||||
|
onChange={setAllowRenewal}
|
||||||
|
trueLabel="开放"
|
||||||
|
falseLabel="关闭"
|
||||||
|
ariaLabel="开放续费"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</PolicyToggleRow>
|
||||||
|
{type === "PROXY" && (
|
||||||
|
<PolicyToggleRow
|
||||||
|
labelId={fieldId("allowTrafficTopup-label")}
|
||||||
|
label="开放增流量"
|
||||||
|
help="用户可在订阅页自助购买额外流量。"
|
||||||
|
>
|
||||||
<BooleanToggle
|
<BooleanToggle
|
||||||
value={allowRenewal}
|
value={allowTrafficTopup}
|
||||||
onChange={setAllowRenewal}
|
onChange={setAllowTrafficTopup}
|
||||||
trueLabel="开放"
|
trueLabel="开放"
|
||||||
falseLabel="关闭"
|
falseLabel="关闭"
|
||||||
ariaLabel="开放续费"
|
ariaLabel="开放增流量"
|
||||||
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PolicyToggleRow>
|
||||||
</div>
|
|
||||||
{type === "PROXY" && (
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/20 p-3">
|
|
||||||
<div>
|
|
||||||
<p id={fieldId("allowTrafficTopup-label")} className="text-sm font-medium">开放增流量</p>
|
|
||||||
<p className="text-xs text-muted-foreground">用户可拖动选择加多少 GB</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-40">
|
|
||||||
<BooleanToggle
|
|
||||||
value={allowTrafficTopup}
|
|
||||||
onChange={setAllowTrafficTopup}
|
|
||||||
trueLabel="开放"
|
|
||||||
falseLabel="关闭"
|
|
||||||
ariaLabel="开放增流量"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="删除失败"
|
||||||
|
|||||||
@@ -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 password: xxx"
|
: "email: xxx 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">
|
||||||
{isEdit ? "保存" : "创建"}
|
<PendingSubmitButton className="h-8 w-full sm:w-auto" pendingLabel={isEdit ? "保存中..." : "创建中..."}>
|
||||||
</PendingSubmitButton>
|
{isEdit ? "保存" : "创建"}
|
||||||
|
</PendingSubmitButton>
|
||||||
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
</DialogBody>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
<h3 className="text-lg font-semibold">全局设置</h3>
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">把注册策略、自动化任务和公告内容集中配置,避免页面状态割裂。</p>
|
<h3 className="text-lg font-semibold">全局设置</h3>
|
||||||
|
<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}
|
|
||||||
>
|
>
|
||||||
{logCleanupTargetOptions.map((option) => (
|
<SelectTrigger id="manualCleanupTarget" className="w-full">
|
||||||
<option key={option.value} value={option.value}>
|
<SelectValue />
|
||||||
{option.label}
|
</SelectTrigger>
|
||||||
</option>
|
<SelectContent align="start">
|
||||||
))}
|
{logCleanupTargetOptions.map((option) => (
|
||||||
</select>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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">
|
||||||
{coupons.map((coupon) => (
|
<SelectValue />
|
||||||
<option key={coupon.id} value={coupon.id}>
|
</SelectTrigger>
|
||||||
{coupon.name} · {coupon.code}
|
<SelectContent align="start">
|
||||||
</option>
|
<SelectItem value="">不发放优惠券</SelectItem>
|
||||||
))}
|
{coupons.map((coupon) => (
|
||||||
</select>
|
<SelectItem key={coupon.id} value={coupon.id}>
|
||||||
|
{coupon.name} · {coupon.code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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"}>
|
||||||
|
|||||||
@@ -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>
|
||||||
<UserStatusBadge status={event.user.status} />
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<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="跨地区访问异常会进入人工队列。"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="商品与订单"
|
eyebrow="商品与订单"
|
||||||
title="订阅风控"
|
title="订阅风控"
|
||||||
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
description="集中处理跨城市、跨省份访问异常。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminFilterBar
|
<AdminFilterBar
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal file
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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="删除失败"
|
||||||
|
|||||||
@@ -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>
|
||||||
仅支持 JPG、PNG、WEBP、GIF、AVIF 图片,最多 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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="删除失败"
|
||||||
|
|||||||
@@ -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 ? "保存" : "创建"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="取消订单失败"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user