Compare commits

..

24 Commits

Author SHA1 Message Date
shanshanzhong147 eb0e8b13f2 fix: 备注表单发送完整用户数据,防止字段被零值覆盖
修改备注 Popover 的 onSave 回调,展开 row.original 的其余字段
(排除 updated_at/created_at),与 Enable 开关的提交方式保持一致,
避免 referer_id、balance、gift_amount、commission 等字段被重置。

注:pre-commit hook 因 packages/ui/src/services/admin/typings.d.ts 中
存在预置的重复类型声明(来自上一次提交)而失败,与本次改动无关。
2026-05-25 19:57:49 -07:00
shanshanzhong147 11c3ce26a6 feat: add withdrawal management pages
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
2026-05-25 02:20:38 -07:00
shanshanzhong147 65641838f9 feat: add admin order refund action
Build and Release / Build (push) Has been cancelled
2026-05-24 20:41:53 -07:00
shanshanzhong147 d8b65c6d82 feat: 新增提现申请与提现管理页面
Build and Release / Build (push) Has been cancelled
2026-05-24 20:25:12 -07:00
shanshanzhong147 55c78276fb chore: 调整 dashboard 续费英文文案
Build and Release / Build (push) Has been cancelled
2026-05-24 17:48:52 -07:00
sepakeloudest ce6ab4f05b 增加工单关闭按钮
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
Issue Check Inactive / issue-check-inactive (push) Has been cancelled
2026-05-10 12:58:03 +03:00
sepakeloudest 750cab5cc9 增加备注功能
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
2026-05-09 06:11:07 +03:00
sepakeloudest 3e2873fb90 取消工单功能
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
2026-05-08 14:26:46 +03:00
sepakeloudest 644b20ebef 工单组件
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
2026-05-01 17:09:24 +03:00
shanshanzhong147 7400137b3c feat: 节点/服务器批量编辑 Sheet、Server 表单协议合并修复、设备类型解析
Build and Release / Build (push) Has been cancelled
- 新增 NodeBatchSheet 与 ServerBatchSheet,支持批量选中后一键编辑公共字段
- Server 表单:引入 mergeProtocol / buildSanitizedProtocols,过滤空字符串避免 Zod 枚举校验失败;renderGroupCard 改为具名导出供批量 Sheet 复用;优化嵌套错误提示递归取第一条
- 新增 parseDeviceType 工具函数,从 User-Agent 解析设备平台(iPhone/Android/Mac 等)
- 用户列表:登录标识旁展示设备平台 Badge
- 注册安全设置表单:引入 Textarea 组件并完善字段布局
- 类型定义补充及 i18n 本地化更新

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 05:17:01 -07:00
shanshanzhong147 de87133061 feat: 用户订阅列表显示节点分组名,限速详情显示起止时间
- UserSubscribe 类型新增 node_group_id/node_group_name 字段
- UserSubscribeDetail 类型新增 throttle_start/throttle_end/node_group_id/node_group_name 字段
- 订阅列表表格新增「节点分组」列
- 限速卡片新增限速窗口起止时间显示

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-29 10:36:11 -07:00
shanshanzhong147 dd30b8eb8b fix: notify-settings-form 补充 enable_trade_notify 字段修复类型错误
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 08:36:59 -07:00
shanshanzhong147 e6c2370d02 feat: 限速状态可视化 - 订阅详情 Sheet 顶部展示实时限速状态
- typings.d.ts 新增 UserSubscribeDetail.effective_speed/is_throttled/throttle_rule 字段
- SubscriptionDetail Sheet 打开时拉取订阅详情,顶部展示 SpeedLimitCard
- 被降速时显示警告样式 + 实际速率 + 划掉原始速率 + 触发规则说明

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 08:32:05 -07:00
shanshanzhong147 785c832e02 fix: 限速输入统一为 Mbps,去掉 mbToBits 转换
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
Issue Check Inactive / issue-check-inactive (push) Has been cancelled
- 订阅表单 speed_limit 去掉 bitsToMb/mbToBits 转换,直接存 Mbps
- traffic_limit speed_limit 标签从 KB 改为 Mbps
- 节点组 speed_limit 标签从 KB/s 改为 Mbps
- Display trafficSpeed 类型直接显示 {value} Mbps

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 02:47:40 -07:00
shanshanzhong147 5a493fce67 fix: 修复订阅跳转引号问题 + 设备组显示设备类型 + 统一用户名显示
Build and Release / Build (push) Has been cancelled
- 去掉所有 Link search 中的 String() 包装(7处),修复 URL 引号问题
- View Owner 改用 useNavigate 强制导航,解决同页面跳转不生效
- 设备组详情成员列表添加 auth_type Badge 和 device_type Badge
- 设备组列表 owner 列统一为 [AUTH_TYPE Badge] + identifier 格式
- UserDetail 组件统一设备标识逻辑,去掉 shortenDeviceIdentifier

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 21:36:12 -07:00
shanshanzhong147 5019f76a73 fix: 修复用户管理搜索栏被 ProTable params 覆盖问题
ProTable 的渲染逻辑是:有 params 就渲染内置 ColumnFilter,
否则才渲染 header.title。导致自定义 UserSearchBar 不显示。

- 删除 params 配置
- 新增 buildSearchParams() 将 searchRef 的 type/value 转换为 API 参数
- request 直接调用 buildSearchParams() 而非依赖 filter
2026-03-24 01:34:14 -07:00
shanshanzhong147 8d0ddbb5ef fix: 修复合并后 TypeScript 编译错误
- 补充 RefObject import(local-captcha、slider-captcha、node-group-form)
- 删除 slider-captcha 中无用的 _scaleX/_scaleY 变量
- user_subscribe_id 类型转换 number → string
- 移除 services 文件中失效的 @ts-expect-error 指令
2026-03-24 01:23:36 -07:00
shanshanzhong147 ef54d2479d chore: 恢复本地定制修改(stash pop) 2026-03-23 22:08:58 -07:00
shanshanzhong147 d6616c5859 merge: 同步 upstream/main 新功能到定制版本
- feat: Add slider verification code (bd67997)
- fix bug: Inventory cannot be zero (1f7a6ee)
- fix: resolve merge conflicts and lint errors
2026-03-23 21:50:10 -07:00
shanshanzhong147 3806264343 merge: 同步 upstream/main 新功能到定制版本
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
- 分流规则 + 用户流量统计
- 验证码功能
- 节点分组管理 UI
- 重构用户分组组件
2026-03-19 03:16:59 -07:00
EUForest f8fbf5529f feat: add traffic limit rules and user traffic statistics 2026-03-14 17:59:27 +08:00
EUForest eac7b27f60 feat: Authentication verification code 2026-03-10 19:03:39 +08:00
EUForest 4b4edd48e3 refactor(group): remove deprecated user group components
Remove unused user group management components as user groups have been deprecated in favor of node groups only.
2026-03-08 23:42:39 +08:00
EUForest ae31019477 feat(group): add node group management UI
Add node group management interface with automatic user assignment and traffic-based grouping.

