Compare commits

...

10 Commits

Author SHA1 Message Date
shanshanzhong147 d6399cf796 feat: add withdrawal management pages 2026-05-24 20:30:19 -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
38 changed files with 2625 additions and 212 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 # Misc
.DS_Store .DS_Store
*.pem *.pem
.mcp.json
Binary file not shown.
@@ -15,7 +15,7 @@
"pending": "Pending", "pending": "Pending",
"pendingTickets": "Pending Tickets", "pendingTickets": "Pending Tickets",
"register": "Register", "register": "Register",
"repurchase": "Repurchase", "repurchase": "Renewal",
"revenueTitle": "Revenue Statistics", "revenueTitle": "Revenue Statistics",
"selectTypePlaceholder": "Select Type", "selectTypePlaceholder": "Select Type",
"today": "Today", "today": "Today",
@@ -32,6 +32,7 @@
"discount_price": "Discount Price", "discount_price": "Discount Price",
"discountDescription": "Set discount based on unit price", "discountDescription": "Set discount based on unit price",
"discountPercent": "Discount Percentage", "discountPercent": "Discount Percentage",
"appleProductId": "Apple Product ID",
"Hour": "Hour", "Hour": "Hour",
"inventory": "Subscription Limit", "inventory": "Subscription Limit",
"language": "Language", "language": "Language",
@@ -32,6 +32,7 @@
"discount_price": "折扣价格", "discount_price": "折扣价格",
"discountDescription": "根据单价设置折扣", "discountDescription": "根据单价设置折扣",
"discountPercent": "折扣百分比", "discountPercent": "折扣百分比",
"appleProductId": "苹果商品ID",
"Hour": "小时", "Hour": "小时",
"inventory": "订阅库存", "inventory": "订阅库存",
"language": "语言", "language": "语言",
@@ -12,7 +12,7 @@
"0": "状态", "0": "状态",
"1": "待跟进", "1": "待跟进",
"2": "待回复", "2": "待回复",
"3": "已解决", "3": "已取消",
"4": "已关闭" "4": "已关闭"
}, },
"ticketList": "工单列表", "ticketList": "工单列表",
+5
View File
@@ -93,6 +93,11 @@ export function useNavs() {
url: "/dashboard/user", url: "/dashboard/user",
icon: "flat-color-icons:conference-call", 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"), title: t("Device Group", "Device Group"),
url: "/dashboard/family", url: "/dashboard/family",
+25
View File
@@ -15,6 +15,9 @@ import { Route as rootRouteImport } from './routes/__root'
const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')() const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')()
const IndexLazyRouteImport = createFileRoute('/')() const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')() const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardWithdrawalLazyRouteImport = createFileRoute(
'/dashboard/withdrawal',
)()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')() const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')() const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')() const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
@@ -109,6 +112,13 @@ const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({
} as any).lazy(() => } as any).lazy(() =>
import('./routes/dashboard/index.lazy').then((d) => d.Route), 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({ const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({
id: '/servers', id: '/servers',
path: '/servers', path: '/servers',
@@ -346,6 +356,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -380,6 +391,7 @@ export interface FileRoutesByTo {
'/': typeof IndexLazyRoute '/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute '/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -416,6 +428,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -453,6 +466,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/' | '/dashboard/'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -487,6 +501,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard' | '/dashboard'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -522,6 +537,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/' | '/dashboard/'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -581,6 +597,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexLazyRouteImport preLoaderRoute: typeof DashboardIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute parentRoute: typeof DashboardRouteLazyRoute
} }
'/dashboard/withdrawal': {
id: '/dashboard/withdrawal'
path: '/withdrawal'
fullPath: '/dashboard/withdrawal'
preLoaderRoute: typeof DashboardWithdrawalLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/servers': { '/dashboard/servers': {
id: '/dashboard/servers' id: '/dashboard/servers'
path: '/servers' path: '/servers'
@@ -797,6 +820,7 @@ declare module '@tanstack/react-router' {
interface DashboardRouteLazyRouteChildren { interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
@@ -831,6 +855,7 @@ interface DashboardRouteLazyRouteChildren {
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute, DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute, DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute, DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute, DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute, DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import WithdrawalManagementPage from "@/sections/withdrawal";
export const Route = createLazyFileRoute("/dashboard/withdrawal")({
component: WithdrawalManagementPage,
});
+10
View File
@@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useNode } from "@/stores/node"; import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server"; import { useServer } from "@/stores/server";
import NodeBatchSheet from "./node-batch-sheet";
import NodeForm from "./node-form"; import NodeForm from "./node-form";
export default function Nodes() { export default function Nodes() {
@@ -245,6 +246,15 @@ export default function Nodes() {
], ],
batchRender(rows) { batchRender(rows) {
return [ return [
<NodeBatchSheet
key="batch-update"
onSuccess={() => {
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
rows={rows}
/>,
<ConfirmButton <ConfirmButton
cancelText={t("cancel", "Cancel")} cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")} confirmText={t("confirm", "Confirm")}
@@ -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>
);
}
+9
View File
@@ -1,3 +1,4 @@
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge"; import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
@@ -26,6 +27,13 @@ import { UserDetail } from "../user/user-detail";
export default function Order() { export default function Order() {
const { t } = useTranslation("order"); const { t } = useTranslation("order");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
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 = [ const statusOptions = [
{ {
@@ -252,6 +260,7 @@ export default function Order() {
}, },
}, },
]} ]}
initialFilters={initialFilters}
params={[ params={[
{ {
key: "status", key: "status",
@@ -115,6 +115,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
z.object({ z.object({
quantity: z.number(), quantity: z.number(),
discount: z.number(), discount: z.number(),
map_apple: z.string().optional(),
}) })
) )
.optional(), .optional(),
@@ -813,6 +814,11 @@ export default function SubscribeForm<T extends Record<string, any>>({
value value
).toString(), ).toString(),
}, },
{
name: "map_apple",
type: "text",
placeholder: t("form.appleProductId"),
},
]} ]}
onChange={( onChange={(
newValues: (API.SubscribeDiscount & { newValues: (API.SubscribeDiscount & {
@@ -22,6 +22,7 @@ import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server"; import { useServer } from "@/stores/server";
import DynamicMultiplier from "./dynamic-multiplier"; import DynamicMultiplier from "./dynamic-multiplier";
import OnlineUsersCell from "./online-users-cell"; import OnlineUsersCell from "./online-users-cell";
import ServerBatchSheet from "./server-batch-sheet";
import ServerConfig from "./server-config"; import ServerConfig from "./server-config";
import ServerForm from "./server-form"; import ServerForm from "./server-form";
import ServerInstall from "./server-install"; import ServerInstall from "./server-install";
@@ -184,6 +185,14 @@ export default function Servers() {
isServerReferencedByNodes(row.id) isServerReferencedByNodes(row.id)
); );
return [ return [
<ServerBatchSheet
key="batch-update"
onSuccess={() => {
ref.current?.refresh();
fetchServers();
}}
rows={rows}
/>,
<ConfirmButton <ConfirmButton
cancelText={t("cancel", "Cancel")} cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")} 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} {...fieldProps}
max={field.max} max={field.max}
min={field.min} min={field.min}
onValueChange={(v) => fieldProps.onChange(v)} onValueChange={(v) =>
fieldProps.onChange(v === "" ? undefined : Number(v))
}
placeholder={field.placeholder} placeholder={field.placeholder}
step={field.step || 1} step={field.step || 1}
suffix={field.suffix} suffix={field.suffix}
@@ -308,7 +310,7 @@ function renderFieldsByGroup(
); );
} }
function renderGroupCard( export function renderGroupCard(
title: string, title: string,
fields: FieldConfig[], fields: FieldConfig[],
group: string, group: string,
@@ -362,15 +364,52 @@ export default function ServerForm(props: {
const { isProtocolUsedInNodes } = useNode(); const { isProtocolUsedInNodes } = useNode();
const PROTOCOL_FIELDS = useProtocolFields(); 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({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: initialValues?.name ?? "",
address: "", address: initialValues?.address ?? "",
country: "", country: initialValues?.country ?? "",
city: "", city: initialValues?.city ?? "",
protocols: [] as any[], protocols: buildSanitizedProtocols(initialValues?.protocols as any[]),
...initialValues,
}, },
}); });
const { control } = form; const { control } = form;
@@ -380,20 +419,11 @@ export default function ServerForm(props: {
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
form.reset({ form.reset({
name: "", name: initialValues.name ?? "",
address: "", address: initialValues.address ?? "",
country: "", country: initialValues.country ?? "",
city: "", city: initialValues.city ?? "",
...initialValues, protocols: buildSanitizedProtocols(initialValues.protocols as any[]),
protocols: PROTOCOLS.map((type) => {
const existingProtocol = initialValues.protocols?.find(
(p) => p.type === type
);
const defaultConfig = getProtocolDefaultConfig(type);
return existingProtocol
? { ...defaultConfig, ...existingProtocol }
: defaultConfig;
}),
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -686,8 +716,22 @@ export default function ServerForm(props: {
<Button <Button
disabled={loading} disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => { onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors; const getFirstErrorMessage = (
if (key) toast.error(String(errors[key]?.message)); 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; return false;
})} })}
> >
@@ -27,10 +27,12 @@ type Props = {
server: API.Server; server: API.Server;
}; };
const DEFAULT_API_HOST = "https://api.hifast.biz";
export default function ServerInstall({ server }: Props) { export default function ServerInstall({ server }: Props) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState(DEFAULT_API_HOST);
const { data: cfgResp } = useQuery({ const { data: cfgResp } = useQuery({
queryKey: ["getNodeConfig"], queryKey: ["getNodeConfig"],
@@ -43,8 +45,7 @@ export default function ServerInstall({ server }: Props) {
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const host = localStorage.getItem("API_HOST") ?? window.location.origin; setDomain(DEFAULT_API_HOST);
setDomain(host);
} }
}, [open]); }, [open]);
@@ -75,7 +76,6 @@ export default function ServerInstall({ server }: Props) {
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDomain(e.target.value); setDomain(e.target.value);
localStorage.setItem("API_HOST", e.target.value);
}, []); }, []);
return ( return (
<Dialog onOpenChange={setOpen} open={open}> <Dialog onOpenChange={setOpen} open={open}>
@@ -20,6 +20,7 @@ import {
SheetTrigger, SheetTrigger,
} from "@workspace/ui/components/sheet"; } from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch"; import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { Combobox } from "@workspace/ui/composed/combobox"; import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
@@ -40,6 +41,8 @@ const registerSchema = z.object({
trial_subscribe: z.number().optional(), trial_subscribe: z.number().optional(),
trial_time: z.number().optional(), trial_time: z.number().optional(),
trial_time_unit: z.string().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(), enable_ip_register_limit: z.boolean().optional(),
ip_register_limit: z.number().optional(), ip_register_limit: z.number().optional(),
ip_register_limit_duration: z.number().optional(), ip_register_limit_duration: z.number().optional(),
@@ -72,6 +75,8 @@ export default function RegisterConfig() {
trial_subscribe: undefined, trial_subscribe: undefined,
trial_time: 0, trial_time: 0,
trial_time_unit: "day", trial_time_unit: "day",
enable_trial_email_whitelist: false,
trial_email_domain_whitelist: "",
enable_ip_register_limit: false, enable_ip_register_limit: false,
ip_register_limit: 1, ip_register_limit: 1,
ip_register_limit_duration: 1, ip_register_limit_duration: 1,
@@ -331,114 +336,182 @@ export default function RegisterConfig() {
/> />
{form.watch("enable_trial") && ( {form.watch("enable_trial") && (
<FormField <>
control={form.control} <FormField
name="trial_time" control={form.control}
render={({ field }) => ( name="trial_time"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
{t("register.trialConfig", "Trial Configuration")} <FormLabel>
</FormLabel> {t("register.trialConfig", "Trial Configuration")}
<FormControl> </FormLabel>
<div className="flex gap-2"> <FormControl>
<EnhancedInput <div className="flex gap-2">
className="flex-1" <EnhancedInput
min={0} className="flex-1"
onValueBlur={(value) => min={0}
field.onChange(Number(value)) onValueBlur={(value) =>
} field.onChange(Number(value))
placeholder={t( }
"register.inputPlaceholder", placeholder={t(
"Please enter" "register.inputPlaceholder",
)} "Please enter"
prefix={ )}
<FormField prefix={
control={form.control} <FormField
name="trial_subscribe" control={form.control}
render={({ field }) => ( name="trial_subscribe"
<Combobox render={({ field }) => (
className="w-32 rounded-r-none bg-secondary" <Combobox
onChange={(value: number) => { className="w-32 rounded-r-none bg-secondary"
if (value) { onChange={(value: number) => {
field.onChange(value); if (value) {
} field.onChange(value);
}} }
options={subscribes?.map((item) => ({ }}
label: item.name!, options={subscribes?.map((item) => ({
value: item.id!, label: item.name!,
}))} value: item.id!,
placeholder={t( }))}
"register.selectPlaceholder", placeholder={t(
"Please select" "register.selectPlaceholder",
)} "Please select"
value={field.value} )}
/> value={field.value}
)} />
/> )}
} />
suffix={ }
<FormField suffix={
control={form.control} <FormField
name="trial_time_unit" control={form.control}
render={({ field: unitField }) => ( name="trial_time_unit"
<Combobox render={({ field: unitField }) => (
className="w-32 rounded-l-none bg-secondary" <Combobox
onChange={(value: string) => { className="w-32 rounded-l-none bg-secondary"
unitField.onChange(value); onChange={(value: string) => {
}} unitField.onChange(value);
options={[ }}
{ options={[
label: t("register.none", "None"), {
value: "None", label: t("register.none", "None"),
}, value: "None",
{ },
label: t("register.year", "Year(s)"), {
value: "Year", label: t("register.year", "Year(s)"),
}, value: "Year",
{ },
label: t("register.month", "Month(s)"), {
value: "Month", label: t(
}, "register.month",
{ "Month(s)"
label: t("register.day", "Day(s)"), ),
value: "Day", value: "Month",
}, },
{ {
label: t("register.hour", "Hour(s)"), label: t("register.day", "Day(s)"),
value: "Hour", value: "Day",
}, },
{ {
label: t( label: t("register.hour", "Hour(s)"),
"register.minute", value: "Hour",
"Minute(s)" },
), {
value: "Minute", label: t(
}, "register.minute",
]} "Minute(s)"
placeholder={t( ),
"register.selectPlaceholder", value: "Minute",
"Please select" },
)} ]}
value={unitField.value} placeholder={t(
/> "register.selectPlaceholder",
)} "Please select"
/> )}
} value={unitField.value}
type="number" />
value={field.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>
</FormControl> <FormDescription>
<FormDescription> {t(
{t( "register.enableTrialEmailWhitelistDescription",
"register.trialConfigDescription", "When enabled, trial subscription is only granted to emails with whitelisted domains"
"Configure trial subscription, duration and time unit for new users upon registration" )}
)} </FormDescription>
</FormDescription> <FormMessage />
<FormMessage /> </FormItem>
</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>
</Form> </Form>
+191 -17
View File
@@ -23,6 +23,10 @@ import {
getTicketList, getTicketList,
updateTicketStatus, updateTicketStatus,
} from "@workspace/ui/services/admin/ticket"; } from "@workspace/ui/services/admin/ticket";
import {
getUserDetail,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -42,6 +46,7 @@ export default function Page() {
const [ticketId, setTicketId] = useState<any>(null); const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [loadingId, setLoadingId] = useState<any>(null);
const { data: ticket, refetch: refetchTicket } = useQuery({ const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ["getTicket", ticketId], queryKey: ["getTicket", ticketId],
@@ -74,45 +79,214 @@ export default function Page() {
action={ref} action={ref}
actions={{ actions={{
render(row) { render(row) {
if (row.status !== 4) { if (row.status === 1) {
return [ return [
<Button key="reply" onClick={() => setTicketId(row.id)}>
{t("reply", "Reply")}
</Button>,
<ConfirmButton <ConfirmButton
cancelText={t("cancel", "Cancel")} cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")} confirmText={t("confirm", "Confirm")}
description={t( description="您确定要取消此次打款吗?取消后系统将记录此操作并关闭工单。"
"closeWarning", key="cancel-payout"
"Once closed, the ticket cannot be operated on. Please proceed with caution." 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" key="colse"
onConfirm={async () => { 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({ await updateTicketStatus({
id: row.id, id: row.id,
status: 4, status: 4,
}); });
toast.success(t("closeSuccess", "Closed successfully")); toast.success(t("closeSuccess", "Closed successfully"));
ref.current?.refresh(); ref.current?.refresh();
setLoadingId(null);
}} }}
title={t("confirmClose", "Are you sure you want to close?")} title="确认已完成打款?"
trigger={ 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 [ return [];
<Button key="check" onClick={() => setTicketId(row.id)} size="sm">
{t("check", "Check")}
</Button>,
];
}, },
}} }}
columns={[ columns={[
{ {
accessorKey: "title", id: "withdrawal_type",
header: t("title", "Title"), 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", accessorKey: "user_id",
+94 -17
View File
@@ -31,6 +31,13 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@workspace/ui/components/sheet"; } from "@workspace/ui/components/sheet";
import { FilePenLine } from 'lucide-react';
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@workspace/ui/components/popover';
import { Switch } from "@workspace/ui/components/switch"; import { Switch } from "@workspace/ui/components/switch";
import { import {
Tabs, Tabs,
@@ -39,7 +46,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@workspace/ui/components/tabs"; } from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox"; import { Combobox } from "@workspace/ui/composed/combobox";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; // import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { import {
ProTable, ProTable,
type ProTableActions, type ProTableActions,
@@ -50,12 +57,13 @@ import {
} from "@workspace/ui/services/admin/group"; } from "@workspace/ui/services/admin/group";
import { import {
createUser, createUser,
deleteUser, // deleteUser,
getUserDetail, getUserDetail,
getUserList, getUserList,
updateUserBasicInfo, updateUserBasicInfo,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { useCallback, useRef, useState } from "react"; import { parseDeviceType } from "@workspace/ui/utils/device";
import React, { useRef, useState, useCallback } from 'react';
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
@@ -71,6 +79,44 @@ import { NotifySettingsForm } from "./user-profile/notify-settings-form";
import UserSubscription from "./user-subscription"; import UserSubscription from "./user-subscription";
// import EditUserGroupDialog from "./edit-user-group-dialog"; // 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 text-sm font-semibold'></div>
<Input
type='text'
value={remark}
onChange={handleInputChange}
placeholder='在此输入备注...'
className='w-full'
/>
<CloseComponent asChild>
<Button onClick={handleSaveClick} variant='default' size={'sm'} className={'mt-2'}>
</Button>
</CloseComponent>
</>
);
};
export default function User() { export default function User() {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -138,7 +184,7 @@ export default function User() {
userId={row.id} userId={row.id}
/>, />,
<PreviewNodesDialog key="preview-nodes" userId={row.id} />, <PreviewNodesDialog key="preview-nodes" userId={row.id} />,
<ConfirmButton /* <ConfirmButton
cancelText={t("cancel", "Cancel")} cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")} confirmText={t("confirm", "Confirm")}
description={t( description={t(
@@ -155,7 +201,7 @@ export default function User() {
trigger={ trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button> <Button variant="destructive">{t("delete", "Delete")}</Button>
} }
/>, />,*/
<DropdownMenu key="more" modal={false}> <DropdownMenu key="more" modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button> <Button variant="outline">{t("more", "More")}</Button>
@@ -231,7 +277,7 @@ export default function User() {
accessorKey: "id", accessorKey: "id",
header: "ID", header: "ID",
}, },
{ /*{
id: "deleted_at", id: "deleted_at",
accessorKey: "deleted_at", accessorKey: "deleted_at",
header: t("isDeleted", "Deleted"), header: t("isDeleted", "Deleted"),
@@ -243,31 +289,63 @@ export default function User() {
<Badge variant="outline">{t("normal", "Normal")}</Badge> <Badge variant="outline">{t("normal", "Normal")}</Badge>
); );
}, },
}, },*/
{ {
id: "auth_methods", id: "auth_methods",
accessorKey: "auth_methods", accessorKey: "auth_methods",
header: t("userName", "Username"), header: '设备码/邮箱',
cell: ({ row }) => { cell: ({ row }) => {
const method = row.original.auth_methods?.[0]; const method = row.original.auth_methods?.[0];
const identifier = method?.auth_identifier || ""; const identifier = method?.auth_identifier || "";
const isDevice = method?.auth_type === "device"; 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; const display = isDevice ? deviceNo || identifier : identifier;
return ( return (
<div> <div className="flex items-center">
<Badge {/* <Badge
className="mr-1 uppercase" className="mr-1 uppercase"
title={method?.verified ? t("verified", "Verified") : ""} title={method?.verified ? t("verified", "Verified") : ""}
> >
{method?.auth_type} {method?.auth_type}
</Badge> </Badge>*/}
{deviceType && (
<Badge className="mr-1" variant="secondary">
{deviceType}
</Badge>
)}
<span title={isDevice ? display : undefined}>{display}</span> <span title={isDevice ? display : undefined}>{display}</span>
<Popover>
<PopoverTrigger>
<div className={'flex items-center'}>
{row.original?.remark ? `${row.original.remark}` : ''}
<FilePenLine size={14} className={'text-primary ml-2'} />
</div>
</PopoverTrigger>
<PopoverContent className={'w-64'}>
<RemarkForm
initialRemark={row.original.remark}
CloseComponent={PopoverClose}
onSave={async (remark) => {
const {
id,
} = row.original;
await updateUserBasicInfo({
user_id: id,
remark,
} as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess'));
ref.current?.refresh();
}}
/>
</PopoverContent>
</Popover>
</div> </div>
); );
}, },
}, },
{ /* {
id: "balance", id: "balance",
accessorKey: "balance", accessorKey: "balance",
header: t("balance", "Balance"), header: t("balance", "Balance"),
@@ -282,7 +360,7 @@ export default function User() {
cell: ({ row }) => ( cell: ({ row }) => (
<Display type="currency" value={row.getValue("gift_amount")} /> <Display type="currency" value={row.getValue("gift_amount")} />
), ),
}, },*/
{ {
id: "commission", id: "commission",
accessorKey: "commission", accessorKey: "commission",
@@ -523,12 +601,11 @@ function UserSearchBar({
}} }}
value={searchType} value={searchType}
> >
<SelectTrigger className="w-24"> <SelectTrigger className="w-48">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="email">{t("email", "Email")}</SelectItem> <SelectItem value="email">/</SelectItem>
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem> <SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
<SelectItem value="subscribe_id"> <SelectItem value="subscribe_id">
{t("subscription", "Subscription")} {t("subscription", "Subscription")}
+6 -6
View File
@@ -59,8 +59,8 @@ export default function UserForm<T extends Record<string, any>>({
referral_percentage: z.number().optional(), referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(), only_first_purchase: z.boolean().optional(),
is_admin: z.boolean().optional(), is_admin: z.boolean().optional(),
balance: z.number().optional(), // balance: z.number().optional(),
gift_amount: z.number().optional(), // gift_amount: z.number().optional(),
commission: z.number().optional(), commission: z.number().optional(),
}); });
const form = useForm({ const form = useForm({
@@ -282,7 +282,7 @@ export default function UserForm<T extends Record<string, any>>({
</FormItem> </FormItem>
)} )}
/> />
<FormField {/* <FormField
control={form.control} control={form.control}
name="balance" name="balance"
render={({ field }) => ( render={({ field }) => (
@@ -309,8 +309,8 @@ export default function UserForm<T extends Record<string, any>>({
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
<FormField {/* <FormField
control={form.control} control={form.control}
name="gift_amount" name="gift_amount"
render={({ field }) => ( render={({ field }) => (
@@ -340,7 +340,7 @@ export default function UserForm<T extends Record<string, any>>({
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> /> */}
<FormField <FormField
control={form.control} control={form.control}
name="commission" name="commission"
@@ -24,7 +24,7 @@ const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(), enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(), enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(), enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(), // enable_trade_notify: z.boolean(),
}); });
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>; type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
@@ -44,13 +44,14 @@ export function NotifySettingsForm({
enable_balance_notify: user.enable_balance_notify, enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify, enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_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) { async function onSubmit(data: NotifySettingsValues) {
await updateUserNotifySetting({ await updateUserNotifySetting({
...data, ...data,
enable_trade_notify: user.enable_trade_notify ?? false,
user_id: user.id, user_id: user.id,
}); });
toast.success(t("updateSuccess", "Updated successfully")); toast.success(t("updateSuccess", "Updated successfully"));
@@ -126,7 +127,7 @@ export function NotifySettingsForm({
)} )}
/> />
<FormField {/* <FormField
control={form.control} control={form.control}
name="enable_trade_notify" name="enable_trade_notify"
render={({ field }) => ( render={({ field }) => (
@@ -142,7 +143,7 @@ export function NotifySettingsForm({
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> /> */}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -329,6 +329,11 @@ export default function UserSubscription({ userId }: { userId: number }) {
: t("permanent", "Permanent"); : t("permanent", "Permanent");
}, },
}, },
{
accessorKey: "node_group_name",
header: t("nodeGroup", "Node Group"),
cell: ({ row }) => row.original.node_group_name || "-",
},
{ {
accessorKey: "created_at", accessorKey: "created_at",
header: t("createdAt", "Created At"), header: t("createdAt", "Created At"),
@@ -451,7 +456,7 @@ function RowReadOnlyActions({
"This action cannot be undone." "This action cannot be undone."
)} )}
onConfirm={async () => { onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: String(row.id) }); await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully")); toast.success(t("deleteSuccess", "Deleted successfully"));
refresh?.(); refresh?.();
}} }}
@@ -636,7 +641,7 @@ function RowMoreActions({
"This action cannot be undone." "This action cannot be undone."
)} )}
onConfirm={async () => { onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: String(row.id) }); await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully")); toast.success(t("deleteSuccess", "Deleted successfully"));
refresh(); refresh();
}} }}
@@ -11,16 +11,92 @@ import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table"; import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { import {
getUserSubscribeById,
getUserSubscribeDevices, getUserSubscribeDevices,
kickOfflineByUserDevice, kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { deviceIdToHash } from "@workspace/ui/utils/device"; 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 { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { IpLink } from "@/components/ip-link"; import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common"; 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({ export function SubscriptionDetail({
trigger, trigger,
userId, userId,
@@ -44,6 +120,7 @@ export function SubscriptionDetail({
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle> <SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto"> <div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
{open && <SpeedLimitCard subscriptionId={subscriptionId} />}
<ProTable<API.UserDevice, Record<string, unknown>> <ProTable<API.UserDevice, Record<string, unknown>>
actions={{ actions={{
render: (row) => { render: (row) => {
@@ -0,0 +1,328 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 type { ProTableActions } from "@workspace/ui/composed/pro-table/pro-table";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import {
approveWithdrawal,
getWithdrawalList,
rejectWithdrawal,
} from "@workspace/ui/services/admin/withdrawal";
import { LoaderCircle } from "lucide-react";
import { useRef, useState, useTransition } 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 rejectionSchema = z.object({
reason: z
.string()
.trim()
.min(1, "Reason is required")
.max(500, "Reason must be 500 characters or fewer"),
});
const WITHDRAWAL_STATUS_OPTIONS = [
{ label: "All", value: "all" },
{ label: "Pending", value: "0" },
{ label: "Approved", value: "1" },
{ label: "Rejected", value: "2" },
] as const;
function getStatusMeta(
status: number,
t: ReturnType<typeof useTranslation<"user">>["t"]
) {
switch (status) {
case 1:
return {
label: t("withdrawal.status.approved", "Approved"),
variant: "default" as const,
};
case 2:
return {
label: t("withdrawal.status.rejected", "Rejected"),
variant: "destructive" as const,
};
default:
return {
label: t("withdrawal.status.pending", "Pending"),
variant: "secondary" as const,
};
}
}
function RejectWithdrawalDialog({
row,
onSuccess,
}: {
row: API.AdminWithdrawalLog;
onSuccess: () => Promise<void> | void;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const [loading, startTransition] = useTransition();
const form = useForm<z.infer<typeof rejectionSchema>>({
resolver: zodResolver(rejectionSchema),
defaultValues: {
reason: "",
},
});
const handleSubmit = (values: z.infer<typeof rejectionSchema>) => {
startTransition(async () => {
await rejectWithdrawal({
withdrawal_id: row.id,
reason: values.reason.trim(),
});
toast.success(t("withdrawal.rejectSuccess", "Withdrawal rejected"));
await onSuccess();
setOpen(false);
form.reset();
});
};
return (
<Dialog
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) form.reset();
}}
open={open}
>
<DialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("withdrawal.reject", "Reject")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("withdrawal.rejectTitle", "Reject Withdrawal")}
</DialogTitle>
<DialogDescription>
{t(
"withdrawal.rejectDescription",
"Provide a reason that will be shown in the user's withdrawal history."
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="space-y-4"
id={`reject-withdrawal-${row.id}`}
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.rejectReason", "Reject Reason")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"withdrawal.rejectReasonPlaceholder",
"Explain why this withdrawal cannot be approved"
)}
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
form={`reject-withdrawal-${row.id}`}
type="submit"
variant="destructive"
>
{loading && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t("withdrawal.rejectConfirm", "Reject Withdrawal")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function WithdrawalManagementPage() {
const { t } = useTranslation("user");
const [approvingId, setApprovingId] = useState<number | null>(null);
const tableActionRef = useRef<ProTableActions | undefined>(undefined);
return (
<ProTable<API.AdminWithdrawalLog, { user_id?: string; status?: string }>
action={tableActionRef}
actions={{
render: (row) => {
if (Number(row.status) !== 0) return [];
const handleApprove = async () => {
try {
await approveWithdrawal({
withdrawal_id: Number(row.id),
});
toast.success(
t("withdrawal.approveSuccess", "Withdrawal approved")
);
await tableActionRef.current?.refresh();
} finally {
setApprovingId(null);
}
};
return [
<Button
disabled={approvingId === Number(row.id)}
key={`approve-${row.id}`}
onClick={() => {
setApprovingId(Number(row.id));
handleApprove();
}}
size="sm"
>
{approvingId === Number(row.id) && (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
)}
{t("withdrawal.approve", "Approve")}
</Button>,
<RejectWithdrawalDialog
key={`reject-${row.id}`}
onSuccess={() => tableActionRef.current?.refresh()}
row={row}
/>,
];
},
}}
columns={[
{
accessorKey: "id",
header: t("withdrawal.column.id", "ID"),
},
{
accessorKey: "user_id",
header: t("withdrawal.column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("withdrawal.column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={Number(row.original.amount)} />
),
},
{
accessorKey: "content",
header: t("withdrawal.column.content", "Withdrawal Info"),
cell: ({ row }) => (
<div className="max-w-[280px] whitespace-pre-wrap break-words text-sm">
{row.original.content}
</div>
),
},
{
accessorKey: "status",
header: t("withdrawal.column.status", "Status"),
cell: ({ row }) => {
const meta = getStatusMeta(Number(row.original.status), t);
return <Badge variant={meta.variant}>{meta.label}</Badge>;
},
},
{
accessorKey: "reason",
header: t("withdrawal.column.reason", "Reject Reason"),
cell: ({ row }) =>
row.original.reason ? (
<div className="max-w-[220px] whitespace-pre-wrap break-words text-muted-foreground text-sm">
{row.original.reason}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
),
},
{
accessorKey: "created_at",
header: t("withdrawal.column.createdAt", "Created At"),
cell: ({ row }) => formatDate(Number(row.original.created_at)),
},
]}
header={{
title: t("withdrawal.title", "Withdrawal Management"),
}}
params={[
{
key: "status",
type: "select",
placeholder: t("withdrawal.filter.status", "Status"),
options: WITHDRAWAL_STATUS_OPTIONS.map((option) => ({
label: t(`withdrawal.statusOption.${option.value}`, option.label),
value: option.value,
})),
},
{
key: "user_id",
placeholder: t("withdrawal.filter.userId", "User ID"),
},
]}
request={async (pagination, filter) => {
const status =
filter.status && filter.status !== "all"
? Number(filter.status)
: undefined;
const userId = filter.user_id ? Number(filter.user_id) : undefined;
const { data } = await getWithdrawalList({
page: pagination.page,
size: pagination.size,
status,
user_id: Number.isFinite(userId) ? userId : undefined,
});
const list = (data.data?.list || []) as API.AdminWithdrawalLog[];
return {
list,
total: Number(data.data?.total || list.length),
};
}}
/>
);
}
+321 -11
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
@@ -9,29 +10,146 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@workspace/ui/components/card"; } from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Textarea } from "@workspace/ui/components/textarea";
import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list";
import { ProList } from "@workspace/ui/composed/pro-list/pro-list"; import { ProList } from "@workspace/ui/composed/pro-list/pro-list";
import { import {
commissionWithdraw,
queryUserAffiliate, queryUserAffiliate,
queryUserAffiliateList, queryUserAffiliateList,
queryWithdrawalLog,
} from "@workspace/ui/services/user/user"; } from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting"; import { formatDate } from "@workspace/ui/utils/formatting";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { useRef, useState, useTransition } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard"; import { CopyToClipboard } from "react-copy-to-clipboard";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
function getWithdrawalStatusMeta(
status: number,
t: ReturnType<typeof useTranslation<"affiliate">>["t"]
) {
switch (status) {
case 1:
return {
label: t("withdrawal.status.approved", "Approved"),
className: "bg-emerald-500/10 text-emerald-600",
};
case 2:
return {
label: t("withdrawal.status.rejected", "Rejected"),
className: "bg-destructive/10 text-destructive",
};
default:
return {
label: t("withdrawal.status.pending", "Pending"),
className: "bg-amber-500/10 text-amber-600",
};
}
}
export default function Affiliate() { export default function Affiliate() {
const { t } = useTranslation("affiliate"); const { t } = useTranslation("affiliate");
const { user, common } = useGlobalStore(); const { user, common, getUserInfo } = useGlobalStore();
const { data } = useQuery({ const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [submitting, startTransition] = useTransition();
const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined);
const affiliateQuery = useQuery({
queryKey: ["queryUserAffiliate"], queryKey: ["queryUserAffiliate"],
queryFn: async () => { queryFn: async () => {
const response = await queryUserAffiliate(); const response = await queryUserAffiliate();
return response.data.data; return response.data.data;
}, },
}); });
const availableCommission = Number(
user?.commission ?? affiliateQuery.data?.total_commission ?? 0
);
const withdrawalFormSchema = z.object({
amount: z
.number()
.int(t("withdrawal.validation.amountInvalid", "Amount is invalid"))
.positive(
t(
"withdrawal.validation.amountPositive",
"Amount must be greater than 0"
)
)
.max(
availableCommission,
t(
"withdrawal.validation.amountExceeded",
"Amount cannot exceed available commission"
)
),
content: z
.string()
.trim()
.min(
1,
t(
"withdrawal.validation.contentRequired",
"Withdrawal info is required"
)
)
.max(
1000,
t("withdrawal.validation.contentTooLong", "Withdrawal info is too long")
),
});
const withdrawalForm = useForm<z.infer<typeof withdrawalFormSchema>>({
resolver: zodResolver(withdrawalFormSchema),
defaultValues: {
amount: 0,
content: "",
},
});
const submitWithdrawal = (values: z.infer<typeof withdrawalFormSchema>) => {
startTransition(async () => {
await commissionWithdraw({
amount: values.amount,
content: values.content.trim(),
});
toast.success(
t("withdrawal.submitSuccess", "Withdrawal request submitted")
);
await Promise.all([
affiliateQuery.refetch(),
getUserInfo(),
withdrawalListActionRef.current?.refresh(),
]);
setWithdrawalOpen(false);
withdrawalForm.reset({
amount: 0,
content: "",
});
});
};
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -43,15 +161,146 @@ export default function Affiliate() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-baseline gap-2"> <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<span className="font-bold text-3xl"> <div className="flex items-baseline gap-2">
<Display type="currency" value={data?.total_commission} /> <span className="font-bold text-3xl">
</span> <Display
<span className="text-muted-foreground text-sm"> type="currency"
({t("commissionRate", "Commission Rate")}:{" "} value={affiliateQuery.data?.total_commission}
{user?.referral_percentage || common?.invite?.referral_percentage} />
%) </span>
</span> <span className="text-muted-foreground text-sm">
({t("commissionRate", "Commission Rate")}:{" "}
{user?.referral_percentage ||
common?.invite?.referral_percentage}
%)
</span>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="text-muted-foreground text-sm">
{t(
"withdrawal.availableCommission",
"Available for withdrawal"
)}
</div>
<div className="font-semibold text-xl">
<Display type="currency" value={availableCommission} />
</div>
<Dialog onOpenChange={setWithdrawalOpen} open={withdrawalOpen}>
<DialogTrigger asChild>
<Button>
{t("withdrawal.apply", "Apply for Withdrawal")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("withdrawal.applyTitle", "Apply for Withdrawal")}
</DialogTitle>
<DialogDescription>
{t(
"withdrawal.applyDescription",
"Enter the withdrawal amount and payout details."
)}
</DialogDescription>
</DialogHeader>
<Form {...withdrawalForm}>
<form
className="space-y-4"
id="withdrawal-form"
onSubmit={withdrawalForm.handleSubmit(submitWithdrawal)}
>
<FormField
control={withdrawalForm.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.amount", "Amount")}
</FormLabel>
<FormControl>
<Input
inputMode="decimal"
min="0"
onChange={(event) => {
const cents = unitConversion(
"dollarsToCents",
Number(event.target.value || 0)
);
field.onChange(Number(cents) || 0);
}}
placeholder={t(
"withdrawal.amountPlaceholder",
"Enter withdrawal amount"
)}
step="0.01"
type="number"
value={
field.value
? unitConversion(
"centsToDollars",
Number(field.value)
)
: ""
}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
{t("withdrawal.amountHint", "Available")}:{" "}
<Display
type="currency"
value={availableCommission}
/>
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={withdrawalForm.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.content", "Payout Information")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"withdrawal.contentPlaceholder",
"Bank account, wallet address, recipient name, or other payout details"
)}
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
disabled={submitting}
onClick={() => setWithdrawalOpen(false)}
variant="outline"
>
{t("withdrawal.cancel", "Cancel")}
</Button>
<Button
disabled={submitting || availableCommission <= 0}
form="withdrawal-form"
type="submit"
>
{submitting
? t("withdrawal.submitting", "Submitting...")
: t("withdrawal.submit", "Submit Withdrawal")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -82,6 +331,67 @@ export default function Affiliate() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<ProList<API.WithdrawalLog, Record<string, unknown>>
action={withdrawalListActionRef}
header={{
title: t("withdrawal.records", "Withdrawal Records"),
}}
renderItem={(item) => {
const statusMeta = getWithdrawalStatusMeta(item.status, t);
return (
<Card className="overflow-hidden">
<CardContent className="space-y-3 p-4 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-semibold text-lg">
<Display type="currency" value={item.amount} />
</div>
<span
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
>
{statusMeta.label}
</span>
</div>
<ul className="grid gap-3 md:grid-cols-2">
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">
{t("withdrawal.record.content", "Payout Information")}
</span>
<span className="whitespace-pre-wrap break-words">
{item.content}
</span>
</li>
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">
{t("withdrawal.record.createdAt", "Created At")}
</span>
<time>{formatDate(item.created_at)}</time>
</li>
{item.reason ? (
<li className="flex flex-col gap-1 md:col-span-2">
<span className="text-muted-foreground">
{t("withdrawal.record.reason", "Reject Reason")}
</span>
<span className="whitespace-pre-wrap break-words text-destructive">
{item.reason}
</span>
</li>
) : null}
</ul>
</CardContent>
</Card>
);
}}
request={async (pagination) => {
const response = await queryWithdrawalLog({
page: pagination.page,
size: pagination.size,
});
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
<ProList<API.UserAffiliate, Record<string, unknown>> <ProList<API.UserAffiliate, Record<string, unknown>>
header={{ header={{
title: t("inviteRecords", "Invite Records"), title: t("inviteRecords", "Invite Records"),
@@ -27,7 +27,7 @@ const FormSchema = z.object({
enable_balance_notify: z.boolean(), enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(), enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(), enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(), // enable_trade_notify: z.boolean(),
}); });
export default function NotifySettings() { export default function NotifySettings() {
@@ -39,7 +39,7 @@ export default function NotifySettings() {
enable_balance_notify: user?.enable_balance_notify ?? false, enable_balance_notify: user?.enable_balance_notify ?? false,
enable_login_notify: user?.enable_login_notify ?? false, enable_login_notify: user?.enable_login_notify ?? false,
enable_subscribe_notify: user?.enable_subscribe_notify ?? false, enable_subscribe_notify: user?.enable_subscribe_notify ?? false,
enable_trade_notify: user?.enable_trade_notify ?? false, // enable_trade_notify: user?.enable_trade_notify ?? false,
}, },
}); });
@@ -80,10 +80,10 @@ export default function NotifySettings() {
name: "enable_subscribe_notify", name: "enable_subscribe_notify",
label: t("notify.subscribe", "Subscribe"), label: t("notify.subscribe", "Subscribe"),
}, },
{ // {
name: "enable_trade_notify", // name: "enable_trade_notify",
label: t("notify.finance", "Finance"), // label: t("notify.finance", "Finance"),
}, // },
].map(({ name, label }) => ( ].map(({ name, label }) => (
<FormField <FormField
control={form.control} control={form.control}
+2 -1
View File
@@ -43,5 +43,6 @@ function PopoverAnchor({
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
const PopoverClose = PopoverPrimitive.Close;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
+1
View File
@@ -55,6 +55,7 @@ function SheetContent({
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
aria-describedby={undefined}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500", "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" && side === "right" &&
+8 -2
View File
@@ -17,7 +17,7 @@ import type { ReactNode } from "react";
interface ConfirmationButtonProps { interface ConfirmationButtonProps {
trigger: ReactNode; trigger: ReactNode;
title: string; title: string;
description: string; description: ReactNode;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
cancelText?: string; cancelText?: string;
confirmText?: string; confirmText?: string;
@@ -36,7 +36,13 @@ export const ConfirmButton: React.FC<ConfirmationButtonProps> = ({
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription asChild>
{typeof description === "string" ? (
<span>{description}</span>
) : (
<div>{description}</div>
)}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel> <AlertDialogCancel>{cancelText}</AlertDialogCancel>
+2
View File
@@ -22,6 +22,7 @@ import * as system from "./system";
import * as ticket from "./ticket"; import * as ticket from "./ticket";
import * as tool from "./tool"; import * as tool from "./tool";
import * as user from "./user"; import * as user from "./user";
import * as withdrawal from "./withdrawal";
export default { export default {
ads, ads,
announcement, announcement,
@@ -43,4 +44,5 @@ export default {
ticket, ticket,
tool, tool,
user, user,
withdrawal,
}; };
+54 -9
View File
@@ -28,6 +28,21 @@ declare namespace API {
updated_at: number; updated_at: number;
}; };
type AdminWithdrawalLog = {
id: number;
user_id: number;
amount: number;
content: string;
status: number;
reason?: string;
created_at: number;
updated_at: number;
};
type ApproveWithdrawalRequest = {
withdrawal_id: number;
};
type AnyTLS = { type AnyTLS = {
port: number; port: number;
security_config: SecurityConfig; security_config: SecurityConfig;
@@ -71,6 +86,18 @@ declare namespace API {
is_default: boolean; is_default: boolean;
}; };
type GetWithdrawalListParams = {
page: number;
size: number;
user_id?: number;
status?: number;
};
type GetWithdrawalListResponse = {
list: AdminWithdrawalLog[];
total: number;
};
type AppUserSubcbribe = { type AppUserSubcbribe = {
id: number; id: number;
name: string; name: string;
@@ -456,7 +483,7 @@ declare namespace API {
}; };
type DeleteUserSubscribeRequest = { type DeleteUserSubscribeRequest = {
user_subscribe_id: string; user_subscribe_id: number;
}; };
type DeviceAuthticateConfig = { type DeviceAuthticateConfig = {
@@ -1791,6 +1818,8 @@ declare namespace API {
trial_subscribe: number; trial_subscribe: number;
trial_time: number; trial_time: number;
trial_time_unit: string; trial_time_unit: string;
enable_trial_email_whitelist: boolean;
trial_email_domain_whitelist: string;
enable_ip_register_limit: boolean; enable_ip_register_limit: boolean;
ip_register_limit: number; ip_register_limit: number;
ip_register_limit_duration: number; ip_register_limit_duration: number;
@@ -1850,11 +1879,11 @@ declare namespace API {
}; };
type ResetUserSubscribeTokenRequest = { type ResetUserSubscribeTokenRequest = {
user_subscribe_id: any; user_subscribe_id: number;
}; };
type ResetUserSubscribeTrafficRequest = { type ResetUserSubscribeTrafficRequest = {
user_subscribe_id: string; user_subscribe_id: number;
}; };
type Response = { type Response = {
@@ -2076,6 +2105,7 @@ declare namespace API {
type SubscribeDiscount = { type SubscribeDiscount = {
quantity: number; quantity: number;
discount: number; discount: number;
map_apple?: string;
}; };
type SubscribeGroup = { type SubscribeGroup = {
@@ -2177,7 +2207,7 @@ declare namespace API {
}; };
type ToggleUserSubscribeStatusRequest = { type ToggleUserSubscribeStatusRequest = {
user_subscribe_id: any; user_subscribe_id: number;
}; };
type TosConfig = { type TosConfig = {
@@ -2406,7 +2436,7 @@ declare namespace API {
}; };
type UpdateUserSubscribeRequest = { type UpdateUserSubscribeRequest = {
user_subscribe_id: string; user_subscribe_id: number;
subscribe_id: number; subscribe_id: number;
traffic: number; traffic: number;
expired_at: number; expired_at: number;
@@ -2431,10 +2461,11 @@ declare namespace API {
enable_login_notify: boolean; enable_login_notify: boolean;
enable_subscribe_notify: boolean; enable_subscribe_notify: boolean;
enable_trade_notify: boolean; enable_trade_notify: boolean;
user_group_id: string; user_group_id: number;
group_locked: boolean; group_locked: boolean;
auth_methods: UserAuthMethod[]; auth_methods: UserAuthMethod[];
user_devices: UserDevice[]; user_devices: UserDevice[];
remark: string;
rules: string[]; rules: string[];
created_at: number; created_at: number;
updated_at: number; updated_at: number;
@@ -2504,6 +2535,8 @@ declare namespace API {
token: string; token: string;
status: number; status: number;
short: string; short: string;
node_group_id?: number;
node_group_name?: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@@ -2523,6 +2556,13 @@ declare namespace API {
upload: number; upload: number;
token: string; token: string;
status: number; status: number;
effective_speed: number;
is_throttled: boolean;
throttle_rule?: string;
throttle_start?: number;
throttle_end?: number;
node_group_id?: number;
node_group_name?: string;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@@ -2824,7 +2864,7 @@ declare namespace API {
type SubscribeGroupMapping = { type SubscribeGroupMapping = {
subscribe_id: number; subscribe_id: number;
user_group_id: string; user_group_id: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
}; };
@@ -2856,8 +2896,8 @@ declare namespace API {
type GroupHistoryDetailItem = { type GroupHistoryDetailItem = {
id: number; id: number;
history_id: number; history_id: number;
user_group_id: string; user_group_id: number;
node_group_id: string; node_group_id: number;
user_count: number; user_count: number;
node_count: number; node_count: number;
user_data?: string; user_data?: string;
@@ -3034,6 +3074,11 @@ declare namespace API {
confirm: boolean; confirm: boolean;
}; };
type RejectWithdrawalRequest = {
withdrawal_id: number;
reason: string;
};
type PreviewUserNodesRequest = { type PreviewUserNodesRequest = {
user_id: number; user_id: number;
}; };
@@ -0,0 +1,55 @@
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Get withdrawal list GET /v1/admin/user/withdrawal/list */
export async function getWithdrawalList(
params: API.GetWithdrawalListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetWithdrawalListResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/withdrawal/list`,
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}
/** Approve withdrawal POST /v1/admin/user/withdrawal/approve */
export async function approveWithdrawal(
body: API.ApproveWithdrawalRequest,
options?: { [key: string]: any }
) {
return request<API.Response>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/withdrawal/approve`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Reject withdrawal POST /v1/admin/user/withdrawal/reject */
export async function rejectWithdrawal(
body: API.RejectWithdrawalRequest,
options?: { [key: string]: any }
) {
return request<API.Response>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/withdrawal/reject`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
+1
View File
@@ -909,6 +909,7 @@ declare namespace API {
type SubscribeDiscount = { type SubscribeDiscount = {
quantity: number; quantity: number;
discount: number; discount: number;
map_apple?: string;
}; };
type SubscribeGroup = { type SubscribeGroup = {
+9
View File
@@ -846,6 +846,14 @@ declare namespace API {
order_no: string; order_no: string;
}; };
type RedeemCodeRequest = {
code: string;
};
type RedeemCodeResponse = {
message: string;
};
type RegisterConfig = { type RegisterConfig = {
stop_register: boolean; stop_register: boolean;
enable_trial: boolean; enable_trial: boolean;
@@ -1003,6 +1011,7 @@ declare namespace API {
type SubscribeDiscount = { type SubscribeDiscount = {
quantity: number; quantity: number;
discount: number; discount: number;
map_apple?: string;
}; };
type SubscribeGroup = { type SubscribeGroup = {
+18
View File
@@ -251,6 +251,24 @@ export async function updateUserPassword(
); );
} }
/** Redeem Code POST /v1/public/redemption/ */
export async function redeemCode(
body: API.RedeemCodeRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.RedeemCodeResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/redemption/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Update User Rules PUT /v1/public/user/rules */ /** Update User Rules PUT /v1/public/user/rules */
export async function updateUserRules( export async function updateUserRules(
body: API.UpdateUserRulesRequest, body: API.UpdateUserRulesRequest,
+18
View File
@@ -31,3 +31,21 @@ export function shortenDeviceIdentifier(identifier: string): string {
.slice(0, 8) .slice(0, 8)
.toUpperCase(); .toUpperCase();
} }
/**
* User-Agent
* parseDeviceType
* @param userAgent - User-Agent
* @returns "iPhone""Android"
*/
export function parseDeviceType(userAgent: string): string {
if (!userAgent) return "";
const ua = userAgent.toLowerCase();
if (ua.includes("iphone")) return "iPhone";
if (ua.includes("ipad")) return "iPad";
if (ua.includes("android")) return "Android";
if (ua.includes("windows")) return "Windows";
if (ua.includes("macintosh") || ua.includes("mac os")) return "Mac";
if (ua.includes("linux")) return "Linux";
return "";
}