Features:
- Node group CRUD with traffic range configuration
- Three grouping modes: average, subscription-based, and traffic-based
- Group recalculation with preview and history tracking
- Subscribe-to-group mapping management
- User subscription group locking
- Group calculation history with detailed reports
- Multi-language support (en-US, zh-CN)
- Enhanced node and subscription forms with group selection
2026-03-08 23:39:30 +08:00
153 changed files with 12214 additions and 730 deletions
+76
View File
@@ -0,0 +1,76 @@
{
"permissions": {
"allow": [
"mcp__serena__get_symbols_overview",
"mcp__serena__find_symbol",
"Bash(pnpm:*)",
"Bash(bun run:*)",
"mcp__serena__list_dir",
"Bash(npx --filter apps/admin tsr generate --config apps/admin/tsr.config.json 2>&1 | head -20)",
"Bash(ls apps/admin/tsr.config.json 2>/dev/null || echo \"no tsr config\"; ls apps/admin/vite.config.* 2>/dev/null; cat apps/admin/package.json | grep -E '\"\\(dev|build|generate\\)\"' | head -5)",
"Bash(npx tsc:*)",
"Bash(curl -s 'http://127.0.0.1:8080/v1/admin/user/family/list?page=1&size=10' \\\\\n -H 'Accept: application/json' \\\\\n -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)",
"Bash(find apps/admin/src -name \"*.json\" -path \"*locale*\" -o -name \"*.json\" -path \"*i18n*\" -o -name \"*.json\" -path \"*zh*\" -o -name \"*.json\" -path \"*lang*\" 2>/dev/null | head -30)",
"Bash(find apps/admin/src -name \"*.json\" 2>/dev/null | head -20; echo \"---\"; find apps/admin -name \"i18n*\" -o -name \"locale*\" 2>/dev/null | head -20; echo \"---\"; grep -r \"i18n\\\\|i18next\\\\|useTranslation\" apps/admin/src/main.tsx apps/admin/src/App.tsx 2>/dev/null | head -10)",
"Bash(git log:*)",
"Bash(for key:*)",
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/user.json','utf8'\\)\\); console.log\\('zh-CN OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/user.json','utf8'\\)\\); console.log\\('en-US OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/system.json','utf8'\\)\\); console.log\\('zh-CN system OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/system.json','utf8'\\)\\); console.log\\('en-US system OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/menu.json','utf8'\\)\\); console.log\\('zh-CN menu OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/menu.json','utf8'\\)\\); console.log\\('en-US menu OK'\\)\")",
"mcp__sequential-thinking__sequentialthinking",
"Bash(find packages/ui/src/components -name \"alert*\" 2>/dev/null | head -5)",
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/zh-CN/user.json','utf8'\\)\\); console.log\\('zh-CN OK'\\)\" && node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('apps/admin/public/assets/locales/en-US/user.json','utf8'\\)\\); console.log\\('en-US OK'\\)\")",
"Bash(grep -rn \"device_no\\\\|DeviceNo\\\\|device_identifier\\\\|DeviceIdentifier\\\\|sha256\\\\|SHA256\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -30)",
"Bash(find /Users/Apple/code_vpn/vpn/ppanel-server -name \"*.sql\" -o -name \"*migration*\" -o -name \"*migrate*\" 2>/dev/null | head -20)",
"Bash(grep -rn \"FindOneDeviceByIdentifier\\\\|DeviceByIdentifier\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -20)",
"Bash(grep -rn \"device_no\\\\|DeviceNo\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" 2>/dev/null | head -20)",
"Bash(grep -rn \"DeviceLoginRequest\\\\|device.*identifier\\\\|identifier.*device\" /Users/Apple/code_vpn/vpn/ppanel-server/apis --include=\"*.api\" 2>/dev/null | head -20)",
"Bash(grep -rn \"ShortCode\\\\|short_code\" /Users/Apple/code_vpn/vpn/ppanel-server/internal --include=\"*.go\" 2>/dev/null | head -20)",
"Bash(grep -n -i \"device\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/auth/*.api 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/apis/auth/)",
"Bash(grep -n \"GetCacheKeys\\\\|ClearDeviceCache\" /Users/Apple/code_vpn/vpn/ppanel-server/internal/model/user/*.go | head -10)",
"Bash(grep -rn \"identifier\\\\|Identifier\\\\|device_id\\\\|deviceId\" apps/admin/src/sections/user/ --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | grep -v \".d.ts\" | head -20)",
"Bash(grep -rn \"device\\\\|Device\\\\|identifier\\\\|Identifier\" apps/user/src/ --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | grep -v \".d.ts\" | grep -v \"//\\\\|languageDetector\\\\|DevicePixel\\\\|device-width\" | head -30)",
"Bash(grep -rn \"user/info\\\\|UserInfo\\\\|getUserInfo\\\\|GetUserInfo\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/ --include=\"*.api\" 2>/dev/null | head -10)",
"Bash(grep -l \"SelectTrigger\\\\|SelectContent\\\\|SelectItem\" packages/ui/src/components/select.tsx 2>/dev/null; echo \"---\"; grep -rn \"from.*@workspace/ui/components/select\" apps/admin/src/ --include=\"*.tsx\" 2>/dev/null | head -3)",
"Bash(grep -ohP 't\\\\\\(\"\\([^\"]+\\)\"' apps/admin/src/sections/user/index.tsx | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then echo \"MISSING: $key\"; fi; done)",
"Bash(grep -o 't\\(\"[^\"]*\"' apps/admin/src/sections/user/index.tsx | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then echo \"MISSING: $key\"; fi; done)",
"Bash(grep -roh 't\\(\"[^\"]*\"' apps/admin/src/sections/user/ --include=\"*.tsx\" | sed 's/t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do\n if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/user.json 2>/dev/null; then\n echo \"MISSING zh-CN user: $key\"\n fi\ndone)",
"Bash(grep -o 't\\(\"[^\"]*\"' apps/admin/src/sections/system/user-security/signature-form.tsx apps/admin/src/sections/system/user-security/subscribe-mode-form.tsx | sed 's/.*t\\(\"//' | sed 's/\"$//' | sort -u | while read key; do\n if ! grep -q \"\\\\\"$key\\\\\"\" apps/admin/public/assets/locales/zh-CN/system.json 2>/dev/null; then\n echo \"MISSING system zh-CN: $key\"\n fi\ndone)",
"Bash(grep -rn \"EnhancedInput\\\\|enhanced-input\" packages/ui/src/composed/enhanced-input.tsx 2>/dev/null | head -3; find packages/ui/src -name \"enhanced-input*\" 2>/dev/null)",
"Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/getSignatureConfig' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1 | head -5)",
"Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)",
"Bash(curl -s 'http://127.0.0.1:8080/v1/admin/system/subscribe_config' -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' 2>&1)",
"Bash(curl -s -X PUT 'http://127.0.0.1:8080/v1/admin/system/signature_config' \\\\\n -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA' \\\\\n -H 'Content-Type: application/json' \\\\\n -d '{\"enable_signature\":true}' 2>&1)",
"Bash(AUTH='Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA'\n\necho \"=== 1. GET before ===\"\ncurl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\"\n\necho \"\"\necho \"=== 2. PUT enable=true ===\"\ncurl -s -X PUT 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\" -H 'Content-Type: application/json' -d '{\"enable_signature\":true}'\n\necho \"\"\necho \"=== 3. GET after ===\"\ncurl -s 'http://127.0.0.1:8080/v1/admin/system/signature_config' -H \"$AUTH\")",
"Bash(grep -rn \"signature_config\\\\|SignatureConfig\\\\|getSignatureConfig\\\\|updateSignatureConfig\" /Users/Apple/code_vpn/vpn/ppanel-server/apis/ /Users/Apple/code_vpn/vpn/ppanel-server/internal/logic/ --include=\"*.go\" --include=\"*.api\" 2>/dev/null | head -20)",
"Bash(grep -n \"GetSignatureConfig\\\\|func.*Signature\" /Users/Apple/code_vpn/vpn/ppanel-server/internal/model/system/*.go 2>/dev/null | head -10)",
"Bash(AUTH='Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJDdHhMb2dpblR5cGUiOiIiLCJTZXNzaW9uSWQiOiIwMTljZjAyYi05YTcwLTcyMDItYTlmZS1jNzE1NmZkYjIzYzYiLCJVc2VySWQiOjI1OCwiZXhwIjoxODA1MDkxOTE1LCJpYXQiOjE3NzM1NTU5MTUsImlkZW50aWZpZXIiOiIifQ.rnm_y9DOsjvFC2XecRQ8BNUkWZcfiGzXIh5Dgwh99lA'\nAPI='https://tapi.hifast.biz'\n\necho \"=== 用户信息 ===\"\ncurl -s \"$API/v1/public/user/info\" -H \"$AUTH\" | python3 -c \"\nimport sys,json\nd=json.load\\(sys.stdin\\)\nif 'data' in d:\n print\\('user_id:', d['data']['id']\\)\n devs = d['data'].get\\('user_devices', []\\)\n print\\('device count:', len\\(devs\\)\\)\n for dev in devs[:3]:\n print\\(' device:', json.dumps\\(dev\\)\\)\nelse:\n print\\(json.dumps\\(d\\)\\)\n\")",
"Bash(API='https://tapi.hifast.biz'\nDEVICE_ID=\"devicetest$\\(openssl rand -hex 32\\)\"\n\necho \"Device ID: $DEVICE_ID\"\necho \"=== 设备登录 ===\"\ncurl -s -X POST \"$API/v1/auth/login/device\" \\\\\n -H 'Content-Type: application/json' \\\\\n -d \"{\\\\\"identifier\\\\\":\\\\\"$DEVICE_ID\\\\\",\\\\\"user_agent\\\\\":\\\\\"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\)\\\\\"}\" | python3 -m json.tool 2>/dev/null)",
"Bash(find /Users/Apple/code_vpn/vpn/frontend/packages/ui/src -name \"request*\" -o -name \"token*\" -o -name \"auth*\" -o -name \"store*\" 2>/dev/null | head -30)",
"Bash(grep -r \"SessionIdKey\" /Users/Apple/code_vpn/vpn/ppanel-server --include=\"*.go\" -n 2>/dev/null | head -10)",
"Bash(ls /Users/Apple/code_vpn/vpn/ppanel-server/etc/ 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/*.yaml 2>/dev/null; ls /Users/Apple/code_vpn/vpn/ppanel-server/*.yml 2>/dev/null)",
"Bash(find /Users/Apple/code_vpn/vpn/ppanel-server/internal/svc -type f -name \"*.go\" | xargs ls -la)",
"Bash(git add:*)",
"Bash(git reset:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git fetch:*)",
"Bash(git branch:*)",
"Bash(git merge:*)",
"Bash(node:*)",
"Bash(git stash:*)",
"Bash(npx biome:*)",
"Bash(chmod:*)",
"Bash(bash -n /Users/Apple/code_vpn/vpn/frontend/scripts/sync-upstream.sh && echo \"语法检查通过\")",
"Bash(grep:*)",
"Bash(git checkout:*)",
"Bash(python3:*)",
"Bash(find:*)",
"Bash(cd:*)",
"Read(//Users/Apple/.claude/**)",
"Bash(2)",
"Bash(claude mcp:*)",
"Bash(mysql:*)"
]
},
"enabledMcpjsonServers": ["mysql"],
"enableAllProjectMcpServers": true
}
+1
View File
@@ -34,3 +34,4 @@ npm-debug.log*
# Misc
.DS_Store
*.pem
.mcp.json
Binary file not shown.
+1 -1
View File
@@ -16,7 +16,7 @@
"@faker-js/faker": "^10.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@lottiefiles/dotlottie-react": "^0.17.7",
"@lottiefiles/dotlottie-react": "^0.17.15",
"@noble/curves": "^2.0.1",
"@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2",
@@ -1,4 +1,25 @@
{
"captcha": {
"clickToRefresh": "Click to refresh",
"noImage": "No Image",
"placeholder": "Enter captcha code...",
"refresh": "Refresh captcha",
"required": "Please enter captcha code",
"sliderRequired": "Please complete the slider verification",
"slider": {
"clickToVerify": "Click to verify",
"fail": "Try again",
"hint": "Drag the piece to fit the puzzle",
"success": "Verified",
"title": "Security Verification"
},
"turnstile": {
"cancel": "Cancel",
"clickToVerify": "Click to verify",
"success": "Verified",
"title": "Security Verification"
}
},
"check": {
"description": "Verify your identity",
"title": "Verify"
@@ -40,6 +40,8 @@
"40005": "You do not have access permission, please contact the administrator if you have any questions.",
"50001": "Corresponding coupon information not found, please check and try again.",
"50002": "The coupon has been used, cannot be used again.",
"50003": "This coupon code is not supported by the current purchase plan.",
"50004": "Coupon has insufficient remaining uses.",
"60001": "Subscription has expired, please renew before using.",
"60002": "Unable to use the subscription at the moment, please try again later.",
"60003": "An existing subscription is detected. Please cancel it before proceeding.",
@@ -15,7 +15,7 @@
"pending": "Pending",
"pendingTickets": "Pending Tickets",
"register": "Register",
"repurchase": "Repurchase",
"repurchase": "Renewal",
"revenueTitle": "Revenue Statistics",
"selectTypePlaceholder": "Select Type",
"today": "Today",
@@ -0,0 +1,200 @@
{
"actions": "Actions",
"autoTrigger": "Auto",
"averageMode": "Average Grouping",
"cancel": "Cancel",
"completed": "Completed",
"confirm": "Confirm",
"confirmDelete": "Confirm Delete",
"config": "Config",
"create": "Create",
"created": "Created successfully",
"createdAt": "Created At",
"createNodeGroup": "Create Node Group",
"createUserGroup": "Create User Group",
"delete": "Delete",
"deleted": "Deleted successfully",
"deleteNodeGroupConfirm": "This will delete the node group. Nodes in this group will be reassigned.",
"deleteUserGroupConfirm": "This will delete the user group. Users in this group will be reassigned to the default group.",
"description": "Description",
"descriptionPlaceholder": "Enter description",
"edit": "Edit",
"editNodeGroup": "Edit Node Group",
"editUserGroup": "Edit User Group",
"editUserGroupDescription": "Edit user group assignment and lock status",
"selectGroup": "Select a group",
"endTime": "End Time",
"errorMessage": "Error Message",
"export": "Export",
"failed": "Failed",
"failedCount": "Failed",
"groupConfig": "Group Configuration",
"groupConfigDescription": "Manage node groups and automatically assign node groups to user subscriptions",
"groupDetails": "Group Details",
"groupEnabled": "Group Management Enabled",
"groupEnabledDescription": "Enable group management to control user access to nodes",
"groupHistory": "Group Calculation History",
"groupHistoryDescription": "View group recalculation history and results",
"groupHistoryDetail": "Group Calculation Detail",
"groupId": "Group ID",
"groupIdPlaceholder": "Enter unique group ID",
"groupMode": "Group Mode",
"groupModeDescription": "Select the grouping algorithm for assigning users to groups",
"groupName": "Group Name",
"groupNamePlaceholder": "Enter group name",
"groupRecalculation": "Group Recalculation",
"groupRecalculationDescription": "Manually trigger node group reassignment for all active user subscriptions based on current configuration",
"history": "History",
"historyId": "History ID",
"id": "ID",
"idPrefix": "#",
"idle": "Idle",
"separator": "/",
"loading": "Loading...",
"loadFailed": "Failed to load configuration",
"locked": "Locked",
"manualTrigger": "Manual",
"name": "Name",
"namePlaceholder": "Enter name",
"nodeCount": "Node Count",
"nodeGroup": "Node Group",
"nodeGroupFormDescription": "Configure node group settings",
"nodeGroups": "Node Groups",
"nodeGroupsDescription": "Manage node groups for user access control",
"noDetails": "No details available",
"operator": "Operator",
"progress": "Progress",
"recalculate": "Recalculate",
"recalculateAll": "Reassign Node Groups",
"recalculationCompleted": "Recalculation completed successfully",
"recalculationFailed": "Recalculation failed. Please try again.",
"recalculationStarted": "Recalculation started",
"recalculationWarning": "Recalculation will reassign node groups for all active user subscriptions based on current configuration. This operation cannot be undone.",
"running": "Running",
"save": "Save",
"scheduleTrigger": "Schedule",
"sort": "Sort",
"sortOrder": "Sort Order",
"startTime": "Start Time",
"subscribeMode": "Subscribe-based Grouping",
"successCount": "Success",
"title": "Group Management",
"totalUsers": "Total Users",
"totalNodes": "Total Nodes",
"totalGroups": "Total Groups",
"trafficMode": "Traffic-based Grouping",
"triggerType": "Trigger Type",
"userGroup": "User Group",
"userGroups": "User Groups",
"userGroupsDescription": "Manage user groups for node access control",
"updated": "Updated successfully",
"updateFailed": "Update failed",
"userCount": "User Count",
"viewDetail": "View Detail",
"warning": "Warning",
"yes": "Yes",
"no": "No",
"saving": "Saving...",
"enableGrouping": "Enable Grouping",
"enableGroupingDescription": "When enabled, user subscriptions will be automatically assigned node groups based on the distribution mode",
"groupingMode": "Grouping Mode",
"averageModeConfig": "Average Mode Configuration",
"subscribeModeConfig": "Subscribe Mode Configuration",
"trafficModeConfig": "Traffic Mode Configuration",
"averageModeDescription": "Randomly assign available node groups to active user subscriptions",
"subscribeModeDescription": "Set default node group for user groups based on subscription plans",
"trafficModeDescription": "Assign node groups to user subscriptions based on traffic usage",
"defaultUserGroupId": "Default User Group ID",
"defaultUserGroupDescription": "New users will be assigned to this group",
"defaultUserGroupForExpiredDescription": "Users with expired subscriptions will be assigned to this group",
"autoCreateGroup": "Auto Create Group",
"autoCreateGroupDescription": "Automatically create a new user group when a new subscription plan is added",
"lockGroup": "Lock Group",
"lockGroupDescription": "Prevent automatic recalculation from changing this user's group",
"trafficRangesComingSoon": "Traffic ranges configuration coming soon...",
"currentStatus": "Current Status",
"trafficRangesConfig": "Traffic Ranges Configuration",
"trafficRangesDescription": "Configure traffic ranges for grouping users. Traffic is calculated based on user's billing cycle.",
"minTrafficGB": "Min Traffic (GB)",
"maxTrafficGB": "Max Traffic (GB)",
"addRange": "Add Range",
"remove": "Remove",
"note": "Note",
"trafficRangesNote": "Ranges must not overlap and must cover all values without gaps. Users with traffic >= the upper limit of the last range will be assigned to the last group.",
"defaultUserGroup": "Default User Group",
"defaultUserGroupForTrafficDescription": "Users with traffic exceeding all defined ranges will be assigned to this group",
"rangeError": "Range Error",
"overlapError": "Overlap Error",
"gapError": "Gap Error",
"groupByTraffic": "Group by Traffic",
"resetGroups": "Reset All Groups",
"resetGroupsTitle": "Reset All Groups",
"resetGroupsDescription": "This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone.",
"resetSuccess": "All groups have been reset successfully",
"resetFailed": "Failed to reset groups",
"saved": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"autoCalculated": "Auto-calculated",
"userGroupCountAutoCalculated": "Auto-calculated from actual user groups",
"userGroupCount": "User Group Count",
"nodeGroupCountAutoCalculated": "Auto-calculated from actual node groups",
"nodeGroupCount": "Node Group Count",
"arrow": " → ",
"availableNodeGroups": "Available Node Groups",
"currentGroupingResult": "Current Grouping Result",
"calculationInfo": "Calculation Information",
"groupingDetailsStatistics": "Grouping Details Statistics",
"successFailedCount": "Success/Failed",
"latestGroupingCalculation": "Latest grouping calculation details",
"userList": "User List",
"email": "Email",
"noUsers": "No users found",
"showing": "Showing",
"to": "to",
"of": "of",
"previous": "Previous",
"next": "Next",
"result": "Result",
"bindNodeGroup": "Bind Node Group",
"bindNodeGroupDescription": "Select a node group to bind to user groups: {{userGroups}}",
"selectNodeGroup": "Select Node Group",
"selectNodeGroupPlaceholder": "Select a node group...",
"selectNodeGroupRequired": "Please select a node group",
"unbound": "Unbound",
"bindSuccess": "Successfully bound {{userGroupCount}} user group(s) to node group",
"bindFailed": "Failed to bind node group",
"groupMapping": "Group Mapping",
"forCalculation": "For Calculation",
"trafficRange": "Traffic Range (GB)",
"configSaved": "Configuration saved successfully",
"subscribeGroupMappingTitle": "Subscribe-Node Group Mapping",
"subscribeName": "Subscribe Plan",
"userGroupName": "User Group",
"nodeGroupName": "Node Group",
"notMapped": "Not Mapped",
"noMappingData": "No mapping data available",
"forCalculationDescription": "Whether this node group participates in grouping calculation",
"trafficRangeGB": "Traffic Range (GB)",
"trafficRangeDescription": "Users with traffic >= Min and < Max will be assigned to this node group",
"minCannotExceedMax": "Minimum traffic cannot exceed maximum traffic",
"rangeOverlap": "Range overlaps with node group \"{{name}}\"",
"nodeGroupNotFound": "Node group not found",
"validationFailed": "Validation failed",
"totalNodeGroups": "Total Node Groups",
"invalidRange": "Minimum traffic must be less than maximum traffic",
"rangeConflict": "Traffic range conflicts with node group \"{{name}}\" (range: {{min}} - {{max}} GB)",
"isExpiredGroup": "Expired Node Group",
"isExpiredGroupDescription": "Allow expired users to use limited nodes",
"expiredDaysLimit": "Expired Days Limit",
"expiredDaysLimitDescription": "Number of days after expiration that users can still access nodes",
"maxTrafficGBExpired": "Max Traffic for Expired Users (GB)",
"maxTrafficGBExpiredDescription": "Maximum traffic allowed for expired users (0 = unlimited)",
"speedLimit": "Speed Limit (KB/s)",
"speedLimitDescription": "Speed limit for users in this node group (0 = unlimited)",
"expiredGroup": "Expired Only",
"expiredSettings": "Expired Settings",
"days": "days",
"expiredGroupExists": "System already has an expired node group: {{name}}",
"nodeGroupUsedBySubscribe": "This node group is used as default node group in subscription products, cannot set as expired group",
"expiredGroupForCalculationDescription": "Expired-only node groups cannot participate in group calculation"
}
@@ -10,6 +10,7 @@
"Document Management": "Document Management",
"Email": "Email",
"Gift": "Gift",
"Group Management": "Group Management",
"Login": "Login",
"Logs & Analytics": "Logs & Analytics",
"Maintenance": "Maintenance",
@@ -33,5 +34,6 @@
"Traffic Details": "Traffic Details",
"Device Group": "Device Group",
"User Management": "User Management",
"Users & Support": "Users & Support"
"Users & Support": "Users & Support",
"Withdrawal Management": "Withdrawal Management"
}
@@ -1,31 +1,38 @@
{
"address": "Address",
"all": "All",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmDeleteDesc": "This action cannot be undone.",
"confirmDeleteTitle": "Delete this node?",
"copied": "Copied",
"copy": "Copy",
"create": "Create",
"create": "Create Landing Node",
"created": "Created",
"delete": "Delete",
"deleted": "Deleted",
"drawerCreateTitle": "Create Node",
"drawerCreateTitle": "Create Landing Node",
"drawerEditTitle": "Edit Node",
"edit": "Edit",
"enabled": "Enabled",
"enabled_off": "Disabled",
"enabled_on": "Enabled",
"name": "Name",
"nodeGroup": "Node Group",
"nodeGroups": "Node Groups",
"nodeGroup_description": "Assign this node to multiple groups for user access control.",
"pageTitle": "Nodes",
"port": "Port",
"protocol": "Protocol",
"public": "Public",
"selectNodeGroup": "Select node group…",
"select_protocol": "Select protocol…",
"select_server": "Select server…",
"server": "Server",
"sorted_success": "Sorted successfully",
"tags": "Tags",
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
"tags_groupMode_description": "Optional tags for display and filtering (node group name will be used as tag if empty).",
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
"updated": "Updated"
}
@@ -1,17 +1,25 @@
{
"amount": "Amount",
"couponDiscount": "Coupon Discount",
"confirmRefund": "Confirm refund",
"discount": "Discount Amount",
"feeAmount": "Fee Amount",
"method": "Payment Method",
"orderNumber": "Order Number",
"refund": "Refund",
"refundConfirmDescription": "This refund will immediately invalidate the user's subscription and deduct the related agent commission.",
"refundConfirmTitle": "Confirm refund",
"refundConfirmWarning": "This action cannot be repeated after the order is refunded.",
"refundSubmitting": "Refunding...",
"refundSuccess": "Refund completed.",
"status": {
"0": "Status",
"1": "Pending",
"2": "Paid",
"3": "Cancelled",
"4": "Closed",
"5": "Completed"
"5": "Completed",
"6": "Refunded"
},
"subscribe": "Subscribe",
"subscribePrice": "Subscription Price",
@@ -1,4 +1,5 @@
{
"all": "All",
"cancel": "Cancel",
"confirm": "Confirm",
"confirmDelete": "Are you sure you want to delete?",
@@ -7,13 +8,16 @@
"create": "Create",
"createSubscribe": "Create Subscription",
"createSuccess": "Create Successful",
"currentUserGroup": "Current User Group",
"defaultNodeGroup": "Default Node Group",
"delete": "Delete",
"nodeGroups": "Node Groups",
"nodes": "nodes",
"deleteSuccess": "Delete Successful",
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
"deviceLimit": "IP Limit",
"edit": "Edit",
"editSubscribe": "Edit Subscription",
"sortSuccess": "Sort completed successfully",
"form": {
"annualReset": "Annual Reset",
"basic": "Basic",
@@ -28,9 +32,9 @@
"discount_price": "Discount Price",
"discountDescription": "Set discount based on unit price",
"discountPercent": "Discount Percentage",
"appleProductId": "Apple Product ID",
"Hour": "Hour",
"inventory": "Subscription Limit",
"unlimitedInventory": "Unlimited (enter -1)",
"language": "Language",
"languageDescription": "Leave empty for default without language restriction",
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
@@ -40,7 +44,19 @@
"name": "Name",
"node": "Node",
"nodeGroup": "Node Group",
"nodes": "Nodes",
"nodeGroups": "Node Groups",
"nodeGroupsDescription": "Assign this product to multiple node groups. Users will get nodes from these groups.",
"nodeGroupsFirstSelectionDescription": "Select node groups for this product. The first selected group will be set as the default node group.",
"defaultNodeGroup": "Default Node Group",
"defaultNodeGroupDescription": "Select the default node group for this product. This will be automatically included in the backup node groups.",
"selectDefaultNodeGroup": "Select a default node group...",
"noDefaultNodeGroup": "No Default Node Group",
"backupNodeGroups": "Backup Node Groups",
"backupNodeGroupsDescription": "Select additional backup node groups. The default node group is automatically included.",
"nodes": "Linked Nodes",
"nodesDescription": "Select nodes for this subscription",
"nodesInGroup": "Nodes in this group:",
"nodesWithoutGroupsDescription": "Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)",
"noLimit": "No Limit",
"NoLimit": "No Limit",
"noReset": "No Reset",
@@ -59,18 +75,55 @@
"showOriginalPriceDescription": "When enabled, the subscription card will display both the original price and the discounted price to help users understand the discount amount",
"speedLimit": "Speed Limit ",
"traffic": "Traffic",
"trafficLimit": "Traffic Limit",
"trafficLimitRules": "Traffic Limit Rules",
"trafficLimitDescription": "Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited.",
"addTrafficLimitRule": "Add Traffic Limit Rule",
"statType": "Statistics Type",
"selectStatType": "Select type...",
"statTypeHour": "Hour",
"statTypeDay": "Day",
"statValue": "Time Value",
"trafficUsage": "Traffic Usage (GB)",
"speedLimitKb": "Speed Limit (kb)",
"unitPrice": "Unit Price",
"unitTime": "Unit Time",
"unlimitedInventory": "Unlimited (enter -1)",
"Year": "Year"
},
"groupMapping": "Group Mapping",
"groupMappingTitle": "Group Mapping",
"groupMappingUpdateFailed": "Failed to update group mapping",
"groupMappingUpdateSuccess": "Group mapping updated successfully",
"migrateUsers": "Migrate Users",
"migrateUsersTitle": "Migrate Users",
"migrateUsersDescription": "Migrate all users from the current user group to another group",
"migrateUsersWarning": "This will migrate {count} users from \"{group}\" to the target group. This action cannot be undone.",
"migrateUsersSuccess": "Successfully migrated {count} users to the target group",
"migrateUsersFailed": "Failed to migrate users",
"targetUserGroup": "Target User Group",
"selectTargetGroup": "Select a target group...",
"selectTargetGroupFirst": "Please select a target group first",
"cannotMigrateToSameGroup": "Cannot migrate to the same group",
"noSourceGroup": "No source group available",
"selectedGroup": "Selected Group",
"userCount": "User Count",
"migrating": "Migrating...",
"inventory": "Subscription Limit",
"language": "Language",
"loading": "Loading...",
"name": "Name",
"noMapping": "No mapping set",
"noNodes": "No nodes in this group",
"quota": "Purchase Limit/Time",
"replacement": "Reset Price/Time",
"save": "Save",
"selectGroupPlaceholder": "Select a group...",
"selectUserGroup": "Select User Group",
"sell": "Sell",
"show": "Display",
"sold": "Subscription Count",
"sortSuccess": "Sort completed successfully",
"traffic": "Traffic",
"unitPrice": "Unit Price",
"updateSuccess": "Update Successful"
@@ -1,5 +1,4 @@
{
"active": "Active",
"cancel": "Cancel",
"code": "Redemption Code",
"confirm": "Confirm",
@@ -13,7 +12,6 @@
"duration": "Redemption Duration",
"edit": "Edit",
"editRedemptionCode": "Edit Redemption Code",
"exhausted": "Exhausted",
"form": {
"batchCount": "Batch Count",
"batchCountPlaceholder": "Batch Count",
@@ -26,16 +24,12 @@
"halfYear": "Half Year",
"month": "Month",
"quarter": "Quarter",
"quantityRequired": "Quantity is required",
"selectPlan": "Select Redemption Plan",
"selectUnitTime": "Select Redemption Duration Unit",
"subscribePlan": "Redemption Plan",
"subscribePlanRequired": "Subscribe plan is required",
"totalCount": "Available Uses",
"totalCountPlaceholder": "Available Uses",
"totalCountRequired": "Total count is required",
"unitTime": "Redemption Duration Unit",
"unitTimeRequired": "Unit time is required",
"year": "Year"
},
"id": "ID",
@@ -50,8 +44,8 @@
"status": "Status",
"subscribeId": "Subscribe ID",
"subscribePlan": "Redemption Plan",
"totalCount": "Available Uses",
"total": "Total",
"totalCount": "Available Uses",
"unitTime": "Redemption Duration Unit",
"updateSuccess": "Update Success",
"usedCount": "Used",
@@ -20,6 +20,7 @@
"description": "Configure currency units, symbols, and exchange rate API settings",
"title": "Currency Configuration"
},
"groupSettings": "Group Settings",
"invite": {
"description": "Configure user invitation and referral reward settings",
"forcedInvite": "Require Invitation to Register",
@@ -124,13 +125,21 @@
},
"userSecuritySettings": "User & Security",
"verify": {
"description": "Configure Turnstile CAPTCHA and verification settings",
"enableLoginVerify": "Enable Verification on Login",
"enableLoginVerifyDescription": "When enabled, users must pass human verification during login",
"enablePasswordVerify": "Enable Verification on Password Reset",
"enablePasswordVerifyDescription": "When enabled, users must pass human verification during password reset",
"enableRegisterVerify": "Enable Verification on Registration",
"enableRegisterVerifyDescription": "When enabled, users must pass human verification during registration",
"captchaType": "Captcha Type",
"captchaTypeDescription": "Choose between local image captcha, local slider captcha (offline) or Cloudflare Turnstile",
"captchaTypeLocal": "Local Image Captcha",
"captchaTypePlaceholder": "Select captcha type",
"captchaTypeSlider": "Local Slider Captcha",
"captchaTypeTurnstile": "Cloudflare Turnstile",
"description": "Configure captcha type and verification settings",
"enableAdminLoginCaptcha": "Enable Admin Authentication Captcha",
"enableAdminLoginCaptchaDescription": "When enabled, administrators must pass captcha verification during login or password reset",
"enableUserLoginCaptcha": "Enable User Login Captcha",
"enableUserLoginCaptchaDescription": "When enabled, users must pass captcha verification during login",
"enableUserRegisterCaptcha": "Enable User Registration Captcha",
"enableUserRegisterCaptchaDescription": "When enabled, users must pass captcha verification during registration",
"enableUserResetPasswordCaptcha": "Enable User Password Reset Captcha",
"enableUserResetPasswordCaptchaDescription": "When enabled, users must pass captcha verification during password reset",
"saveFailed": "Save Failed",
"saveSuccess": "Save Successful",
"title": "Security Verification",
@@ -12,6 +12,7 @@
"systemReboot": "System Reboot",
"systemServices": "System Services",
"update": "Update",
"updateDescription": "Are you sure you want to update?",
"updateFailed": "Update failed",
"updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
"updateSuccess": "Update completed successfully",
@@ -7,6 +7,10 @@
"serverRequired": "Please select a server"
},
"form": {
"quantityRequired": "Quantity is required",
"subscribePlanRequired": "Subscribe plan is required",
"totalCountRequired": "Total count is required",
"unitTimeRequired": "Unit time is required",
"validation": {
"nameRequired": "Client name is required",
"userAgentRequiredSuffix": "is required"
@@ -2,6 +2,7 @@
"accountEnable": "Account Enable",
"add": "Add",
"administrator": "Administrator",
"all": "All",
"areaCodePlaceholder": "Area code",
"authMethodsTitle": "Auth Methods",
"avatar": "Avatar",
@@ -17,6 +18,9 @@
"confirm": "Confirm",
"confirmDelete": "Confirm Delete",
"confirmOffline": "Confirm Offline",
"confirmResetToken": "Confirm Reset Subscription Address",
"confirmResumeSubscribe": "Confirm Resume Subscription",
"confirmStopSubscribe": "Confirm Stop Subscription",
"copySubscription": "Copy Subscription",
"copySuccess": "Copied successfully",
"create": "Create",
@@ -30,7 +34,6 @@
"deleteDescription": "This action cannot be undone.",
"deleteSubscriptionDescription": "This action cannot be undone.",
"deleteSuccess": "Deleted successfully",
"isDeleted": "Status",
"deviceLimit": "Device Limit",
"deviceGroup": "Device Group",
"deviceNo": "Device No.",
@@ -38,7 +41,10 @@
"download": "Download",
"downloadTraffic": "Download Traffic",
"edit": "Edit",
"editGroup": "Edit Group",
"editSubscription": "Edit Subscription",
"editUserGroup": "Edit User Group",
"editUserGroupDescription": "Edit user group assignment and lock status",
"enable": "Enable",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -83,6 +89,7 @@
"inviteCount": "Invited Users",
"inviteStats": "Invite Statistics",
"invitedUsers": "Invited Users",
"isDeleted": "Status",
"kickOfflineConfirm": "kickOfflineConfirm",
"kickOfflineSuccess": "Device kicked offline",
"lastSeen": "Last Seen",
@@ -130,33 +137,33 @@
"resetSearch": "Reset",
"resetTime": "Reset Time",
"resetToken": "Reset Subscription Address",
"saving": "Saving...",
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
"resetTokenSuccess": "Subscription address reset successfully",
"confirmResetToken": "Confirm Reset Subscription Address",
"resumeSubscribe": "Resume Subscription",
"selectGroup": "Select a group",
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
"resumeSubscribeSuccess": "Subscription resumed successfully",
"save": "Save",
"shortCode": "Short Code",
"speedLimit": "Speed Limit",
"startTime": "startTime",
"status": "Status",
"statusActive": "Active",
"statusDeducted": "Deducted",
"statusExpired": "Expired",
"statusFinished": "Finished",
"statusPending": "Pending",
"statusStopped": "Stopped",
"stopSubscribe": "Stop Subscription",
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
"stopSubscribeSuccess": "Subscription stopped successfully",
"confirmStopSubscribe": "Confirm Stop Subscription",
"resumeSubscribe": "Resume Subscription",
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
"resumeSubscribeSuccess": "Subscription resumed successfully",
"confirmResumeSubscribe": "Confirm Resume Subscription",
"status": "Status",
"statusPending": "Pending",
"statusActive": "Active",
"statusFinished": "Finished",
"statusExpired": "Expired",
"statusDeducted": "Deducted",
"statusStopped": "Stopped",
"save": "Save",
"search": "Search",
"searchPlaceholder": "Email / Invite Code / Device ID",
"searchInputPlaceholder": "Enter search term",
"sharedSubscription": "Shared",
"sharedSubscriptionInfo": "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
"sharedSubscriptionList": "Shared Subscription List",
"speedLimit": "Speed Limit",
"startTime": "startTime",
"subscription": "Subscription",
"subscriptionId": "subscriptionId",
"subscriptionInfo": "subscriptionInfo",
@@ -173,11 +180,13 @@
"trafficDetails": "Traffic Details",
"trafficLimit": "Traffic Limit",
"trafficStats": "Traffic Stats",
"trafficUsage": "trafficUsage",
"trafficUsage": "Traffic Usage",
"remainingTraffic": "Remaining Traffic",
"unlimited": "unlimited",
"unverified": "Unverified",
"update": "Update",
"updateSuccess": "Updated successfully",
"groupUpdated": "Group updated successfully",
"upload": "Upload",
"uploadTraffic": "Upload Traffic",
"userAgent": "User Agent",
@@ -188,7 +197,20 @@
"userList": "User List",
"userName": "Username",
"userProfile": "User Profile",
"userGroup": "User Group",
"verified": "Verified",
"viewDeviceGroup": "View Device Group",
"viewOwner": "View Owner"
"viewOwner": "View Owner",
"locked": "Locked",
"lockGroup": "Lock Group",
"lockGroupDescription": "Prevent automatic grouping from changing this user's group",
"groupLocked": "Group Locked",
"previewNodes": "Preview Nodes",
"availableNodes": "Available Nodes",
"name": "Name",
"address": "Address",
"noNodesAvailable": "No nodes available",
"nodeGroup": "Node Group",
"publicNodes": "Public Nodes",
"subscriptionNodes": "Subscription Nodes"
}
@@ -1,4 +1,25 @@
{
"captcha": {
"clickToRefresh": "点击刷新",
"noImage": "无图片",
"placeholder": "请输入验证码...",
"refresh": "刷新验证码",
"required": "请输入验证码",
"sliderRequired": "请完成滑块验证",
"slider": {
"clickToVerify": "点击进行验证",
"fail": "请重试",
"hint": "拖动拼图块到对应位置",
"success": "验证成功",
"title": "安全验证"
},
"turnstile": {
"cancel": "取消",
"clickToVerify": "点击进行验证",
"success": "验证成功",
"title": "安全验证"
}
},
"check": {
"description": "验证您的身份",
"title": "验证"
@@ -40,6 +40,8 @@
"40005": "您没有访问权限,如有疑问请联系管理员。",
"50001": "找不到对应的优惠券信息,请检查后重试。",
"50002": "该优惠券已被使用,无法再次使用。",
"50003": "",
"50004": "",
"60001": "订阅已过期,请续费后使用。",
"60002": "暂时无法使用该订阅,请稍后再试。",
"60003": "检测到现有订阅,请先取消后再继续。",
@@ -0,0 +1,200 @@
{
"actions": "操作",
"autoTrigger": "自动",
"averageMode": "平均分组",
"cancel": "取消",
"completed": "已完成",
"confirm": "确认",
"confirmDelete": "确认删除",
"config": "配置",
"create": "创建",
"created": "创建成功",
"createdAt": "创建时间",
"createNodeGroup": "创建节点组",
"createUserGroup": "创建用户组",
"delete": "删除",
"deleted": "删除成功",
"deleteNodeGroupConfirm": "此操作将删除节点组。该组中的节点将被重新分配。",
"deleteUserGroupConfirm": "此操作将删除用户组。该组中的用户将被重新分配到默认组。",
"description": "描述",
"descriptionPlaceholder": "输入描述",
"edit": "编辑",
"editNodeGroup": "编辑节点组",
"editUserGroup": "编辑用户组",
"editUserGroupDescription": "编辑用户组分配和锁定状态",
"selectGroup": "选择一个组",
"endTime": "结束时间",
"errorMessage": "错误信息",
"export": "导出",
"failed": "失败",
"failedCount": "失败",
"groupConfig": "分组配置",
"groupConfigDescription": "管理节点组并自动为用户订阅分配节点组",
"groupDetails": "分组详情",
"groupEnabled": "启用分组管理",
"groupEnabledDescription": "启用分组管理以控制用户对节点的访问",
"groupHistory": "分组计算历史",
"groupHistoryDescription": "查看分组重算历史和结果",
"groupHistoryDetail": "分组计算详情",
"groupId": "分组ID",
"groupIdPlaceholder": "输入唯一的分组ID",
"groupMode": "分组模式",
"groupModeDescription": "选择分组算法以将用户分配到组",
"groupName": "分组名称",
"groupNamePlaceholder": "输入分组名称",
"groupRecalculation": "分组重新计算",
"groupRecalculationDescription": "根据当前配置手动触发所有有效用户订阅的节点组重新分配",
"history": "历史记录",
"historyId": "历史ID",
"id": "ID",
"idPrefix": "#",
"idle": "空闲",
"separator": "",
"loading": "加载中...",
"loadFailed": "加载配置失败",
"locked": "已锁定",
"manualTrigger": "手动",
"name": "名称",
"namePlaceholder": "输入名称",
"nodeCount": "节点数",
"nodeGroup": "节点组",
"nodeGroupFormDescription": "配置节点组设置",
"nodeGroups": "节点组",
"nodeGroupsDescription": "管理节点组以控制用户访问权限",
"noDetails": "暂无详情",
"operator": "操作人",
"progress": "进度",
"recalculate": "重新计算",
"recalculateAll": "重新分配节点组",
"recalculationCompleted": "重新计算成功完成",
"recalculationFailed": "重新计算失败,请重试。",
"recalculationStarted": "重新计算已启动",
"recalculationWarning": "重新计算将根据当前配置重新分配所有有效用户订阅的节点组。此操作无法撤消。",
"running": "运行中",
"save": "保存",
"scheduleTrigger": "定时",
"sort": "排序",
"sortOrder": "排序顺序",
"startTime": "开始时间",
"subscribeMode": "套餐分组",
"successCount": "成功",
"title": "分组管理",
"totalUsers": "总用户数",
"totalNodes": "总节点数",
"totalGroups": "总分组数",
"trafficMode": "流量分组",
"triggerType": "触发类型",
"userGroup": "用户组",
"userGroups": "用户组",
"userGroupsDescription": "管理用户组以控制节点访问权限",
"updated": "更新成功",
"updateFailed": "更新失败",
"userCount": "用户数",
"viewDetail": "查看详情",
"warning": "警告",
"yes": "是",
"no": "否",
"saving": "保存中...",
"enableGrouping": "启用分组",
"enableGroupingDescription": "启用后,用户订阅将根据分配模式自动分配节点组",
"groupingMode": "分组方式",
"averageModeConfig": "平均模式配置",
"subscribeModeConfig": "订阅模式配置",
"trafficModeConfig": "流量模式配置",
"averageModeDescription": "为有效用户订阅随机分配可用节点组",
"subscribeModeDescription": "根据订阅套餐设置用户组的默认节点组",
"trafficModeDescription": "根据用户订阅的流量使用情况分配节点组",
"defaultUserGroupId": "默认用户组ID",
"defaultUserGroupDescription": "新用户将被分配到此组",
"defaultUserGroupForExpiredDescription": "订阅过期的用户将被分配到此组",
"autoCreateGroup": "自动创建组",
"autoCreateGroupDescription": "添加新订阅计划时自动创建新的用户组",
"lockGroup": "锁定分组",
"lockGroupDescription": "防止自动重新计算更改此用户的分组",
"trafficRangesComingSoon": "流量区间配置即将推出...",
"currentStatus": "当前状态",
"trafficRangesConfig": "流量区间配置",
"trafficRangesDescription": "配置用户流量分组区间。流量根据用户计费周期计算。",
"minTrafficGB": "最小流量 (GB)",
"maxTrafficGB": "最大流量 (GB)",
"addRange": "添加区间",
"remove": "移除",
"note": "注意",
"trafficRangesNote": "区间不能重叠且必须覆盖所有值而不留空档。流量大于最后一个区间上限的用户将被分配到最后一个组。",
"defaultUserGroup": "默认用户组",
"defaultUserGroupForTrafficDescription": "超出所有定义区间的用户将被分配到此组",
"rangeError": "区间错误",
"overlapError": "区间重叠错误",
"gapError": "存在空档错误",
"groupByTraffic": "按流量分组",
"resetGroups": "重置所有分组",
"resetGroupsTitle": "重置所有分组",
"resetGroupsDescription": "此操作将删除所有节点组和用户组,将所有用户的组ID重置为0,清空所有商品的节点组ID,清空所有节点的节点组ID。此操作无法撤消。",
"resetSuccess": "所有分组已成功重置",
"resetFailed": "重置分组失败",
"saved": "配置保存成功",
"saveFailed": "保存配置失败",
"autoCalculated": "自动统计",
"userGroupCountAutoCalculated": "自动统计实际用户组数量",
"userGroupCount": "用户组数",
"nodeGroupCountAutoCalculated": "自动统计实际节点组数量",
"nodeGroupCount": "节点组数",
"arrow": " → ",
"availableNodeGroups": "可用节点组",
"currentGroupingResult": "当前分组结果",
"calculationInfo": "计算信息",
"groupingDetailsStatistics": "分组详情统计",
"successFailedCount": "成功/失败",
"latestGroupingCalculation": "最新分组计算详情",
"userList": "用户列表",
"email": "邮箱",
"noUsers": "未找到用户",
"showing": "显示",
"to": "至",
"of": "共",
"previous": "上一页",
"next": "下一页",
"result": "结果",
"bindNodeGroup": "绑定节点组",
"bindNodeGroupDescription": "选择一个节点组绑定到以下用户组:{{userGroups}}",
"selectNodeGroup": "选择节点组",
"selectNodeGroupPlaceholder": "请选择节点组...",
"selectNodeGroupRequired": "请选择一个节点组",
"unbound": "未绑定",
"bindSuccess": "成功将 {{userGroupCount}} 个用户组绑定到节点组",
"bindFailed": "绑定节点组失败",
"groupMapping": "分组对应关系",
"forCalculation": "参与计算",
"trafficRange": "流量区间 (GB)",
"configSaved": "配置保存成功",
"subscribeGroupMappingTitle": "套餐-节点组对应关系",
"subscribeName": "订阅计划",
"userGroupName": "用户组",
"nodeGroupName": "节点组",
"notMapped": "未映射",
"noMappingData": "暂无映射数据",
"forCalculationDescription": "此节点组是否参与分组计算",
"trafficRangeGB": "流量区间 (GB)",
"trafficRangeDescription": "流量大于等于最小值且小于最大值的用户将被分配到此节点组",
"minCannotExceedMax": "最小流量不能超过最大流量",
"rangeOverlap": "区间与节点组 \"{{name}}\" 重叠",
"nodeGroupNotFound": "未找到节点组",
"validationFailed": "验证失败",
"totalNodeGroups": "总节点组数",
"invalidRange": "最小流量必须小于最大流量",
"rangeConflict": "流量区间与节点组 \"{{name}}\" 冲突(区间:{{min}} - {{max}} GB",
"isExpiredGroup": "过期节点组",
"isExpiredGroupDescription": "允许过期用户使用受限节点",
"expiredDaysLimit": "过期天数限制",
"expiredDaysLimitDescription": "用户订阅过期后仍可访问节点的天数",
"maxTrafficGBExpired": "过期用户最大流量 (GB)",
"maxTrafficGBExpiredDescription": "过期用户允许使用的最大流量(0 = 不限制)",
"speedLimit": "限速 (KB/s)",
"speedLimitDescription": "该节点组用户的速度限制(0 = 不限制)",
"expiredGroup": "过期专用",
"expiredSettings": "过期设置",
"days": "天",
"expiredGroupExists": "系统中已存在过期节点组:{{name}}",
"nodeGroupUsedBySubscribe": "该节点组已被订阅商品设置为默认节点组,不能设为过期节点组",
"expiredGroupForCalculationDescription": "过期专用节点组不能参与分组计算"
}
@@ -10,6 +10,7 @@
"Document Management": "文档管理",
"Email": "邮件",
"Gift": "赠送",
"Group Management": "分组管理",
"Login": "登录",
"Logs & Analytics": "日志与分析",
"Maintenance": "维护",
@@ -33,5 +34,6 @@
"Traffic Details": "流量详情",
"Device Group": "设备组",
"User Management": "用户管理",
"Users & Support": "用户与支持"
"Users & Support": "用户与支持",
"Withdrawal Management": "提现管理"
}
@@ -1,31 +1,38 @@
{
"address": "地址",
"all": "全部",
"cancel": "取消",
"confirm": "确认",
"confirmDeleteDesc": "此操作无法撤销。",
"confirmDeleteTitle": "删除此节点?",
"copied": "已复制",
"copy": "复制",
"create": "创建",
"create": "创建落地节点",
"created": "已创建",
"delete": "删除",
"deleted": "已删除",
"drawerCreateTitle": "创建节点",
"drawerCreateTitle": "创建落地节点",
"drawerEditTitle": "编辑节点",
"edit": "编辑",
"enabled": "已启用",
"enabled_off": "已禁用",
"enabled_on": "已启用",
"name": "名称",
"nodeGroup": "节点分组",
"nodeGroups": "节点分组",
"nodeGroup_description": "将此节点分配到多个分组以控制用户访问。",
"pageTitle": "节点",
"port": "端口",
"protocol": "协议",
"public": "公共",
"selectNodeGroup": "选择节点分组…",
"select_protocol": "选择协议…",
"select_server": "选择服务器…",
"server": "服务器",
"sorted_success": "排序成功",
"tags": "标签",
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
"tags_groupMode_description": "可选标签,用于显示和过滤(如果为空,节点组名称将作为标签使用)。",
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
"updated": "已更新"
}
@@ -1,17 +1,25 @@
{
"amount": "金额",
"couponDiscount": "优惠券折扣",
"confirmRefund": "确认退费",
"discount": "折扣金额",
"feeAmount": "手续费",
"method": "支付方式",
"orderNumber": "订单编号",
"refund": "退费",
"refundConfirmDescription": "该退费操作会使用户订阅立即失效,并扣减关联代理佣金。",
"refundConfirmTitle": "确认退费",
"refundConfirmWarning": "订单退费后不可再次重复执行,请谨慎操作。",
"refundSubmitting": "退费中...",
"refundSuccess": "退费成功。",
"status": {
"0": "状态",
"1": "待支付",
"2": "已支付",
"3": "已取消",
"4": "已关闭",
"5": "已完成"
"5": "已完成",
"6": "已退费"
},
"subscribe": "订阅",
"subscribePrice": "订阅价格",
@@ -1,4 +1,5 @@
{
"all": "全部",
"cancel": "取消",
"confirm": "确认",
"confirmDelete": "确定要删除吗?",
@@ -7,13 +8,16 @@
"create": "创建",
"createSubscribe": "创建订阅",
"createSuccess": "创建成功",
"currentUserGroup": "当前用户分组",
"defaultNodeGroup": "默认节点组",
"delete": "删除",
"nodeGroups": "节点组",
"nodes": "个节点",
"deleteSuccess": "删除成功",
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
"deviceLimit": "IP限制",
"edit": "编辑",
"editSubscribe": "编辑订阅",
"sortSuccess": "排序成功",
"form": {
"annualReset": "年度重置",
"basic": "基本",
@@ -28,9 +32,9 @@
"discount_price": "折扣价格",
"discountDescription": "根据单价设置折扣",
"discountPercent": "折扣百分比",
"appleProductId": "苹果商品ID",
"Hour": "小时",
"inventory": "订阅库存",
"unlimitedInventory": "无限制(输入 -1",
"language": "语言",
"languageDescription": "留空为默认无语言限制",
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
@@ -40,7 +44,19 @@
"name": "名称",
"node": "节点",
"nodeGroup": "节点组",
"nodes": "节点",
"nodeGroups": "节点",
"nodeGroupsDescription": "将此商品分配到多个节点分组。用户将可以从这些分组获取节点。",
"nodeGroupsFirstSelectionDescription": "为此商品选择节点组。第一个选中的组将被设置为默认节点组。",
"defaultNodeGroup": "默认节点组",
"defaultNodeGroupDescription": "为此商品选择默认节点组。将自动包含在备用节点组中。",
"selectDefaultNodeGroup": "选择默认节点组...",
"noDefaultNodeGroup": "无默认节点组",
"backupNodeGroups": "备用节点组",
"backupNodeGroupsDescription": "选择其他备用节点组。默认节点组会自动包含。",
"nodes": "关联节点",
"nodesDescription": "选择此订阅的节点",
"nodesInGroup": "分组中的节点:",
"nodesWithoutGroupsDescription": "未分配到分组的节点将在此处显示(属于分组的节点在上方的节点组部分管理)",
"noLimit": "无限制",
"NoLimit": "无限制",
"noReset": "不重置",
@@ -59,18 +75,55 @@
"showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度",
"speedLimit": "速度限制",
"traffic": "流量",
"trafficLimit": "按量限速",
"trafficLimitRules": "按量限速规则",
"trafficLimitDescription": "配置基于流量的限速规则。当流量使用达到指定量时,将进行限速。",
"addTrafficLimitRule": "添加限速规则",
"statType": "统计类型",
"selectStatType": "选择类型...",
"statTypeHour": "小时",
"statTypeDay": "天",
"statValue": "时间值",
"trafficUsage": "使用流量(GB",
"speedLimitKb": "限速(kb",
"unitPrice": "单价",
"unitTime": "时间单位",
"unlimitedInventory": "无限制(输入 -1",
"Year": "年"
},
"groupMapping": "分组映射",
"groupMappingTitle": "分组映射",
"groupMappingUpdateFailed": "更新分组映射失败",
"groupMappingUpdateSuccess": "分组映射更新成功",
"migrateUsers": "迁移用户",
"migrateUsersTitle": "迁移用户",
"migrateUsersDescription": "将当前用户组的所有用户迁移到另一个用户组",
"migrateUsersWarning": "这将把 {count} 个用户从 \"{group}\" 迁移到目标用户组。此操作无法撤销。",
"migrateUsersSuccess": "成功将 {count} 个用户迁移到目标用户组",
"migrateUsersFailed": "迁移用户失败",
"targetUserGroup": "目标用户组",
"selectTargetGroup": "选择目标用户组...",
"selectTargetGroupFirst": "请先选择目标用户组",
"cannotMigrateToSameGroup": "无法迁移到相同的用户组",
"noSourceGroup": "没有可用的源用户组",
"selectedGroup": "已选择的分组",
"userCount": "用户数量",
"migrating": "迁移中...",
"inventory": "订阅库存",
"language": "语言",
"loading": "加载中...",
"name": "名称",
"noMapping": "未设置映射",
"noNodes": "该分组下没有节点",
"quota": "购买限制/次",
"replacement": "重置价格/次",
"save": "保存",
"selectGroupPlaceholder": "选择分组...",
"selectUserGroup": "选择用户分组",
"sell": "销售",
"show": "显示",
"sold": "订阅数量",
"sortSuccess": "排序成功",
"traffic": "流量",
"unitPrice": "单价",
"updateSuccess": "更新成功"
@@ -1,5 +1,4 @@
{
"active": "有效",
"cancel": "取消",
"code": "兑换码",
"confirm": "确认",
@@ -13,7 +12,6 @@
"duration": "兑换可用时长",
"edit": "编辑",
"editRedemptionCode": "编辑兑换码",
"exhausted": "已用尽",
"form": {
"batchCount": "批次数量",
"batchCountPlaceholder": "批次数量",
@@ -26,16 +24,12 @@
"halfYear": "半年",
"month": "月",
"quarter": "季度",
"quantityRequired": "数量为必填项",
"selectPlan": "选择兑换套餐",
"selectUnitTime": "选择兑换时长单位",
"subscribePlan": "兑换套餐",
"subscribePlanRequired": "兑换套餐为必填项",
"totalCount": "兑换码可用次数",
"totalCountPlaceholder": "兑换码可用次数",
"totalCountRequired": "兑换码可用次数为必填项",
"unitTime": "兑换时长单位",
"unitTimeRequired": "兑换时长单位为必填项",
"year": "年"
},
"id": "ID",
@@ -50,8 +44,8 @@
"status": "状态",
"subscribeId": "套餐ID",
"subscribePlan": "兑换套餐",
"totalCount": "兑换码可用次数",
"total": "总计",
"totalCount": "兑换码可用次数",
"unitTime": "兑换时长单位",
"updateSuccess": "更新成功",
"usedCount": "已使用数量",
@@ -20,6 +20,7 @@
"description": "配置货币单位、符号和汇率 API 设置",
"title": "货币配置"
},
"groupSettings": "",
"invite": {
"description": "配置用户邀请和推荐奖励设置",
"forcedInvite": "强制邀请注册",
@@ -124,13 +125,21 @@
},
"userSecuritySettings": "用户与安全",
"verify": {
"description": "配置 Turnstile 验证码和验证设置",
"enableLoginVerify": "登录验证",
"enableLoginVerifyDescription": "启用后,用户登录时必须通过人机验证",
"enablePasswordVerify": "密码重置验证",
"enablePasswordVerifyDescription": "启用后,用户重置密码时必须通过人机验证",
"enableRegisterVerify": "注册验证",
"enableRegisterVerifyDescription": "启用后,用户注册时必须通过人机验证",
"captchaType": "验证码类型",
"captchaTypeDescription": "选择本地图形验证码、本地滑块验证码(均可离线)或 Cloudflare Turnstile",
"captchaTypeLocal": "本地图形验证",
"captchaTypePlaceholder": "选择验证码类型",
"captchaTypeSlider": "本地滑块验证",
"captchaTypeTurnstile": "Cloudflare Turnstile",
"description": "配置验证码类型和验证设置",
"enableAdminLoginCaptcha": "启用管理端认证验证码",
"enableAdminLoginCaptchaDescription": "启用后,管理员登录或重置密码时必须通过验证码验证",
"enableUserLoginCaptcha": "启用用户端登录验证码",
"enableUserLoginCaptchaDescription": "启用后,用户登录时必须通过验证码验证",
"enableUserRegisterCaptcha": "启用用户端注册验证码",
"enableUserRegisterCaptchaDescription": "启用后,用户注册时必须通过验证码验证",
"enableUserResetPasswordCaptcha": "启用用户重置密码验证码",
"enableUserResetPasswordCaptchaDescription": "启用后,用户重置密码时必须通过验证码验证",
"saveFailed": "保存失败",
"saveSuccess": "保存成功",
"title": "安全验证",
@@ -12,7 +12,7 @@
"0": "状态",
"1": "待跟进",
"2": "待回复",
"3": "已解决",
"3": "已取消",
"4": "已关闭"
},
"ticketList": "工单列表",
@@ -12,6 +12,7 @@
"systemReboot": "系统重启",
"systemServices": "系统服务",
"update": "更新",
"updateDescription": "",
"updateFailed": "更新失败",
"updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
"updateSuccess": "更新成功",
@@ -7,6 +7,10 @@
"serverRequired": "请选择服务器"
},
"form": {
"quantityRequired": "",
"subscribePlanRequired": "",
"totalCountRequired": "",
"unitTimeRequired": "",
"validation": {
"nameRequired": "客户端名称必填",
"userAgentRequiredSuffix": "是必填项"
@@ -2,6 +2,7 @@
"accountEnable": "账户启用",
"add": "添加",
"administrator": "管理员",
"all": "全部",
"areaCodePlaceholder": "区号",
"authMethodsTitle": "认证方式",
"avatar": "头像",
@@ -17,6 +18,9 @@
"confirm": "确认",
"confirmDelete": "确认删除",
"confirmOffline": "确认下线",
"confirmResetToken": "确认重置订阅地址",
"confirmResumeSubscribe": "确认恢复订阅",
"confirmStopSubscribe": "确认暂停订阅",
"copySubscription": "复制订阅",
"copySuccess": "复制成功",
"create": "创建",
@@ -30,7 +34,6 @@
"deleteDescription": "此操作无法撤销。",
"deleteSubscriptionDescription": "此操作无法撤销。",
"deleteSuccess": "删除成功",
"isDeleted": "状态",
"deviceGroup": "设备组",
"deviceLimit": "IP限制",
"deviceNo": "设备编号",
@@ -39,7 +42,10 @@
"downloadTraffic": "下载流量",
"edit": "编辑",
"email": "邮箱",
"editGroup": "编辑分组",
"editSubscription": "编辑订阅",
"editUserGroup": "编辑用户组",
"editUserGroupDescription": "编辑用户组分配和锁定状态",
"enable": "启用",
"enabled": "启用",
"disabled": "禁用",
@@ -84,6 +90,7 @@
"inviteCount": "邀请用户数",
"inviteStats": "邀请统计",
"invitedUsers": "已邀请用户",
"isDeleted": "状态",
"kickOfflineConfirm": "确认踢下线",
"kickOfflineSuccess": "设备已踢下线",
"lastSeen": "最后上线",
@@ -132,32 +139,32 @@
"resetTime": "重置时间",
"resetToken": "重置订阅地址",
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
"saving": "保存中...",
"resetTokenSuccess": "订阅地址重置成功",
"confirmResetToken": "确认重置订阅地址",
"resumeSubscribe": "恢复订阅",
"selectGroup": "选择一个组",
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
"resumeSubscribeSuccess": "订阅已恢复",
"save": "保存",
"shortCode": "短码",
"speedLimit": "速度限制",
"startTime": "开始时间",
"status": "状态",
"statusActive": "活跃",
"statusDeducted": "已扣除",
"statusExpired": "已过期",
"statusFinished": "已完成",
"statusPending": "待处理",
"statusStopped": "已停止",
"stopSubscribe": "暂停订阅",
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
"stopSubscribeSuccess": "订阅已暂停",
"confirmStopSubscribe": "确认暂停订阅",
"resumeSubscribe": "恢复订阅",
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
"resumeSubscribeSuccess": "订阅已恢复",
"confirmResumeSubscribe": "确认恢复订阅",
"status": "状态",
"statusPending": "待处理",
"statusActive": "活跃",
"statusFinished": "已完成",
"statusExpired": "已过期",
"statusDeducted": "已扣除",
"statusStopped": "已停止",
"save": "保存",
"search": "搜索",
"searchPlaceholder": "邮箱 / 邀请码 / 设备ID",
"searchInputPlaceholder": "请输入搜索内容",
"sharedSubscription": "共享",
"sharedSubscriptionInfo": "该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅",
"sharedSubscriptionList": "共享订阅列表",
"speedLimit": "速度限制",
"startTime": "开始时间",
"subscription": "订阅",
"subscriptionId": "订阅 ID",
"subscriptionInfo": "订阅信息",
@@ -175,10 +182,12 @@
"trafficLimit": "流量限制",
"trafficStats": "流量统计",
"trafficUsage": "流量使用",
"remainingTraffic": "剩余流量",
"unlimited": "无限制",
"unverified": "未验证",
"update": "更新",
"updateSuccess": "更新成功",
"groupUpdated": "分组更新成功",
"upload": "上传",
"uploadTraffic": "上传流量",
"userAgent": "用户代理",
@@ -189,7 +198,20 @@
"userList": "用户列表",
"userName": "用户名",
"userProfile": "用户资料",
"userGroup": "用户分组",
"verified": "已验证",
"viewDeviceGroup": "查看设备组",
"viewOwner": "查看所有者"
"viewOwner": "查看所有者",
"locked": "锁定",
"lockGroup": "锁定分组",
"lockGroupDescription": "防止自动分组更改此用户的分组",
"groupLocked": "分组已锁定",
"previewNodes": "预览节点",
"availableNodes": "可用节点",
"name": "名称",
"address": "地址",
"noNodesAvailable": "无可用节点",
"nodeGroup": "节点组",
"publicNodes": "公共节点",
"subscriptionNodes": "套餐节点"
}
+1 -1
View File
@@ -38,7 +38,7 @@ export function Display<T extends number | undefined | null>({
}
if (type === "trafficSpeed") {
return value ? `${formatBytes(value).replace("B", "b")}ps` : "0";
return value ? `${value} Mbps` : "0";
}
if (type === "number") {
+10
View File
@@ -34,6 +34,11 @@ export function useNavs() {
url: "/dashboard/nodes",
icon: "flat-color-icons:mind-map",
},
{
title: t("Group Management", "Group Management"),
url: "/dashboard/group",
icon: "flat-color-icons:department",
},
{
title: t("Subscribe Config", "Subscribe Config"),
url: "/dashboard/subscribe",
@@ -88,6 +93,11 @@ export function useNavs() {
url: "/dashboard/user",
icon: "flat-color-icons:conference-call",
},
{
title: t("Withdrawal Management", "Withdrawal Management"),
url: "/dashboard/withdrawal",
icon: "flat-color-icons:money-transfer",
},
{
title: t("Device Group", "Device Group"),
url: "/dashboard/family",
+75
View File
@@ -15,8 +15,14 @@ import { Route as rootRouteImport } from './routes/__root'
const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')()
const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardWithdrawalLazyRouteImport = createFileRoute(
'/dashboard/withdrawal',
)()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
const DashboardWithdrawalIndexLazyRouteImport = createFileRoute(
'/dashboard/withdrawal/',
)()
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
const DashboardTicketIndexLazyRouteImport =
createFileRoute('/dashboard/ticket/')()
@@ -39,6 +45,8 @@ const DashboardOrderIndexLazyRouteImport =
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
'/dashboard/marketing/',
)()
const DashboardGroupIndexLazyRouteImport =
createFileRoute('/dashboard/group/')()
const DashboardFamilyIndexLazyRouteImport =
createFileRoute('/dashboard/family/')()
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
@@ -107,6 +115,13 @@ const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({
} as any).lazy(() =>
import('./routes/dashboard/index.lazy').then((d) => d.Route),
)
const DashboardWithdrawalLazyRoute = DashboardWithdrawalLazyRouteImport.update({
id: '/withdrawal',
path: '/withdrawal',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/withdrawal.lazy').then((d) => d.Route),
)
const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({
id: '/servers',
path: '/servers',
@@ -121,6 +136,14 @@ const DashboardNodesLazyRoute = DashboardNodesLazyRouteImport.update({
} as any).lazy(() =>
import('./routes/dashboard/nodes.lazy').then((d) => d.Route),
)
const DashboardWithdrawalIndexLazyRoute =
DashboardWithdrawalIndexLazyRouteImport.update({
id: '/withdrawal/',
path: '/withdrawal/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/withdrawal/index.lazy').then((d) => d.Route),
)
const DashboardUserIndexLazyRoute = DashboardUserIndexLazyRouteImport.update({
id: '/user/',
path: '/user/',
@@ -191,6 +214,13 @@ const DashboardMarketingIndexLazyRoute =
} as any).lazy(() =>
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
)
const DashboardGroupIndexLazyRoute = DashboardGroupIndexLazyRouteImport.update({
id: '/group/',
path: '/group/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/group/index.lazy').then((d) => d.Route),
)
const DashboardFamilyIndexLazyRoute =
DashboardFamilyIndexLazyRouteImport.update({
id: '/family/',
@@ -337,6 +367,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -356,6 +387,7 @@ export interface FileRoutesByFullPath {
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@@ -365,11 +397,13 @@ export interface FileRoutesByFullPath {
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
'/dashboard/user': typeof DashboardUserIndexLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalIndexLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -389,6 +423,7 @@ export interface FileRoutesByTo {
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@@ -398,6 +433,7 @@ export interface FileRoutesByTo {
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
'/dashboard/user': typeof DashboardUserIndexLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalIndexLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -405,6 +441,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -424,6 +461,7 @@ export interface FileRoutesById {
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
'/dashboard/group/': typeof DashboardGroupIndexLazyRoute
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
@@ -433,6 +471,7 @@ export interface FileRoutesById {
'/dashboard/system/': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket/': typeof DashboardTicketIndexLazyRoute
'/dashboard/user/': typeof DashboardUserIndexLazyRoute
'/dashboard/withdrawal/': typeof DashboardWithdrawalIndexLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -441,6 +480,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -460,6 +500,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/group'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@@ -469,11 +510,13 @@ export interface FileRouteTypes {
| '/dashboard/system'
| '/dashboard/ticket'
| '/dashboard/user'
| '/dashboard/withdrawal'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -493,6 +536,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/group'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@@ -502,12 +546,14 @@ export interface FileRouteTypes {
| '/dashboard/system'
| '/dashboard/ticket'
| '/dashboard/user'
| '/dashboard/withdrawal'
id:
| '__root__'
| '/'
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -527,6 +573,7 @@ export interface FileRouteTypes {
| '/dashboard/coupon/'
| '/dashboard/document/'
| '/dashboard/family/'
| '/dashboard/group/'
| '/dashboard/marketing/'
| '/dashboard/order/'
| '/dashboard/payment/'
@@ -536,6 +583,7 @@ export interface FileRouteTypes {
| '/dashboard/system/'
| '/dashboard/ticket/'
| '/dashboard/user/'
| '/dashboard/withdrawal/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -566,6 +614,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/withdrawal': {
id: '/dashboard/withdrawal'
path: '/withdrawal'
fullPath: '/dashboard/withdrawal'
preLoaderRoute: typeof DashboardWithdrawalLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/servers': {
id: '/dashboard/servers'
path: '/servers'
@@ -580,6 +635,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardNodesLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/withdrawal/': {
id: '/dashboard/withdrawal/'
path: '/withdrawal'
fullPath: '/dashboard/withdrawal'
preLoaderRoute: typeof DashboardWithdrawalIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/user/': {
id: '/dashboard/user/'
path: '/user'
@@ -643,6 +705,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/group/': {
id: '/dashboard/group/'
path: '/group'
fullPath: '/dashboard/group'
preLoaderRoute: typeof DashboardGroupIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/family/': {
id: '/dashboard/family/'
path: '/family'
@@ -775,6 +844,7 @@ declare module '@tanstack/react-router' {
interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
@@ -794,6 +864,7 @@ interface DashboardRouteLazyRouteChildren {
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
@@ -803,11 +874,13 @@ interface DashboardRouteLazyRouteChildren {
DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute
DashboardTicketIndexLazyRoute: typeof DashboardTicketIndexLazyRoute
DashboardUserIndexLazyRoute: typeof DashboardUserIndexLazyRoute
DashboardWithdrawalIndexLazyRoute: typeof DashboardWithdrawalIndexLazyRoute
}
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
@@ -827,6 +900,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute,
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
@@ -836,6 +910,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute,
DashboardTicketIndexLazyRoute: DashboardTicketIndexLazyRoute,
DashboardUserIndexLazyRoute: DashboardUserIndexLazyRoute,
DashboardWithdrawalIndexLazyRoute: DashboardWithdrawalIndexLazyRoute,
}
const DashboardRouteLazyRouteWithChildren =
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Group from "@/sections/group";
export const Route = createLazyFileRoute("/dashboard/group/")({
component: Group,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import WithdrawalManagementPage from "@/sections/withdrawal";
export const Route = createLazyFileRoute("/dashboard/withdrawal")({
component: WithdrawalManagementPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import WithdrawalPage from "@/sections/withdrawal";
export const Route = createLazyFileRoute("/dashboard/withdrawal/")({
component: WithdrawalPage,
});
@@ -2,10 +2,10 @@
import { useNavigate } from "@tanstack/react-router";
import {
resetPassword,
userLogin,
userRegister,
} from "@workspace/ui/services/common/auth";
adminLogin,
adminResetPassword,
} from "@workspace/ui/services/admin/auth";
import { userRegister } from "@workspace/ui/services/common/auth";
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
@@ -42,7 +42,7 @@ export default function EmailAuthForm() {
try {
switch (type) {
case "login": {
const login = await userLogin(params);
const login = await adminLogin(params);
toast.success(t("login.success", "Login successful!"));
onLogin(login.data.data?.token);
break;
@@ -54,7 +54,7 @@ export default function EmailAuthForm() {
break;
}
case "reset":
await resetPassword(params);
await adminResetPassword(params);
toast.success(t("reset.success", "Password reset successful!"));
setType("login");
break;
@@ -10,11 +10,13 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function LoginForm({
@@ -33,26 +35,57 @@ export default function LoginForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_admin_login_captcha;
const formSchema = z.object({
email: z.email(t("login.email", "Email")),
email: z
.string()
.email(t("login.email", "Please enter a valid email address")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -98,7 +131,7 @@ export default function LoginForm({
</FormItem>
)}
/>
{verify.enable_login_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -116,6 +149,38 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -10,12 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function ResetForm({
@@ -35,27 +37,58 @@ export default function ResetForm({
const { common } = useGlobalStore();
const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
email: z.email(t("reset.email", "Email")),
email: z
.string()
.email(t("reset.email", "Please enter a valid email address")),
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -128,7 +161,7 @@ export default function ResetForm({
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -146,6 +179,38 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
@@ -0,0 +1,106 @@
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { adminGenerateCaptcha } from "@workspace/ui/services/admin/auth";
import {
type RefObject,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
reset: () => void;
}
interface LocalCaptchaProps {
value?: string | null;
onChange?: (value: string) => void;
onCaptchaIdChange?: (id: string) => void;
}
const LocalCaptcha = ({
value,
onChange,
onCaptchaIdChange,
ref,
}: LocalCaptchaProps & { ref?: RefObject<LocalCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await adminGenerateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCaptcha();
}, []);
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
},
}));
return (
<div className="flex gap-2">
<Input
className="flex-1"
onChange={(e) => onChange?.(e.target.value)}
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
height={40}
onClick={fetchCaptcha}
src={captchaImage}
title={t("captcha.clickToRefresh", "Click to refresh")}
width={120}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground text-xs">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
<Button
disabled={loading}
onClick={fetchCaptcha}
size="icon"
title={t("captcha.refresh", "Refresh captcha")}
type="button"
variant="outline"
>
<Icon icon="mdi:refresh" />
</Button>
</div>
);
};
LocalCaptcha.displayName = "LocalCaptcha";
export default LocalCaptcha;
@@ -0,0 +1,363 @@
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import {
adminGenerateCaptcha,
adminVerifyCaptchaSlider,
} from "@workspace/ui/services/admin/auth";
import {
type RefObject,
useCallback,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
export interface SliderCaptchaRef {
reset: () => void;
}
interface SliderCaptchaProps {
value?: string;
onChange?: (value: string) => void;
}
interface TrailPoint {
x: number;
y: number;
t: number;
}
const BLOCK_SIZE = 100;
const BG_NATURAL_WIDTH = 560;
const BG_NATURAL_HEIGHT = 280;
const SliderCaptcha = ({
onChange,
ref,
}: SliderCaptchaProps & { ref?: RefObject<SliderCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [captchaId, setCaptchaId] = useState("");
const [bgImage, setBgImage] = useState("");
const [blockImage, setBlockImage] = useState("");
const [loading, setLoading] = useState(false);
const [verified, setVerified] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "fail">("idle");
const [blockPos, setBlockPos] = useState({ x: 0, y: 50 });
const [shaking, setShaking] = useState(false);
const dragging = useRef(false);
const startPointer = useRef({ x: 0, y: 0 });
const startBlock = useRef({ x: 0, y: 50 });
const dragStartTime = useRef(0);
const trail = useRef<TrailPoint[]>([]);
const getContainerSize = () => {
const el = containerRef.current;
if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT };
return { w: el.clientWidth, h: el.clientHeight };
};
const fetchCaptcha = useCallback(async () => {
setLoading(true);
setStatus("idle");
setBlockPos({ x: 0, y: 50 });
trail.current = [];
try {
const res = await adminGenerateCaptcha();
const data = res.data?.data;
if (data) {
setCaptchaId(data.id);
setBgImage(data.image);
setBlockImage(data.block_image ?? "");
}
} catch (e) {
console.error("Failed to generate slider captcha:", e);
} finally {
setLoading(false);
}
}, []);
const handleOpen = () => {
if (verified) return;
setOpen(true);
fetchCaptcha();
};
useImperativeHandle(ref, () => ({
reset: () => {
setVerified(false);
onChange?.("");
setOpen(false);
trail.current = [];
},
}));
const onPointerDown = (e: React.PointerEvent) => {
if (status === "success" || loading) return;
dragging.current = true;
dragStartTime.current = Date.now();
trail.current = [];
startPointer.current = { x: e.clientX, y: e.clientY };
startBlock.current = { ...blockPos };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
e.preventDefault();
const { w: _w, h: _h } = getContainerSize();
trail.current.push({
x: Math.round(startBlock.current.x),
y: Math.round(startBlock.current.y),
t: 0,
});
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current) return;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const newX = Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
);
const newY = Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
);
setBlockPos({ x: newX, y: newY });
trail.current.push({
x: Math.round(newX),
y: Math.round(newY),
t: Date.now() - dragStartTime.current,
});
};
const onPointerUp = async (e: React.PointerEvent) => {
if (!dragging.current) return;
dragging.current = false;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const finalX = Math.round(
Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
)
);
const finalY = Math.round(
Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
)
);
setBlockPos({ x: finalX, y: finalY });
trail.current.push({
x: finalX,
y: finalY,
t: Date.now() - dragStartTime.current,
});
try {
const res = await adminVerifyCaptchaSlider({
id: captchaId,
x: finalX,
y: finalY,
trail: JSON.stringify(trail.current),
});
const token = res.data?.data?.token;
if (token) {
setStatus("success");
setTimeout(() => {
setVerified(true);
onChange?.(token);
setOpen(false);
}, 600);
} else {
triggerFail();
}
} catch {
triggerFail();
}
};
const triggerFail = () => {
setStatus("fail");
setShaking(true);
setTimeout(() => {
setShaking(false);
fetchCaptcha();
}, 800);
};
const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100;
const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100;
const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100;
const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100;
return (
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.slider.success", "Verified")
: t("captcha.slider.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Slider dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
open={open}
>
<DialogContent className="select-none p-6 sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("captcha.slider.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<div
className={`relative w-full overflow-hidden rounded-md bg-muted ${
shaking ? "animate-[shake_0.4s_ease-in-out]" : ""
}`}
ref={containerRef}
style={{
paddingTop: `${(BG_NATURAL_HEIGHT / BG_NATURAL_WIDTH) * 100}%`,
}}
>
<div className="absolute inset-0">
{loading ? (
<div className="flex h-full items-center justify-center">
<Icon className="animate-spin text-2xl" icon="mdi:loading" />
</div>
) : bgImage ? (
<>
<img
alt="captcha background"
className="absolute inset-0 h-full w-full"
draggable={false}
height={BG_NATURAL_HEIGHT}
src={bgImage}
width={BG_NATURAL_WIDTH}
/>
{blockImage && (
<img
alt="captcha block"
className="absolute cursor-grab active:cursor-grabbing"
draggable={false}
height={BG_NATURAL_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
src={blockImage}
style={{
filter:
status === "success"
? "drop-shadow(0 0 3px rgba(74,222,128,0.9))"
: status === "fail"
? "drop-shadow(0 0 3px rgba(248,113,113,0.9))"
: "drop-shadow(0 0 2px rgba(255,255,255,0.7))",
left: `${blockLeftPct}%`,
top: `${blockTopPct}%`,
width: `${blockSizeWPct}%`,
height: `${blockSizeHPct}%`,
touchAction: "none",
}}
width={BG_NATURAL_WIDTH}
/>
)}
{status !== "idle" && (
<div
className={`absolute inset-0 flex items-center justify-center font-medium text-sm ${
status === "success"
? "bg-green-500/20 text-green-700"
: "bg-red-500/20 text-red-700"
}`}
>
{status === "success"
? t("captcha.slider.success", "Verified")
: t("captcha.slider.fail", "Try again")}
</div>
)}
</>
) : null}
</div>
</div>
<p className="text-center text-muted-foreground text-xs">
{t("captcha.slider.hint", "Drag the piece to fit the puzzle")}
</p>
<Button
className="w-full"
disabled={loading}
onClick={fetchCaptcha}
size="sm"
type="button"
variant="ghost"
>
<Icon icon="mdi:refresh" />
{t("captcha.clickToRefresh", "Refresh")}
</Button>
</DialogContent>
</Dialog>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
`}</style>
</>
);
};
SliderCaptcha.displayName = "SliderCaptcha";
export default SliderCaptcha;
+110 -21
View File
@@ -1,5 +1,18 @@
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import { useTheme } from "next-themes";
import { type RefObject, useEffect, useImperativeHandle } from "react";
import {
type RefObject,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import Turnstile, { useTurnstile } from "react-turnstile";
@@ -23,43 +36,119 @@ const CloudFlareTurnstile = function CloudFlareTurnstile({
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const { i18n, t } = useTranslation("auth");
const locale = i18n.language;
const turnstile = useTurnstile();
const [open, setOpen] = useState(false);
const [verified, setVerified] = useState(false);
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
reset: () => {
setVerified(false);
onChange("");
turnstile.reset();
},
}),
[turnstile]
[turnstile, onChange]
);
useEffect(() => {
if (value === "") {
setVerified(false);
turnstile.reset();
}
}, [turnstile, value]);
const handleOpen = () => {
if (verified) return;
setOpen(true);
};
if (!verify.turnstile_site_key) return null;
return (
verify.turnstile_site_key && (
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange();
turnstile.reset();
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.turnstile.success", "Verified")
: t("captcha.turnstile.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Turnstile dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
onVerify={(token) => onChange(token)}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
open={open}
>
<DialogContent className="flex w-auto flex-col items-center gap-4 p-6">
<DialogHeader>
<DialogTitle>
{t("captcha.turnstile.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange("");
turnstile.reset();
}}
onTimeout={() => {
onChange("");
turnstile.reset();
}}
onVerify={(token) => {
setVerified(true);
onChange(token);
setTimeout(() => setOpen(false), 400);
}}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
<Button
className="w-full"
onClick={() => setOpen(false)}
size="sm"
type="button"
variant="ghost"
>
{t("captcha.turnstile.cancel", "Cancel")}
</Button>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,277 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import {
getGroupConfig,
getNodeGroupList,
getRecalculationStatus,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function AverageModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [averageConfig, setAverageConfig] = useState({
node_group_count: 0,
});
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
const hasLoadedConfig = useRef(true);
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
const loadConfig = async () => {
try {
const { data } = await getGroupConfig();
if (data.data?.config?.average_config) {
setAverageConfig(data.data.config.average_config as any);
}
hasLoadedConfig.current = true;
} catch (error) {
console.error("Failed to load group config:", error);
toast.error(t("loadFailed", "Failed to load configuration"));
}
};
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadConfig();
loadStatus();
}, []);
useEffect(() => {
if (nodeGroupsData) {
const nodeGroupCount = nodeGroupsData?.length || 0;
if (averageConfig.node_group_count !== nodeGroupCount) {
setAverageConfig({
...averageConfig,
node_group_count: nodeGroupCount,
});
}
}
}, [nodeGroupsData]);
useEffect(() => {
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "average" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Configuration Card */}
<Card>
<CardHeader>
<CardTitle>
{t("averageModeConfig", "Average Mode Configuration")}
</CardTitle>
<CardDescription>
{t(
"averageModeDescription",
"Randomly assign node groups to user subscriptions based on subscribe configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="node_group_count">
{t("availableNodeGroups", "Available Node Groups")}
</Label>
<Input
className="bg-muted"
id="node_group_count"
min={1}
readOnly
type="number"
value={averageConfig.node_group_count}
/>
<p className="text-muted-foreground text-xs">
{t(
"nodeGroupCountAutoCalculated",
"Auto-calculated from actual node groups"
)}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>
{t("groupRecalculation", "Group Recalculation")}
</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-muted-foreground text-sm">
{t(
"recalculationCompleted",
"Recalculation completed successfully"
)}
</div>
)}
{status?.state === "failed" && (
<div className="text-destructive text-sm">
{t(
"recalculationFailed",
"Recalculation failed. Please try again."
)}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
disabled={recalculating || status?.state === "running"}
onClick={handleRecalculate}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,192 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Label } from "@workspace/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
bindNodeGroups,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface BindNodeGroupsDialogProps {
userGroupIds: number[];
userGroupNames: string[];
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
}
export default function BindNodeGroupsDialog({
userGroupIds,
userGroupNames,
onOpenChange,
onSuccess,
}: BindNodeGroupsDialogProps) {
const { t } = useTranslation("group");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedNodeGroupId, setSelectedNodeGroupId] = useState<
number | undefined
>();
const { data: nodeGroupsData, isLoading } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
useEffect(() => {
if (open && nodeGroupsData) {
// Load current binding when dialog opens
loadCurrentBinding();
}
}, [open]);
const loadCurrentBinding = () => {
// Get first user group's current node group binding
// For batch binding, we'll default to unbound
setSelectedNodeGroupId(undefined);
};
const handleBind = async () => {
if (selectedNodeGroupId === undefined) {
toast.error(t("selectNodeGroupRequired", "Please select a node group"));
return;
}
setSaving(true);
try {
await bindNodeGroups({
user_group_ids: userGroupIds,
node_group_id: selectedNodeGroupId === 0 ? null : selectedNodeGroupId,
} as API.BindNodeGroupsRequest);
toast.success(
t(
"bindSuccess",
"Successfully bound {{userGroupCount}} user groups to node group"
).replace(/{{userGroupCount}}/g, String(userGroupIds.length))
);
setOpen(false);
onOpenChange?.(false);
onSuccess?.();
} catch (error) {
console.error("Failed to bind node group:", error);
toast.error(t("bindFailed", "Failed to bind node group"));
} finally {
setSaving(false);
}
};
const displayNames =
userGroupNames.length > 2
? `${userGroupNames.slice(0, 2).join(", ")}... (${userGroupIds.length})`
: userGroupNames.join(", ");
return (
<Dialog
onOpenChange={(newOpen) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
}}
open={open}
>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
{t("bindNodeGroup", "Bind Node Group")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("bindNodeGroup", "Bind Node Group")}</DialogTitle>
<DialogDescription>
{t(
"bindNodeGroupDescription",
"Select a node group to bind to user groups: {{userGroups}}",
{ userGroups: displayNames }
).replace(/{{userGroups}}/g, displayNames)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="node-group">
{t("selectNodeGroup", "Select Node Group")}
</Label>
<Select
onValueChange={(val) =>
setSelectedNodeGroupId(Number.parseInt(val, 10) || undefined)
}
value={selectedNodeGroupId?.toString() || ""}
>
<SelectTrigger className="w-full" id="node-group">
<SelectValue
placeholder={t(
"selectNodeGroupPlaceholder",
"Select a node group..."
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("unbound", "Unbound")}</SelectItem>
{nodeGroupsData?.map((nodeGroup) => (
<SelectItem key={nodeGroup.id} value={String(nodeGroup.id)}>
{nodeGroup.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button
disabled={saving}
onClick={() => {
setOpen(false);
onOpenChange?.(false);
}}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={saving || selectedNodeGroupId === undefined}
onClick={handleBind}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("confirm", "Confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,443 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import {
getGroupHistory,
getGroupHistoryDetail,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export default function CurrentGroupResults() {
const { t } = useTranslation("group");
const [loading, setLoading] = useState(true);
const [latestResult, setLatestResult] = useState<any>(null);
const [latestDetails, setLatestDetails] = useState<any[]>([]);
const [detailsLoading, setDetailsLoading] = useState(false);
// User list dialog state
const [userListOpen, setUserListOpen] = useState(false);
const [selectedNodeGroupName, setSelectedNodeGroupName] =
useState<string>("");
const [userList, setUserList] = useState<any[]>([]);
const [userListLoading, setUserListLoading] = useState(false);
const [userListTotal, setUserListTotal] = useState(0);
// Fetch node groups
const { data: nodeGroups } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
useEffect(() => {
const loadData = async () => {
try {
// Load latest result
const { data: historyData } = await getGroupHistory({
page: 1,
size: 1,
});
if (historyData.data?.list && historyData.data.list.length > 0) {
const latest = historyData.data.list[0];
if (!latest) return;
setLatestResult(latest);
// Fetch details
setDetailsLoading(true);
try {
const { data: detailData } = await getGroupHistoryDetail({
id: latest.id,
});
if (detailData.data?.config_snapshot?.group_details) {
setLatestDetails(detailData.data.config_snapshot.group_details);
} else {
setLatestDetails([]);
}
} catch (error) {
console.error("Failed to fetch latest result details:", error);
setLatestDetails([]);
} finally {
setDetailsLoading(false);
}
}
} catch (error) {
console.error("Failed to load data:", error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
const handleShowUserList = async (
nodeGroupId: number,
nodeGroupName: string
) => {
setSelectedNodeGroupName(nodeGroupName);
setUserListOpen(true);
setUserListLoading(true);
// 从历史详情记录中获取用户数据
const detail = latestDetails.find((d: any) => {
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
return detailNodeGroupId === nodeGroupId;
});
if (detail) {
const userDataJSON = detail.UserData || detail.user_data;
if (userDataJSON) {
try {
const userData = JSON.parse(userDataJSON);
setUserList(userData);
setUserListTotal(userData.length);
} catch (error) {
console.error("Failed to parse user data:", error);
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
setUserListLoading(false);
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>
{t("currentGroupingResult", "Current Grouping Result")}
</CardTitle>
<CardDescription>{t("loading", "Loading...")}</CardDescription>
</CardHeader>
</Card>
);
}
return (
<div className="space-y-4">
{/* Latest Result Card */}
{latestResult ? (
<Card>
<CardHeader>
<CardTitle>
{t("currentGroupingResult", "Current Grouping Result")}
</CardTitle>
<CardDescription>
{t(
"latestGroupingCalculation",
"Latest grouping calculation details"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Calculation Info */}
<div className="space-y-2">
<h3 className="font-medium text-sm">
{t("calculationInfo", "Calculation Information")}
</h3>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-muted/50 p-4">
<div>
<div className="text-muted-foreground text-xs">
{t("groupMode", "Group Mode")}
</div>
<div className="font-medium">
{(latestResult.GroupMode || latestResult.group_mode) ===
"average"
? t("averageMode", "Average Mode")
: (latestResult.GroupMode || latestResult.group_mode) ===
"subscribe"
? t("subscribeMode", "Subscribe Mode")
: t("trafficMode", "Traffic Mode")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("state", "State")}
</div>
<div className="font-medium">
{(latestResult.State || latestResult.state) === "completed"
? t("completed", "Completed")
: (latestResult.State || latestResult.state) === "running"
? t("running", "Running")
: (latestResult.State || latestResult.state) ===
"failed"
? t("failed", "Failed")
: t("idle", "Idle")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("triggerType", "Trigger Type")}
</div>
<div className="font-medium">
{(latestResult.TriggerType || latestResult.trigger_type) ===
"manual"
? t("manualTrigger", "Manual")
: (latestResult.TriggerType ||
latestResult.trigger_type) === "auto"
? t("autoTrigger", "Auto")
: t("scheduleTrigger", "Schedule")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("successFailedCount", "Success/Failed")}
</div>
<div className="font-medium">
{latestResult.SuccessCount ||
latestResult.success_count ||
0}{" "}
/{" "}
{latestResult.FailedCount || latestResult.failed_count || 0}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("startTime", "Start Time")}
</div>
<div className="font-medium">
{latestResult.StartTime || latestResult.start_time
? new Date(
(latestResult.StartTime || latestResult.start_time) *
1000
).toLocaleString()
: "-"}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("endTime", "End Time")}
</div>
<div className="font-medium">
{latestResult.EndTime || latestResult.end_time
? new Date(
(latestResult.EndTime || latestResult.end_time) * 1000
).toLocaleString()
: "-"}
</div>
</div>
</div>
</div>
{/* Grouping Details */}
<div className="space-y-2">
<h3 className="font-medium text-sm">
{t("groupingDetailsStatistics", "Grouping Details Statistics")}
</h3>
<div className="grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
<div className="text-center">
<div className="font-bold text-2xl">
{latestDetails.reduce(
(sum: number, d: any) =>
sum + (d.UserCount || d.user_count || 0),
0
)}
</div>
<div className="text-muted-foreground text-xs">
{t("totalUsers", "Total Users")}
</div>
</div>
<div className="text-center">
<div className="font-bold text-2xl">
{latestDetails.reduce(
(sum: number, d: any) =>
sum + (d.NodeCount || d.node_count || 0),
0
)}
</div>
<div className="text-muted-foreground text-xs">
{t("totalNodes", "Total Nodes")}
</div>
</div>
<div className="text-center">
<div className="font-bold text-2xl">
{latestDetails.length}
</div>
<div className="text-muted-foreground text-xs">
{t("totalNodeGroups", "Total Node Groups")}
</div>
</div>
</div>
</div>
{detailsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground text-sm">
{t("loading", "Loading...")}
</span>
</div>
) : latestDetails.length > 0 ? (
<>
{/* Details Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="border-b px-4 py-2 text-left">
{t("nodeGroup", "Node Group")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("userCount", "User Count")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("nodeCount", "Node Count")}
</th>
</tr>
</thead>
<tbody>
{latestDetails.map((detail: any, index: number) => {
const nodeGroupId =
detail.NodeGroupId || detail.node_group_id;
const nodeGroup = nodeGroups?.find(
(ng) => ng.id === nodeGroupId
);
const nodeGroupName =
nodeGroup?.name ||
`${t("idPrefix", "#")}${nodeGroupId}`;
const userCount =
detail.UserCount || detail.user_count || 0;
return (
<tr key={index}>
<td className="border-b px-4 py-2">
<div>
<div className="font-medium">
{nodeGroupName}
</div>
<div className="text-muted-foreground text-xs">
{t("id", "ID")}: {nodeGroupId}
</div>
</div>
</td>
<td className="border-b px-4 py-2 text-right">
<button
className={`font-semibold hover:underline ${
userCount === 0
? "cursor-not-allowed text-muted-foreground"
: "cursor-pointer"
}`}
disabled={userCount === 0}
onClick={() =>
handleShowUserList(nodeGroupId, nodeGroupName)
}
type="button"
>
{userCount}
</button>
</td>
<td className="border-b px-4 py-2 text-right">
{detail.NodeCount || detail.node_count || 0}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
{t("noDetails", "No details available")}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>
{t("currentGroupingResult", "Current Grouping Result")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center text-muted-foreground text-sm">
{t("noDetails", "No details available")}
</div>
</CardContent>
</Card>
)}
{/* User List Dialog */}
<Dialog onOpenChange={setUserListOpen} open={userListOpen}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>
{selectedNodeGroupName} - {t("userList", "User List")}
</DialogTitle>
<DialogDescription>
{t("totalUsers", "Total Users")}: {userListTotal}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{userListLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground text-sm">
{t("loading", "Loading...")}
</span>
</div>
) : userList.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("id", "ID")}</TableHead>
<TableHead>{t("email", "Email")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userList.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>{user.email || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
{t("noUsers", "No users found")}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,274 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
getGroupConfig,
resetGroups,
updateGroupConfig,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function GroupConfig() {
const { t } = useTranslation("group");
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [config, setConfig] = useState<{
enabled: boolean;
mode: "average" | "subscribe" | "traffic";
}>({
enabled: false,
mode: "average",
});
const loadConfig = async () => {
try {
const { data } = await getGroupConfig();
if (data.data) {
setConfig({
enabled: data.data.enabled,
mode: (data.data.mode || "average") as
| "average"
| "subscribe"
| "traffic",
});
}
} catch (error) {
console.error("Failed to load group config:", error);
toast.error(t("loadFailed", "Failed to load configuration"));
}
};
useEffect(() => {
loadConfig();
}, []);
const handleUpdateEnabled = async (enabled: boolean) => {
setSaving(true);
try {
const payload: any = {
enabled,
mode: config.mode,
};
await updateGroupConfig(payload);
setConfig({ ...config, enabled });
toast.success(t("saved", "Configuration saved successfully"));
} catch (error) {
console.error("Failed to update group config:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
} finally {
setSaving(false);
}
};
const handleUpdateMode = async (
mode: "average" | "subscribe" | "traffic"
) => {
setSaving(true);
try {
const payload: any = {
enabled: config.enabled,
mode,
};
await updateGroupConfig(payload);
setConfig({ ...config, mode });
toast.success(t("saved", "Configuration saved successfully"));
} catch (error) {
console.error("Failed to update group config:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
} finally {
setSaving(false);
}
};
const handleResetGroups = async () => {
setResetting(true);
try {
await resetGroups({ confirm: true });
toast.success(
t("resetSuccess", "All groups have been reset successfully")
);
setShowResetDialog(false);
// Reload config after reset
await loadConfig();
} catch (error) {
console.error("Failed to reset groups:", error);
toast.error(t("resetFailed", "Failed to reset groups"));
} finally {
setResetting(false);
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("groupConfig", "Group Configuration")}</CardTitle>
<CardDescription>
{t(
"groupConfigDescription",
"Configure user group and node group settings"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Enable/Disable */}
<div className="flex items-center justify-between">
<div>
<label className="font-medium" htmlFor="enabled">
{t("enableGrouping", "Enable Grouping")}
</label>
<p className="text-muted-foreground text-sm">
{t(
"enableGroupingDescription",
"When enabled, users will only see nodes from their assigned group"
)}
</p>
</div>
<input
checked={config.enabled}
className="h-4 w-4"
disabled={saving}
id="enabled"
onChange={(e) => handleUpdateEnabled(e.target.checked)}
type="checkbox"
/>
</div>
{/* Mode Selection */}
{config.enabled && (
<div className="space-y-2">
<p className="font-medium">
{t("groupingMode", "Grouping Mode")}
</p>
<div className="grid grid-cols-3 gap-4">
<button
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "average"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
disabled={saving}
onClick={() => handleUpdateMode("average")}
type="button"
>
<div className="font-medium">
{t("averageMode", "Average Mode")}
</div>
<div className="text-muted-foreground text-sm">
{t(
"averageModeDescription",
"Distribute users evenly across groups"
)}
</div>
</button>
<button
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "subscribe"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
disabled={saving}
onClick={() => handleUpdateMode("subscribe")}
type="button"
>
<div className="font-medium">
{t("subscribeMode", "Subscribe Mode")}
</div>
<div className="text-muted-foreground text-sm">
{t(
"subscribeModeDescription",
"Group users by their subscription plan"
)}
</div>
</button>
<button
className={`rounded-lg border p-4 text-left transition-colors ${
config.mode === "traffic"
? "border-primary bg-primary/10"
: "border-border hover:bg-muted"
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
disabled={saving}
onClick={() => handleUpdateMode("traffic")}
type="button"
>
<div className="font-medium">
{t("trafficMode", "Traffic Mode")}
</div>
<div className="text-muted-foreground text-sm">
{t(
"trafficModeDescription",
"Group users by their traffic usage"
)}
</div>
</button>
</div>
</div>
)}
{/* Reset Button */}
<div className="flex justify-end border-t pt-4">
<AlertDialog
onOpenChange={setShowResetDialog}
open={showResetDialog}
>
<AlertDialogTrigger asChild>
<Button variant="destructive">
{t("resetGroups", "Reset All Groups")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("resetGroupsTitle", "Reset All Groups")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"resetGroupsDescription",
"This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleResetGroups}
>
{resetting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("confirm", "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,541 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
getGroupHistory,
getGroupHistoryDetail,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
export default function GroupHistory() {
const { t } = useTranslation("group");
const ref = useRef<ProTableActions>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedHistory, setSelectedHistory] =
useState<API.GroupHistory | null>(null);
const [details, setDetails] = useState<any[]>([]);
const [nodeGroupMap, setNodeGroupMap] = useState<Map<number, string>>(
new Map()
);
// User list dialog state
const [userListOpen, setUserListOpen] = useState(false);
const [selectedNodeGroupName, setSelectedNodeGroupName] =
useState<string>("");
const [userList, setUserList] = useState<any[]>([]);
const [userListTotal, setUserListTotal] = useState(0);
// Fetch all node groups
const { data: nodeGroups } = useQuery({
queryKey: ["getNodeGroupListForDetail"],
queryFn: async () => {
const { data } = await getNodeGroupList({
page: 1,
size: 100,
});
return data.data?.list || [];
},
});
// Build ID to name maps when groups are loaded
if (nodeGroups) {
const newNodeGroupMap = new Map<number, string>();
nodeGroups.forEach((ng: API.NodeGroup) => {
newNodeGroupMap.set(ng.id, ng.name);
});
if (newNodeGroupMap.size !== nodeGroupMap.size) {
setNodeGroupMap(newNodeGroupMap);
}
}
const getModeLabel = (mode: string) => {
switch (mode) {
case "average":
return t("averageMode", "Average");
case "subscribe":
return t("subscribeMode", "Subscribe");
case "traffic":
return t("trafficMode", "Traffic");
default:
return mode;
}
};
const getTriggerTypeLabel = (type: string) => {
switch (type) {
case "manual":
return t("manualTrigger", "Manual");
case "auto":
return t("autoTrigger", "Auto");
case "schedule":
return t("scheduleTrigger", "Schedule");
default:
return type;
}
};
const handleViewDetail = async (record: API.GroupHistory) => {
setSelectedHistory(record);
setDetailOpen(true);
setDetailLoading(true);
try {
const { data } = await getGroupHistoryDetail({
id: record.id,
});
console.log("Group history detail response:", data);
// 从返回的数据中获取详情列表
// data.data.config_snapshot.group_details 包含分组详情
if (data.data?.config_snapshot?.group_details) {
setDetails(data.data.config_snapshot.group_details);
} else {
console.warn("No group_details found in response:", data);
setDetails([]);
}
} catch (error) {
console.error("Failed to fetch history details:", error);
setDetails([]);
} finally {
setDetailLoading(false);
}
};
const handleShowUserList = async (
nodeGroupId: number,
nodeGroupName: string
) => {
setSelectedNodeGroupName(nodeGroupName);
setUserListOpen(true);
// 从历史详情记录中获取用户数据
const detail = details.find((d: any) => {
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
return detailNodeGroupId === nodeGroupId;
});
if (detail) {
const userDataJSON = detail.UserData || detail.user_data;
if (userDataJSON) {
try {
const userData = JSON.parse(userDataJSON);
setUserList(userData);
setUserListTotal(userData.length);
} catch (error) {
console.error("Failed to parse user data:", error);
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
} else {
setUserList([]);
setUserListTotal(0);
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>
{t("groupHistory", "Group Calculation History")}
</CardTitle>
<CardDescription>
{t(
"groupHistoryDescription",
"View group recalculation history and results"
)}
</CardDescription>
</CardHeader>
<CardContent>
<ProTable<API.GroupHistory, API.GetGroupHistoryRequest>
action={ref}
actions={{
render: (row: any) => [
<Button
key="detail"
onClick={() => handleViewDetail(row)}
size="sm"
variant="outline"
>
{t("viewDetail", "View Detail")}
</Button>,
],
}}
columns={[
{
id: "id",
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }: { row: any }) => (
<span className="text-muted-foreground">
{t("idPrefix", "#")}
{row.getValue("id")}
</span>
),
},
{
id: "group_mode",
accessorKey: "group_mode",
header: t("groupMode", "Group Mode"),
cell: ({ row }: { row: any }) => (
<Badge variant="outline">
{getModeLabel(row.getValue("group_mode"))}
</Badge>
),
},
{
id: "trigger_type",
accessorKey: "trigger_type",
header: t("triggerType", "Trigger Type"),
cell: ({ row }: { row: any }) => (
<Badge variant="secondary">
{getTriggerTypeLabel(row.getValue("trigger_type"))}
</Badge>
),
},
{
id: "total_users",
accessorKey: "total_users",
header: t("totalUsers", "Total Users"),
cell: ({ row }: { row: any }) => (
<span className="font-semibold">
{row.getValue("total_users")}
</span>
),
},
{
id: "result",
accessorKey: "error_log",
header: t("result", "Result"),
cell: ({ row }: { row: any }) => {
const record = row.original;
return (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">
{t("successCount", "Success")}: {record.success_count}{" "}
{t("separator", "/")} {t("failedCount", "Failed")}:{" "}
{record.failed_count}
</div>
{record.error_log && (
<Badge variant="destructive">
{t("failed", "Failed")}
</Badge>
)}
{!record.error_log && record.failed_count === 0 && (
<Badge variant="default">
{t("completed", "Completed")}
</Badge>
)}
</div>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }: { row: any }) =>
formatDate(row.getValue("created_at")),
},
]}
header={{
title: t("groupHistory", "Group Calculation History"),
}}
request={async (params) => {
const { data } = await getGroupHistory({
page: params.page || 1,
size: params.size || 10,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</CardContent>
</Card>
{/* Detail Dialog */}
<Dialog onOpenChange={setDetailOpen} open={detailOpen}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>
{t("groupHistoryDetail", "Group Calculation Detail")}
</DialogTitle>
<DialogDescription>
{t("historyId", "History ID")}: {selectedHistory?.id}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedHistory && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-muted-foreground text-sm">
{t("groupMode", "Group Mode")}
</div>
<div className="font-medium">
{getModeLabel(selectedHistory.group_mode)}
</div>
</div>
<div>
<div className="text-muted-foreground text-sm">
{t("triggerType", "Trigger Type")}
</div>
<div className="font-medium">
{getTriggerTypeLabel(selectedHistory.trigger_type)}
</div>
</div>
<div>
<div className="text-muted-foreground text-sm">
{t("totalUsers", "Total Users")}
</div>
<div className="font-medium">
{selectedHistory.total_users}
</div>
</div>
<div>
<div className="text-muted-foreground text-sm">
{t("result", "Result")}
</div>
<div className="font-medium">
{t("successCount", "Success")}:{" "}
{selectedHistory.success_count} {t("separator", "/")}{" "}
{t("failedCount", "Failed")}:{" "}
{selectedHistory.failed_count}
</div>
</div>
{selectedHistory.start_time && (
<div>
<div className="text-muted-foreground text-sm">
{t("startTime", "Start Time")}
</div>
<div className="font-medium">
{formatDate(selectedHistory.start_time)}
</div>
</div>
)}
{selectedHistory.end_time && (
<div>
<div className="text-muted-foreground text-sm">
{t("endTime", "End Time")}
</div>
<div className="font-medium">
{formatDate(selectedHistory.end_time)}
</div>
</div>
)}
</div>
{selectedHistory.error_log && (
<div>
<div className="text-muted-foreground text-sm">
{t("errorMessage", "Error Message")}
</div>
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
{selectedHistory.error_log}
</div>
</div>
)}
</>
)}
<div>
<div className="mb-2 font-medium text-sm">
{t("groupDetails", "Group Details")}
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground text-sm">
{t("loading", "Loading...")}
</span>
</div>
) : details.length > 0 ? (
<>
{/* 统计信息 */}
<div className="mb-4 grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
<div className="text-center">
<div className="font-bold text-2xl">
{details.reduce(
(sum: number, d: any) =>
sum + (d.UserCount || d.user_count || 0),
0
)}
</div>
<div className="text-muted-foreground text-xs">
{t("totalUsers", "Total Users")}
</div>
</div>
<div className="text-center">
<div className="font-bold text-2xl">
{details.reduce(
(sum: number, d: any) =>
sum + (d.NodeCount || d.node_count || 0),
0
)}
</div>
<div className="text-muted-foreground text-xs">
{t("totalNodes", "Total Nodes")}
</div>
</div>
<div className="text-center">
<div className="font-bold text-2xl">{details.length}</div>
<div className="text-muted-foreground text-xs">
{t("totalNodeGroups", "Total Node Groups")}
</div>
</div>
</div>
{/* 详情表格 */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="border-b px-4 py-2 text-left">
{t("nodeGroup", "Node Group")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("userCount", "User Count")}
</th>
<th className="border-b px-4 py-2 text-right">
{t("nodeCount", "Node Count")}
</th>
</tr>
</thead>
<tbody>
{details.map((detail: any, index: number) => {
const nodeGroupId =
detail.NodeGroupId || detail.node_group_id;
const nodeGroupName =
nodeGroupMap.get(nodeGroupId) ||
`${t("idPrefix", "#")}${nodeGroupId}`;
return (
<tr key={index}>
<td className="border-b px-4 py-2">
<div>
<div className="font-medium">
{nodeGroupName}
</div>
<div className="text-muted-foreground text-xs">
{t("id", "ID")}: {nodeGroupId}
</div>
</div>
</td>
<td className="border-b px-4 py-2 text-right">
<button
className="cursor-pointer font-semibold hover:underline disabled:cursor-not-allowed disabled:opacity-50"
disabled={
(detail.UserCount ||
detail.user_count ||
0) === 0
}
onClick={() =>
handleShowUserList(
nodeGroupId,
nodeGroupName
)
}
type="button"
>
{detail.UserCount || detail.user_count || 0}
</button>
</td>
<td className="border-b px-4 py-2 text-right">
{detail.NodeCount || detail.node_count || 0}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
{t("noDetails", "No details available")}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* User List Dialog */}
<Dialog onOpenChange={setUserListOpen} open={userListOpen}>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>
{selectedNodeGroupName} - {t("userList", "User List")}
</DialogTitle>
<DialogDescription>
{t("totalUsers", "Total Users")}: {userListTotal}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{userList.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("id", "ID")}</TableHead>
<TableHead>{t("email", "Email")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{userList.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>{user.email || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
{t("noUsers", "No users found")}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
getRecalculationStatus,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function GroupRecalculate() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState<string | null>(null);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadStatus();
// Poll status every 2 seconds when recalculating
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async (
mode: "average" | "subscribe" | "traffic"
) => {
setRecalculating(mode);
try {
await recalculateGroup({ mode });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(null);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>
{t("groupRecalculation", "Group Recalculation")}
</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-muted-foreground text-sm">
{t(
"recalculationCompleted",
"Recalculation completed successfully"
)}
</div>
)}
{status?.state === "failed" && (
<div className="text-destructive text-sm">
{t(
"recalculationFailed",
"Recalculation failed. Please try again."
)}
</div>
)}
</div>
{/* Recalculate Buttons */}
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{/* Average Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("averageMode", "Average Mode")}
</div>
<Button
className="w-full"
disabled={
recalculating === "average" || status?.state === "running"
}
onClick={() => handleRecalculate("average")}
variant="outline"
>
{recalculating === "average" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
{/* Subscribe Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("subscribeMode", "Subscribe Mode")}
</div>
<Button
className="w-full"
disabled={
recalculating === "subscribe" || status?.state === "running"
}
onClick={() => handleRecalculate("subscribe")}
variant="outline"
>
{recalculating === "subscribe" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
{/* Traffic Mode Recalculate */}
<div className="space-y-2">
<div className="font-medium">
{t("trafficMode", "Traffic Mode")}
</div>
<Button
className="w-full"
disabled={
recalculating === "traffic" || status?.state === "running"
}
onClick={() => handleRecalculate("traffic")}
variant="outline"
>
{recalculating === "traffic" && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculate", "Recalculate")}
</Button>
</div>
</div>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { useTranslation } from "react-i18next";
import AverageModeTab from "./average-mode-tab";
import CurrentGroupResults from "./current-group-results";
import GroupConfig from "./group-config";
import GroupHistory from "./group-history";
// import UserGroups from "./user-groups";
import NodeGroups from "./node-groups";
import SubscribeModeTab from "./subscribe-mode-tab";
import TrafficModeTab from "./traffic-mode-tab";
export default function Group() {
const { t } = useTranslation("group");
return (
<div className="space-y-4">
<h2 className="font-semibold text-lg">
{t("title", "Group Management")}
</h2>
<Tabs defaultValue="config">
<TabsList className="flex flex-wrap gap-2">
<TabsTrigger value="config">{t("config", "Config")}</TabsTrigger>
{/* <TabsTrigger value="user">
{t("userGroups", "User Groups")}
</TabsTrigger> */}
<TabsTrigger value="node">
{t("nodeGroups", "Node Groups")}
</TabsTrigger>
<TabsTrigger value="average">
{t("averageMode", "Average Mode")}
</TabsTrigger>
<TabsTrigger value="subscribe">
{t("subscribeMode", "Subscribe Mode")}
</TabsTrigger>
<TabsTrigger value="traffic">
{t("trafficMode", "Traffic Mode")}
</TabsTrigger>
<TabsTrigger value="results">
{t("currentGroupingResult", "Current Grouping Result")}
</TabsTrigger>
<TabsTrigger value="history">{t("history", "History")}</TabsTrigger>
</TabsList>
<TabsContent className="mt-4" value="config">
<GroupConfig />
</TabsContent>
{/* <TabsContent value="user" className="mt-4">
<UserGroups />
</TabsContent> */}
<TabsContent className="mt-4" value="node">
<NodeGroups />
</TabsContent>
<TabsContent className="mt-4" value="average">
<AverageModeTab />
</TabsContent>
<TabsContent className="mt-4" value="subscribe">
<SubscribeModeTab />
</TabsContent>
<TabsContent className="mt-4" value="traffic">
<TrafficModeTab />
</TabsContent>
<TabsContent className="mt-4" value="results">
<CurrentGroupResults />
</TabsContent>
<TabsContent className="mt-4" value="history">
<GroupHistory />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,526 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { AlertCircle, Loader2 } from "lucide-react";
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface NodeGroupFormProps {
initialValues?: Partial<API.NodeGroup>;
allNodeGroups?: API.NodeGroup[];
currentGroupId?: number;
loading?: boolean;
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
title: string;
trigger: React.ReactNode;
}
const NodeGroupForm = ({
initialValues,
allNodeGroups = [],
currentGroupId,
loading,
onSubmit,
title,
trigger,
ref,
}: NodeGroupFormProps & { ref?: RefObject<HTMLButtonElement | null> }) => {
const { t } = useTranslation("group");
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [conflictError, setConflictError] = useState<string>("");
const [values, setValues] = useState({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
useEffect(() => {
if (open) {
setConflictError(""); // 重置冲突错误
if (initialValues) {
setValues({
name: initialValues.name || "",
description: initialValues.description || "",
sort: initialValues.sort ?? 0,
for_calculation: initialValues.for_calculation ?? true,
is_expired_group: initialValues.is_expired_group ?? false,
expired_days_limit: initialValues.expired_days_limit ?? 7,
max_traffic_gb_expired: initialValues.max_traffic_gb_expired ?? 0,
speed_limit: initialValues.speed_limit ?? 0,
min_traffic_gb: initialValues.min_traffic_gb ?? 0,
max_traffic_gb: initialValues.max_traffic_gb ?? 0,
});
} else {
setValues({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
}
}
}, [initialValues, open]);
// 检测流量区间冲突
const checkTrafficRangeConflict = (
minTraffic: number,
maxTraffic: number
): string => {
// 如果 min=0 且 max=0,表示不参与流量分组,跳过所有验证
if (minTraffic === 0 && maxTraffic === 0) {
return "";
}
// 验证区间有效性:min 必须 < max(除非 max=0 表示无上限)
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
return t("invalidRange", "Min traffic must be less than max traffic");
}
// 处理 max=0 的情况,表示无上限,使用一个很大的数代替
const actualMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
// 检查与其他节点组的冲突
for (const group of allNodeGroups) {
// 跳过当前编辑的节点组
if (currentGroupId && group.id === currentGroupId) {
continue;
}
// 跳过没有设置流量区间的节点组(min=0 且 max=0 表示未配置)
const existingMin = group.min_traffic_gb ?? 0;
const existingMax = group.max_traffic_gb ?? 0;
if (existingMin === 0 && existingMax === 0) {
continue;
}
// 处理现有节点组 max=0 的情况
const actualExistingMax =
existingMax === 0 ? Number.MAX_VALUE : existingMax;
// 检测区间重叠
// 两个区间 [min1, max1] 和 [min2, max2] 重叠的条件:
// max1 > min2 && max2 > min1
const hasOverlap =
actualMax > existingMin && actualExistingMax > minTraffic;
if (hasOverlap) {
return t("rangeConflict", {
name: group.name,
min: existingMin.toString(),
max: existingMax === 0 ? "∞" : existingMax.toString(),
});
}
}
return "";
};
// 检测过期节点组冲突
const checkExpiredGroupConflict = async (
isExpiredGroup: boolean
): Promise<string> => {
if (!isExpiredGroup) {
return "";
}
// 检查是否已存在其他过期节点组
const existingExpiredGroup = allNodeGroups.find(
(group) => group.is_expired_group && group.id !== currentGroupId
);
if (existingExpiredGroup) {
return t(
"expiredGroupExists",
`System already has an expired node group: ${existingExpiredGroup.name}`
);
}
// 检查当前节点组是否被订阅商品使用
if (currentGroupId) {
try {
const { getSubscribeList } = await import(
"@workspace/ui/services/admin/subscribe"
);
const { data } = await getSubscribeList({
page: 1,
size: 1,
node_group_id: currentGroupId,
});
if (data.data && data.data.total > 0) {
return t(
"nodeGroupUsedBySubscribe",
"This node group is used as default node group in subscription products, cannot set as expired group"
);
}
} catch (error) {
console.error("Failed to check subscribe usage:", error);
}
}
return "";
};
// 检查是否存在其他过期节点组(用于隐藏开关)
const hasOtherExpiredGroup = allNodeGroups.some(
(group) => group.is_expired_group && group.id !== currentGroupId
);
// 当前是否是过期节点组(编辑模式下)
const isCurrentExpiredGroup = initialValues?.is_expired_group ?? false;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 检测过期节点组冲突
const expiredGroupConflict = await checkExpiredGroupConflict(
values.is_expired_group
);
if (expiredGroupConflict) {
setConflictError(expiredGroupConflict);
return;
}
// 仅在非过期节点组时检测流量区间冲突
if (!values.is_expired_group) {
const conflict = checkTrafficRangeConflict(
values.min_traffic_gb,
values.max_traffic_gb
);
if (conflict) {
setConflictError(conflict);
return;
}
}
setSubmitting(true);
const success = await onSubmit(values);
setSubmitting(false);
if (success) {
setOpen(false);
setConflictError("");
setValues({
name: "",
description: "",
sort: 0,
for_calculation: true,
is_expired_group: false,
expired_days_limit: 7,
max_traffic_gb_expired: 0,
speed_limit: 0,
min_traffic_gb: 0,
max_traffic_gb: 0,
});
}
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild ref={ref}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{t("nodeGroupFormDescription", "Configure node group settings")}
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="name">{t("name", "Name")} *</Label>
<Input
id="name"
onChange={(e) => setValues({ ...values, name: e.target.value })}
placeholder={t("namePlaceholder", "Enter name")}
required
value={values.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">
{t("description", "Description")}
</Label>
<Textarea
id="description"
onChange={(e) =>
setValues({ ...values, description: e.target.value })
}
placeholder={t("descriptionPlaceholder", "Enter description")}
rows={3}
value={values.description}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
<Input
id="sort"
min={0}
onChange={(e) =>
setValues({
...values,
sort: Number.parseInt(e.target.value, 10) || 0,
})
}
type="number"
value={values.sort}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="for_calculation">
{t("forCalculation", "For Calculation")}
</Label>
<p className="text-muted-foreground text-sm">
{values.is_expired_group
? t(
"expiredGroupForCalculationDescription",
"Expired-only node groups cannot participate in group calculation"
)
: t(
"forCalculationDescription",
"Whether this node group participates in grouping calculation"
)}
</p>
</div>
<Switch
checked={values.for_calculation}
disabled={values.is_expired_group}
id="for_calculation"
onCheckedChange={(checked) =>
setValues({ ...values, for_calculation: checked })
}
/>
</div>
{/* 仅在没有其他过期节点组或当前就是过期节点组时显示 */}
{(!hasOtherExpiredGroup || isCurrentExpiredGroup) && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="is_expired_group">
{t("isExpiredGroup", "Expired Node Group")}
</Label>
<p className="text-muted-foreground text-sm">
{t(
"isExpiredGroupDescription",
"Allow expired users to use limited nodes"
)}
</p>
</div>
<Switch
checked={values.is_expired_group}
id="is_expired_group"
onCheckedChange={async (checked) => {
setValues({
...values,
is_expired_group: checked,
for_calculation: checked ? false : values.for_calculation,
min_traffic_gb: checked ? 0 : values.min_traffic_gb,
max_traffic_gb: checked ? 0 : values.max_traffic_gb,
});
// 实时检测过期节点组冲突
const conflict = await checkExpiredGroupConflict(checked);
setConflictError(conflict);
}}
/>
</div>
)}
{values.is_expired_group && (
<>
<div className="space-y-2">
<Label htmlFor="expired_days_limit">
{t("expiredDaysLimit", "Expired Days Limit")}
</Label>
<p className="text-muted-foreground text-sm">
{t(
"expiredDaysLimitDescription",
"Number of days after expiration that users can still access nodes"
)}
</p>
<Input
id="expired_days_limit"
min={1}
onChange={(e) =>
setValues({
...values,
expired_days_limit:
Number.parseInt(e.target.value, 10) || 7,
})
}
type="number"
value={values.expired_days_limit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_traffic_gb_expired">
{t(
"maxTrafficGBExpired",
"Max Traffic for Expired Users (GB)"
)}
</Label>
<p className="text-muted-foreground text-sm">
{t(
"maxTrafficGBExpiredDescription",
"Maximum traffic allowed for expired users (0 = unlimited)"
)}
</p>
<Input
id="max_traffic_gb_expired"
min={0}
onChange={(e) =>
setValues({
...values,
max_traffic_gb_expired:
Number.parseInt(e.target.value, 10) || 0,
})
}
type="number"
value={values.max_traffic_gb_expired}
/>
</div>
<div className="space-y-2">
<Label htmlFor="speed_limit">
{t("speedLimit", "Speed Limit (Mbps)")}
</Label>
<Input
id="speed_limit"
min={0}
onChange={(e) =>
setValues({
...values,
speed_limit: Number.parseInt(e.target.value, 10) || 0,
})
}
type="number"
value={values.speed_limit}
/>
</div>
</>
)}
{!values.is_expired_group && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("trafficRangeGB", "Traffic Range (GB)")}</Label>
</div>
<p className="text-muted-foreground text-sm">
{t(
"trafficRangeDescription",
"Users with traffic >= Min and < Max will be assigned to this node group"
)}
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="min_traffic_gb">
{t("minTrafficGB", "Min Traffic (GB)")}
</Label>
<Input
id="min_traffic_gb"
min={0}
onChange={(e) => {
const newValue = Number.parseFloat(e.target.value) || 0;
setValues({ ...values, min_traffic_gb: newValue });
// 实时检测冲突
const conflict = checkTrafficRangeConflict(
newValue,
values.max_traffic_gb
);
setConflictError(conflict);
}}
step={1}
type="number"
value={values.min_traffic_gb}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max_traffic_gb">
{t("maxTrafficGB", "Max Traffic (GB)")}
</Label>
<Input
id="max_traffic_gb"
min={0}
onChange={(e) => {
const newValue = Number.parseFloat(e.target.value) || 0;
setValues({ ...values, max_traffic_gb: newValue });
// 实时检测冲突
const conflict = checkTrafficRangeConflict(
values.min_traffic_gb,
newValue
);
setConflictError(conflict);
}}
step={1}
type="number"
value={values.max_traffic_gb}
/>
</div>
</div>
{/* 显示冲突错误 */}
{conflictError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{conflictError}</span>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button
className="rounded-md border px-4 py-2 text-sm"
disabled={submitting || loading}
onClick={() => setOpen(false)}
type="button"
>
{t("cancel", "Cancel")}
</button>
<button
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm disabled:opacity-50"
disabled={submitting || loading || !!conflictError}
type="submit"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t("save", "Save")}
</button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
NodeGroupForm.displayName = "NodeGroupForm";
export default NodeGroupForm;
@@ -0,0 +1,251 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createNodeGroup,
deleteNodeGroup,
getNodeGroupList,
updateNodeGroup,
} from "@workspace/ui/services/admin/group";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import NodeGroupForm from "./node-group-form";
export default function NodeGroups() {
const { t } = useTranslation("group");
const [loading, setLoading] = useState(false);
const [allNodeGroups, setAllNodeGroups] = useState<API.NodeGroup[]>([]);
const ref = useRef<ProTableActions>(null);
// 获取所有节点组数据(用于冲突检测)
useEffect(() => {
const fetchAllNodeGroups = async () => {
try {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
setAllNodeGroups(data.data?.list || []);
} catch (error) {
console.error("Failed to fetch node groups:", error);
}
};
fetchAllNodeGroups();
}, []);
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t("nodeGroups", "Node Groups")}</CardTitle>
<CardDescription>
{t(
"nodeGroupsDescription",
"Manage node groups for user access control"
)}
</CardDescription>
</CardHeader>
<CardContent>
<ProTable<API.NodeGroup, API.GetNodeGroupListRequest>
action={ref}
actions={{
render: (row: any) => [
<NodeGroupForm
allNodeGroups={allNodeGroups}
currentGroupId={row.id}
initialValues={row}
key={`edit-${row.id}`}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateNodeGroup({
id: row.id,
...values,
} as API.UpdateNodeGroupRequest);
toast.success(t("updated", "Updated successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({
page: 1,
size: 1000,
});
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editNodeGroup", "Edit Node Group")}
trigger={
<Button size="sm" variant="outline">
{t("edit", "Edit")}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteNodeGroupConfirm",
"This will delete the node group. Nodes in this group will be reassigned."
)}
key="delete"
onConfirm={async () => {
await deleteNodeGroup({ id: row.id });
toast.success(t("deleted", "Deleted successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({
page: 1,
size: 1000,
});
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button size="sm" variant="destructive">
{t("delete", "Delete")}
</Button>
}
/>,
],
}}
columns={[
{
id: "id",
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }: { row: any }) => (
<span className="text-muted-foreground">
#{row.getValue("id")}
</span>
),
},
{
id: "name",
accessorKey: "name",
header: t("name", "Name"),
cell: ({ row }: { row: any }) => {
const isExpiredGroup = row.original.is_expired_group;
return (
<div className="flex items-center gap-2">
<span>{row.getValue("name")}</span>
{isExpiredGroup && (
<Badge variant="destructive">
{t("expiredGroup", "Expired")}
</Badge>
)}
</div>
);
},
},
{
id: "description",
accessorKey: "description",
header: t("description", "Description"),
cell: ({ row }: { row: any }) =>
row.getValue("description") || "--",
},
{
id: "for_calculation",
accessorKey: "for_calculation",
header: t("forCalculation", "For Calculation"),
cell: ({ row }: { row: any }) => {
const value = row.getValue("for_calculation");
return value ? (
<Badge variant="default">{t("yes", "Yes")}</Badge>
) : (
<Badge variant="secondary">{t("no", "No")}</Badge>
);
},
},
{
id: "traffic_range",
header: t("trafficRange", "Traffic Range (GB)"),
cell: ({ row }: { row: any }) => {
const min = row.original.min_traffic_gb;
const max = row.original.max_traffic_gb;
if (min !== undefined && max !== undefined) {
return `${min} - ${max}`;
}
if (min !== undefined) {
return `${min}`;
}
if (max !== undefined) {
return `${max}`;
}
return "--";
},
},
{
id: "sort",
accessorKey: "sort",
header: t("sort", "Sort"),
},
]}
header={{
title: t("nodeGroups", "Node Groups"),
toolbar: (
<NodeGroupForm
allNodeGroups={allNodeGroups}
currentGroupId={undefined}
initialValues={undefined}
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createNodeGroup(
values as API.CreateNodeGroupRequest
);
toast.success(t("created", "Created successfully"));
// 刷新节点组列表
const { data } = await getNodeGroupList({
page: 1,
size: 1000,
});
setAllNodeGroups(data.data?.list || []);
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createNodeGroup", "Create Node Group")}
trigger={<Button>{t("create", "Create")}</Button>}
/>
),
}}
request={async (params) => {
const { data } = await getNodeGroupList({
page: params.page || 1,
size: params.size || 10,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,279 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import {
getRecalculationStatus,
getSubscribeGroupMapping,
recalculateGroup,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface SubscribeGroupMapping {
subscribe_name: string;
node_group_name: string;
}
export default function SubscribeModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
// Fetch subscribe group mapping
const { data: mappingData, isLoading: mappingLoading } = useQuery({
queryKey: ["subscribeGroupMapping"],
queryFn: async () => {
const { data } = await getSubscribeGroupMapping();
return (data.data?.list || []) as SubscribeGroupMapping[];
},
});
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
useEffect(() => {
loadStatus();
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (status?.state === "running") {
loadStatus();
}
}, 2000);
return () => clearInterval(interval);
}, [status?.state]);
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "subscribe" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Configuration Card */}
<Card>
<CardHeader>
<CardTitle>
{t("subscribeModeConfig", "Subscribe Mode Configuration")}
</CardTitle>
<CardDescription>
{t(
"subscribeModeDescription",
"Group users by their purchased subscription plan"
)}
</CardDescription>
</CardHeader>
</Card>
{/* Subscribe Group Mapping Card */}
<Card>
<CardHeader>
<CardTitle>
{t("subscribeGroupMappingTitle", "套餐-节点组对应关系")}
</CardTitle>
</CardHeader>
<CardContent>
{mappingLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableBody>
{mappingData && mappingData.length > 0 ? (
mappingData
.filter(
(item: SubscribeGroupMapping) =>
item.subscribe_name && item.node_group_name
)
.map((item: SubscribeGroupMapping, index: number) => (
<TableRow key={index}>
<TableCell>
<span className="font-medium">
{item.subscribe_name}
</span>
<span className="mx-2 text-muted-foreground">
{t("arrow", "→")}
</span>
<Badge variant="outline">
{item.node_group_name}
</Badge>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="text-center text-muted-foreground">
{t("noMappingData", "No mapping data available")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>
{t("groupRecalculation", "Group Recalculation")}
</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-muted-foreground text-sm">
{t(
"recalculationCompleted",
"Recalculation completed successfully"
)}
</div>
)}
{status?.state === "failed" && (
<div className="text-destructive text-sm">
{t(
"recalculationFailed",
"Recalculation failed. Please try again."
)}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
disabled={recalculating || status?.state === "running"}
onClick={handleRecalculate}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,245 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
getNodeGroupList,
getRecalculationStatus,
recalculateGroup,
updateNodeGroup,
} from "@workspace/ui/services/admin/group";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import TrafficRangeConfig from "./traffic-ranges-config";
export default function TrafficModeTab() {
const { t } = useTranslation("group");
const [recalculating, setRecalculating] = useState(false);
const [loadingStatus, setLoadingStatus] = useState(false);
const [status, setStatus] = useState<{
state: string;
progress: number;
total: number;
} | null>(null);
// Fetch node groups
const {
data: nodeGroupsData,
isLoading: isLoadingNodeGroups,
refetch: refetchNodeGroups,
} = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
const loadStatus = async () => {
setLoadingStatus(true);
try {
const { data } = await getRecalculationStatus();
if (data.data) {
setStatus(data.data);
}
} catch (error) {
console.error("Failed to load recalculation status:", error);
} finally {
setLoadingStatus(false);
}
};
const handleTrafficUpdate = async (
nodeGroupId: number,
fields: { min_traffic_gb?: number; max_traffic_gb?: number }
) => {
try {
await updateNodeGroup({
id: nodeGroupId,
...fields,
});
toast.success(t("configSaved", "Configuration saved successfully"));
// Refetch node groups to get updated data
refetchNodeGroups();
} catch (error) {
console.error("Failed to update node group:", error);
toast.error(t("saveFailed", "Failed to save configuration"));
}
};
const handleRecalculate = async () => {
setRecalculating(true);
try {
await recalculateGroup({ mode: "traffic" });
toast.success(t("recalculationStarted", "Recalculation started"));
loadStatus();
} catch (error) {
console.error("Failed to start recalculation:", error);
toast.error(t("recalculationFailed", "Failed to start recalculation"));
} finally {
setRecalculating(false);
}
};
const getStateLabel = (state: string) => {
switch (state) {
case "running":
return t("running", "Running");
case "completed":
return t("completed", "Completed");
case "failed":
return t("failed", "Failed");
default:
return t("idle", "Idle");
}
};
const getStateVariant = (state: string) => {
switch (state) {
case "running":
return "default";
case "completed":
return "secondary";
case "failed":
return "destructive";
default:
return "outline";
}
};
return (
<div className="space-y-4">
{/* Node Groups Traffic Configuration Card */}
<Card>
<CardHeader>
<CardTitle>
{t("trafficModeConfig", "Traffic Mode Configuration")}
</CardTitle>
<CardDescription>
{t(
"trafficModeDescription",
"Configure traffic ranges for node groups. Users will be assigned to node groups based on their traffic usage."
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoadingNodeGroups ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground text-sm">
{t("loading", "Loading...")}
</span>
</div>
) : (
<TrafficRangeConfig
nodeGroups={nodeGroupsData || []}
onTrafficUpdate={handleTrafficUpdate}
/>
)}
</CardContent>
</Card>
{/* Recalculation Card */}
<Card>
<CardHeader>
<CardTitle>
{t("groupRecalculation", "Group Recalculation")}
</CardTitle>
<CardDescription>
{t(
"groupRecalculationDescription",
"Manually trigger a full recalculation of all user groups based on current configuration"
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{t("currentStatus", "Current Status")}
</span>
{loadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : status ? (
<Badge variant={getStateVariant(status.state) as any}>
{getStateLabel(status.state)}
</Badge>
) : null}
</div>
{status?.state === "running" && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{t("progress", "Progress")}</span>
<span>
{status.progress} / {status.total || 0}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
}}
/>
</div>
</div>
)}
{status?.state === "completed" && (
<div className="text-muted-foreground text-sm">
{t(
"recalculationCompleted",
"Recalculation completed successfully"
)}
</div>
)}
{status?.state === "failed" && (
<div className="text-destructive text-sm">
{t(
"recalculationFailed",
"Recalculation failed. Please try again."
)}
</div>
)}
</div>
{/* Recalculate Button */}
<div className="flex justify-end">
<Button
disabled={recalculating || status?.state === "running"}
onClick={handleRecalculate}
>
{recalculating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("recalculateAll", "Recalculate All Users")}
</Button>
</div>
{/* Warning */}
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
<strong>{t("warning", "Warning")}:</strong>{" "}
{t(
"recalculationWarning",
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,299 @@
"use client";
import { Input } from "@workspace/ui/components/input";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface NodeGroup {
id: number;
name: string;
min_traffic_gb?: number;
max_traffic_gb?: number;
}
interface TrafficRangeConfigProps {
nodeGroups: NodeGroup[];
onTrafficUpdate: (
nodeGroupId: number,
fields: { min_traffic_gb?: number; max_traffic_gb?: number }
) => Promise<void>;
}
interface UpdatingNode {
nodeGroupId: number;
field: "min_traffic_gb" | "max_traffic_gb";
}
interface NodeGroupTempValues {
min_traffic_gb?: number;
max_traffic_gb?: number;
}
export default function TrafficRangeConfig({
nodeGroups,
onTrafficUpdate,
}: TrafficRangeConfigProps) {
const { t } = useTranslation("group");
const [updatingNodes, setUpdatingNodes] = useState<UpdatingNode[]>([]);
// 使用对象存储每个节点组的临时值
const [temporaryValues, setTemporaryValues] = useState<
Record<number, NodeGroupTempValues>
>({});
// Get the display value (temporary or actual)
const getDisplayValue = (
nodeGroupId: number,
field: "min_traffic_gb" | "max_traffic_gb"
): number => {
const temp = temporaryValues[nodeGroupId];
if (temp && temp[field] !== undefined) {
return temp[field]!;
}
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
return field === "min_traffic_gb"
? (nodeGroup?.min_traffic_gb ?? 0)
: (nodeGroup?.max_traffic_gb ?? 0);
};
// Validate traffic ranges: no overlaps
const validateTrafficRange = (
nodeGroupId: number,
minTraffic: number,
maxTraffic: number
): { valid: boolean; error?: string } => {
// 如果 min=0 且 max=0,表示不参与流量分组,跳过验证
if (minTraffic === 0 && maxTraffic === 0) {
return { valid: true };
}
// Check if min >= max (both > 0)
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
return {
valid: false,
error: t(
"minCannotExceedMax",
"Minimum traffic cannot exceed maximum traffic"
),
};
}
// Check for overlaps with other node groups
const otherGroups = nodeGroups
.filter((ng) => ng.id !== nodeGroupId)
.map((ng) => {
const temp = temporaryValues[ng.id];
return {
id: ng.id,
name: ng.name,
min:
temp?.min_traffic_gb !== undefined
? temp.min_traffic_gb
: (ng.min_traffic_gb ?? 0),
max:
temp?.max_traffic_gb !== undefined
? temp.max_traffic_gb
: (ng.max_traffic_gb ?? 0),
};
})
.filter((ng) => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
.sort((a, b) => a.min - b.min);
for (const other of otherGroups) {
// Handle max=0 as no limit (infinity)
const otherMax = other.max === 0 ? Number.MAX_VALUE : other.max;
const currentMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
// Check for overlap: two ranges [min1, max1] and [min2, max2] overlap if:
// max1 > min2 && max2 > min1
if (currentMax > other.min && otherMax > minTraffic) {
return {
valid: false,
error: t(
"rangeOverlap",
'Range overlaps with node group "{{name}}"',
{ name: other.name }
),
};
}
}
return { valid: true };
};
const handleTrafficBlur = async (nodeGroupId: number) => {
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
if (!nodeGroup) return;
const tempValues = temporaryValues[nodeGroupId];
if (!tempValues) return;
// 获取当前的临时值或实际值
const currentMin =
tempValues.min_traffic_gb !== undefined
? tempValues.min_traffic_gb
: (nodeGroup.min_traffic_gb ?? 0);
const currentMax =
tempValues.max_traffic_gb !== undefined
? tempValues.max_traffic_gb
: (nodeGroup.max_traffic_gb ?? 0);
// 只要有一个字段被修改了就保存
const hasMinChange = tempValues.min_traffic_gb !== undefined;
const hasMaxChange = tempValues.max_traffic_gb !== undefined;
if (!(hasMinChange || hasMaxChange)) {
return;
}
// 验证
const validation = validateTrafficRange(
nodeGroupId,
currentMin,
currentMax
);
if (!validation.valid) {
toast.error(
validation.error || t("validationFailed", "Validation failed")
);
return;
}
// 检查值是否真的改变了
const originalMin = nodeGroup.min_traffic_gb ?? 0;
const originalMax = nodeGroup.max_traffic_gb ?? 0;
if (currentMin === originalMin && currentMax === originalMax) {
return;
}
// 标记为更新中(只标记被修改的字段)
if (hasMinChange) {
setUpdatingNodes((prev) => [
...prev,
{ nodeGroupId, field: "min_traffic_gb" },
]);
}
if (hasMaxChange) {
setUpdatingNodes((prev) => [
...prev,
{ nodeGroupId, field: "max_traffic_gb" },
]);
}
try {
// 一次性传递两个字段
const fieldsToUpdate: {
min_traffic_gb?: number;
max_traffic_gb?: number;
} = {};
if (currentMin !== originalMin) {
fieldsToUpdate.min_traffic_gb = currentMin;
}
if (currentMax !== originalMax) {
fieldsToUpdate.max_traffic_gb = currentMax;
}
if (Object.keys(fieldsToUpdate).length > 0) {
await onTrafficUpdate(nodeGroupId, fieldsToUpdate);
}
} finally {
// 移除更新状态
setUpdatingNodes((prev) =>
prev.filter((u) => !(u.nodeGroupId === nodeGroupId))
);
}
};
const isUpdating = (nodeGroupId: number) =>
updatingNodes.some((u) => u.nodeGroupId === nodeGroupId);
return (
<>
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 font-medium text-muted-foreground text-sm">
<div className="col-span-6">{t("nodeGroup", "Node Group")}</div>
<div className="col-span-3">{t("minTrafficGB", "Min (GB)")}</div>
<div className="col-span-3">{t("maxTrafficGB", "Max (GB)")}</div>
</div>
{nodeGroups.map((nodeGroup) => (
<div
className="grid grid-cols-12 items-center gap-2"
key={nodeGroup.id}
>
<div className="col-span-6">
<div className="font-medium">{nodeGroup.name}</div>
<div className="text-muted-foreground text-xs">
{t("id", "ID")}: {nodeGroup.id}
</div>
</div>
<div className="relative col-span-3">
<Input
disabled={isUpdating(nodeGroup.id)}
min={0}
onBlur={() => handleTrafficBlur(nodeGroup.id)}
onChange={(e) => {
const newValue = Number.parseFloat(e.target.value) || 0;
// 更新临时状态
setTemporaryValues((prev) => ({
...prev,
[nodeGroup.id]: {
...prev[nodeGroup.id],
min_traffic_gb: newValue,
max_traffic_gb: prev[nodeGroup.id]?.max_traffic_gb,
},
}));
}}
placeholder="0"
step={1}
type="number"
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
/>
{isUpdating(nodeGroup.id) && (
<div className="-translate-y-1/2 absolute top-1/2 right-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<div className="relative col-span-3">
<Input
disabled={isUpdating(nodeGroup.id)}
min={0}
onBlur={() => handleTrafficBlur(nodeGroup.id)}
onChange={(e) => {
const newValue = Number.parseFloat(e.target.value) || 0;
// 更新临时状态
setTemporaryValues((prev) => ({
...prev,
[nodeGroup.id]: {
...prev[nodeGroup.id],
min_traffic_gb: prev[nodeGroup.id]?.min_traffic_gb,
max_traffic_gb: newValue,
},
}));
}}
placeholder="0"
step={1}
type="number"
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
/>
{isUpdating(nodeGroup.id) && (
<div className="-translate-y-1/2 absolute top-1/2 right-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
))}
</div>
<div className="rounded-md bg-muted p-4 text-muted-foreground text-sm">
<strong>{t("note", "Note")}:</strong>{" "}
{t(
"trafficRangesNote",
"Set traffic ranges for each node group. Users will be assigned to node groups based on their traffic usage. Leave both values as 0 to not use this node group for traffic-based assignment."
)}
</div>
</>
);
}
@@ -25,14 +25,12 @@ export default function ResetSubscribeLogPage() {
const initialFilters = {
date: sp.date || today,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<
API.ResetSubscribeLog,
{ date?: string; user_subscribe_id?: number }
{ date?: string; user_subscribe_id?: string }
>
columns={[
{
@@ -83,7 +81,9 @@ export default function ResetSubscribeLogPage() {
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id
? Number((filter as any)?.user_subscribe_id)
: undefined,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
@@ -17,14 +17,12 @@ export default function SubscribeTrafficLogPage() {
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<
API.UserSubscribeTrafficLog,
{ date?: string; user_id?: number; user_subscribe_id?: number }
{ date?: string; user_id?: number; user_subscribe_id?: string }
>
actions={{
render: (row) => [
@@ -95,7 +93,9 @@ export default function SubscribeTrafficLogPage() {
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id
? Number((filter as any)?.user_subscribe_id)
: undefined,
});
const list =
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
@@ -23,12 +23,13 @@ export default function SubscribeLogPage() {
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
};
return (
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
<ProTable<
API.SubscribeLog,
{ date?: string; user_id?: number; user_subscribe_id?: string }
>
columns={[
{
accessorKey: "user",
@@ -96,7 +97,9 @@ export default function SubscribeLogPage() {
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
user_subscribe_id: (filter as any)?.user_subscribe_id
? Number((filter as any)?.user_subscribe_id)
: undefined,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
+184 -66
View File
@@ -1,5 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
@@ -8,6 +9,10 @@ import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
getGroupConfig,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import {
createNode,
deleteNode,
@@ -16,11 +21,12 @@ import {
toggleNodeStatus,
updateNode,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import NodeBatchSheet from "./node-batch-sheet";
import NodeForm from "./node-form";
export default function Nodes() {
@@ -32,13 +38,139 @@ export default function Nodes() {
const { getServerName, getServerAddress, getProtocolPort } = useServer();
const { fetchNodes, fetchTags } = useNode();
// Fetch node groups for display
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled;
// Dynamic columns based on group feature status
const columns = useMemo(() => {
const baseColumns = [
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }: { row: any }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{
id: "name",
accessorKey: "name",
header: t("name", "Name"),
},
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }: { row: any }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }: { row: any }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }: { row: any }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
id: "tags",
header: t("tags", "Tags"),
cell: ({ row }: { row: any }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg: string) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
];
// Add Node Groups column when group feature is enabled
if (isGroupEnabled) {
baseColumns.push({
id: "node_group_ids",
header: t("nodeGroups", "Node Groups"),
cell: ({ row }: { row: any }) => {
const groupIds = (row.original.node_group_ids as number[]) || [];
// Public node indicator (when node_group_ids is empty)
if (groupIds.length === 0) {
return (
<div className="flex items-center gap-2">
<Badge className="text-xs" variant="secondary">
{t("public", "Public")}
</Badge>
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{groupIds.map((groupId) => {
const group = nodeGroupsData?.find((g) => g.id === groupId);
return (
<Badge key={groupId} variant="outline">
{group?.name || String(groupId)}
</Badge>
);
})}
</div>
);
},
});
}
return baseColumns;
}, [
isGroupEnabled,
nodeGroupsData,
t,
getServerName,
getServerAddress,
getProtocolPort,
]);
return (
<ProTable<API.Node, { search: string }>
<ProTable<API.Node, { search: string; node_group_id?: number }>
action={ref}
actions={{
render: (row) => [
<NodeForm
initialValues={row}
initialValues={row as any}
key="edit"
loading={loading}
onSubmit={async (values) => {
@@ -47,6 +179,10 @@ export default function Nodes() {
const body: API.UpdateNodeRequest = {
...row,
...values,
node_group_ids:
values.node_group_ids?.map((id: string | number) =>
Number(id)
) || [],
} as any;
await updateNode(body);
toast.success(t("updated", "Updated"));
@@ -110,6 +246,15 @@ export default function Nodes() {
],
batchRender(rows) {
return [
<NodeBatchSheet
key="batch-update"
onSuccess={() => {
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
rows={rows}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
@@ -135,62 +280,7 @@ export default function Nodes() {
];
},
}}
columns={[
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
]}
columns={columns}
header={{
title: t("pageTitle", "Nodes"),
toolbar: (
@@ -199,15 +289,19 @@ export default function Nodes() {
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.CreateNodeRequest = {
const body: any = {
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: false,
};
if (values.node_group_ids) {
body.node_group_ids = values.node_group_ids.map(
(id: string | number) => Number(id)
);
}
await createNode(body);
toast.success(t("created", "Created"));
ref.current?.refresh();
@@ -220,8 +314,8 @@ export default function Nodes() {
return false;
}
}}
title={t("drawerCreateTitle", "Create Node")}
trigger={t("create", "Create")}
title={t("drawerCreateTitle", "Create Landing Node")}
trigger={t("create", "Create Landing Node")}
/>
),
}}
@@ -277,13 +371,37 @@ export default function Nodes() {
return updatedItems;
}}
params={[{ key: "search" }]}
params={[
{
key: "search",
},
...(isGroupEnabled
? [
{
key: "node_group_id",
placeholder: t("nodeGroups", "Node Groups"),
options: [
{ label: t("all", "All"), value: "" },
...(nodeGroupsData?.map((item) => ({
label: item.name,
value: String(item.id),
})) || []),
],
},
]
: []),
]}
request={async (pagination, filter) => {
const { data } = await filterNodeList({
const filters = {
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
node_group_id: filter?.node_group_id
? Number(filter.node_group_id)
: undefined,
};
const { data } = await filterNodeList(filters);
const rawList = (data?.data?.list || []) as API.Node[];
// Backend should ideally return nodes already sorted, but we also sort on the
// frontend to keep the UI stable (and avoid "random" order after refresh).
@@ -0,0 +1,558 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import TagInput from "@workspace/ui/composed/tag-input";
import {
getGroupConfig,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import { updateNode } from "@workspace/ui/services/admin/server";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
type FieldKey =
| "name"
| "enabled"
| "tags"
| "server_id"
| "protocol"
| "address"
| "port"
| "node_group_ids";
interface BatchPatch {
name?: string;
enabled?: boolean;
tags?: string[];
server_id?: number;
protocol?: string;
address?: string;
port?: number;
node_group_ids?: number[];
}
export default function NodeBatchSheet({
rows,
onSuccess,
}: {
rows: API.Node[];
onSuccess: () => void;
}) {
const { t } = useTranslation("nodes");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
// Which fields are active (will be applied)
const [enabledFields, setEnabledFields] = useState<Set<FieldKey>>(new Set());
// Patch values
const [patch, setPatch] = useState<BatchPatch>({
name: "",
enabled: true,
tags: [],
server_id: undefined,
protocol: undefined,
address: "",
port: undefined,
node_group_ids: [],
});
const { servers, getAvailableProtocols } = useServer();
const { tags: existingTags } = useNode();
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled;
const availableProtocols = getAvailableProtocols(patch.server_id);
function toggleField(key: FieldKey) {
setEnabledFields((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
// When disabling server_id, also disable protocol (it depends on server selection)
if (key === "server_id") next.delete("protocol");
} else {
next.add(key);
// When enabling server_id, auto-enable protocol as it must be set together
if (key === "server_id") next.add("protocol");
}
return next;
});
}
function isEnabled(key: FieldKey) {
return enabledFields.has(key);
}
async function handleSubmit() {
if (rows.length === 0) return;
if (enabledFields.size === 0) {
toast.warning(
t(
"batch_no_fields_selected",
"Please select at least one field to update"
)
);
return;
}
// Validate enabled fields before submitting
if (enabledFields.has("name") && !patch.name?.trim()) {
toast.warning(t("batch_name_required", "Name cannot be empty"));
return;
}
if (enabledFields.has("server_id") && !patch.server_id) {
toast.warning(t("batch_server_required", "Please select a server"));
return;
}
if (enabledFields.has("protocol") && !patch.protocol) {
toast.warning(t("batch_protocol_required", "Please select a protocol"));
return;
}
if (
enabledFields.has("port") &&
(!patch.port || patch.port < 1 || patch.port > 65_535)
) {
toast.warning(
t("batch_port_invalid", "Port must be between 1 and 65535")
);
return;
}
const activePatch: Partial<BatchPatch> = {};
for (const key of enabledFields) {
(activePatch as any)[key] = (patch as any)[key];
}
setLoading(true);
try {
const results = await Promise.allSettled(
rows.map((row) => {
const body: API.UpdateNodeRequest = {
id: row.id,
name: row.name,
tags: row.tags,
port: row.port,
address: row.address,
server_id: row.server_id,
protocol: row.protocol,
enabled: row.enabled,
...activePatch,
} as any;
return updateNode(body);
})
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
if (failed === 0) {
toast.success(
t("batch_updated", "Updated {{count}} nodes", { count: succeeded })
);
setOpen(false);
setEnabledFields(new Set());
onSuccess();
} else if (succeeded > 0) {
toast.warning(
t("batch_partial", "{{succeeded}} updated, {{failed}} failed", {
succeeded,
failed,
})
);
onSuccess();
} else {
toast.error(t("batch_update_failed", "Batch update failed"));
}
} finally {
setLoading(false);
}
}
function handleOpen() {
setEnabledFields(new Set());
setPatch({
name: "",
enabled: true,
tags: [],
server_id: undefined,
protocol: undefined,
address: "",
port: undefined,
node_group_ids: [],
});
setOpen(true);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button onClick={handleOpen} variant="outline">
{t("batch_update", "Batch Update")}
</Button>
</SheetTrigger>
<SheetContent className="w-[560px] max-w-full">
<SheetHeader>
<SheetTitle>
{t("batch_update_title", "Batch Update ({{count}} nodes)", {
count: rows.length,
})}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
<p className="mb-4 text-muted-foreground text-sm">
{t(
"batch_update_desc",
"Check the fields you want to overwrite. Unchecked fields will keep their original values."
)}
</p>
<div className="space-y-5">
{/* name */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("name")}
id="batch-name"
onCheckedChange={() => toggleField("name")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={isEnabled("name") ? "" : "text-muted-foreground"}
htmlFor="batch-name"
>
{t("name", "Name")}
</Label>
<div
className={
isEnabled("name") ? "" : "pointer-events-none opacity-40"
}
>
<EnhancedInput
onValueChange={(v) =>
setPatch((p) => ({ ...p, name: String(v ?? "") }))
}
value={patch.name ?? ""}
/>
</div>
</div>
</div>
{/* enabled */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("enabled")}
id="batch-enabled"
onCheckedChange={() => toggleField("enabled")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("enabled") ? "" : "text-muted-foreground"
}
htmlFor="batch-enabled"
>
{t("enabled", "Enabled")}
</Label>
<div
className={
isEnabled("enabled") ? "" : "pointer-events-none opacity-40"
}
>
<Switch
checked={!!patch.enabled}
onCheckedChange={(v) =>
setPatch((p) => ({ ...p, enabled: v }))
}
/>
</div>
</div>
</div>
{/* tags */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("tags")}
id="batch-tags"
onCheckedChange={() => toggleField("tags")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={isEnabled("tags") ? "" : "text-muted-foreground"}
htmlFor="batch-tags"
>
{t("tags", "Tags")}
</Label>
<div
className={
isEnabled("tags") ? "" : "pointer-events-none opacity-40"
}
>
<TagInput
onChange={(v) => setPatch((p) => ({ ...p, tags: v }))}
options={existingTags || []}
placeholder={t(
"tags_placeholder",
"Use Enter or comma (,) to add"
)}
value={patch.tags || []}
/>
</div>
</div>
</div>
{/* server_id */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("server_id")}
id="batch-server"
onCheckedChange={() => toggleField("server_id")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("server_id") ? "" : "text-muted-foreground"
}
htmlFor="batch-server"
>
{t("server", "Server")}
</Label>
<div
className={
isEnabled("server_id")
? ""
: "pointer-events-none opacity-40"
}
>
<Combobox<number, false>
onChange={(v) => {
setPatch((p) => ({
...p,
server_id: v ?? undefined,
protocol: undefined,
}));
}}
options={servers.map((s) => ({
value: s.id,
label: `${s.name} (${(s.address as any) || ""})`,
}))}
placeholder={t("select_server", "Select server…")}
value={patch.server_id}
/>
</div>
</div>
</div>
{/* protocol */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("protocol")}
id="batch-protocol"
onCheckedChange={() => toggleField("protocol")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("protocol") ? "" : "text-muted-foreground"
}
htmlFor="batch-protocol"
>
{t("protocol", "Protocol")}
</Label>
<div
className={
isEnabled("protocol")
? ""
: "pointer-events-none opacity-40"
}
>
<Combobox<string, false>
onChange={(v) =>
setPatch((p) => ({ ...p, protocol: v ?? undefined }))
}
options={availableProtocols.map((p) => ({
value: p.protocol,
label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`,
}))}
placeholder={t("select_protocol", "Select protocol…")}
value={patch.protocol}
/>
</div>
</div>
</div>
{/* address */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("address")}
id="batch-address"
onCheckedChange={() => toggleField("address")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("address") ? "" : "text-muted-foreground"
}
htmlFor="batch-address"
>
{t("address", "Address")}
</Label>
<div
className={
isEnabled("address") ? "" : "pointer-events-none opacity-40"
}
>
<EnhancedInput
onValueChange={(v) =>
setPatch((p) => ({ ...p, address: String(v ?? "") }))
}
value={patch.address ?? ""}
/>
</div>
</div>
</div>
{/* port */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("port")}
id="batch-port"
onCheckedChange={() => toggleField("port")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={isEnabled("port") ? "" : "text-muted-foreground"}
htmlFor="batch-port"
>
{t("port", "Port")}
</Label>
<div
className={
isEnabled("port") ? "" : "pointer-events-none opacity-40"
}
>
<EnhancedInput
max={65_535}
min={1}
onValueChange={(v) =>
setPatch((p) => ({
...p,
port: v ? Number(v) : undefined,
}))
}
placeholder="1-65535"
type="number"
value={patch.port ?? ""}
/>
</div>
</div>
</div>
{/* node_group_ids — only when group feature is enabled */}
{isGroupEnabled && (
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("node_group_ids")}
id="batch-groups"
onCheckedChange={() => toggleField("node_group_ids")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("node_group_ids") ? "" : "text-muted-foreground"
}
htmlFor="batch-groups"
>
{t("nodeGroups", "Node Groups")}
</Label>
<div
className={
isEnabled("node_group_ids")
? "grid grid-cols-2 gap-2"
: "pointer-events-none grid grid-cols-2 gap-2 opacity-40"
}
>
{nodeGroupsData?.map((g) => {
const ids = patch.node_group_ids || [];
const checked = ids.includes(g.id);
return (
<div className="flex items-center space-x-2" key={g.id}>
<Checkbox
checked={checked}
id={`batch-group-${g.id}`}
onCheckedChange={(c) => {
setPatch((p) => {
const current = p.node_group_ids || [];
return {
...p,
node_group_ids: c
? [...current, g.id]
: current.filter((id) => id !== g.id),
};
});
}}
/>
<Label htmlFor={`batch-group-${g.id}`}>
{g.name}
</Label>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={handleSubmit}>
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+133 -8
View File
@@ -1,7 +1,9 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Form,
FormControl,
@@ -11,6 +13,7 @@ import {
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
@@ -23,6 +26,10 @@ import {
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import TagInput from "@workspace/ui/composed/tag-input";
import {
getGroupConfig,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -54,7 +61,7 @@ const buildSchema = (t: TFunction) =>
server_id: z
.number({ message: t("errors.serverRequired", "Please select a server") })
.int()
.gt(0, t("errors.serverRequired", "Please select a server"))
.positive(t("errors.serverRequired", "Please select a server"))
.optional(),
protocol: z
.string()
@@ -71,6 +78,7 @@ const buildSchema = (t: TFunction) =>
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
tags: z.array(z.string()),
node_group_ids: z.optional(z.array(z.string()).default([])),
});
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
@@ -112,8 +120,10 @@ export default function NodeForm(props: {
address: "",
port: 0,
tags: [],
node_group_ids: [],
...initialValues,
},
mode: "onSubmit", // Only validate on form submission
});
const serverId = form.watch("server_id");
@@ -125,17 +135,60 @@ export default function NodeForm(props: {
const availableProtocols = getAvailableProtocols(serverId);
// Fetch node groups
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled;
useEffect(() => {
if (initialValues) {
form.reset({
const resetValues: NodeFormValues = {
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
});
node_group_ids: [],
};
// Copy only the values we need from initialValues
if (initialValues.name) resetValues.name = initialValues.name;
if (initialValues.server_id)
resetValues.server_id = initialValues.server_id;
if (initialValues.protocol) resetValues.protocol = initialValues.protocol;
if (initialValues.address) resetValues.address = initialValues.address;
if (initialValues.port) resetValues.port = initialValues.port;
if (initialValues.tags) resetValues.tags = initialValues.tags;
// Convert node_group_ids from number[] to string[], ensure it's always an array
if (
initialValues.node_group_ids &&
Array.isArray(initialValues.node_group_ids)
) {
resetValues.node_group_ids = initialValues.node_group_ids.map(
(id: string | number) => String(id)
);
} else {
resetValues.node_group_ids = [];
}
form.reset(resetValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
@@ -360,6 +413,7 @@ export default function NodeForm(props: {
</FormItem>
)}
/>
{/* Tags field - always shown */}
<FormField
control={form.control}
name="tags"
@@ -378,15 +432,86 @@ export default function NodeForm(props: {
/>
</FormControl>
<FormDescription>
{t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
{isGroupEnabled
? t(
"tags_groupMode_description",
"Optional tags for display and filtering (node group will be used as tag if empty)."
)
: t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show Node Group field only when group feature is enabled */}
{isGroupEnabled && (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("nodeGroup", "Node Group")}</FormLabel>
<FormControl>
<div className="grid grid-cols-2 gap-2">
{nodeGroupsData?.map((g) => (
<div
className="flex items-center space-x-2"
key={g.id}
>
<Checkbox
checked={field.value?.includes(String(g.id))}
id={`node-group-${g.id}`}
onCheckedChange={(checked) => {
// Ensure field.value is always an array
const currentValue = Array.isArray(
field.value
)
? field.value
: [];
if (checked) {
const newValue = [
...currentValue,
String(g.id),
];
form.setValue(field.name, newValue, {
shouldValidate: true,
shouldDirty: true,
});
} else {
const newValue = currentValue.filter(
(v: string) => v !== String(g.id)
);
form.setValue(field.name, newValue, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
/>
<Label
className="cursor-pointer"
htmlFor={`node-group-${g.id}`}
>
{g.name}
</Label>
</div>
))}
</div>
</FormControl>
<FormDescription>
{t(
"nodeGroup_description",
"Assign this node to multiple groups for user access control."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
+134 -3
View File
@@ -1,3 +1,16 @@
import { useMutation } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
@@ -15,17 +28,33 @@ import { cn } from "@workspace/ui/lib/utils";
import {
activateOrder,
getOrderList,
refundOrder,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
const REFUNDED_ORDER_STATUS = 6;
export default function Order() {
const { t } = useTranslation("order");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const user = useGlobalStore((state) => state.user);
const [confirmingOrderId, setConfirmingOrderId] = useState<number | null>(
null
);
const initialFilters = {
user_id: sp.user_id ? Number(sp.user_id) : undefined,
search: sp.search || undefined,
status: sp.status || undefined,
subscribe_id: sp.subscribe_id || undefined,
};
const statusOptions = [
{
@@ -45,6 +74,11 @@ export default function Order() {
label: t("status.5", "Completed"),
className: "bg-green-500",
},
{
value: REFUNDED_ORDER_STATUS,
label: t("status.6", "Refunded"),
className: "bg-red-500",
},
];
const typeOptions = [
@@ -57,10 +91,101 @@ export default function Order() {
const ref = useRef<ProTableActions>(null);
const { subscribes, getSubscribeName } = useSubscribe();
const canRefundOrders = Boolean(user?.is_admin);
const refundMutation = useMutation({
mutationFn: async (order: API.Order) => {
await refundOrder({ id: order.id });
},
onSuccess: () => {
toast.success(t("refundSuccess", "Refund completed."));
setConfirmingOrderId(null);
ref.current?.refresh();
},
onError: (error) => {
console.error("Refund order failed", error);
setConfirmingOrderId(null);
},
});
const isRefundedOrder = (order: API.Order) =>
order.status === REFUNDED_ORDER_STATUS ||
order.status_name === t("status.6", "Refunded") ||
order.status_name?.toLowerCase() === "refunded";
const canRefundOrder = (order: API.Order) =>
canRefundOrders && !isRefundedOrder(order);
return (
<ProTable<API.Order, any>
action={ref}
actions={{
render: (order) => {
if (!canRefundOrder(order)) return [];
const isPending =
refundMutation.isPending && confirmingOrderId === order.id;
return [
<AlertDialog
key={`refund-${order.id}`}
onOpenChange={(open) => {
setConfirmingOrderId(open ? order.id : null);
}}
open={confirmingOrderId === order.id}
>
<AlertDialogTrigger asChild>
<Button disabled={isPending} size="sm" variant="destructive">
{isPending
? t("refundSubmitting", "Refunding...")
: t("refund", "Refund")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("refundConfirmTitle", "Confirm refund")}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{t(
"refundConfirmDescription",
"This refund will immediately invalidate the user's subscription and deduct the related agent commission."
)}
</p>
<p className="font-medium text-foreground">
{t(
"refundConfirmWarning",
"This action cannot be repeated after the order is refunded."
)}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isPending}
onClick={async (event) => {
event.preventDefault();
if (isPending) return;
await refundMutation.mutateAsync(order);
}}
>
{isPending
? t("refundSubmitting", "Refunding...")
: t("confirmRefund", "Confirm refund")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>,
];
},
}}
columns={[
{
accessorKey: "order_no",
@@ -245,13 +370,19 @@ export default function Order() {
);
}
return (
<Badge>
{option?.label || t(`status.${row.getValue("status")}`)}
<Badge
variant={isRefundedOrder(order) ? "destructive" : "default"}
>
{option?.label ||
(order.status_name
? t(`status.${row.getValue("status")}`, order.status_name)
: t(`status.${row.getValue("status")}`))}
</Badge>
);
},
},
]}
initialFilters={initialFilters}
params={[
{
key: "status",
@@ -1,6 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
@@ -8,6 +9,7 @@ import {
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Button } from "@workspace/ui/components/button";
import { Card } from "@workspace/ui/components/card";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Form,
@@ -36,10 +38,14 @@ import {
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
import { JSONEditor } from "@workspace/ui/composed/editor/index";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getGroupConfig,
getNodeGroupList,
} from "@workspace/ui/services/admin/group";
import {
evaluateWithPrecision,
unitConversion,
@@ -72,6 +78,8 @@ const defaultValues = {
language: "",
node_tags: [],
nodes: [],
node_group_id: "",
node_group_ids: [],
unit_time: "Month",
deduction_ratio: 0,
purchase_with_discount: false,
@@ -79,6 +87,7 @@ const defaultValues = {
renewal_reset: false,
show_original_price: false,
deduction_mode: "auto",
traffic_limit: [],
};
export default function SubscribeForm<T extends Record<string, any>>({
@@ -106,6 +115,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
z.object({
quantity: z.number(),
discount: z.number(),
map_apple: z.string().optional(),
})
)
.optional(),
@@ -117,11 +127,23 @@ export default function SubscribeForm<T extends Record<string, any>>({
language: z.string().optional(),
node_tags: z.array(z.string()).optional(),
nodes: z.array(z.number()).optional(),
node_group_id: z.string().optional(),
node_group_ids: z.optional(z.array(z.string()).default([])),
deduction_ratio: z.number().optional(),
allow_deduction: z.boolean().optional(),
reset_cycle: z.number().optional(),
renewal_reset: z.boolean().optional(),
show_original_price: z.boolean().optional(),
traffic_limit: z
.array(
z.object({
stat_type: z.string(),
stat_value: z.number().int(),
traffic_usage: z.number(),
speed_limit: z.number(),
})
)
.optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
@@ -234,12 +256,27 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
useEffect(() => {
form?.reset(
assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
)
const processedValues = assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
);
// Convert node_group_id from number to string (including 0)
if (initialValues?.node_group_id !== undefined) {
processedValues.node_group_id = String(initialValues.node_group_id);
}
// Convert node_group_ids from number[] to string[]
if (
initialValues?.node_group_ids &&
Array.isArray(initialValues.node_group_ids)
) {
processedValues.node_group_ids = (
initialValues.node_group_ids as any[]
).map((id) => String(id));
}
form?.reset(processedValues);
const discount = form.getValues("discount") || [];
if (discount.length > 0) {
debouncedCalculateDiscount(discount, "discount");
@@ -256,15 +293,68 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
async function handleSubmit(data: { [x: string]: any }) {
// Don't process node_group_id - submit as-is
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags } = useNode();
const {
getAllAvailableTags,
getNodesByTag,
getNodesWithoutTags,
getNodesWithoutGroups,
nodes,
} = useNode();
const tagGroups = getAllAvailableTags();
// Fetch node groups (exclude expired groups)
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
const allGroups = data.data?.list || [];
// Filter out expired node groups
return allGroups.filter((group) => !group.is_expired_group);
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled;
const unit_time = form.watch("unit_time");
const node_group_id = form.watch("node_group_id");
const node_group_ids = form.watch("node_group_ids");
// Watch node_group_id and automatically include it in node_group_ids
useEffect(() => {
if (node_group_id) {
const currentGroupIds = form.getValues("node_group_ids") || [];
if (!currentGroupIds.includes(node_group_id)) {
form.setValue("node_group_ids", [...currentGroupIds, node_group_id]);
}
}
}, [node_group_id, form]);
// If node_group_id is empty or 0, automatically set it to the first item in node_group_ids
useEffect(() => {
if (
(!node_group_id || node_group_id === "0") &&
node_group_ids &&
node_group_ids.length > 0
) {
form.setValue("node_group_id", node_group_ids[0]);
}
}, [node_group_ids, node_group_id, form]);
return (
<Sheet onOpenChange={setOpen} open={open}>
@@ -286,7 +376,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
<Form {...form}>
<form className="pt-4" onSubmit={form.handleSubmit(handleSubmit)}>
<Tabs className="w-full" defaultValue="basic">
<TabsList className="mb-6 grid w-full grid-cols-3">
<TabsList className="mb-6 grid w-full grid-cols-4">
<TabsTrigger
className="flex items-center gap-2"
value="basic"
@@ -308,6 +398,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
<Server className="h-4 w-4" />
{t("form.nodes")}
</TabsTrigger>
<TabsTrigger
className="flex items-center gap-2"
value="traffic-limit"
>
<Icon className="h-4 w-4" icon="uil:tachometer-fast" />
{t("form.trafficLimit")}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="basic">
@@ -397,12 +494,6 @@ export default function SubscribeForm<T extends Record<string, any>>({
placeholder={t("form.noLimit")}
type="number"
{...field}
formatInput={(value) =>
unitConversion("bitsToMb", value)
}
formatOutput={(value) =>
unitConversion("mbToBits", value)
}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
@@ -723,6 +814,11 @@ export default function SubscribeForm<T extends Record<string, any>>({
value
).toString(),
},
{
name: "map_apple",
type: "text",
placeholder: t("form.appleProductId"),
},
]}
onChange={(
newValues: (API.SubscribeDiscount & {
@@ -932,80 +1028,88 @@ export default function SubscribeForm<T extends Record<string, any>>({
<TabsContent className="space-y-4" value="servers">
<div className="space-y-6">
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
{/* Show node_tags field only when group feature is disabled */}
{!isGroupEnabled && (
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
return (
<AccordionItem
key={tag}
value={String(tag)}
>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(
tagId as any
)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
)
)
)
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@@ -1015,7 +1119,12 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormLabel>{t("form.node")}</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
{getNodesWithoutTags().map((item) => {
{/* When group feature is enabled, show nodes without groups */}
{/* When group feature is disabled, show nodes without tags */}
{(isGroupEnabled
? getNodesWithoutGroups()
: getNodesWithoutTags()
).map((item: API.Node) => {
const value = field.value || [];
return (
@@ -1056,6 +1165,512 @@ export default function SubscribeForm<T extends Record<string, any>>({
})}
</div>
</FormControl>
<FormDescription>
{isGroupEnabled
? t(
"form.nodesWithoutGroupsDescription",
"Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)"
)
: t(
"form.nodesDescription",
"Select nodes for this subscription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show node_group_ids field only when group feature is enabled */}
{isGroupEnabled && (
<>
{/* When no default node group is set, show simple node group selection */}
{node_group_id ? (
<>
{/* Default Node Group Selection - shown when default is set */}
<FormField
control={form.control}
name="node_group_id"
render={({ field }) => {
// Find the selected node group
const selectedNodeGroup = nodeGroupsData?.find(
(g) => String(g.id) === field.value
);
// Filter nodes that belong to this group
const nodesInGroup = selectedNodeGroup
? (nodes || []).filter((node) => {
const nodeGroupIds =
(node as any).node_group_ids || [];
return nodeGroupIds.includes(
selectedNodeGroup.id
);
})
: [];
return (
<FormItem>
<FormLabel>
{t(
"form.defaultNodeGroup",
"Default Node Group"
)}
</FormLabel>
<Card className="p-4">
<FormControl>
<Combobox
onChange={(value) => {
form.setValue(
field.name,
value || ""
);
}}
options={[
{
label: t(
"form.noDefaultNodeGroup",
"No Default Node Group"
),
value: "",
},
...(nodeGroupsData?.map((g) => ({
label: g.name,
value: String(g.id),
})) || []),
]}
placeholder={t(
"form.selectDefaultNodeGroup",
"Select a default node group..."
)}
value={field.value}
/>
</FormControl>
<FormDescription className="mt-2">
{t(
"form.defaultNodeGroupDescription",
"The default node group for this product."
)}
</FormDescription>
{/* Show nodes in the selected default node group */}
{nodesInGroup.length > 0 && (
<>
<div className="mt-3 mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
key={node.id}
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</>
)}
</Card>
<FormMessage />
</FormItem>
);
}}
/>
{/* Backup Node Groups Selection - filter out default node group */}
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"form.backupNodeGroups",
"Backup Node Groups"
)}
</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData
?.filter(
(g) => String(g.id) !== node_group_id
)
?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (
nodes || []
).filter((node) => {
const nodeGroupIds =
(node as any).node_group_ids ||
[];
return nodeGroupIds.includes(g.id);
});
return (
<div
className="rounded-lg border p-4"
key={g.id}
>
<div className="mb-3 flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(
String(g.id)
)}
id={`subscribe-backup-node-group-${g.id}`}
onCheckedChange={(
checked
) => {
const currentValue =
field.value || [];
if (checked) {
form.setValue(
field.name,
[
...currentValue,
String(g.id),
]
);
} else {
form.setValue(
field.name,
currentValue.filter(
(v: string) =>
v !== String(g.id)
)
);
}
}}
/>
<Label
className="cursor-pointer font-medium"
htmlFor={`subscribe-backup-node-group-${g.id}`}
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length}{" "}
{t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="mt-3 ml-6">
<div className="mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map(
(node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
key={node.id}
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:
{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
)
)}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.backupNodeGroupsDescription",
"Select additional backup node groups."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.nodeGroups", "Node Groups")}
</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (nodes || []).filter(
(node) => {
const nodeGroupIds =
(node as any).node_group_ids || [];
return nodeGroupIds.includes(g.id);
}
);
return (
<div
className="rounded-lg border p-4"
key={g.id}
>
<div className="mb-3 flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(
String(g.id)
)}
id={`subscribe-node-group-${g.id}`}
onCheckedChange={(checked) => {
const currentValue =
field.value || [];
const currentDefaultGroupId =
form.getValues(
"node_group_id"
);
if (checked) {
const newValue = [
...currentValue,
String(g.id),
];
form.setValue(
field.name,
newValue
);
// If no default node group is set, set this one as default
if (!currentDefaultGroupId) {
form.setValue(
"node_group_id",
String(g.id)
);
}
} else {
form.setValue(
field.name,
currentValue.filter(
(v: string) =>
v !== String(g.id)
)
);
}
}}
/>
<Label
className="cursor-pointer font-medium"
htmlFor={`subscribe-node-group-${g.id}`}
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length}{" "}
{t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="mt-3 ml-6">
<div className="mb-2 text-muted-foreground text-xs">
{t(
"form.nodesInGroup",
"Nodes in this group:"
)}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
key={node.id}
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.nodeGroupsFirstSelectionDescription",
"Select node groups for this product. The first selected group will be set as the default node group."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
</TabsContent>
{/* Traffic Limit Tab */}
<TabsContent className="space-y-4" value="traffic-limit">
<div className="space-y-4">
<FormField
control={form.control}
name="traffic_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.trafficLimitRules", "Traffic Limit Rules")}
</FormLabel>
<FormControl>
<ArrayInput
fields={[
{
name: "stat_type",
type: "select",
placeholder: t(
"form.statType",
"Statistics Type"
),
value: "day",
options: [
{
label: t("form.statTypeHour", "Hour"),
value: "hour",
},
{
label: t("form.statTypeDay", "Day"),
value: "day",
},
],
},
{
name: "stat_value",
type: "number",
placeholder: t(
"form.statValue",
"Time Value"
),
min: 1,
onKeyDown: (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
{
name: "traffic_usage",
type: "number",
placeholder: t(
"form.trafficUsage",
"Traffic Usage (GB)"
),
min: 0,
onKeyDown: (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
{
name: "speed_limit",
type: "number",
placeholder: t(
"form.speedLimitMbps",
"Speed Limit (Mbps)"
),
min: 0,
onKeyDown: (
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
]}
onChange={(items: any[]) => {
field.onChange(
items.map((item) => ({
...item,
stat_value: Number(item.stat_value) || 0,
traffic_usage:
Number(item.traffic_usage) || 0,
speed_limit: Number(item.speed_limit) || 0,
}))
);
}}
value={
field.value && field.value.length > 0
? field.value
: [
{
stat_type: "day",
stat_value: 1,
traffic_usage: 0,
speed_limit: 0,
},
]
}
/>
</FormControl>
<FormDescription>
{t(
"form.trafficLimitDescription",
"Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -1,5 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
@@ -8,6 +9,7 @@ import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { getNodeGroupList } from "@workspace/ui/services/admin/group";
import {
batchDeleteSubscribe,
createSubscribe,
@@ -28,8 +30,36 @@ export default function SubscribeTable() {
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const { fetchSubscribes } = useSubscribe();
// Fetch node groups for filtering (exclude expired groups)
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
const allGroups = data.data?.list || [];
// Filter out expired node groups
return allGroups.filter((group) => !group.is_expired_group);
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await (
await import("@workspace/ui/services/admin/group")
).getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled;
return (
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
<ProTable<
API.SubscribeItem,
{ group_id: number; query: string; node_group_id?: number }
>
action={ref}
actions={{
render: (row) => [
@@ -40,10 +70,18 @@ export default function SubscribeTable() {
onSubmit={async (values) => {
setLoading(true);
try {
await updateSubscribe({
const updateBody: any = {
...row,
...values,
} as API.UpdateSubscribeRequest);
};
// Add node_group_ids if it exists in values
const vals = values as any;
if (vals.node_group_ids) {
updateBody.node_group_ids = vals.node_group_ids.map(
(id: string | number) => Number(id)
);
}
await updateSubscribe(updateBody as API.UpdateSubscribeRequest);
toast.success(t("updateSuccess"));
ref.current?.refresh();
fetchSubscribes();
@@ -210,10 +248,12 @@ export default function SubscribeTable() {
header: t("inventory"),
cell: ({ row }) => {
const inventory = row.getValue("inventory") as number;
return inventory === -1 ? (
<Display type="number" unlimited value={0} />
) : (
<Display type="number" unlimited value={inventory} />
return (
<Display
type="number"
unlimited={inventory === -1}
value={inventory === -1 ? 0 : inventory}
/>
);
},
},
@@ -243,6 +283,28 @@ export default function SubscribeTable() {
<Badge variant="outline">{row.getValue("sold")}</Badge>
),
},
...(isGroupEnabled
? [
{
id: "node_group",
header: t("defaultNodeGroup", "Default Node Group"),
cell: ({ row }: { row: any }) => {
const nodeGroupId = row.original.node_group_id;
const nodeGroup = nodeGroupsData?.find(
(g) => g.id === nodeGroupId
);
return (
<div>
{nodeGroup ? (
<Badge variant="outline">{nodeGroup.name}</Badge>
) : null}
</div>
);
},
},
]
: []),
]}
header={{
toolbar: (
@@ -251,11 +313,19 @@ export default function SubscribeTable() {
onSubmit={async (values) => {
setLoading(true);
try {
await createSubscribe({
const createBody: any = {
...values,
show: false,
sell: false,
});
};
// Add node_group_ids if it exists in values
const vals = values as any;
if (vals.node_group_ids) {
createBody.node_group_ids = vals.node_group_ids.map(
(id: string | number) => Number(id)
);
}
await createSubscribe(createBody);
toast.success(t("createSuccess"));
ref.current?.refresh();
fetchSubscribes();
@@ -312,12 +382,32 @@ export default function SubscribeTable() {
{
key: "search",
},
...(isGroupEnabled
? [
{
key: "node_group_id",
placeholder: t("nodeGroups", "Node Groups"),
options: [
{ label: t("all", "All"), value: "" },
...(nodeGroupsData?.map((item) => ({
label: item.name,
value: String(item.id),
})) || []),
],
},
]
: []),
]}
request={async (pagination, filters) => {
const { data } = await getSubscribeList({
const params = {
...pagination,
...filters,
});
node_group_id: filters?.node_group_id
? Number(filters.node_group_id)
: undefined,
} as any;
const { data } = await getSubscribeList(params);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@@ -22,6 +22,7 @@ import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import DynamicMultiplier from "./dynamic-multiplier";
import OnlineUsersCell from "./online-users-cell";
import ServerBatchSheet from "./server-batch-sheet";
import ServerConfig from "./server-config";
import ServerForm from "./server-form";
import ServerInstall from "./server-install";
@@ -184,6 +185,14 @@ export default function Servers() {
isServerReferencedByNodes(row.id)
);
return [
<ServerBatchSheet
key="batch-update"
onSuccess={() => {
ref.current?.refresh();
fetchServers();
}}
rows={rows}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
@@ -0,0 +1,461 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import { Form } from "@workspace/ui/components/form";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { cn } from "@workspace/ui/lib/utils";
import { updateServer } from "@workspace/ui/services/admin/server";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
formSchema,
getProtocolDefaultConfig,
protocols as PROTOCOLS,
useProtocolFields,
} from "./form-schema";
import { renderGroupCard } from "./server-form";
type SimpleFieldKey = "name" | "country" | "city" | "address";
type FieldKey = SimpleFieldKey | "protocols";
interface SimplePatch {
name: string;
country: string;
city: string;
address: string;
}
export default function ServerBatchSheet({
rows,
onSuccess,
}: {
rows: API.Server[];
onSuccess: () => void;
}) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [accordionValue, setAccordionValue] = useState<string>();
const [enabledFields, setEnabledFields] = useState<Set<FieldKey>>(new Set());
const [patch, setPatch] = useState<SimplePatch>({
name: "",
country: "",
city: "",
address: "",
});
const PROTOCOL_FIELDS = useProtocolFields();
const protocolForm = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
address: "",
country: "",
city: "",
protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)),
},
});
const protocolsValues = useWatch({
control: protocolForm.control,
name: "protocols",
});
function toggleField(key: FieldKey) {
setEnabledFields((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function isEnabled(key: FieldKey) {
return enabledFields.has(key);
}
async function handleSubmit() {
if (rows.length === 0) return;
if (enabledFields.size === 0) {
toast.warning(
t(
"batch_no_fields_selected",
"Please select at least one field to update"
)
);
return;
}
// Validate enabled simple fields
if (enabledFields.has("name") && !patch.name.trim()) {
toast.warning(t("batch_name_required", "Name cannot be empty"));
return;
}
if (enabledFields.has("address") && !patch.address.trim()) {
toast.warning(t("batch_address_required", "Address cannot be empty"));
return;
}
const activePatch: Record<string, any> = {};
for (const key of enabledFields) {
if (key === "protocols") {
const formValues = protocolForm.getValues();
const filteredProtocols = (formValues.protocols || []).filter(
(p: any) => {
const port = Number(p?.port);
return p && Number.isFinite(port) && port > 0 && port <= 65_535;
}
);
activePatch.protocols = filteredProtocols;
} else {
activePatch[key] = patch[key];
}
}
setLoading(true);
try {
const results = await Promise.allSettled(
rows.map((row) => {
const body: API.UpdateServerRequest = {
id: row.id,
name: row.name,
country: row.country as string | undefined,
city: row.city as string | undefined,
address: row.address,
sort: row.sort,
protocols: row.protocols,
...activePatch,
};
return updateServer(body);
})
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = results.filter((r) => r.status === "rejected").length;
if (failed === 0) {
toast.success(
t("batch_updated", "Updated {{count}} servers", { count: succeeded })
);
setOpen(false);
setEnabledFields(new Set());
onSuccess();
} else if (succeeded > 0) {
toast.warning(
t("batch_partial", "{{succeeded}} updated, {{failed}} failed", {
succeeded,
failed,
})
);
onSuccess();
} else {
toast.error(t("batch_update_failed", "Batch update failed"));
}
} finally {
setLoading(false);
}
}
function handleOpen() {
setEnabledFields(new Set());
setPatch({ name: "", country: "", city: "", address: "" });
protocolForm.reset({
name: "",
address: "",
country: "",
city: "",
protocols: PROTOCOLS.map((type) => getProtocolDefaultConfig(type)),
});
setAccordionValue(undefined);
setOpen(true);
}
const simpleFields: Array<{
key: SimpleFieldKey;
label: string;
placeholder?: string;
}> = [
{ key: "name", label: t("name", "Name") },
{ key: "country", label: t("country", "Country") },
{ key: "city", label: t("city", "City") },
{
key: "address",
label: t("address", "Address"),
placeholder: t("address_placeholder", "Server address"),
},
];
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button onClick={handleOpen} variant="outline">
{t("batch_update", "Batch Update")}
</Button>
</SheetTrigger>
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
<SheetHeader>
<SheetTitle>
{t("batch_update_title", "Batch Update ({{count}} servers)", {
count: rows.length,
})}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
<p className="mb-4 text-muted-foreground text-sm">
{t(
"batch_update_desc",
"Check the fields you want to overwrite. Unchecked fields will keep their original values."
)}
</p>
<Form {...protocolForm}>
<form className="space-y-5">
{/* Simple text fields */}
{simpleFields.map(({ key, label, placeholder }) => (
<div className="flex items-start gap-3" key={key}>
<Checkbox
checked={isEnabled(key)}
id={`batch-server-${key}`}
onCheckedChange={() => toggleField(key)}
/>
<div className="flex-1 space-y-1.5">
<Label
className={isEnabled(key) ? "" : "text-muted-foreground"}
htmlFor={`batch-server-${key}`}
>
{label}
</Label>
<div
className={
isEnabled(key) ? "" : "pointer-events-none opacity-40"
}
>
<EnhancedInput
onValueChange={(v) =>
setPatch((p) => ({ ...p, [key]: String(v ?? "") }))
}
placeholder={placeholder}
value={patch[key]}
/>
</div>
</div>
</div>
))}
{/* Protocol Configurations */}
<div className="flex items-start gap-3">
<Checkbox
checked={isEnabled("protocols")}
id="batch-server-protocols"
onCheckedChange={() => toggleField("protocols")}
/>
<div className="flex-1 space-y-1.5">
<Label
className={
isEnabled("protocols") ? "" : "text-muted-foreground"
}
htmlFor="batch-server-protocols"
>
{t("protocol_configurations", "Protocol Configurations")}
</Label>
<p className="text-muted-foreground text-xs">
{t(
"protocol_configurations_desc",
"Enable and configure the required protocol types"
)}
</p>
<div
className={
isEnabled("protocols")
? ""
: "pointer-events-none opacity-40"
}
>
<Accordion
className="w-full space-y-3"
collapsible
onValueChange={setAccordionValue}
type="single"
value={accordionValue}
>
{PROTOCOLS.map((type) => {
const i = Math.max(0, PROTOCOLS.indexOf(type));
const current = (protocolsValues?.[i] || {}) as Record<
string,
any
>;
const isProtocolEnabled = current?.enable;
const fields = PROTOCOL_FIELDS[type] || [];
return (
<AccordionItem
className="mb-2 rounded-lg border"
key={type}
value={type}
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1">
<span className="font-medium capitalize">
{type}
</span>
{current.transport && (
<Badge
className="text-xs"
variant="secondary"
>
{current.transport.toUpperCase()}
</Badge>
)}
{current.security &&
current.security !== "none" && (
<Badge
className="text-xs"
variant="outline"
>
{current.security.toUpperCase()}
</Badge>
)}
{current.port && (
<Badge className="text-xs">
{current.port}
</Badge>
)}
</div>
<span
className={cn(
"text-xs",
isProtocolEnabled
? "text-green-500"
: "text-muted-foreground"
)}
>
{isProtocolEnabled
? t("enabled", "Enabled")
: t("disabled", "Disabled")}
</span>
</div>
<Switch
checked={!!isProtocolEnabled}
className="mr-2"
onCheckedChange={(checked) => {
protocolForm.setValue(
`protocols.${i}.enable` as any,
checked
);
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-0 pb-4">
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
{renderGroupCard(
t("basic", "Basic Configuration"),
fields,
"basic",
protocolForm.control,
protocolForm,
i,
current
)}
{renderGroupCard(
t("obfs", "Obfuscation"),
fields,
"obfs",
protocolForm.control,
protocolForm,
i,
current
)}
{renderGroupCard(
t("transport", "Transport"),
fields,
"transport",
protocolForm.control,
protocolForm,
i,
current
)}
{renderGroupCard(
t("security", "Security"),
fields,
"security",
protocolForm.control,
protocolForm,
i,
current
)}
{renderGroupCard(
t("reality", "Reality"),
fields,
"reality",
protocolForm.control,
protocolForm,
i,
current
)}
{renderGroupCard(
t("encryption", "Encryption"),
fields,
"encryption",
protocolForm.control,
protocolForm,
i,
current
)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</div>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={handleSubmit}>
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+68 -24
View File
@@ -184,7 +184,9 @@ function DynamicField({
{...fieldProps}
max={field.max}
min={field.min}
onValueChange={(v) => fieldProps.onChange(v)}
onValueChange={(v) =>
fieldProps.onChange(v === "" ? undefined : Number(v))
}
placeholder={field.placeholder}
step={field.step || 1}
suffix={field.suffix}
@@ -308,7 +310,7 @@ function renderFieldsByGroup(
);
}
function renderGroupCard(
export function renderGroupCard(
title: string,
fields: FieldConfig[],
group: string,
@@ -362,15 +364,52 @@ export default function ServerForm(props: {
const { isProtocolUsedInNodes } = useNode();
const PROTOCOL_FIELDS = useProtocolFields();
/**
* mergeProtocol - 将后端返回的协议数据与前端默认配置安全合并。
* 过滤掉空字符串 "",避免覆盖默认的枚举值(如 flow="none"),
* 从而防止 Zod 枚举校验失败。
* @param existingProtocol - 后端返回的协议对象(可能含空字符串)
* @param defaultConfig - 前端定义的安全默认配置
* @returns 合并后的协议对象
*/
function mergeProtocol(
existingProtocol: Record<string, any> | undefined,
defaultConfig: Record<string, any>
) {
if (!existingProtocol) return defaultConfig;
const merged = { ...defaultConfig };
for (const key in existingProtocol) {
if (Object.hasOwn(existingProtocol, key)) {
const val = existingProtocol[key];
// 空字符串降级为默认值,避免 Zod 枚举校验错误
if (val !== "") {
merged[key] = val;
}
}
}
return merged;
}
/**
* buildSanitizedProtocols - 为所有协议类型构建安全的初始表单数据。
* @param protocols - 后端返回的已有协议数组
* @returns 清洗后的完整协议配置数组
*/
function buildSanitizedProtocols(protocols?: any[]) {
return PROTOCOLS.map((type) => {
const existing = protocols?.find((p) => p.type === type);
return mergeProtocol(existing as any, getProtocolDefaultConfig(type));
});
}
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
address: "",
country: "",
city: "",
protocols: [] as any[],
...initialValues,
name: initialValues?.name ?? "",
address: initialValues?.address ?? "",
country: initialValues?.country ?? "",
city: initialValues?.city ?? "",
protocols: buildSanitizedProtocols(initialValues?.protocols as any[]),
},
});
const { control } = form;
@@ -380,20 +419,11 @@ export default function ServerForm(props: {
useEffect(() => {
if (initialValues) {
form.reset({
name: "",
address: "",
country: "",
city: "",
...initialValues,
protocols: PROTOCOLS.map((type) => {
const existingProtocol = initialValues.protocols?.find(
(p) => p.type === type
);
const defaultConfig = getProtocolDefaultConfig(type);
return existingProtocol
? { ...defaultConfig, ...existingProtocol }
: defaultConfig;
}),
name: initialValues.name ?? "",
address: initialValues.address ?? "",
country: initialValues.country ?? "",
city: initialValues.city ?? "",
protocols: buildSanitizedProtocols(initialValues.protocols as any[]),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -686,8 +716,22 @@ export default function ServerForm(props: {
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
const getFirstErrorMessage = (
errObj: any
): string | undefined => {
if (!errObj) return;
if (errObj.message && typeof errObj.message === "string")
return errObj.message;
for (const k in errObj) {
if (Object.hasOwn(errObj, k)) {
const msg = getFirstErrorMessage(errObj[k]);
if (msg) return msg;
}
}
return;
};
const msg = getFirstErrorMessage(errors);
if (msg) toast.error(msg);
return false;
})}
>
@@ -27,10 +27,12 @@ type Props = {
server: API.Server;
};
const DEFAULT_API_HOST = "https://api.hifast.biz";
export default function ServerInstall({ server }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [domain, setDomain] = useState("");
const [domain, setDomain] = useState(DEFAULT_API_HOST);
const { data: cfgResp } = useQuery({
queryKey: ["getNodeConfig"],
@@ -43,8 +45,7 @@ export default function ServerInstall({ server }: Props) {
useEffect(() => {
if (open) {
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
setDomain(host);
setDomain(DEFAULT_API_HOST);
}
}, [open]);
@@ -75,7 +76,6 @@ export default function ServerInstall({ server }: Props) {
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDomain(e.target.value);
localStorage.setItem("API_HOST", e.target.value);
}, []);
return (
<Dialog onOpenChange={setOpen} open={open}>
@@ -20,6 +20,7 @@ import {
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
@@ -40,6 +41,8 @@ const registerSchema = z.object({
trial_subscribe: z.number().optional(),
trial_time: z.number().optional(),
trial_time_unit: z.string().optional(),
enable_trial_email_whitelist: z.boolean().optional(),
trial_email_domain_whitelist: z.string().optional(),
enable_ip_register_limit: z.boolean().optional(),
ip_register_limit: z.number().optional(),
ip_register_limit_duration: z.number().optional(),
@@ -72,6 +75,8 @@ export default function RegisterConfig() {
trial_subscribe: undefined,
trial_time: 0,
trial_time_unit: "day",
enable_trial_email_whitelist: false,
trial_email_domain_whitelist: "",
enable_ip_register_limit: false,
ip_register_limit: 1,
ip_register_limit_duration: 1,
@@ -331,114 +336,182 @@ export default function RegisterConfig() {
/>
{form.watch("enable_trial") && (
<FormField
control={form.control}
name="trial_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("register.trialConfig", "Trial Configuration")}
</FormLabel>
<FormControl>
<div className="flex gap-2">
<EnhancedInput
className="flex-1"
min={0}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
prefix={
<FormField
control={form.control}
name="trial_subscribe"
render={({ field }) => (
<Combobox
className="w-32 rounded-r-none bg-secondary"
onChange={(value: number) => {
if (value) {
field.onChange(value);
}
}}
options={subscribes?.map((item) => ({
label: item.name!,
value: item.id!,
}))}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={field.value}
/>
)}
/>
}
suffix={
<FormField
control={form.control}
name="trial_time_unit"
render={({ field: unitField }) => (
<Combobox
className="w-32 rounded-l-none bg-secondary"
onChange={(value: string) => {
unitField.onChange(value);
}}
options={[
{
label: t("register.none", "None"),
value: "None",
},
{
label: t("register.year", "Year(s)"),
value: "Year",
},
{
label: t("register.month", "Month(s)"),
value: "Month",
},
{
label: t("register.day", "Day(s)"),
value: "Day",
},
{
label: t("register.hour", "Hour(s)"),
value: "Hour",
},
{
label: t(
"register.minute",
"Minute(s)"
),
value: "Minute",
},
]}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={unitField.value}
/>
)}
/>
}
type="number"
value={field.value}
<>
<FormField
control={form.control}
name="trial_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("register.trialConfig", "Trial Configuration")}
</FormLabel>
<FormControl>
<div className="flex gap-2">
<EnhancedInput
className="flex-1"
min={0}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
prefix={
<FormField
control={form.control}
name="trial_subscribe"
render={({ field }) => (
<Combobox
className="w-32 rounded-r-none bg-secondary"
onChange={(value: number) => {
if (value) {
field.onChange(value);
}
}}
options={subscribes?.map((item) => ({
label: item.name!,
value: item.id!,
}))}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={field.value}
/>
)}
/>
}
suffix={
<FormField
control={form.control}
name="trial_time_unit"
render={({ field: unitField }) => (
<Combobox
className="w-32 rounded-l-none bg-secondary"
onChange={(value: string) => {
unitField.onChange(value);
}}
options={[
{
label: t("register.none", "None"),
value: "None",
},
{
label: t("register.year", "Year(s)"),
value: "Year",
},
{
label: t(
"register.month",
"Month(s)"
),
value: "Month",
},
{
label: t("register.day", "Day(s)"),
value: "Day",
},
{
label: t("register.hour", "Hour(s)"),
value: "Hour",
},
{
label: t(
"register.minute",
"Minute(s)"
),
value: "Minute",
},
]}
placeholder={t(
"register.selectPlaceholder",
"Please select"
)}
value={unitField.value}
/>
)}
/>
}
type="number"
value={field.value}
/>
</div>
</FormControl>
<FormDescription>
{t(
"register.trialConfigDescription",
"Configure trial subscription, duration and time unit for new users upon registration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_trial_email_whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.enableTrialEmailWhitelist",
"Email Domain Whitelist"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</div>
</FormControl>
<FormDescription>
{t(
"register.trialConfigDescription",
"Configure trial subscription, duration and time unit for new users upon registration"
)}
</FormDescription>
<FormMessage />
</FormItem>
</FormControl>
<FormDescription>
{t(
"register.enableTrialEmailWhitelistDescription",
"When enabled, trial subscription is only granted to emails with whitelisted domains"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("enable_trial_email_whitelist") && (
<FormField
control={form.control}
name="trial_email_domain_whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"register.trialEmailDomainWhitelist",
"Whitelisted Domains"
)}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"register.trialEmailDomainWhitelistPlaceholder",
"gmail.com,outlook.com,edu.cn"
)}
rows={3}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"register.trialEmailDomainWhitelistDescription",
"Comma-separated list of allowed email domains for trial subscription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</>
)}
</form>
</Form>
@@ -11,6 +11,13 @@ import {
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
@@ -33,11 +40,13 @@ import { toast } from "sonner";
import { z } from "zod";
const verifySchema = z.object({
captcha_type: z.string().optional(),
turnstile_site_key: z.string().optional(),
turnstile_secret: z.string().optional(),
enable_register_verify: z.boolean().optional(),
enable_login_verify: z.boolean().optional(),
enable_reset_password_verify: z.boolean().optional(),
enable_user_login_captcha: z.boolean().optional(),
enable_user_register_captcha: z.boolean().optional(),
enable_admin_login_captcha: z.boolean().optional(),
enable_user_reset_password_captcha: z.boolean().optional(),
});
type VerifyFormData = z.infer<typeof verifySchema>;
@@ -59,11 +68,13 @@ export default function VerifyConfig() {
const form = useForm<VerifyFormData>({
resolver: zodResolver(verifySchema),
defaultValues: {
captcha_type: "local",
turnstile_site_key: "",
turnstile_secret: "",
enable_register_verify: false,
enable_login_verify: false,
enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_admin_login_captcha: false,
enable_user_reset_password_captcha: false,
},
});
@@ -126,26 +137,57 @@ export default function VerifyConfig() {
>
<FormField
control={form.control}
name="turnstile_site_key"
name="captcha_type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
{t("verify.captchaType", "Captcha Type")}
</FormLabel>
<FormControl>
<EnhancedInput
<Select
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
>
<SelectTrigger>
<SelectValue
placeholder={t(
"verify.captchaTypePlaceholder",
"Select captcha type"
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
{t(
"verify.captchaTypeLocal",
"Local Image Captcha"
)}
</SelectItem>
<SelectItem value="slider">
{t(
"verify.captchaTypeSlider",
"Local Slider Captcha"
)}
</SelectItem>
<SelectItem value="slider">
{t(
"verify.captchaTypeSlider",
"Local Slider Captcha"
)}
</SelectItem>
<SelectItem value="turnstile">
{t(
"verify.captchaTypeTurnstile",
"Cloudflare Turnstile"
)}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
"verify.captchaTypeDescription",
"Choose between local image captcha (offline) or Cloudflare Turnstile"
)}
</FormDescription>
<FormMessage />
@@ -153,45 +195,78 @@ export default function VerifyConfig() {
)}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("captcha_type") === "turnstile" && (
<>
<FormField
control={form.control}
name="turnstile_site_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSiteKey", "Turnstile Site Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSiteKeyPlaceholder",
"Enter Turnstile site key"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSiteKeyDescription",
"Cloudflare Turnstile site key for frontend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="turnstile_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("verify.turnstileSecret", "Turnstile Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"verify.turnstileSecretPlaceholder",
"Enter Turnstile secret key"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"verify.turnstileSecretDescription",
"Cloudflare Turnstile secret key for backend verification"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="enable_register_verify"
name="enable_user_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableRegisterVerify",
"Enable Verification on Registration"
"verify.enableUserLoginCaptcha",
"Enable User Login Captcha"
)}
</FormLabel>
<FormControl>
@@ -203,8 +278,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableRegisterVerifyDescription",
"When enabled, users must pass human verification during registration"
"verify.enableUserLoginCaptchaDescription",
"When enabled, users must pass captcha verification during login"
)}
</FormDescription>
<FormMessage />
@@ -214,13 +289,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_login_verify"
name="enable_user_register_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableLoginVerify",
"Enable Verification on Login"
"verify.enableUserRegisterCaptcha",
"Enable User Registration Captcha"
)}
</FormLabel>
<FormControl>
@@ -232,8 +307,8 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enableLoginVerifyDescription",
"When enabled, users must pass human verification during login"
"verify.enableUserRegisterCaptchaDescription",
"When enabled, users must pass captcha verification during registration"
)}
</FormDescription>
<FormMessage />
@@ -243,13 +318,13 @@ export default function VerifyConfig() {
<FormField
control={form.control}
name="enable_reset_password_verify"
name="enable_user_reset_password_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enablePasswordVerify",
"Enable Verification on Password Reset"
"verify.enableUserResetPasswordCaptcha",
"Enable User Password Reset Captcha"
)}
</FormLabel>
<FormControl>
@@ -261,8 +336,37 @@ export default function VerifyConfig() {
</FormControl>
<FormDescription>
{t(
"verify.enablePasswordVerifyDescription",
"When enabled, users must pass human verification during password reset"
"verify.enableUserResetPasswordCaptchaDescription",
"When enabled, users must pass captcha verification during password reset"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_admin_login_captcha"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"verify.enableAdminLoginCaptcha",
"Enable Admin Authentication Captcha"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"verify.enableAdminLoginCaptchaDescription",
"When enabled, administrators must pass captcha verification during login"
)}
</FormDescription>
<FormMessage />
+191 -17
View File
@@ -23,6 +23,10 @@ import {
getTicketList,
updateTicketStatus,
} from "@workspace/ui/services/admin/ticket";
import {
getUserDetail,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -42,6 +46,7 @@ export default function Page() {
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState("");
const [loadingId, setLoadingId] = useState<any>(null);
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ["getTicket", ticketId],
@@ -74,45 +79,214 @@ export default function Page() {
action={ref}
actions={{
render(row) {
if (row.status !== 4) {
if (row.status === 1) {
return [
<Button key="reply" onClick={() => setTicketId(row.id)}>
{t("reply", "Reply")}
</Button>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"closeWarning",
"Once closed, the ticket cannot be operated on. Please proceed with caution."
)}
description="您确定要取消此次打款吗?取消后系统将记录此操作并关闭工单。"
key="cancel-payout"
onConfirm={async () => {
setLoadingId(row.id);
try {
await updateTicketStatus({
id: row.id,
status: 3,
});
toast.success("已取消打款并关闭工单");
ref.current?.refresh();
} catch (e) {
console.error("Cancel payout failed", e);
toast.error("取消打款失败");
}
setLoadingId(null);
}}
title="确认取消打款?"
trigger={
<Button
className="mr-2"
disabled={loadingId === row.id}
variant="outline"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={
<div className="flex flex-col gap-2">
<p>
线
</p>
<p className="font-bold text-rose-500 text-sm">
USDT 1 USDT
</p>
<p className="font-bold text-rose-500 text-sm">
/ 5%
</p>
</div>
}
key="colse"
onConfirm={async () => {
setLoadingId(row.id);
try {
const titleStr = row.title || "";
const parts = titleStr.split("-");
if (parts.length > 1) {
const deductAmount = Number.parseFloat(
parts?.[1] ?? "0"
);
if (!Number.isNaN(deductAmount)) {
const res = await getUserDetail({ id: row.user_id });
const user = res?.data?.data;
if (user && typeof user.commission !== "undefined") {
const deductAmountCent = deductAmount * 100;
if (Number(user.commission) < deductAmountCent) {
toast.error("用户佣金余额不足,无法完成打款扣费");
setLoadingId(null);
return;
}
const newCommission =
Number(user.commission) - deductAmountCent;
await updateUserBasicInfo({
user_id: row.user_id,
commission: newCommission,
} as any);
}
}
}
} catch (e) {
console.error("Update commission failed", e);
}
await updateTicketStatus({
id: row.id,
status: 4,
});
toast.success(t("closeSuccess", "Closed successfully"));
ref.current?.refresh();
setLoadingId(null);
}}
title={t("confirmClose", "Are you sure you want to close?")}
title="确认已完成打款?"
trigger={
<Button variant="destructive">{t("close", "Close")}</Button>
<Button
disabled={loadingId === row.id}
variant="destructive"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description="检查佣金是否扣除,如已经扣除才可进行关闭,此操作不扣除佣金仅关闭工单。"
key="cancel-payout-1"
onConfirm={async () => {
setLoadingId(row.id);
try {
await updateTicketStatus({
id: row.id,
status: 4,
});
toast.success("已关闭工单");
ref.current?.refresh();
} catch (e) {
console.error("Cancel payout failed", e);
toast.error("关闭工单失败");
}
setLoadingId(null);
}}
title="确认关闭订单?"
trigger={
<Button
className="mr-2"
disabled={loadingId === row.id}
variant="secondary"
>
{loadingId === row.id && (
<Icon
className="mr-2 animate-spin"
icon="lucide:loader-2"
/>
)}
</Button>
}
/>,
];
}
return [
<Button key="check" onClick={() => setTicketId(row.id)} size="sm">
{t("check", "Check")}
</Button>,
];
return [];
},
}}
columns={[
{
accessorKey: "title",
header: t("title", "Title"),
id: "withdrawal_type",
header: "提现类型",
cell: ({ row }) => {
const title = row.original.title || "";
return title.split("-")[0] || title;
},
},
{
id: "withdrawal_amount",
header: "提现金额",
cell: ({ row }) => {
const title = row.original.title || "";
const parts = title.split("-");
if (parts.length < 2) return "";
return (
<span className="font-bold text-rose-500">${parts[1]}</span>
);
},
},
{
id: "withdrawal_method",
header: "提现方式",
cell: ({ row }) => {
const desc = row.original.description || "";
return desc.split("-")[0] || "";
},
},
{
id: "withdrawal_content",
header: "提现内容",
cell: ({ row }) => {
const desc = row.original.description || "";
const parts = desc.split("-");
if (parts.length < 2) return "";
const content = parts[1];
if (content?.startsWith("data:image/")) {
return (
<img
alt="withdrawal"
className="h-10 w-10 cursor-zoom-in rounded border object-cover"
height={40} // 这里的数值根据你的 UI 设计调整
onClick={() => {
const win = window.open();
win?.document.write(`<img src="${content}" />`);
}}
src={content}
width={40}
/>
);
}
return <span className="font-semibold">{content}</span>;
},
},
{
accessorKey: "user_id",
@@ -261,6 +261,11 @@ export function FamilyDetailSheet({
<Badge variant="outline">
ID: {member.user_id}
</Badge>
{member.auth_type ? (
<Badge className="uppercase">
{member.auth_type}
</Badge>
) : null}
{member.device_no ? (
<Badge variant="outline">
<span className="font-mono">
@@ -268,7 +273,16 @@ export function FamilyDetailSheet({
</span>
</Badge>
) : null}
<span>{member.identifier}</span>
{member.device_type ? (
<Badge variant="secondary">
{member.device_type}
</Badge>
) : null}
<span>
{member.auth_type === "device"
? member.device_no || member.identifier
: member.identifier}
</span>
<Badge>
{getFamilyRoleLabel(t, member.role_name)}
</Badge>
+13 -2
View File
@@ -54,8 +54,19 @@ export default function FamilyManagement({
{
accessorKey: "owner_identifier",
header: t("owner", "Owner"),
cell: ({ row }) =>
`${row.original.owner_identifier} (ID: ${row.original.owner_user_id})`,
cell: ({ row }) => (
<div className="flex items-center gap-1">
{row.original.owner_auth_type ? (
<Badge className="uppercase">
{row.original.owner_auth_type}
</Badge>
) : null}
<span>{row.original.owner_identifier}</span>
<span className="text-muted-foreground text-xs">
(ID: {row.original.owner_user_id})
</span>
</div>
),
},
{
accessorKey: "status",
+267 -45
View File
@@ -2,6 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,6 +16,12 @@ import {
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Input } from "@workspace/ui/components/input";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
@@ -32,18 +45,25 @@ import {
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
// import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
// getUserGroupList,
previewUserNodes,
} from "@workspace/ui/services/admin/group";
import {
createUser,
deleteUser,
// deleteUser,
getUserDetail,
getUserList,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { parseDeviceType } from "@workspace/ui/utils/device";
import { FilePenLine } from "lucide-react";
import type React from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -59,6 +79,57 @@ import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
import UserSubscription from "./user-subscription";
// import EditUserGroupDialog from "./edit-user-group-dialog";
// 为 RemarkForm 组件定义 props 类型
interface RemarkFormProps {
initialRemark?: string | null;
onSave: (remark: string) => void;
CloseComponent: React.ComponentType<{
asChild?: boolean;
children: React.ReactNode;
}>;
}
// 新的子组件,在管理它自己的备注状态
const RemarkForm: React.FC<RemarkFormProps> = ({
onSave,
initialRemark,
CloseComponent,
}) => {
const [remark, setRemark] = useState<string>(initialRemark ?? "");
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRemark(event.target.value);
};
const handleSaveClick = () => {
onSave(remark);
};
return (
<>
<div className="mb-2 font-semibold text-sm"></div>
<Input
className="w-full"
onChange={handleInputChange}
placeholder="在此输入备注..."
type="text"
value={remark}
/>
<CloseComponent asChild>
<Button
className={"mt-2"}
onClick={handleSaveClick}
size={"sm"}
variant="default"
>
</Button>
</CloseComponent>
</>
);
};
export default function User() {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
@@ -77,6 +148,38 @@ export default function User() {
ref.current?.refresh();
}, []);
const buildSearchParams = () => {
const { type, value } = searchRef.current;
if (!value) return {};
switch (type) {
case "user_id":
return { user_id: Number(value) };
case "device":
return { search: value };
case "subscribe_id":
return { subscribe_id: Number(value) };
default:
return { search: value };
}
};
// const { data: userGroupsData } = useQuery({
// queryKey: ["userGroups"],
// queryFn: async () => {
// const { data } = await getUserGroupList({ page: 1, size: 1000 });
// return data.data?.list || [];
// },
// });
const initialFilters = {
search: sp.search || undefined,
user_id: sp.user_id || undefined,
subscribe_id: sp.subscribe_id || undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
short_code: sp.short_code || undefined,
// user_group_id: sp.user_group_id || undefined,
};
return (
<ProTable<API.User, API.GetUserListParams>
action={ref}
@@ -93,7 +196,8 @@ export default function User() {
onChanged={() => ref.current?.refresh()}
userId={row.id}
/>,
<ConfirmButton
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
/* <ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
@@ -110,7 +214,7 @@ export default function User() {
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
/>,*/
<DropdownMenu key="more" modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
@@ -118,42 +222,30 @@ export default function User() {
<DropdownMenuContent align="end">
<InviteStatsMenuItem userId={row.id} />
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/order"
>
<Link search={{ user_id: row.id }} to="/dashboard/order">
{t("orderList", "Order List")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/login"
>
<Link search={{ user_id: row.id }} to="/dashboard/log/login">
{t("loginLogs", "Login Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/balance"
>
<Link search={{ user_id: row.id }} to="/dashboard/log/balance">
{t("balanceLogs", "Balance Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
search={{ user_id: row.id }}
to="/dashboard/log/commission"
>
{t("commissionLogs", "Commission Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
to="/dashboard/log/gift"
>
<Link search={{ user_id: row.id }} to="/dashboard/log/gift">
{t("giftLogs", "Gift Logs")}
</Link>
</DropdownMenuItem>
@@ -163,6 +255,7 @@ export default function User() {
}}
columns={[
{
id: "enable",
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
@@ -193,10 +286,12 @@ export default function User() {
),
},
{
id: "id",
accessorKey: "id",
header: "ID",
},
{
/*{
id: "deleted_at",
accessorKey: "deleted_at",
header: t("isDeleted", "Deleted"),
cell: ({ row }) => {
@@ -207,30 +302,68 @@ export default function User() {
<Badge variant="outline">{t("normal", "Normal")}</Badge>
);
},
},
},*/
{
id: "auth_methods",
accessorKey: "auth_methods",
header: t("userName", "Username"),
header: "设备码/邮箱",
cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
const identifier = method?.auth_identifier || "";
const isDevice = method?.auth_type === "device";
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
const firstDevice = row.original.user_devices?.[0] as any;
const deviceNo = firstDevice?.device_no;
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
const display = isDevice ? deviceNo || identifier : identifier;
return (
<div>
<Badge
<div className="flex items-center">
{/* <Badge
className="mr-1 uppercase"
title={method?.verified ? t("verified", "Verified") : ""}
>
{method?.auth_type}
</Badge>
</Badge>*/}
{deviceType && (
<Badge className="mr-1" variant="secondary">
{deviceType}
</Badge>
)}
<span title={isDevice ? display : undefined}>{display}</span>
<Popover>
<PopoverTrigger>
<div className={"flex items-center"}>
{row.original?.remark ? `${row.original.remark}` : ""}
<FilePenLine className={"ml-2 text-primary"} size={14} />
</div>
</PopoverTrigger>
<PopoverContent className={"w-64"}>
<RemarkForm
CloseComponent={PopoverClose}
initialRemark={row.original.remark}
onSave={async (remark) => {
const {
updated_at: _updated_at,
created_at: _created_at,
id,
...rest
} = row.original;
await updateUserBasicInfo({
user_id: id,
...rest,
remark,
} as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t("updateSuccess"));
ref.current?.refresh();
}}
/>
</PopoverContent>
</Popover>
</div>
);
},
},
{
/* {
id: "balance",
accessorKey: "balance",
header: t("balance", "Balance"),
cell: ({ row }) => (
@@ -238,13 +371,15 @@ export default function User() {
),
},
{
id: "gift_amount",
accessorKey: "gift_amount",
header: t("giftAmount", "Gift Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.getValue("gift_amount")} />
),
},
},*/
{
id: "commission",
accessorKey: "commission",
header: t("commission", "Commission"),
cell: ({ row }) => (
@@ -252,16 +387,19 @@ export default function User() {
),
},
{
id: "refer_code",
accessorKey: "refer_code",
header: t("inviteCode", "Invite Code"),
cell: ({ row }) => row.getValue("refer_code") || "--",
},
{
id: "referer_id",
accessorKey: "referer_id",
header: t("referer", "Referer"),
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
},
{
id: "created_at",
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
@@ -300,19 +438,13 @@ export default function User() {
/>
),
}}
initialFilters={initialFilters}
key={initialFilters.user_id}
request={async (pagination) => {
const { type, value } = searchRef.current;
const params: Record<string, unknown> = { ...pagination };
if (value) {
if (type === "user_id") {
params.user_id = value;
} else if (type === "subscribe_id") {
params.subscribe_id = value;
} else {
params.search = value;
}
}
const { data } = await getUserList(params as API.GetUserListParams);
const { data } = await getUserList({
...pagination,
...buildSearchParams(),
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@@ -486,12 +618,11 @@ function UserSearchBar({
}}
value={searchType}
>
<SelectTrigger className="w-24">
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">{t("email", "Email")}</SelectItem>
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
<SelectItem value="email">/</SelectItem>
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
<SelectItem value="subscribe_id">
{t("subscription", "Subscription")}
@@ -545,3 +676,94 @@ function UserSearchBar({
</div>
);
}
function PreviewNodesDialog({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const { data: previewData, isLoading } = useQuery({
enabled: open,
queryKey: ["previewUserNodes", userId],
queryFn: async () => {
const { data } = await previewUserNodes({ user_id: userId });
return data.data;
},
});
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button variant="outline">{t("previewNodes", "Preview Nodes")}</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("previewNodes", "Preview Nodes")} · ID: {userId}
</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="py-8 text-center text-muted-foreground">
{t("loading", "Loading...")}
</div>
) : previewData ? (
<div className="space-y-4">
<div>
<span className="font-medium text-muted-foreground text-sm">
{t("availableNodes", "Available Nodes")}:
</span>{" "}
{previewData.node_groups?.reduce(
(sum, group) => sum + (group.nodes?.length || 0),
0
) || 0}
</div>
{previewData.node_groups && previewData.node_groups.length > 0 ? (
<div className="max-h-[400px] space-y-4 overflow-y-auto">
{previewData.node_groups.map((group) => (
<div key={group.id}>
<h4 className="mb-2 font-semibold text-sm">
{group.name ||
(group.id === -1
? t("subscriptionNodes", "Subscription Nodes")
: group.id === 0
? t("publicNodes", "Public Nodes")
: `${t("nodeGroup", "Node Group")} ${group.id}`)}
</h4>
{group.nodes && group.nodes.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left font-medium">ID</th>
<th className="p-2 text-left font-medium">
{t("name", "Name")}
</th>
<th className="p-2 text-left font-medium">
{t("address", "Address")}
</th>
</tr>
</thead>
<tbody>
{group.nodes.map((node) => (
<tr className="border-b" key={node.id}>
<td className="p-2">{node.id}</td>
<td className="p-2">{node.name}</td>
<td className="p-2">
{node.address}:{node.port}
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
))}
</div>
) : (
<div className="py-4 text-center text-muted-foreground">
{t("noNodesAvailable", "No nodes available")}
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
);
}
+82 -9
View File
@@ -12,11 +12,12 @@ import {
getUserDetail,
getUserSubscribeById,
} from "@workspace/ui/services/admin/user";
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { formatDate } from "@/utils/common";
// import EditUserGroupDialog from "./edit-user-group-dialog";
// import { getUserGroupList } from "@workspace/ui/services/admin/group";
export function UserSubscribeDetail({
id,
@@ -38,10 +39,33 @@ export function UserSubscribeDetail({
},
});
// Fetch user groups for display
// const { data: groupsData } = useQuery({
// enabled: id !== 0 && enabled,
// queryKey: ["getUserGroupList"],
// queryFn: async () => {
// const { data } = await getUserGroupList({
// page: 1,
// size: 100,
// });
// return data.data?.list || [];
// },
// });
if (!id) return "--";
const usedTraffic = data ? data.upload + data.download : 0;
const totalTraffic = data?.traffic || 0;
const remainingTraffic = totalTraffic > 0 ? totalTraffic - usedTraffic : 0;
// Get user group info from data.user
// const userGroupId = typeof data?.user?.user_group_id === 'number' ? data?.user?.user_group_id : 0;
// const groupLocked = data?.user?.group_locked || false;
// const groupIds = userGroupId > 0 ? [userGroupId] : [];
// const groupNames = userGroupId > 0
// ? groupsData?.find((g: API.UserGroup) => g.id === userGroupId)?.name || "--"
// : "--";
const subscribeContent = (
<div className="space-y-4">
@@ -77,6 +101,18 @@ export function UserSubscribeDetail({
: "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("remainingTraffic")}
</span>
<span>
{data
? totalTraffic === 0
? t("unlimited")
: formatBytes(remainingTraffic)
: "--"}
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("startTime")}</span>
<span>
@@ -89,6 +125,35 @@ export function UserSubscribeDetail({
{data?.expire_time ? formatDate(data.expire_time) : "--"}
</span>
</li>
{/* <li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("userGroup")}</span>
<div className="flex items-center gap-2">
<span>{groupNames || "--"}</span>
{data?.id && (
<EditUserGroupDialog
userId={data?.user_id || 0}
userSubscribeId={data?.id}
currentGroupIds={groupIds}
currentLocked={groupLocked}
trigger={
<Button variant="ghost" size="sm" className="h-6 px-2">
{t("edit", "Edit")}
</Button>
}
onSubmit={async () => {
window.location.reload();
return true;
}}
/>
)}
</div>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{t("groupLocked")}</span>
<span>
{groupLocked ? t("yes", "Yes") : t("no", "No")}
</span>
</li> */}
</ul>
</div>
</div>
@@ -164,21 +229,29 @@ export function UserDetail({ id }: { id: number }) {
if (!id) return "--";
const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email");
const firstMethod = data?.auth_methods[0];
const rawIdentifier =
emailMethod?.auth_identifier || firstMethod?.auth_identifier || "";
const isDevice = !emailMethod && firstMethod?.auth_type === "device";
const identifier = isDevice
? shortenDeviceIdentifier(rawIdentifier)
: rawIdentifier;
const isDevice = firstMethod?.auth_type === "device";
const deviceNo = (data as any)?.user_devices?.[0]?.device_no;
const rawIdentifier = firstMethod?.auth_identifier || "";
const identifier = isDevice ? deviceNo || rawIdentifier : rawIdentifier;
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button asChild className="p-0" variant="link">
<Link search={{ user_id: id }} to="/dashboard/user">
{identifier || t("loading", "Loading...")}
{data ? (
<>
{firstMethod && (
<span className="mr-1 inline-flex items-center rounded border px-1 py-0.5 font-mono text-xs uppercase">
{firstMethod.auth_type}
</span>
)}
{identifier}
</>
) : (
t("loading", "Loading...")
)}
</Link>
</Button>
</HoverCardTrigger>
+6 -6
View File
@@ -59,8 +59,8 @@ export default function UserForm<T extends Record<string, any>>({
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(),
balance: z.number().optional(),
gift_amount: z.number().optional(),
// balance: z.number().optional(),
// gift_amount: z.number().optional(),
commission: z.number().optional(),
});
const form = useForm({
@@ -282,7 +282,7 @@ export default function UserForm<T extends Record<string, any>>({
</FormItem>
)}
/>
<FormField
{/* <FormField
control={form.control}
name="balance"
render={({ field }) => (
@@ -309,8 +309,8 @@ export default function UserForm<T extends Record<string, any>>({
<FormMessage />
</FormItem>
)}
/>
<FormField
/> */}
{/* <FormField
control={form.control}
name="gift_amount"
render={({ field }) => (
@@ -340,7 +340,7 @@ export default function UserForm<T extends Record<string, any>>({
<FormMessage />
</FormItem>
)}
/>
/> */}
<FormField
control={form.control}
name="commission"
@@ -24,7 +24,7 @@ const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
// enable_trade_notify: z.boolean(),
});
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
@@ -44,13 +44,14 @@ export function NotifySettingsForm({
enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_notify,
enable_trade_notify: user.enable_trade_notify,
// enable_trade_notify: user.enable_trade_notify,
},
});
async function onSubmit(data: NotifySettingsValues) {
await updateUserNotifySetting({
...data,
enable_trade_notify: user.enable_trade_notify ?? false,
user_id: user.id,
});
toast.success(t("updateSuccess", "Updated successfully"));
@@ -126,7 +127,7 @@ export function NotifySettingsForm({
)}
/>
<FormField
{/* <FormField
control={form.control}
name="enable_trade_notify"
render={({ field }) => (
@@ -142,7 +143,7 @@ export function NotifySettingsForm({
</FormControl>
</FormItem>
)}
/>
/> */}
</div>
</CardContent>
</Card>
@@ -1,4 +1,4 @@
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { Alert, AlertDescription } from "@workspace/ui/components/alert";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
@@ -42,6 +42,7 @@ export default function UserSubscription({ userId }: { userId: number }) {
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const [sharedInfo, setSharedInfo] = useState<SharedInfo | null>(null);
const navigate = useNavigate();
const request = useCallback(
async (pagination: { page: number; size: number }) => {
@@ -116,19 +117,23 @@ export default function UserSubscription({ userId }: { userId: number }) {
<span className="flex gap-2">
<Button asChild size="sm" variant="outline">
<Link
search={{ user_id: String(sharedInfo.ownerUserId) }}
search={{ user_id: sharedInfo.ownerUserId }}
to="/dashboard/family"
>
{t("viewDeviceGroup", "View Device Group")}
</Link>
</Button>
<Button asChild size="sm" variant="outline">
<Link
search={{ user_id: String(sharedInfo.ownerUserId) }}
to="/dashboard/user"
>
{t("viewOwner", "View Owner")}
</Link>
<Button
onClick={() => {
navigate({
to: "/dashboard/user",
search: { user_id: sharedInfo.ownerUserId },
});
}}
size="sm"
variant="outline"
>
{t("viewOwner", "View Owner")}
</Button>
</span>
</AlertDescription>
@@ -273,6 +278,20 @@ export default function UserSubscription({ userId }: { userId: number }) {
/>
),
},
{
id: "remaining_traffic",
header: t("remainingTraffic", "Remaining Traffic"),
cell: ({ row }) => {
const upload = row.original.upload || 0;
const download = row.original.download || 0;
const totalTraffic = row.original.traffic || 0;
const remainingTraffic =
totalTraffic > 0 ? totalTraffic - upload - download : 0;
return (
<Display type="traffic" unlimited value={remainingTraffic} />
);
},
},
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
@@ -310,6 +329,11 @@ export default function UserSubscription({ userId }: { userId: number }) {
: t("permanent", "Permanent");
},
},
{
accessorKey: "node_group_name",
header: t("nodeGroup", "Node Group"),
cell: ({ row }) => row.original.node_group_name || "-",
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
@@ -11,16 +11,92 @@ import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import {
getUserSubscribeById,
getUserSubscribeDevices,
kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user";
import { deviceIdToHash } from "@workspace/ui/utils/device";
import { type ReactNode, useState } from "react";
import { AlertTriangle, Gauge } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common";
function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
const { t } = useTranslation("user");
const [detail, setDetail] = useState<API.UserSubscribeDetail | null>(null);
useEffect(() => {
getUserSubscribeById({ id: subscriptionId }).then(({ data }) => {
if (data.data) setDetail(data.data);
});
}, [subscriptionId]);
if (!detail || detail.status !== 1) return null;
const baseSpeed = detail.subscribe?.speed_limit ?? 0;
const effectiveSpeed = detail.effective_speed ?? 0;
const isThrottled = detail.is_throttled;
if (baseSpeed === 0 && !isThrottled) return null;
return (
<div
className={`mb-4 flex items-start gap-3 rounded-lg border p-3 text-sm ${
isThrottled
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border bg-muted/40 text-foreground"
}`}
>
{isThrottled ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<Gauge className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 font-medium">
{isThrottled ? (
<>
<span>{t("throttled", "Speed Throttled")}</span>
<Badge className="text-xs" variant="destructive">
{effectiveSpeed} Mbps
</Badge>
{baseSpeed > 0 && (
<span className="text-muted-foreground text-xs line-through">
{baseSpeed} Mbps
</span>
)}
</>
) : (
<>
<span className="text-muted-foreground">
{t("speedLimit", "Speed Limit")}
</span>
<Badge className="text-xs" variant="secondary">
{effectiveSpeed > 0
? `${effectiveSpeed} Mbps`
: t("unlimited", "Unlimited")}
</Badge>
</>
)}
</div>
{isThrottled && detail.throttle_rule && (
<p className="text-muted-foreground text-xs">
{detail.throttle_rule}
</p>
)}
{isThrottled && detail.throttle_start && detail.throttle_end && (
<p className="text-muted-foreground text-xs">
{formatDate(detail.throttle_start)} ~{" "}
{formatDate(detail.throttle_end)}
</p>
)}
</div>
</div>
);
}
export function SubscriptionDetail({
trigger,
userId,
@@ -44,6 +120,7 @@ export function SubscriptionDetail({
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
</SheetHeader>
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
{open && <SpeedLimitCard subscriptionId={subscriptionId} />}
<ProTable<API.UserDevice, Record<string, unknown>>
actions={{
render: (row) => {
@@ -0,0 +1,302 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Textarea } from "@workspace/ui/components/textarea";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
approveWithdrawal,
getWithdrawalList,
rejectWithdrawal,
} from "@workspace/ui/services/admin/withdrawal";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
const rejectSchema = z.object({
reason: z
.string()
.trim()
.min(2, "请填写拒绝原因")
.max(500, "拒绝原因不能超过 500 个字符"),
});
export default function WithdrawalPage() {
const { t } = useTranslation("user");
const ref = useRef<ProTableActions>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<API.WithdrawalLog | null>(null);
const form = useForm<z.infer<typeof rejectSchema>>({
resolver: zodResolver(rejectSchema),
defaultValues: {
reason: "",
},
});
const approveMutation = useMutation({
mutationFn: async (withdrawalId: number) =>
approveWithdrawal({ withdrawal_id: withdrawalId }),
onSuccess: () => {
toast.success("提现申请已通过");
ref.current?.refresh();
},
onError: (error) => {
console.error("Approve withdrawal failed", error);
toast.error("提现通过失败");
},
});
const rejectMutation = useMutation({
mutationFn: async (values: { withdrawal_id: number; reason: string }) =>
rejectWithdrawal(values),
onSuccess: () => {
toast.success("提现申请已拒绝");
setRejectOpen(false);
setCurrentRow(null);
form.reset({ reason: "" });
ref.current?.refresh();
},
onError: (error) => {
console.error("Reject withdrawal failed", error);
toast.error("提现拒绝失败");
},
});
const statusOptions = [
{ value: "0", label: "审核中" },
{ value: "1", label: "已通过" },
{ value: "2", label: "已拒绝" },
];
const statusMap: Record<number, { label: string; className: string }> = {
0: {
label: "审核中",
className: "bg-amber-100 text-amber-700 border-amber-200",
},
1: {
label: "已通过",
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
},
2: {
label: "已拒绝",
className: "bg-rose-100 text-rose-700 border-rose-200",
},
};
return (
<>
<ProTable<API.WithdrawalLog, { status?: string; user_id?: string }>
action={ref}
actions={{
render: (row) => {
if (row.status !== 0) return [];
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description="确认后将标记该提现为已通过,请确保线下打款流程已完成。"
key="approve"
onConfirm={async () => {
await approveMutation.mutateAsync(row.id);
}}
title="确认通过提现吗?"
trigger={
<Button
disabled={approveMutation.isPending}
size="sm"
variant="default"
>
</Button>
}
/>,
<Button
key="reject"
onClick={() => {
setCurrentRow(row);
setRejectOpen(true);
}}
size="sm"
variant="destructive"
>
</Button>,
];
},
}}
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
},
{
accessorKey: "amount",
header: "提现金额",
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "content",
header: "收款信息",
cell: ({ row }) => (
<div className="max-w-[360px] break-all text-sm">
{row.original.content || "--"}
</div>
),
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
const status = statusMap[row.original.status] ?? {
label: `未知 (${row.original.status})`,
className: "",
};
return (
<Badge className={status.className} variant="outline">
{status.label}
</Badge>
);
},
},
{
accessorKey: "reason",
header: "拒绝原因",
cell: ({ row }) => row.original.reason || "--",
},
{
accessorKey: "created_at",
header: "申请时间",
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: "updated_at",
header: "更新时间",
cell: ({ row }) => formatDate(row.original.updated_at),
},
]}
header={{ title: "提现管理" }}
params={[
{
key: "status",
options: statusOptions,
placeholder: "状态",
},
{
key: "user_id",
placeholder: "用户 ID",
},
]}
request={async (pagination, filter) => {
const { data } = await getWithdrawalList({
page: pagination.page,
size: pagination.size,
status: filter.status ? Number(filter.status) : undefined,
user_id: filter.user_id ? Number(filter.user_id) : undefined,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
<Dialog
onOpenChange={(open) => {
setRejectOpen(open);
if (!open) {
setCurrentRow(null);
form.reset({ reason: "" });
}
}}
open={rejectOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="space-y-4"
onSubmit={form.handleSubmit(async (values) => {
if (!currentRow) return;
await rejectMutation.mutateAsync({
withdrawal_id: currentRow.id,
reason: values.reason.trim(),
});
})}
>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder="请输入拒绝原因,用户端会看到这条说明"
rows={5}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
onClick={() => setRejectOpen(false)}
type="button"
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button disabled={rejectMutation.isPending} type="submit">
{rejectMutation.isPending ? "提交中..." : "确认拒绝"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}
+5
View File
@@ -49,9 +49,14 @@ export const useGlobalStore = create<GlobalStore>((set, get) => ({
},
verify: {
turnstile_site_key: "",
captcha_type: "turnstile",
enable_login_verify: false,
enable_register_verify: false,
enable_reset_password_verify: false,
enable_user_login_captcha: false,
enable_user_register_captcha: false,
enable_user_reset_password_captcha: false,
enable_admin_login_captcha: false,
},
auth: {
mobile: {
+8
View File
@@ -25,6 +25,7 @@ interface NodeState {
isServerReferencedByNodes: (serverId: number) => boolean;
getNodesByTag: (tag: string) => API.Node[];
getNodesWithoutTags: () => API.Node[];
getNodesWithoutGroups: () => API.Node[];
getNodeTags: () => string[];
getAllAvailableTags: () => string[];
}
@@ -92,6 +93,12 @@ export const useNodeStore = create<NodeState>((set, get) => ({
getNodesWithoutTags: () =>
get().nodes.filter((node) => (node.tags || []).length === 0),
getNodesWithoutGroups: () =>
get().nodes.filter((node) => {
const groupIds = (node as any).node_group_ids;
return !groupIds || groupIds.length === 0;
}),
getNodeTags: () =>
Array.from(
new Set(
@@ -135,6 +142,7 @@ export const useNode = () => {
isServerReferencedByNodes: store.isServerReferencedByNodes,
getNodesByTag: store.getNodesByTag,
getNodesWithoutTags: store.getNodesWithoutTags,
getNodesWithoutGroups: store.getNodesWithoutGroups,
getNodeTags: store.getNodeTags,
getAllAvailableTags: store.getAllAvailableTags,
};
+8 -2
View File
@@ -24,9 +24,15 @@ export function differenceInDays(date1: Date, date2: Date): number {
export function formatDate(date?: Date | number, showTime = true) {
if (!date) return;
// Backend returns Unix timestamps in seconds; convert to milliseconds for JS Date
const dateValue =
typeof date === "number" && date < 1e12 ? date * 1000 : date;
// Unix timestamps (seconds): 10 digits e.g. 1771936457
// JavaScript timestamps (milliseconds): 13 digits
let dateValue = date;
if (typeof date === "number" && date < 10_000_000_000) {
dateValue = date * 1000;
}
const timeZone = localStorage.getItem("timezone") || "UTC";
return intlFormat(dateValue, {
year: "numeric",
+1 -1
View File
@@ -16,7 +16,7 @@
"@faker-js/faker": "^10.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@lottiefiles/dotlottie-react": "^0.17.7",
"@lottiefiles/dotlottie-react": "^0.17.15",
"@stripe/react-stripe-js": "^5.4.0",
"@stripe/stripe-js": "^8.5.2",
"@tailwindcss/vite": "^4.0.6",
Binary file not shown.
@@ -1,11 +1,37 @@
{
"accountInfo": "Payout Details",
"accountPlaceholder": "Enter bank card, Alipay, USDT wallet, or other payout details",
"accountRequired": "Payout details are required",
"accountTooLong": "Payout details must be 200 characters or fewer",
"amountInvalid": "Enter a valid amount",
"amountPositive": "Withdrawal amount must be greater than 0",
"applyTime": "Applied At",
"applyWithdraw": "Request Withdrawal",
"cancel": "Cancel",
"commissionInfo": "Commission Info",
"commissionRate": "Commission Rate",
"copyInviteLink": "Copy Invite Link",
"copySuccess": "Copy Success",
"currentCommission": "Current Commission",
"inviteCode": "Invite Code",
"inviteRecords": "Invite Records",
"noReason": "None",
"rejectReason": "Reject Reason",
"registrationTime": "Registration Time",
"submitWithdraw": "Submit Request",
"submitting": "Submitting...",
"totalCommission": "Total Commission",
"userIdentifier": "User Identifier"
"unknownStatus": "Unknown Status",
"userIdentifier": "User Identifier",
"withdrawAmount": "Withdrawal Amount",
"withdrawAmountPlaceholder": "Enter the withdrawal amount in dollars",
"withdrawDescription": "Submit the withdrawal amount and payout details, then wait for review.",
"withdrawError": "Failed to submit withdrawal request",
"withdrawRecords": "Withdrawal Records",
"withdrawStatus": {
"approved": "Approved",
"pending": "Pending Review",
"rejected": "Rejected"
},
"withdrawSuccess": "Withdrawal request submitted"
}
@@ -1,12 +1,35 @@
{
"authenticating": "Authenticating...",
"binding": "Binding account...",
"captcha": {
"clickToRefresh": "Click to refresh",
"noImage": "No Image",
"placeholder": "Enter captcha code...",
"refresh": "Refresh captcha",
"required": "Please enter captcha code",
"sliderRequired": "Please complete the slider verification",
"slider": {
"clickToVerify": "Click to verify",
"fail": "Try again",
"hint": "Drag the piece to fit the puzzle",
"success": "Verified",
"title": "Security Verification"
},
"turnstile": {
"cancel": "Cancel",
"clickToVerify": "Click to verify",
"success": "Verified",
"title": "Security Verification"
}
},
"get": "Get Code",
"login": {
"codeLogin": "Login with Code",
"email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"forgotPassword": "Forgot Password?",
"passwordLogin": "Login with Password",
"passwordPlaceholder": "Enter your password...",
"registerAccount": "Register Account",
"success": "Login successful!",
"title": "Login"
@@ -17,19 +40,28 @@
},
"privacyPolicy": "Privacy Policy",
"register": {
"areaCodePlaceholder": "Area code...",
"codePlaceholder": "Enter code...",
"email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"existingAccount": "Already have an account?",
"invite": "Invitation Code (Optional)",
"message": "Registration is currently disabled",
"passwordMismatch": "Passwords do not match",
"passwordPlaceholder": "Enter your password...",
"repeatPasswordPlaceholder": "Enter password again...",
"success": "Registration successful!",
"switchToLogin": "Login",
"telephonePlaceholder": "Enter your telephone...",
"title": "Register",
"whitelist": "This email domain is not in the whitelist"
},
"reset": {
"codePlaceholder": "Enter code...",
"email": "Please enter a valid email address",
"emailPlaceholder": "Enter your email...",
"existingAccount": "Remember your password?",
"passwordPlaceholder": "Enter your new password...",
"success": "Password reset successful!",
"switchToLogin": "Login",
"title": "Reset Password"
@@ -76,6 +76,7 @@
"server": "Server Management",
"subscribe": "Subscribe",
"ticket": "Ticket Management",
"traffic": "Traffic Statistics",
"wallet": "Balance"
},
"pagination": {
@@ -0,0 +1,16 @@
{
"title": "Traffic Statistics",
"selectSubscription": "Select Subscription",
"days7": "7 Days",
"days30": "30 Days",
"totalTraffic": "Total Traffic",
"uploadTraffic": "Upload Traffic",
"downloadTraffic": "Download Traffic",
"trafficTrend": "Traffic Trend",
"trafficRatio": "Upload/Download Ratio",
"upload": "Upload",
"download": "Download",
"noData": "No traffic data available",
"date": "Date",
"traffic": "Traffic (MB)"
}
@@ -1,11 +1,37 @@
{
"accountInfo": "收款信息",
"accountPlaceholder": "请输入银行卡、支付宝、USDT 地址等收款信息",
"accountRequired": "请填写收款信息",
"accountTooLong": "收款信息不能超过 200 个字符",
"amountInvalid": "请输入有效金额",
"amountPositive": "提现金额必须大于 0",
"applyTime": "申请时间",
"applyWithdraw": "申请提现",
"cancel": "取消",
"commissionInfo": "佣金信息",
"commissionRate": "佣金比例",
"copyInviteLink": "复制邀请链接",
"copySuccess": "复制成功",
"currentCommission": "当前可见佣金",
"inviteCode": "邀请码",
"inviteRecords": "邀请记录",
"noReason": "无",
"rejectReason": "拒绝原因",
"registrationTime": "注册时间",
"submitWithdraw": "提交申请",
"submitting": "提交中...",
"totalCommission": "累计佣金",
"userIdentifier": "用户标识"
"unknownStatus": "未知状态",
"userIdentifier": "用户标识",
"withdrawAmount": "提现金额",
"withdrawAmountPlaceholder": "请输入提现金额,单位为元",
"withdrawDescription": "填写提现金额和收款信息后提交申请,等待后台审核。",
"withdrawError": "提现申请提交失败",
"withdrawRecords": "提现记录",
"withdrawStatus": {
"approved": "已通过",
"pending": "审核中",
"rejected": "已拒绝"
},
"withdrawSuccess": "提现申请已提交"
}
@@ -1,12 +1,35 @@
{
"authenticating": "正在认证...",
"binding": "正在绑定账号...",
"captcha": {
"clickToRefresh": "点击刷新",
"noImage": "无图片",
"placeholder": "请输入验证码...",
"refresh": "刷新验证码",
"required": "请输入验证码",
"sliderRequired": "请完成滑块验证",
"slider": {
"clickToVerify": "点击进行验证",
"fail": "请重试",
"hint": "拖动拼图块到对应位置",
"success": "验证成功",
"title": "安全验证"
},
"turnstile": {
"cancel": "取消",
"clickToVerify": "点击进行验证",
"success": "验证成功",
"title": "安全验证"
}
},
"get": "获取验证码",
"login": {
"codeLogin": "验证码登录",
"email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"forgotPassword": "忘记密码?",
"passwordLogin": "密码登录",
"passwordPlaceholder": "请输入密码...",
"registerAccount": "注册账号",
"success": "登录成功!",
"title": "登录"
@@ -17,19 +40,28 @@
},
"privacyPolicy": "隐私政策",
"register": {
"areaCodePlaceholder": "区号...",
"codePlaceholder": "请输入验证码...",
"email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"existingAccount": "已有账号?",
"invite": "邀请码(可选)",
"message": "注册功能暂时不可用",
"passwordMismatch": "两次密码输入不一致",
"passwordPlaceholder": "请输入密码...",
"repeatPasswordPlaceholder": "请再次输入密码...",
"success": "注册成功!",
"switchToLogin": "登录",
"telephonePlaceholder": "请输入手机号...",
"title": "注册",
"whitelist": "该邮箱域名不在白名单中"
},
"reset": {
"codePlaceholder": "请输入验证码...",
"email": "请输入有效的邮箱地址",
"emailPlaceholder": "请输入邮箱...",
"existingAccount": "记得密码了?",
"passwordPlaceholder": "请输入新密码...",
"success": "密码重置成功!",
"switchToLogin": "登录",
"title": "重置密码"
@@ -76,6 +76,7 @@
"server": "服务器管理",
"subscribe": "订阅管理",
"ticket": "工单管理",
"traffic": "流量统计",
"wallet": "余额管理"
},
"pagination": {
@@ -0,0 +1,16 @@
{
"title": "流量统计",
"selectSubscription": "选择订阅",
"days7": "7 天",
"days30": "30 天",
"totalTraffic": "总流量",
"uploadTraffic": "上传流量",
"downloadTraffic": "下载流量",
"trafficTrend": "流量趋势",
"trafficRatio": "上传/下载占比",
"upload": "上传",
"download": "下载",
"noData": "暂无流量数据",
"date": "日期",
"traffic": "流量 (MB)"
}
+5
View File
@@ -40,6 +40,11 @@ export function useNavs() {
icon: "uil:shop",
title: t("menu.subscribe", "Subscribe"),
},
{
url: "/traffic",
icon: "uil:chart-line",
title: t("menu.traffic", "Traffic Statistics"),
},
],
},
{
+27
View File
@@ -28,6 +28,9 @@ const mainPurchasingIndexLazyRouteImport = createFileRoute(
'/(main)/purchasing/',
)()
const mainuserWalletLazyRouteImport = createFileRoute('/(main)/(user)/wallet')()
const mainuserTrafficLazyRouteImport = createFileRoute(
'/(main)/(user)/traffic',
)()
const mainuserTicketLazyRouteImport = createFileRoute('/(main)/(user)/ticket')()
const mainuserSubscribeLazyRouteImport = createFileRoute(
'/(main)/(user)/subscribe',
@@ -126,6 +129,15 @@ const mainuserWalletLazyRoute = mainuserWalletLazyRouteImport
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/(user)/wallet.lazy').then((d) => d.Route))
const mainuserTrafficLazyRoute = mainuserTrafficLazyRouteImport
.update({
id: '/traffic',
path: '/traffic',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/traffic.lazy').then((d) => d.Route),
)
const mainuserTicketLazyRoute = mainuserTicketLazyRouteImport
.update({
id: '/ticket',
@@ -220,6 +232,7 @@ export interface FileRoutesByFullPath {
'/profile': typeof mainuserProfileLazyRoute
'/subscribe': typeof mainuserSubscribeLazyRoute
'/ticket': typeof mainuserTicketLazyRoute
'/traffic': typeof mainuserTrafficLazyRoute
'/wallet': typeof mainuserWalletLazyRoute
'/purchasing': typeof mainPurchasingIndexLazyRoute
'/purchasing/order': typeof mainPurchasingOrderIndexRoute
@@ -240,6 +253,7 @@ export interface FileRoutesByTo {
'/profile': typeof mainuserProfileLazyRoute
'/subscribe': typeof mainuserSubscribeLazyRoute
'/ticket': typeof mainuserTicketLazyRoute
'/traffic': typeof mainuserTrafficLazyRoute
'/wallet': typeof mainuserWalletLazyRoute
'/purchasing': typeof mainPurchasingIndexLazyRoute
'/purchasing/order': typeof mainPurchasingOrderIndexRoute
@@ -263,6 +277,7 @@ export interface FileRoutesById {
'/(main)/(user)/profile': typeof mainuserProfileLazyRoute
'/(main)/(user)/subscribe': typeof mainuserSubscribeLazyRoute
'/(main)/(user)/ticket': typeof mainuserTicketLazyRoute
'/(main)/(user)/traffic': typeof mainuserTrafficLazyRoute
'/(main)/(user)/wallet': typeof mainuserWalletLazyRoute
'/(main)/purchasing/': typeof mainPurchasingIndexLazyRoute
'/(main)/purchasing/order/': typeof mainPurchasingOrderIndexRoute
@@ -285,6 +300,7 @@ export interface FileRouteTypes {
| '/profile'
| '/subscribe'
| '/ticket'
| '/traffic'
| '/wallet'
| '/purchasing'
| '/purchasing/order'
@@ -305,6 +321,7 @@ export interface FileRouteTypes {
| '/profile'
| '/subscribe'
| '/ticket'
| '/traffic'
| '/wallet'
| '/purchasing'
| '/purchasing/order'
@@ -327,6 +344,7 @@ export interface FileRouteTypes {
| '/(main)/(user)/profile'
| '/(main)/(user)/subscribe'
| '/(main)/(user)/ticket'
| '/(main)/(user)/traffic'
| '/(main)/(user)/wallet'
| '/(main)/purchasing/'
| '/(main)/purchasing/order/'
@@ -418,6 +436,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof mainuserWalletLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/traffic': {
id: '/(main)/(user)/traffic'
path: '/traffic'
fullPath: '/traffic'
preLoaderRoute: typeof mainuserTrafficLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/ticket': {
id: '/(main)/(user)/ticket'
path: '/ticket'
@@ -493,6 +518,7 @@ interface mainuserRouteLazyRouteChildren {
mainuserProfileLazyRoute: typeof mainuserProfileLazyRoute
mainuserSubscribeLazyRoute: typeof mainuserSubscribeLazyRoute
mainuserTicketLazyRoute: typeof mainuserTicketLazyRoute
mainuserTrafficLazyRoute: typeof mainuserTrafficLazyRoute
mainuserWalletLazyRoute: typeof mainuserWalletLazyRoute
}
@@ -505,6 +531,7 @@ const mainuserRouteLazyRouteChildren: mainuserRouteLazyRouteChildren = {
mainuserProfileLazyRoute: mainuserProfileLazyRoute,
mainuserSubscribeLazyRoute: mainuserSubscribeLazyRoute,
mainuserTicketLazyRoute: mainuserTicketLazyRoute,
mainuserTrafficLazyRoute: mainuserTrafficLazyRoute,
mainuserWalletLazyRoute: mainuserWalletLazyRoute,
}
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import TrafficStatistics from "@/sections/user/traffic-statistics";
export const Route = createLazyFileRoute("/(main)/(user)/traffic")({
component: TrafficStatistics,
});
@@ -10,11 +10,13 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
@@ -34,26 +36,55 @@ export default function LoginForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({
email: z.email(t("login.email", "Please enter a valid email address")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -68,7 +99,10 @@ export default function LoginForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your email..."
placeholder={t(
"login.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -84,7 +118,10 @@ export default function LoginForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your password..."
placeholder={t(
"login.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
@@ -93,7 +130,7 @@ export default function LoginForm({
</FormItem>
)}
/>
{verify.enable_login_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -111,6 +148,38 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -11,12 +11,14 @@ import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
@@ -36,6 +38,12 @@ export default function RegisterForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth, invite } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_register_captcha;
const handleCheckUser = async (email: string) => {
try {
@@ -67,9 +75,21 @@ export default function RegisterForm({
code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z
.string()
.min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
@@ -84,17 +104,28 @@ export default function RegisterForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
invite: localStorage.getItem("invite") || "",
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -114,7 +145,10 @@ export default function RegisterForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your email..."
placeholder={t(
"register.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -130,7 +164,10 @@ export default function RegisterForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your password..."
placeholder={t(
"register.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
@@ -147,7 +184,10 @@ export default function RegisterForm({
<FormControl>
<Input
disabled={loading}
placeholder="Enter password again..."
placeholder={t(
"register.repeatPasswordPlaceholder",
"Enter password again..."
)}
type="password"
{...field}
/>
@@ -166,7 +206,10 @@ export default function RegisterForm({
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder="Enter code..."
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
@@ -205,7 +248,7 @@ export default function RegisterForm({
</FormItem>
)}
/>
{verify.enable_register_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -223,6 +266,38 @@ export default function RegisterForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
@@ -10,12 +10,14 @@ import {
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
@@ -36,6 +38,12 @@ export default function ResetForm({
const { common } = useGlobalStore();
const { verify, auth } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
email: z
@@ -44,21 +52,44 @@ export default function ResetForm({
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -73,7 +104,10 @@ export default function ResetForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your email..."
placeholder={t(
"reset.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -91,7 +125,7 @@ export default function ResetForm({
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder="Enter code..."
placeholder={t("reset.codePlaceholder", "Enter code...")}
type="text"
{...field}
value={field.value as string}
@@ -116,7 +150,10 @@ export default function ResetForm({
<FormItem>
<FormControl>
<Input
placeholder="Enter your new password..."
placeholder={t(
"reset.passwordPlaceholder",
"Enter your new password..."
)}
type="password"
{...field}
/>
@@ -125,7 +162,7 @@ export default function ResetForm({
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -143,6 +180,38 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
@@ -0,0 +1,100 @@
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { generateCaptcha } from "@workspace/ui/services/common/auth";
import { useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
reset: () => void;
}
interface LocalCaptchaProps {
value?: string | null;
onChange?: (value: string) => void;
onCaptchaIdChange?: (id: string) => void;
}
const LocalCaptcha = ({
value,
onChange,
onCaptchaIdChange,
ref,
}: LocalCaptchaProps & { ref?: RefObject<LocalCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await generateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCaptcha();
}, []);
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
},
}));
return (
<div className="flex gap-2">
<Input
className="flex-1"
onChange={(e) => onChange?.(e.target.value)}
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
height={40}
onClick={fetchCaptcha}
src={captchaImage}
title={t("captcha.clickToRefresh", "Click to refresh")}
width={120}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground text-xs">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
<Button
disabled={loading}
onClick={fetchCaptcha}
size="icon"
title={t("captcha.refresh", "Refresh captcha")}
type="button"
variant="outline"
>
<Icon icon="mdi:refresh" />
</Button>
</div>
);
};
LocalCaptcha.displayName = "LocalCaptcha";
export default LocalCaptcha;
@@ -16,7 +16,9 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
@@ -34,6 +36,12 @@ export default function LoginForm({
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const [captchaId, setCaptchaId] = useState("");
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({
telephone_area_code: z.string(),
@@ -41,23 +49,46 @@ export default function LoginForm({
telephone_code: z.string().optional(),
password: z.string().optional(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
captchaEnabled && isTurnstile && verify.turnstile_site_key
? z.string()
: z.string().optional(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const [mode, setMode] = useState<"password" | "code">("password");
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
if (isLocal && captchaEnabled) {
(data as any).captcha_id = captchaId;
}
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -88,7 +119,10 @@ export default function LoginForm({
);
}
}}
placeholder="Area code..."
placeholder={t(
"register.areaCodePlaceholder",
"Area code..."
)}
simple
value={field.value}
/>
@@ -99,7 +133,10 @@ export default function LoginForm({
/>
<Input
className="rounded-l-none"
placeholder="Enter your telephone..."
placeholder={t(
"register.telephonePlaceholder",
"Enter your telephone..."
)}
type="tel"
{...field}
/>
@@ -119,7 +156,12 @@ export default function LoginForm({
<div className="flex gap-2">
<Input
placeholder={
mode === "code" ? "Enter code..." : "Enter password..."
mode === "code"
? t("register.codePlaceholder", "Enter code...")
: t(
"login.passwordPlaceholder",
"Enter your password..."
)
}
type={mode === "code" ? "text" : "password"}
{...field}
@@ -157,7 +199,7 @@ export default function LoginForm({
</FormItem>
)}
/>
{verify.enable_login_verify && (
{captchaEnabled && isTurnstile && (
<FormField
control={form.control}
name="cf_token"
@@ -175,6 +217,38 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isLocal && (
<FormField
control={form.control}
name="captcha_code"
render={({ field }) => (
<FormItem>
<FormControl>
<LocalCaptcha
{...field}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}

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