Compare commits
48 Commits
b45ec4b56e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 511ce4ff65 | |||
| 64fe23f877 | |||
| 09c5216f60 | |||
| 4d84095af2 | |||
| d66b664198 | |||
| a7d89acc7f | |||
| 2a3d5a3234 | |||
| 100b641552 | |||
| aeca20ad1e | |||
| d1697bed75 | |||
| 14d4c5f36f | |||
| 37ceb5adfc | |||
| 71aaa08230 | |||
| cad75b92bc | |||
| 11c3ce26a6 | |||
| 65641838f9 | |||
| d8b65c6d82 | |||
| 55c78276fb | |||
| ce6ab4f05b | |||
| 750cab5cc9 | |||
| 3e2873fb90 | |||
| 644b20ebef | |||
| 7400137b3c | |||
| de87133061 | |||
| dd30b8eb8b | |||
| e6c2370d02 | |||
| 785c832e02 | |||
| 5a493fce67 | |||
| 5019f76a73 | |||
| 8d0ddbb5ef | |||
| ef54d2479d | |||
| d6616c5859 | |||
| 3806264343 | |||
| 830430645c | |||
| 6b92979c7c | |||
| f8fbf5529f | |||
| eac7b27f60 | |||
| 31803a1d24 | |||
| 4b4edd48e3 | |||
| ae31019477 | |||
| 778e78ac68 | |||
| 8317e93d6b | |||
| 116f6e5360 | |||
| 370d59d5ad | |||
| 08702fb643 | |||
| bbea15dea4 | |||
| 98da7b1476 | |||
| dc55d85056 |
@@ -0,0 +1,136 @@
|
|||||||
|
# 实施计划:后台管理 - 共享订阅显示
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
设备组成员加入后,其原始订阅被删除,使用所有者的共享订阅。
|
||||||
|
在后台管理的用户订阅面板中,查看设备组成员的订阅时显示为空,因为数据已在合并时被删除。
|
||||||
|
需要在后台自动检测并显示共享订阅信息。
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
**纯前端方案**,不需要后端 API 变更。利用现有 API 组合实现:
|
||||||
|
|
||||||
|
1. `getUserSubscribe({ user_id })` → 获取用户自身订阅(可能为空)
|
||||||
|
2. `getFamilyList({ user_id, page: 1, size: 1 })` → 检测用户是否属于设备组
|
||||||
|
3. `getUserSubscribe({ user_id: owner_user_id })` → 获取所有者的共享订阅
|
||||||
|
|
||||||
|
**核心逻辑**:当用户自身订阅为空时,自动检查是否为设备组成员。若是非所有者成员,则展示所有者的订阅信息,并添加"共享订阅"视觉标识。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
### Step 1: 修改 UserSubscription 组件
|
||||||
|
|
||||||
|
**文件**: `apps/admin/src/sections/user/user-subscription/index.tsx`
|
||||||
|
|
||||||
|
将组件从纯 ProTable 改为带有共享订阅检测逻辑的组件:
|
||||||
|
|
||||||
|
```
|
||||||
|
伪代码:
|
||||||
|
function UserSubscription({ userId }) {
|
||||||
|
// 1. 正常获取用户订阅
|
||||||
|
const { data: ownSubscriptions } = useQuery(getUserSubscribe({ user_id: userId }))
|
||||||
|
|
||||||
|
// 2. 当自身订阅为空时,检查设备组成员身份
|
||||||
|
const hasOwnSubscriptions = ownSubscriptions.list.length > 0
|
||||||
|
const { data: familyData } = useQuery(
|
||||||
|
getFamilyList({ user_id: userId, page: 1, size: 1 }),
|
||||||
|
{ enabled: !hasOwnSubscriptions } // 仅当订阅为空时触发
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 判断是否为非所有者成员
|
||||||
|
const family = familyData?.list?.[0]
|
||||||
|
const isNonOwnerMember = family && family.owner_user_id !== userId && family.status === 'active'
|
||||||
|
const ownerUserId = family?.owner_user_id
|
||||||
|
|
||||||
|
// 4. 若为成员,获取所有者的订阅
|
||||||
|
const { data: sharedSubscriptions } = useQuery(
|
||||||
|
getUserSubscribe({ user_id: ownerUserId }),
|
||||||
|
{ enabled: isNonOwnerMember && !!ownerUserId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. 决定展示内容
|
||||||
|
const isSharedView = isNonOwnerMember && sharedSubscriptions?.list?.length > 0
|
||||||
|
const displayData = isSharedView ? sharedSubscriptions : ownSubscriptions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isSharedView && <SharedSubscriptionBanner ownerUserId={ownerUserId} familyId={family.family_id} />}
|
||||||
|
<ProTable
|
||||||
|
data={displayData}
|
||||||
|
actions={isSharedView ? { render: () => [只读操作] } : { render: () => [完整操作] }}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键变更点**:
|
||||||
|
- 将 ProTable 的 `request` 回调改为 React Query 管理数据获取
|
||||||
|
- 或者保持 ProTable request 模式,在外层用 state 管理共享视图切换
|
||||||
|
- 推荐方案:保持 ProTable 的 request 模式,但在 request 回调内部做链式检查
|
||||||
|
|
||||||
|
### Step 2: 添加共享订阅信息横幅
|
||||||
|
|
||||||
|
在 ProTable 上方显示提示信息:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ℹ️ 该用户为设备组成员,当前显示所有者 (ID: 258) │
|
||||||
|
│ 的共享订阅。[查看设备组] [查看所有者] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 使用 Alert 组件展示
|
||||||
|
- 提供跳转到设备组详情和所有者用户页面的链接
|
||||||
|
- 标题列后追加 `<Badge variant="secondary">共享</Badge>` 标识
|
||||||
|
|
||||||
|
### Step 3: 共享视图下禁用写操作
|
||||||
|
|
||||||
|
当处于共享订阅视图时:
|
||||||
|
- **隐藏** "添加订阅" 按钮(toolbar)
|
||||||
|
- **隐藏** "编辑" 按钮
|
||||||
|
- **隐藏** 删除、停止/恢复、重置令牌等破坏性操作
|
||||||
|
- **保留** 只读操作:查看日志、流量统计、在线设备等
|
||||||
|
|
||||||
|
### Step 4: 添加国际化翻译
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `apps/admin/public/assets/locales/zh-CN/user.json`
|
||||||
|
- `apps/admin/public/assets/locales/en-US/user.json`
|
||||||
|
|
||||||
|
新增翻译 key:
|
||||||
|
| Key | 中文 | 英文 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `sharedSubscription` | 共享订阅 | Shared Subscription |
|
||||||
|
| `sharedSubscriptionInfo` | 该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅 | This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}}) |
|
||||||
|
| `viewDeviceGroup` | 查看设备组 | View Device Group |
|
||||||
|
| `viewOwner` | 查看所有者 | View Owner |
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `apps/admin/src/sections/user/user-subscription/index.tsx` | 修改 | 添加共享订阅检测与展示逻辑 |
|
||||||
|
| `apps/admin/public/assets/locales/zh-CN/user.json` | 修改 | 新增共享订阅相关中文翻译 |
|
||||||
|
| `apps/admin/public/assets/locales/en-US/user.json` | 修改 | 新增共享订阅相关英文翻译 |
|
||||||
|
|
||||||
|
## 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解措施 |
|
||||||
|
|------|----------|
|
||||||
|
| 设备组 API 调用失败 | 捕获异常,静默降级为显示空列表(现有行为) |
|
||||||
|
| 所有者订阅也为空 | 正常显示空列表,不展示共享订阅横幅 |
|
||||||
|
| 用户同时有自身订阅和设备组成员身份 | 优先显示自身订阅(按描述,加入时会删除,不应同时存在) |
|
||||||
|
| 多个设备组 | 取第一个活跃的设备组即可(一个用户通常只属于一个组) |
|
||||||
|
|
||||||
|
## 边界情况
|
||||||
|
|
||||||
|
1. 用户无订阅 + 不在设备组 → 正常空列表
|
||||||
|
2. 用户无订阅 + 在设备组但为所有者 → 正常空列表(所有者自己订阅为空说明确实没有)
|
||||||
|
3. 用户无订阅 + 在设备组但组已禁用 → 正常空列表
|
||||||
|
4. 用户无订阅 + 在设备组且为活跃成员 → 显示所有者共享订阅 + 横幅提示
|
||||||
|
|
||||||
|
## SESSION_ID
|
||||||
|
- CODEX_SESSION: N/A(纯前端方案,未调用外部模型)
|
||||||
|
- GEMINI_SESSION: N/A
|
||||||
@@ -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,30 +1,29 @@
|
|||||||
name: Dependabot Auto Merge
|
name: Dependabot Auto Merge
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [labeled, edited]
|
types: [opened, reopened, synchronize, ready_for_review, labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dependabot-auto-merge-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
merge:
|
merge:
|
||||||
if: contains(github.event.pull_request.labels.*.name, 'dependencies')
|
if: >
|
||||||
|
github.actor == 'dependabot[bot]' &&
|
||||||
|
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'dependencies')
|
||||||
name: Dependabot Auto Merge
|
name: Dependabot Auto Merge
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Enable auto-merge
|
||||||
|
env:
|
||||||
- name: Install pnpm
|
GH_TOKEN: ${{ github.token }}
|
||||||
uses: pnpm/action-setup@v4
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
run: gh pr merge "$PR_URL" --auto --merge
|
||||||
- name: Setup Node.js environment
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Merge
|
|
||||||
uses: ahmadnassri/action-dependabot-auto-merge@v2
|
|
||||||
with:
|
|
||||||
command: merge
|
|
||||||
target: minor
|
|
||||||
github-token: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: PR Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- internal
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- internal
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-check-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Lint, Check, Test and Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: '1.3.1'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Check
|
||||||
|
run: bun run check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
@@ -9,20 +9,25 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: 'latest'
|
bun-version: '1.3.1'
|
||||||
|
|
||||||
- name: Cache Bun dependencies
|
- name: Cache Bun dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun
|
~/.bun
|
||||||
@@ -32,7 +37,7 @@ jobs:
|
|||||||
${{ runner.os }}-bun-cache-
|
${{ runner.os }}-bun-cache-
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: bun install
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ npm-debug.log*
|
|||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.mcp.json
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- vue
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
@@ -19,6 +19,42 @@ This document records all notable changes to ShadCN Admin.
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/perfect-panel/frontend/compare/v1.3.15...v1.4.0) (2026-03-13)
|
||||||
|
|
||||||
|
### ✨ Features / 新功能
|
||||||
|
|
||||||
|
* **servers:** add Reality support for anytls; fix vless flow ([be24ba0](https://github.com/perfect-panel/frontend/commit/be24ba03f564181d2bd6bc611917004d24e21aee))
|
||||||
|
|
||||||
|
## [1.3.15](https://github.com/perfect-panel/frontend/compare/v1.3.14...v1.3.15) (2026-03-12)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|
||||||
|
* **ci:** remove deprecated forwardRef usage in turnstile components ([89ece6e](https://github.com/perfect-panel/frontend/commit/89ece6e959892808c3a67ffb43612c9b90f1e38a))
|
||||||
|
|
||||||
|
## [1.3.14](https://github.com/perfect-panel/frontend/compare/v1.3.13...v1.3.14) (2026-03-11)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|
||||||
|
* improve renewal button readability on subscription card ([#25](https://github.com/perfect-panel/frontend/issues/25)) ([ced5c1d](https://github.com/perfect-panel/frontend/commit/ced5c1d24e5af26ff8fdf700fd2b9b864706694a))
|
||||||
|
|
||||||
|
## [1.3.13](https://github.com/perfect-panel/frontend/compare/v1.3.12...v1.3.13) (2026-03-07)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|
||||||
|
* **user:** show expire time & improve renewal dialog on mobile ([#22](https://github.com/perfect-panel/frontend/issues/22)) ([889dbf9](https://github.com/perfect-panel/frontend/commit/889dbf97736252d54d3e1d7ed0622e91ee4e8a1e))
|
||||||
|
|
||||||
|
## [1.3.12](https://github.com/perfect-panel/frontend/compare/v1.3.11...v1.3.12) (2026-02-26)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|
||||||
|
* **admin:** prioritize follow-up tickets ([#18](https://github.com/perfect-panel/frontend/issues/18)) ([a07d1ca](https://github.com/perfect-panel/frontend/commit/a07d1ca48e6471077f2ec86e55e967c7d1aa8acd))
|
||||||
|
|
||||||
|
## [1.3.11](https://github.com/perfect-panel/frontend/compare/v1.3.10...v1.3.11) (2026-02-21)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|
||||||
|
* **admin:** stabilize node sorting with duplicate sort values ([15fc37d](https://github.com/perfect-panel/frontend/commit/15fc37db9eae389644c287763b09c88eed9e2f75))
|
||||||
|
|
||||||
## [1.3.10](https://github.com/perfect-panel/frontend/compare/v1.3.9...v1.3.10) (2026-02-10)
|
## [1.3.10](https://github.com/perfect-panel/frontend/compare/v1.3.9...v1.3.10) (2026-02-10)
|
||||||
|
|
||||||
### 🐛 Bug Fixes / 问题修复
|
### 🐛 Bug Fixes / 问题修复
|
||||||
|
|||||||
Binary file not shown.
@@ -14,7 +14,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@faker-js/faker": "^10.0.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"@lottiefiles/dotlottie-react": "^0.17.15",
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@stripe/react-stripe-js": "^5.4.0",
|
"@stripe/react-stripe-js": "^5.4.0",
|
||||||
"@stripe/stripe-js": "^8.5.2",
|
"@stripe/stripe-js": "^8.5.2",
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
"title": "Email Settings",
|
"title": "Email Settings",
|
||||||
"trafficExceedEmailTemplate": "Traffic Exceed Email Template",
|
"trafficExceedEmailTemplate": "Traffic Exceed Email Template",
|
||||||
"trafficTemplate": "Traffic Template",
|
"trafficTemplate": "Traffic Template",
|
||||||
|
"deleteAccountEmailTemplate": "Delete Account Email Template",
|
||||||
|
"deleteAccountTemplate": "Delete Account Template",
|
||||||
"verifyEmailTemplate": "Verify Email Template",
|
"verifyEmailTemplate": "Verify Email Template",
|
||||||
"verifyTemplate": "Verify Template",
|
"verifyTemplate": "Verify Template",
|
||||||
"whitelistSuffixes": "Whitelist Suffixes",
|
"whitelistSuffixes": "Whitelist Suffixes",
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"captcha": {
|
||||||
|
"clickToRefresh": "Click to refresh",
|
||||||
|
"noImage": "No Image",
|
||||||
|
"placeholder": "Enter captcha code...",
|
||||||
|
"refresh": "Refresh captcha",
|
||||||
|
"required": "Please enter captcha code",
|
||||||
|
"sliderRequired": "Please complete the slider verification",
|
||||||
|
"slider": {
|
||||||
|
"clickToVerify": "Click to verify",
|
||||||
|
"fail": "Try again",
|
||||||
|
"hint": "Drag the piece to fit the puzzle",
|
||||||
|
"success": "Verified",
|
||||||
|
"title": "Security Verification"
|
||||||
|
},
|
||||||
|
"turnstile": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"clickToVerify": "Click to verify",
|
||||||
|
"success": "Verified",
|
||||||
|
"title": "Security Verification"
|
||||||
|
}
|
||||||
|
},
|
||||||
"check": {
|
"check": {
|
||||||
"description": "Verify your identity",
|
"description": "Verify your identity",
|
||||||
"title": "Verify"
|
"title": "Verify"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"40005": "You do not have access permission, please contact the administrator if you have any questions.",
|
"40005": "You do not have access permission, please contact the administrator if you have any questions.",
|
||||||
"50001": "Corresponding coupon information not found, please check and try again.",
|
"50001": "Corresponding coupon information not found, please check and try again.",
|
||||||
"50002": "The coupon has been used, cannot be used again.",
|
"50002": "The coupon has been used, cannot be used again.",
|
||||||
|
"50003": "This coupon code is not supported by the current purchase plan.",
|
||||||
|
"50004": "Coupon has insufficient remaining uses.",
|
||||||
"60001": "Subscription has expired, please renew before using.",
|
"60001": "Subscription has expired, please renew before using.",
|
||||||
"60002": "Unable to use the subscription at the moment, please try again later.",
|
"60002": "Unable to use the subscription at the moment, please try again later.",
|
||||||
"60003": "An existing subscription is detected. Please cancel it before proceeding.",
|
"60003": "An existing subscription is detected. Please cancel it before proceeding.",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -29,5 +29,6 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"userTitle": "User Statistics",
|
"userTitle": "User Statistics",
|
||||||
"userTraffic": "User Traffic",
|
"userTraffic": "User Traffic",
|
||||||
|
"withdrawalManagement": "Withdrawal Management",
|
||||||
"yesterday": "Yesterday"
|
"yesterday": "Yesterday"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"actions": "Actions",
|
||||||
|
"autoTrigger": "Auto",
|
||||||
|
"averageMode": "Average Grouping",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"completed": "Completed",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"confirmDelete": "Confirm Delete",
|
||||||
|
"config": "Config",
|
||||||
|
"create": "Create",
|
||||||
|
"created": "Created successfully",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"createNodeGroup": "Create Node Group",
|
||||||
|
"createUserGroup": "Create User Group",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleted": "Deleted successfully",
|
||||||
|
"deleteNodeGroupConfirm": "This will delete the node group. Nodes in this group will be reassigned.",
|
||||||
|
"deleteUserGroupConfirm": "This will delete the user group. Users in this group will be reassigned to the default group.",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Enter description",
|
||||||
|
"edit": "Edit",
|
||||||
|
"editNodeGroup": "Edit Node Group",
|
||||||
|
"editUserGroup": "Edit User Group",
|
||||||
|
"editUserGroupDescription": "Edit user group assignment and lock status",
|
||||||
|
"selectGroup": "Select a group",
|
||||||
|
"endTime": "End Time",
|
||||||
|
"errorMessage": "Error Message",
|
||||||
|
"export": "Export",
|
||||||
|
"failed": "Failed",
|
||||||
|
"failedCount": "Failed",
|
||||||
|
"groupConfig": "Group Configuration",
|
||||||
|
"groupConfigDescription": "Manage node groups and automatically assign node groups to user subscriptions",
|
||||||
|
"groupDetails": "Group Details",
|
||||||
|
"groupEnabled": "Group Management Enabled",
|
||||||
|
"groupEnabledDescription": "Enable group management to control user access to nodes",
|
||||||
|
"groupHistory": "Group Calculation History",
|
||||||
|
"groupHistoryDescription": "View group recalculation history and results",
|
||||||
|
"groupHistoryDetail": "Group Calculation Detail",
|
||||||
|
"groupId": "Group ID",
|
||||||
|
"groupIdPlaceholder": "Enter unique group ID",
|
||||||
|
"groupMode": "Group Mode",
|
||||||
|
"groupModeDescription": "Select the grouping algorithm for assigning users to groups",
|
||||||
|
"groupName": "Group Name",
|
||||||
|
"groupNamePlaceholder": "Enter group name",
|
||||||
|
"groupRecalculation": "Group Recalculation",
|
||||||
|
"groupRecalculationDescription": "Manually trigger node group reassignment for all active user subscriptions based on current configuration",
|
||||||
|
"history": "History",
|
||||||
|
"historyId": "History ID",
|
||||||
|
"id": "ID",
|
||||||
|
"idPrefix": "#",
|
||||||
|
"idle": "Idle",
|
||||||
|
"separator": "/",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadFailed": "Failed to load configuration",
|
||||||
|
"locked": "Locked",
|
||||||
|
"manualTrigger": "Manual",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter name",
|
||||||
|
"nodeCount": "Node Count",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"nodeGroupFormDescription": "Configure node group settings",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroupsDescription": "Manage node groups for user access control",
|
||||||
|
"noDetails": "No details available",
|
||||||
|
"operator": "Operator",
|
||||||
|
"progress": "Progress",
|
||||||
|
"recalculate": "Recalculate",
|
||||||
|
"recalculateAll": "Reassign Node Groups",
|
||||||
|
"recalculationCompleted": "Recalculation completed successfully",
|
||||||
|
"recalculationFailed": "Recalculation failed. Please try again.",
|
||||||
|
"recalculationStarted": "Recalculation started",
|
||||||
|
"recalculationWarning": "Recalculation will reassign node groups for all active user subscriptions based on current configuration. This operation cannot be undone.",
|
||||||
|
"running": "Running",
|
||||||
|
"save": "Save",
|
||||||
|
"scheduleTrigger": "Schedule",
|
||||||
|
"sort": "Sort",
|
||||||
|
"sortOrder": "Sort Order",
|
||||||
|
"startTime": "Start Time",
|
||||||
|
"subscribeMode": "Subscribe-based Grouping",
|
||||||
|
"successCount": "Success",
|
||||||
|
"title": "Group Management",
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"totalNodes": "Total Nodes",
|
||||||
|
"totalGroups": "Total Groups",
|
||||||
|
"trafficMode": "Traffic-based Grouping",
|
||||||
|
"triggerType": "Trigger Type",
|
||||||
|
"userGroup": "User Group",
|
||||||
|
"userGroups": "User Groups",
|
||||||
|
"userGroupsDescription": "Manage user groups for node access control",
|
||||||
|
"updated": "Updated successfully",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"userCount": "User Count",
|
||||||
|
"viewDetail": "View Detail",
|
||||||
|
"warning": "Warning",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"enableGrouping": "Enable Grouping",
|
||||||
|
"enableGroupingDescription": "When enabled, user subscriptions will be automatically assigned node groups based on the distribution mode",
|
||||||
|
"groupingMode": "Grouping Mode",
|
||||||
|
"averageModeConfig": "Average Mode Configuration",
|
||||||
|
"subscribeModeConfig": "Subscribe Mode Configuration",
|
||||||
|
"trafficModeConfig": "Traffic Mode Configuration",
|
||||||
|
"averageModeDescription": "Randomly assign available node groups to active user subscriptions",
|
||||||
|
"subscribeModeDescription": "Set default node group for user groups based on subscription plans",
|
||||||
|
"trafficModeDescription": "Assign node groups to user subscriptions based on traffic usage",
|
||||||
|
"defaultUserGroupId": "Default User Group ID",
|
||||||
|
"defaultUserGroupDescription": "New users will be assigned to this group",
|
||||||
|
"defaultUserGroupForExpiredDescription": "Users with expired subscriptions will be assigned to this group",
|
||||||
|
"autoCreateGroup": "Auto Create Group",
|
||||||
|
"autoCreateGroupDescription": "Automatically create a new user group when a new subscription plan is added",
|
||||||
|
"lockGroup": "Lock Group",
|
||||||
|
"lockGroupDescription": "Prevent automatic recalculation from changing this user's group",
|
||||||
|
"trafficRangesComingSoon": "Traffic ranges configuration coming soon...",
|
||||||
|
"currentStatus": "Current Status",
|
||||||
|
"trafficRangesConfig": "Traffic Ranges Configuration",
|
||||||
|
"trafficRangesDescription": "Configure traffic ranges for grouping users. Traffic is calculated based on user's billing cycle.",
|
||||||
|
"minTrafficGB": "Min Traffic (GB)",
|
||||||
|
"maxTrafficGB": "Max Traffic (GB)",
|
||||||
|
"addRange": "Add Range",
|
||||||
|
"remove": "Remove",
|
||||||
|
"note": "Note",
|
||||||
|
"trafficRangesNote": "Ranges must not overlap and must cover all values without gaps. Users with traffic >= the upper limit of the last range will be assigned to the last group.",
|
||||||
|
"defaultUserGroup": "Default User Group",
|
||||||
|
"defaultUserGroupForTrafficDescription": "Users with traffic exceeding all defined ranges will be assigned to this group",
|
||||||
|
"rangeError": "Range Error",
|
||||||
|
"overlapError": "Overlap Error",
|
||||||
|
"gapError": "Gap Error",
|
||||||
|
"groupByTraffic": "Group by Traffic",
|
||||||
|
"resetGroups": "Reset All Groups",
|
||||||
|
"resetGroupsTitle": "Reset All Groups",
|
||||||
|
"resetGroupsDescription": "This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone.",
|
||||||
|
"resetSuccess": "All groups have been reset successfully",
|
||||||
|
"resetFailed": "Failed to reset groups",
|
||||||
|
"saved": "Configuration saved successfully",
|
||||||
|
"saveFailed": "Failed to save configuration",
|
||||||
|
"autoCalculated": "Auto-calculated",
|
||||||
|
"userGroupCountAutoCalculated": "Auto-calculated from actual user groups",
|
||||||
|
"userGroupCount": "User Group Count",
|
||||||
|
"nodeGroupCountAutoCalculated": "Auto-calculated from actual node groups",
|
||||||
|
"nodeGroupCount": "Node Group Count",
|
||||||
|
"arrow": " → ",
|
||||||
|
"availableNodeGroups": "Available Node Groups",
|
||||||
|
"currentGroupingResult": "Current Grouping Result",
|
||||||
|
"calculationInfo": "Calculation Information",
|
||||||
|
"groupingDetailsStatistics": "Grouping Details Statistics",
|
||||||
|
"successFailedCount": "Success/Failed",
|
||||||
|
"latestGroupingCalculation": "Latest grouping calculation details",
|
||||||
|
"userList": "User List",
|
||||||
|
"email": "Email",
|
||||||
|
"noUsers": "No users found",
|
||||||
|
"showing": "Showing",
|
||||||
|
"to": "to",
|
||||||
|
"of": "of",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"result": "Result",
|
||||||
|
"bindNodeGroup": "Bind Node Group",
|
||||||
|
"bindNodeGroupDescription": "Select a node group to bind to user groups: {{userGroups}}",
|
||||||
|
"selectNodeGroup": "Select Node Group",
|
||||||
|
"selectNodeGroupPlaceholder": "Select a node group...",
|
||||||
|
"selectNodeGroupRequired": "Please select a node group",
|
||||||
|
"unbound": "Unbound",
|
||||||
|
"bindSuccess": "Successfully bound {{userGroupCount}} user group(s) to node group",
|
||||||
|
"bindFailed": "Failed to bind node group",
|
||||||
|
"groupMapping": "Group Mapping",
|
||||||
|
"forCalculation": "For Calculation",
|
||||||
|
"trafficRange": "Traffic Range (GB)",
|
||||||
|
"configSaved": "Configuration saved successfully",
|
||||||
|
"subscribeGroupMappingTitle": "Subscribe-Node Group Mapping",
|
||||||
|
"subscribeName": "Subscribe Plan",
|
||||||
|
"userGroupName": "User Group",
|
||||||
|
"nodeGroupName": "Node Group",
|
||||||
|
"notMapped": "Not Mapped",
|
||||||
|
"noMappingData": "No mapping data available",
|
||||||
|
"forCalculationDescription": "Whether this node group participates in grouping calculation",
|
||||||
|
"trafficRangeGB": "Traffic Range (GB)",
|
||||||
|
"trafficRangeDescription": "Users with traffic >= Min and < Max will be assigned to this node group",
|
||||||
|
"minCannotExceedMax": "Minimum traffic cannot exceed maximum traffic",
|
||||||
|
"rangeOverlap": "Range overlaps with node group \"{{name}}\"",
|
||||||
|
"nodeGroupNotFound": "Node group not found",
|
||||||
|
"validationFailed": "Validation failed",
|
||||||
|
"totalNodeGroups": "Total Node Groups",
|
||||||
|
"invalidRange": "Minimum traffic must be less than maximum traffic",
|
||||||
|
"rangeConflict": "Traffic range conflicts with node group \"{{name}}\" (range: {{min}} - {{max}} GB)",
|
||||||
|
"isExpiredGroup": "Expired Node Group",
|
||||||
|
"isExpiredGroupDescription": "Allow expired users to use limited nodes",
|
||||||
|
"expiredDaysLimit": "Expired Days Limit",
|
||||||
|
"expiredDaysLimitDescription": "Number of days after expiration that users can still access nodes",
|
||||||
|
"maxTrafficGBExpired": "Max Traffic for Expired Users (GB)",
|
||||||
|
"maxTrafficGBExpiredDescription": "Maximum traffic allowed for expired users (0 = unlimited)",
|
||||||
|
"speedLimit": "Speed Limit (KB/s)",
|
||||||
|
"speedLimitDescription": "Speed limit for users in this node group (0 = unlimited)",
|
||||||
|
"expiredGroup": "Expired Only",
|
||||||
|
"expiredSettings": "Expired Settings",
|
||||||
|
"days": "days",
|
||||||
|
"expiredGroupExists": "System already has an expired node group: {{name}}",
|
||||||
|
"nodeGroupUsedBySubscribe": "This node group is used as default node group in subscription products, cannot set as expired group",
|
||||||
|
"expiredGroupForCalculationDescription": "Expired-only node groups cannot participate in group calculation"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"commission": "Commission",
|
||||||
|
"days": "{{count}} days",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"empty": "No invite records",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"hasPurchased": "Purchased",
|
||||||
|
"invitee": "Invitee",
|
||||||
|
"inviteeGiftDays": "Invitee Gift Days",
|
||||||
|
"inviteeId": "Invitee ID",
|
||||||
|
"invitedAt": "Invited At",
|
||||||
|
"inviter": "Inviter",
|
||||||
|
"inviterGiftDays": "Inviter Gift Days",
|
||||||
|
"inviterId": "Inviter ID",
|
||||||
|
"loadErrorDescription": "Refresh the table or try again later.",
|
||||||
|
"loadErrorTitle": "Failed to load invites",
|
||||||
|
"no": "No",
|
||||||
|
"orderCount": "Orders",
|
||||||
|
"searchPlaceholder": "Email or phone",
|
||||||
|
"status": "Status",
|
||||||
|
"title": "Invite Management",
|
||||||
|
"yes": "Yes"
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"Document Management": "Document Management",
|
"Document Management": "Document Management",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Gift": "Gift",
|
"Gift": "Gift",
|
||||||
|
"Group Management": "Group Management",
|
||||||
|
"Invite Management": "Invite Management",
|
||||||
"Login": "Login",
|
"Login": "Login",
|
||||||
"Logs & Analytics": "Logs & Analytics",
|
"Logs & Analytics": "Logs & Analytics",
|
||||||
"Maintenance": "Maintenance",
|
"Maintenance": "Maintenance",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"Order Management": "Order Management",
|
"Order Management": "Order Management",
|
||||||
"Payment Config": "Payment Config",
|
"Payment Config": "Payment Config",
|
||||||
"Product Management": "Product Management",
|
"Product Management": "Product Management",
|
||||||
|
"Promo Management": "Promo Management",
|
||||||
"Redemption Management": "Redemption Management",
|
"Redemption Management": "Redemption Management",
|
||||||
"Register": "Register",
|
"Register": "Register",
|
||||||
"Reset Subscribe": "Reset Subscribe",
|
"Reset Subscribe": "Reset Subscribe",
|
||||||
@@ -31,6 +34,8 @@
|
|||||||
"System Config": "System Config",
|
"System Config": "System Config",
|
||||||
"Ticket Management": "Ticket Management",
|
"Ticket Management": "Ticket Management",
|
||||||
"Traffic Details": "Traffic Details",
|
"Traffic Details": "Traffic Details",
|
||||||
|
"Device Group": "Device Group",
|
||||||
"User Management": "User Management",
|
"User Management": "User Management",
|
||||||
"Users & Support": "Users & Support"
|
"Users & Support": "Users & Support",
|
||||||
|
"Withdrawal Management": "Withdrawal Management"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
{
|
{
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
|
"all": "All",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDeleteDesc": "This action cannot be undone.",
|
"confirmDeleteDesc": "This action cannot be undone.",
|
||||||
"confirmDeleteTitle": "Delete this node?",
|
"confirmDeleteTitle": "Delete this node?",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"create": "Create",
|
"create": "Create Landing Node",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"drawerCreateTitle": "Create Node",
|
"drawerCreateTitle": "Create Landing Node",
|
||||||
"drawerEditTitle": "Edit Node",
|
"drawerEditTitle": "Edit Node",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"enabled_off": "Disabled",
|
"enabled_off": "Disabled",
|
||||||
"enabled_on": "Enabled",
|
"enabled_on": "Enabled",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroup_description": "Assign this node to multiple groups for user access control.",
|
||||||
"pageTitle": "Nodes",
|
"pageTitle": "Nodes",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"protocol": "Protocol",
|
"protocol": "Protocol",
|
||||||
|
"public": "Public",
|
||||||
|
"selectNodeGroup": "Select node group…",
|
||||||
"select_protocol": "Select protocol…",
|
"select_protocol": "Select protocol…",
|
||||||
"select_server": "Select server…",
|
"select_server": "Select server…",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"sorted_success": "Sorted successfully",
|
"sorted_success": "Sorted successfully",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
|
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
|
||||||
|
"tags_groupMode_description": "Optional tags for display and filtering (node group name will be used as tag if empty).",
|
||||||
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
|
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
|
||||||
"updated": "Updated"
|
"updated": "Updated"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
{
|
{
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"couponDiscount": "Coupon Discount",
|
"couponDiscount": "Coupon Discount",
|
||||||
|
"confirmRefund": "Confirm refund",
|
||||||
"discount": "Discount Amount",
|
"discount": "Discount Amount",
|
||||||
"feeAmount": "Fee Amount",
|
"feeAmount": "Fee Amount",
|
||||||
"method": "Payment Method",
|
"method": "Payment Method",
|
||||||
"orderNumber": "Order Number",
|
"orderNumber": "Order Number",
|
||||||
|
"refund": "Refund",
|
||||||
|
"refundConfirmDescription": "This refund will immediately invalidate the user's subscription and deduct the related agent commission.",
|
||||||
|
"refundConfirmTitle": "Confirm refund",
|
||||||
|
"refundConfirmWarning": "This action cannot be repeated after the order is refunded.",
|
||||||
|
"refundSubmitting": "Refunding...",
|
||||||
|
"refundSuccess": "Refund completed.",
|
||||||
"status": {
|
"status": {
|
||||||
"0": "Status",
|
"0": "Status",
|
||||||
"1": "Pending",
|
"1": "Pending",
|
||||||
"2": "Paid",
|
"2": "Paid",
|
||||||
"3": "Cancelled",
|
"3": "Cancelled",
|
||||||
"4": "Closed",
|
"4": "Closed",
|
||||||
"5": "Completed"
|
"5": "Completed",
|
||||||
|
"6": "Refunded"
|
||||||
},
|
},
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"subscribePrice": "Subscription Price",
|
"subscribePrice": "Subscription Price",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"all": "All",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDelete": "Are you sure you want to delete?",
|
"confirmDelete": "Are you sure you want to delete?",
|
||||||
@@ -7,13 +8,16 @@
|
|||||||
"create": "Create",
|
"create": "Create",
|
||||||
"createSubscribe": "Create Subscription",
|
"createSubscribe": "Create Subscription",
|
||||||
"createSuccess": "Create Successful",
|
"createSuccess": "Create Successful",
|
||||||
|
"currentUserGroup": "Current User Group",
|
||||||
|
"defaultNodeGroup": "Default Node Group",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodes": "nodes",
|
||||||
"deleteSuccess": "Delete Successful",
|
"deleteSuccess": "Delete Successful",
|
||||||
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
||||||
"deviceLimit": "IP Limit",
|
"deviceLimit": "IP Limit",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"editSubscribe": "Edit Subscription",
|
"editSubscribe": "Edit Subscription",
|
||||||
"sortSuccess": "Sort completed successfully",
|
|
||||||
"form": {
|
"form": {
|
||||||
"annualReset": "Annual Reset",
|
"annualReset": "Annual Reset",
|
||||||
"basic": "Basic",
|
"basic": "Basic",
|
||||||
@@ -28,9 +32,9 @@
|
|||||||
"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",
|
||||||
"unlimitedInventory": "Unlimited (enter -1)",
|
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDescription": "Leave empty for default without language restriction",
|
"languageDescription": "Leave empty for default without language restriction",
|
||||||
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
|
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
|
||||||
@@ -40,7 +44,19 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"node": "Node",
|
"node": "Node",
|
||||||
"nodeGroup": "Node Group",
|
"nodeGroup": "Node Group",
|
||||||
"nodes": "Nodes",
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroupsDescription": "Assign this product to multiple node groups. Users will get nodes from these groups.",
|
||||||
|
"nodeGroupsFirstSelectionDescription": "Select node groups for this product. The first selected group will be set as the default node group.",
|
||||||
|
"defaultNodeGroup": "Default Node Group",
|
||||||
|
"defaultNodeGroupDescription": "Select the default node group for this product. This will be automatically included in the backup node groups.",
|
||||||
|
"selectDefaultNodeGroup": "Select a default node group...",
|
||||||
|
"noDefaultNodeGroup": "No Default Node Group",
|
||||||
|
"backupNodeGroups": "Backup Node Groups",
|
||||||
|
"backupNodeGroupsDescription": "Select additional backup node groups. The default node group is automatically included.",
|
||||||
|
"nodes": "Linked Nodes",
|
||||||
|
"nodesDescription": "Select nodes for this subscription",
|
||||||
|
"nodesInGroup": "Nodes in this group:",
|
||||||
|
"nodesWithoutGroupsDescription": "Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)",
|
||||||
"noLimit": "No Limit",
|
"noLimit": "No Limit",
|
||||||
"NoLimit": "No Limit",
|
"NoLimit": "No Limit",
|
||||||
"noReset": "No Reset",
|
"noReset": "No Reset",
|
||||||
@@ -59,18 +75,55 @@
|
|||||||
"showOriginalPriceDescription": "When enabled, the subscription card will display both the original price and the discounted price to help users understand the discount amount",
|
"showOriginalPriceDescription": "When enabled, the subscription card will display both the original price and the discounted price to help users understand the discount amount",
|
||||||
"speedLimit": "Speed Limit ",
|
"speedLimit": "Speed Limit ",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
|
"trafficLimit": "Traffic Limit",
|
||||||
|
"trafficLimitRules": "Traffic Limit Rules",
|
||||||
|
"trafficLimitDescription": "Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited.",
|
||||||
|
"addTrafficLimitRule": "Add Traffic Limit Rule",
|
||||||
|
"statType": "Statistics Type",
|
||||||
|
"selectStatType": "Select type...",
|
||||||
|
"statTypeHour": "Hour",
|
||||||
|
"statTypeDay": "Day",
|
||||||
|
"statValue": "Time Value",
|
||||||
|
"trafficUsage": "Traffic Usage (GB)",
|
||||||
|
"speedLimitKb": "Speed Limit (kb)",
|
||||||
"unitPrice": "Unit Price",
|
"unitPrice": "Unit Price",
|
||||||
"unitTime": "Unit Time",
|
"unitTime": "Unit Time",
|
||||||
|
"unlimitedInventory": "Unlimited (enter -1)",
|
||||||
"Year": "Year"
|
"Year": "Year"
|
||||||
},
|
},
|
||||||
|
"groupMapping": "Group Mapping",
|
||||||
|
"groupMappingTitle": "Group Mapping",
|
||||||
|
"groupMappingUpdateFailed": "Failed to update group mapping",
|
||||||
|
"groupMappingUpdateSuccess": "Group mapping updated successfully",
|
||||||
|
"migrateUsers": "Migrate Users",
|
||||||
|
"migrateUsersTitle": "Migrate Users",
|
||||||
|
"migrateUsersDescription": "Migrate all users from the current user group to another group",
|
||||||
|
"migrateUsersWarning": "This will migrate {count} users from \"{group}\" to the target group. This action cannot be undone.",
|
||||||
|
"migrateUsersSuccess": "Successfully migrated {count} users to the target group",
|
||||||
|
"migrateUsersFailed": "Failed to migrate users",
|
||||||
|
"targetUserGroup": "Target User Group",
|
||||||
|
"selectTargetGroup": "Select a target group...",
|
||||||
|
"selectTargetGroupFirst": "Please select a target group first",
|
||||||
|
"cannotMigrateToSameGroup": "Cannot migrate to the same group",
|
||||||
|
"noSourceGroup": "No source group available",
|
||||||
|
"selectedGroup": "Selected Group",
|
||||||
|
"userCount": "User Count",
|
||||||
|
"migrating": "Migrating...",
|
||||||
"inventory": "Subscription Limit",
|
"inventory": "Subscription Limit",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"loading": "Loading...",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"noMapping": "No mapping set",
|
||||||
|
"noNodes": "No nodes in this group",
|
||||||
"quota": "Purchase Limit/Time",
|
"quota": "Purchase Limit/Time",
|
||||||
"replacement": "Reset Price/Time",
|
"replacement": "Reset Price/Time",
|
||||||
|
"save": "Save",
|
||||||
|
"selectGroupPlaceholder": "Select a group...",
|
||||||
|
"selectUserGroup": "Select User Group",
|
||||||
"sell": "Sell",
|
"sell": "Sell",
|
||||||
"show": "Display",
|
"show": "Display",
|
||||||
"sold": "Subscription Count",
|
"sold": "Subscription Count",
|
||||||
|
"sortSuccess": "Sort completed successfully",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"unitPrice": "Unit Price",
|
"unitPrice": "Unit Price",
|
||||||
"updateSuccess": "Update Successful"
|
"updateSuccess": "Update Successful"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"confirmDelete": "Are you sure you want to delete?",
|
||||||
|
"createPrice": "Create Promo Price",
|
||||||
|
"createRule": "Create Rule",
|
||||||
|
"createSuccess": "Create Success",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteSuccess": "Delete Success",
|
||||||
|
"deleteWarning": "Once deleted, data cannot be recovered. Please proceed with caution.",
|
||||||
|
"detail": "Detail",
|
||||||
|
"edit": "Edit",
|
||||||
|
"editRule": "Edit Rule",
|
||||||
|
"emptyPrices": "No promo prices",
|
||||||
|
"emptyRules": "No promo rules",
|
||||||
|
"emptyUsage": "No promo usage records",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"endTime": "End Time",
|
||||||
|
"form": {
|
||||||
|
"enterPromoPrice": "Enter price",
|
||||||
|
"inactiveMonths": "Inactive Months",
|
||||||
|
"inactiveMonthsPlaceholder": "Months without purchase",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Rule name",
|
||||||
|
"noDiscounts": "This subscribe has no discount quantities and cannot be configured.",
|
||||||
|
"promoPriceLessThanOriginalPrice": "Promo price must be lower than original price",
|
||||||
|
"promoPriceLessThanUnitPrice": "Promo price must be lower than unit price",
|
||||||
|
"selectEndTime": "Select time",
|
||||||
|
"selectRule": "Select rule",
|
||||||
|
"selectStartTime": "Select time",
|
||||||
|
"selectQuantity": "Select discount quantity",
|
||||||
|
"selectSubscribe": "Select subscribe",
|
||||||
|
"type": "Rule Type",
|
||||||
|
"windowHours": "Window Hours",
|
||||||
|
"windowHoursPlaceholder": "Hours after registration"
|
||||||
|
},
|
||||||
|
"loadErrorDescription": "Refresh the table or try again later.",
|
||||||
|
"loadPricesError": "Failed to load promo prices",
|
||||||
|
"loadRulesError": "Failed to load promo rules",
|
||||||
|
"loadUsageError": "Failed to load promo usage",
|
||||||
|
"name": "Name",
|
||||||
|
"no": "No",
|
||||||
|
"orderNo": "Order No.",
|
||||||
|
"originalPriceHint": "Original price",
|
||||||
|
"params": "Params",
|
||||||
|
"prices": "Prices",
|
||||||
|
"priority": "Priority",
|
||||||
|
"promoPrice": "Promo Price",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"rule": "Rule",
|
||||||
|
"ruleDetail": "Rule Detail",
|
||||||
|
"rules": "Rules",
|
||||||
|
"searchRule": "Rule name",
|
||||||
|
"startTime": "Start Time",
|
||||||
|
"subscribe": "Subscribe",
|
||||||
|
"type": "Type",
|
||||||
|
"types": {
|
||||||
|
"campaign": "Campaign",
|
||||||
|
"inactive_user": "Inactive User",
|
||||||
|
"new_user": "New User"
|
||||||
|
},
|
||||||
|
"unitPrice": "Unit Price",
|
||||||
|
"unitPriceHint": "Unit price",
|
||||||
|
"updateSuccess": "Update Success",
|
||||||
|
"usage": "Usage",
|
||||||
|
"usedAt": "Used At",
|
||||||
|
"userId": "User ID",
|
||||||
|
"validityPeriod": "Validity Period",
|
||||||
|
"yes": "Yes"
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"active": "Active",
|
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"code": "Redemption Code",
|
"code": "Redemption Code",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
"duration": "Redemption Duration",
|
"duration": "Redemption Duration",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"editRedemptionCode": "Edit Redemption Code",
|
"editRedemptionCode": "Edit Redemption Code",
|
||||||
"exhausted": "Exhausted",
|
|
||||||
"form": {
|
"form": {
|
||||||
"batchCount": "Batch Count",
|
"batchCount": "Batch Count",
|
||||||
"batchCountPlaceholder": "Batch Count",
|
"batchCountPlaceholder": "Batch Count",
|
||||||
@@ -26,16 +24,12 @@
|
|||||||
"halfYear": "Half Year",
|
"halfYear": "Half Year",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"quantityRequired": "Quantity is required",
|
|
||||||
"selectPlan": "Select Redemption Plan",
|
"selectPlan": "Select Redemption Plan",
|
||||||
"selectUnitTime": "Select Redemption Duration Unit",
|
"selectUnitTime": "Select Redemption Duration Unit",
|
||||||
"subscribePlan": "Redemption Plan",
|
"subscribePlan": "Redemption Plan",
|
||||||
"subscribePlanRequired": "Subscribe plan is required",
|
|
||||||
"totalCount": "Available Uses",
|
"totalCount": "Available Uses",
|
||||||
"totalCountPlaceholder": "Available Uses",
|
"totalCountPlaceholder": "Available Uses",
|
||||||
"totalCountRequired": "Total count is required",
|
|
||||||
"unitTime": "Redemption Duration Unit",
|
"unitTime": "Redemption Duration Unit",
|
||||||
"unitTimeRequired": "Unit time is required",
|
|
||||||
"year": "Year"
|
"year": "Year"
|
||||||
},
|
},
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@@ -50,8 +44,8 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"subscribeId": "Subscribe ID",
|
"subscribeId": "Subscribe ID",
|
||||||
"subscribePlan": "Redemption Plan",
|
"subscribePlan": "Redemption Plan",
|
||||||
"totalCount": "Available Uses",
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"totalCount": "Available Uses",
|
||||||
"unitTime": "Redemption Duration Unit",
|
"unitTime": "Redemption Duration Unit",
|
||||||
"updateSuccess": "Update Success",
|
"updateSuccess": "Update Success",
|
||||||
"usedCount": "Used",
|
"usedCount": "Used",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save Settings",
|
"save": "Save Settings",
|
||||||
|
"saving": "Saving...",
|
||||||
"saveFailed": "Save Failed",
|
"saveFailed": "Save Failed",
|
||||||
"saveSuccess": "Save Successful"
|
"saveSuccess": "Save Successful"
|
||||||
},
|
},
|
||||||
@@ -19,10 +20,15 @@
|
|||||||
"description": "Configure currency units, symbols, and exchange rate API settings",
|
"description": "Configure currency units, symbols, and exchange rate API settings",
|
||||||
"title": "Currency Configuration"
|
"title": "Currency Configuration"
|
||||||
},
|
},
|
||||||
|
"groupSettings": "Group Settings",
|
||||||
"invite": {
|
"invite": {
|
||||||
"description": "Configure user invitation and referral reward settings",
|
"description": "Configure user invitation and referral reward settings",
|
||||||
"forcedInvite": "Require Invitation to Register",
|
"forcedInvite": "Require Invitation to Register",
|
||||||
"forcedInviteDescription": "When enabled, users must register through an invitation link",
|
"forcedInviteDescription": "When enabled, users must register through an invitation link",
|
||||||
|
"giftDays": "Invite Gift Days",
|
||||||
|
"giftDaysDescription": "When referral percentage is 0, both the inviter and invitee receive this many extra subscription days after the invitee makes a purchase",
|
||||||
|
"giftDaysPlaceholder": "Enter days",
|
||||||
|
"giftDaysSuffix": "day(s)",
|
||||||
"inputPlaceholder": "Please enter",
|
"inputPlaceholder": "Please enter",
|
||||||
"onlyFirstPurchase": "First Purchase Reward Only",
|
"onlyFirstPurchase": "First Purchase Reward Only",
|
||||||
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
|
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
|
||||||
@@ -42,6 +48,22 @@
|
|||||||
"title": "Log Cleanup Settings"
|
"title": "Log Cleanup Settings"
|
||||||
},
|
},
|
||||||
"logSettings": "Log Settings",
|
"logSettings": "Log Settings",
|
||||||
|
"signature": {
|
||||||
|
"title": "Request Signature",
|
||||||
|
"description": "Enable or disable request signature verification for public APIs",
|
||||||
|
"enable": "Enable Signature Verification",
|
||||||
|
"enableDescription": "When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1",
|
||||||
|
"saveSuccess": "Save Successful",
|
||||||
|
"saveFailed": "Save Failed"
|
||||||
|
},
|
||||||
|
"subscribeMode": {
|
||||||
|
"title": "Subscription Mode",
|
||||||
|
"description": "Configure single or multiple subscription purchase behavior",
|
||||||
|
"singleSubscriptionMode": "Single Subscription Mode",
|
||||||
|
"singleSubscriptionModeDescription": "After enabling, users can only purchase/renew one subscription in the same account",
|
||||||
|
"saveSuccess": "Subscription mode updated successfully",
|
||||||
|
"saveFailed": "Failed to update settings"
|
||||||
|
},
|
||||||
"privacyPolicy": {
|
"privacyPolicy": {
|
||||||
"description": "Edit and manage privacy policy content",
|
"description": "Edit and manage privacy policy content",
|
||||||
"title": "Privacy Policy"
|
"title": "Privacy Policy"
|
||||||
@@ -103,13 +125,21 @@
|
|||||||
},
|
},
|
||||||
"userSecuritySettings": "User & Security",
|
"userSecuritySettings": "User & Security",
|
||||||
"verify": {
|
"verify": {
|
||||||
"description": "Configure Turnstile CAPTCHA and verification settings",
|
"captchaType": "Captcha Type",
|
||||||
"enableLoginVerify": "Enable Verification on Login",
|
"captchaTypeDescription": "Choose between local image captcha, local slider captcha (offline) or Cloudflare Turnstile",
|
||||||
"enableLoginVerifyDescription": "When enabled, users must pass human verification during login",
|
"captchaTypeLocal": "Local Image Captcha",
|
||||||
"enablePasswordVerify": "Enable Verification on Password Reset",
|
"captchaTypePlaceholder": "Select captcha type",
|
||||||
"enablePasswordVerifyDescription": "When enabled, users must pass human verification during password reset",
|
"captchaTypeSlider": "Local Slider Captcha",
|
||||||
"enableRegisterVerify": "Enable Verification on Registration",
|
"captchaTypeTurnstile": "Cloudflare Turnstile",
|
||||||
"enableRegisterVerifyDescription": "When enabled, users must pass human verification during registration",
|
"description": "Configure captcha type and verification settings",
|
||||||
|
"enableAdminLoginCaptcha": "Enable Admin Authentication Captcha",
|
||||||
|
"enableAdminLoginCaptchaDescription": "When enabled, administrators must pass captcha verification during login or password reset",
|
||||||
|
"enableUserLoginCaptcha": "Enable User Login Captcha",
|
||||||
|
"enableUserLoginCaptchaDescription": "When enabled, users must pass captcha verification during login",
|
||||||
|
"enableUserRegisterCaptcha": "Enable User Registration Captcha",
|
||||||
|
"enableUserRegisterCaptchaDescription": "When enabled, users must pass captcha verification during registration",
|
||||||
|
"enableUserResetPasswordCaptcha": "Enable User Password Reset Captcha",
|
||||||
|
"enableUserResetPasswordCaptchaDescription": "When enabled, users must pass captcha verification during password reset",
|
||||||
"saveFailed": "Save Failed",
|
"saveFailed": "Save Failed",
|
||||||
"saveSuccess": "Save Successful",
|
"saveSuccess": "Save Successful",
|
||||||
"title": "Security Verification",
|
"title": "Security Verification",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"systemReboot": "System Reboot",
|
"systemReboot": "System Reboot",
|
||||||
"systemServices": "System Services",
|
"systemServices": "System Services",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
"updateDescription": "Are you sure you want to update?",
|
||||||
"updateFailed": "Update failed",
|
"updateFailed": "Update failed",
|
||||||
"updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
|
"updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
|
||||||
"updateSuccess": "Update completed successfully",
|
"updateSuccess": "Update completed successfully",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
"serverRequired": "Please select a server"
|
"serverRequired": "Please select a server"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
|
"quantityRequired": "Quantity is required",
|
||||||
|
"subscribePlanRequired": "Subscribe plan is required",
|
||||||
|
"totalCountRequired": "Total count is required",
|
||||||
|
"unitTimeRequired": "Unit time is required",
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Client name is required",
|
"nameRequired": "Client name is required",
|
||||||
"userAgentRequiredSuffix": "is required"
|
"userAgentRequiredSuffix": "is required"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"accountEnable": "Account Enable",
|
"accountEnable": "Account Enable",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"administrator": "Administrator",
|
"administrator": "Administrator",
|
||||||
|
"all": "All",
|
||||||
"areaCodePlaceholder": "Area code",
|
"areaCodePlaceholder": "Area code",
|
||||||
"authMethodsTitle": "Auth Methods",
|
"authMethodsTitle": "Auth Methods",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDelete": "Confirm Delete",
|
"confirmDelete": "Confirm Delete",
|
||||||
"confirmOffline": "Confirm Offline",
|
"confirmOffline": "Confirm Offline",
|
||||||
|
"confirmResetToken": "Confirm Reset Subscription Address",
|
||||||
|
"confirmResumeSubscribe": "Confirm Resume Subscription",
|
||||||
|
"confirmStopSubscribe": "Confirm Stop Subscription",
|
||||||
"copySubscription": "Copy Subscription",
|
"copySubscription": "Copy Subscription",
|
||||||
"copySuccess": "Copied successfully",
|
"copySuccess": "Copied successfully",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@@ -24,26 +28,68 @@
|
|||||||
"createSubscription": "Create Subscription",
|
"createSubscription": "Create Subscription",
|
||||||
"createSuccess": "Created successfully",
|
"createSuccess": "Created successfully",
|
||||||
"createUser": "Create User",
|
"createUser": "Create User",
|
||||||
|
"currentCommission": "Current Commission",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"deleteDescription": "This action cannot be undone.",
|
"deleteDescription": "This action cannot be undone.",
|
||||||
"deleteSubscriptionDescription": "This action cannot be undone.",
|
"deleteSubscriptionDescription": "This action cannot be undone.",
|
||||||
"deleteSuccess": "Deleted successfully",
|
"deleteSuccess": "Deleted successfully",
|
||||||
"isDeleted": "Status",
|
|
||||||
"deviceLimit": "Device Limit",
|
"deviceLimit": "Device Limit",
|
||||||
|
"deviceGroup": "Device Group",
|
||||||
|
"deviceNo": "Device No.",
|
||||||
|
"deviceSearch": "Device",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTraffic": "Download Traffic",
|
"downloadTraffic": "Download Traffic",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"editGroup": "Edit Group",
|
||||||
"editSubscription": "Edit Subscription",
|
"editSubscription": "Edit Subscription",
|
||||||
|
"editUserGroup": "Edit User Group",
|
||||||
|
"editUserGroupDescription": "Edit user group assignment and lock status",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
"expiredAt": "Expired At",
|
"expiredAt": "Expired At",
|
||||||
"expireTime": "expireTime",
|
"expireTime": "expireTime",
|
||||||
|
"familyActions": "Actions",
|
||||||
|
"familyConfirmDissolve": "Confirm Dissolve",
|
||||||
|
"familyConfirmRemoveMember": "Confirm Remove Member",
|
||||||
|
"familyDetail": "Device Group Detail",
|
||||||
|
"familyDisabled": "Disabled",
|
||||||
|
"familyDissolve": "Dissolve",
|
||||||
|
"familyDissolved": "Device group dissolved",
|
||||||
|
"familyDissolveDescription": "This will dissolve the device group and remove all active members.",
|
||||||
|
"familyId": "Device Group ID",
|
||||||
|
"familyInvalidMaxMembers": "Invalid max members",
|
||||||
|
"familyJoinSource": "Join Source",
|
||||||
|
"familyJoinSourceOwnerInit": "Owner Init",
|
||||||
|
"familyJoinedAt": "Joined At",
|
||||||
|
"familyLeftAt": "Left At",
|
||||||
|
"familyManagement": "Device Group Management",
|
||||||
|
"familyMaxMembers": "Max Members",
|
||||||
|
"familyMaxMembersTooSmall": "Max members cannot be lower than active member count",
|
||||||
|
"familyMember": "Member",
|
||||||
|
"familyMemberLeft": "Left",
|
||||||
|
"familyMemberRemoved": "Removed",
|
||||||
|
"familyMembers": "Members",
|
||||||
|
"familyNoData": "No device group data",
|
||||||
|
"familyNoMembers": "No members",
|
||||||
|
"familyOwnerUserId": "Owner User ID",
|
||||||
|
"familyRemoveMemberDescription": "This will remove the member from the active device group.",
|
||||||
|
"familyStatus": "Status",
|
||||||
|
"familySummary": "Summary",
|
||||||
|
"familyUpdateMaxMembers": "Update Max Members",
|
||||||
|
"firstPurchaseOnly": "First purchase only",
|
||||||
"giftAmount": "Gift Amount",
|
"giftAmount": "Gift Amount",
|
||||||
"giftAmountPlaceholder": "Enter gift amount",
|
"giftAmountPlaceholder": "Enter gift amount",
|
||||||
"giftLogs": "Gift Logs",
|
"giftLogs": "Gift Logs",
|
||||||
|
"globalDefault": "Global Default",
|
||||||
"invalidEmailFormat": "Invalid email format",
|
"invalidEmailFormat": "Invalid email format",
|
||||||
"inviteCode": "Invite Code",
|
"inviteCode": "Invite Code",
|
||||||
"inviteCodePlaceholder": "Enter invite code",
|
"inviteCodePlaceholder": "Enter invite code",
|
||||||
|
"inviteCount": "Invited Users",
|
||||||
|
"inviteStats": "Invite Statistics",
|
||||||
|
"invitedUsers": "Invited Users",
|
||||||
|
"isDeleted": "Status",
|
||||||
"kickOfflineConfirm": "kickOfflineConfirm",
|
"kickOfflineConfirm": "kickOfflineConfirm",
|
||||||
"kickOfflineSuccess": "Device kicked offline",
|
"kickOfflineSuccess": "Device kicked offline",
|
||||||
"lastSeen": "Last Seen",
|
"lastSeen": "Last Seen",
|
||||||
@@ -52,17 +98,24 @@
|
|||||||
"loginNotifications": "Login Notifications",
|
"loginNotifications": "Login Notifications",
|
||||||
"loginStatus": "Login Status",
|
"loginStatus": "Login Status",
|
||||||
"manager": "Administrator",
|
"manager": "Administrator",
|
||||||
|
"memberCount": "Member Count",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
|
"neverLoggedIn": "Never logged in",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
|
"next": "Next",
|
||||||
|
"noInvitedUsers": "No invited users yet",
|
||||||
"notifySettingsTitle": "Notify Settings",
|
"notifySettingsTitle": "Notify Settings",
|
||||||
|
"inactive": "Inactive",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"onlineDevices": "Online Devices",
|
"onlineDevices": "Online Devices",
|
||||||
"onlyFirstPurchase": "First Purchase Only",
|
"onlyFirstPurchase": "First Purchase Only",
|
||||||
"orderList": "Order List",
|
"orderList": "Order List",
|
||||||
|
"owner": "Owner",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordPlaceholder": "Enter password",
|
"passwordPlaceholder": "Enter password",
|
||||||
"permanent": "Permanent",
|
"permanent": "Permanent",
|
||||||
|
"prev": "Prev",
|
||||||
"pleaseEnterEmail": "Enter email",
|
"pleaseEnterEmail": "Enter email",
|
||||||
"referer": "Referer",
|
"referer": "Referer",
|
||||||
"refererId": "Referer ID",
|
"refererId": "Referer ID",
|
||||||
@@ -71,7 +124,9 @@
|
|||||||
"referralPercentage": "Referral Percentage",
|
"referralPercentage": "Referral Percentage",
|
||||||
"referralPercentagePlaceholder": "Enter percentage",
|
"referralPercentagePlaceholder": "Enter percentage",
|
||||||
"referrerUserId": "Referrer User ID",
|
"referrerUserId": "Referrer User ID",
|
||||||
|
"registeredAt": "Registered At",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"removeSuccess": "Removed successfully",
|
||||||
"resetLogs": "Reset Logs",
|
"resetLogs": "Reset Logs",
|
||||||
"resetTraffic": "Reset Traffic",
|
"resetTraffic": "Reset Traffic",
|
||||||
"toggleStatus": "Toggle Status",
|
"toggleStatus": "Toggle Status",
|
||||||
@@ -81,29 +136,36 @@
|
|||||||
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
|
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
|
||||||
"toggleSubscriptionStatus": "Toggle Status",
|
"toggleSubscriptionStatus": "Toggle Status",
|
||||||
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
|
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
|
||||||
|
"resetSearch": "Reset",
|
||||||
"resetTime": "Reset Time",
|
"resetTime": "Reset Time",
|
||||||
"resetToken": "Reset Subscription Address",
|
"resetToken": "Reset Subscription Address",
|
||||||
|
"saving": "Saving...",
|
||||||
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
||||||
"resetTokenSuccess": "Subscription address reset successfully",
|
"resetTokenSuccess": "Subscription address reset successfully",
|
||||||
"confirmResetToken": "Confirm Reset Subscription Address",
|
"resumeSubscribe": "Resume Subscription",
|
||||||
|
"selectGroup": "Select a group",
|
||||||
|
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
|
||||||
|
"resumeSubscribeSuccess": "Subscription resumed successfully",
|
||||||
|
"save": "Save",
|
||||||
|
"shortCode": "Short Code",
|
||||||
|
"speedLimit": "Speed Limit",
|
||||||
|
"startTime": "startTime",
|
||||||
|
"status": "Status",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusDeducted": "Deducted",
|
||||||
|
"statusExpired": "Expired",
|
||||||
|
"statusFinished": "Finished",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusStopped": "Stopped",
|
||||||
"stopSubscribe": "Stop Subscription",
|
"stopSubscribe": "Stop Subscription",
|
||||||
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
|
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
|
||||||
"stopSubscribeSuccess": "Subscription stopped successfully",
|
"stopSubscribeSuccess": "Subscription stopped successfully",
|
||||||
"confirmStopSubscribe": "Confirm Stop Subscription",
|
"search": "Search",
|
||||||
"resumeSubscribe": "Resume Subscription",
|
"searchPlaceholder": "Email / Invite Code / Device ID",
|
||||||
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
|
"searchInputPlaceholder": "Enter search term",
|
||||||
"resumeSubscribeSuccess": "Subscription resumed successfully",
|
"sharedSubscription": "Shared",
|
||||||
"confirmResumeSubscribe": "Confirm Resume Subscription",
|
"sharedSubscriptionInfo": "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
|
||||||
"status": "Status",
|
"sharedSubscriptionList": "Shared Subscription List",
|
||||||
"statusPending": "Pending",
|
|
||||||
"statusActive": "Active",
|
|
||||||
"statusFinished": "Finished",
|
|
||||||
"statusExpired": "Expired",
|
|
||||||
"statusDeducted": "Deducted",
|
|
||||||
"statusStopped": "Stopped",
|
|
||||||
"save": "Save",
|
|
||||||
"speedLimit": "Speed Limit",
|
|
||||||
"startTime": "startTime",
|
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"subscriptionId": "subscriptionId",
|
"subscriptionId": "subscriptionId",
|
||||||
"subscriptionInfo": "subscriptionInfo",
|
"subscriptionInfo": "subscriptionInfo",
|
||||||
@@ -114,18 +176,22 @@
|
|||||||
"telephone": "Phone",
|
"telephone": "Phone",
|
||||||
"telephonePlaceholder": "Enter phone number",
|
"telephonePlaceholder": "Enter phone number",
|
||||||
"token": "token",
|
"token": "token",
|
||||||
|
"totalCommission": "Total Commission",
|
||||||
"totalTraffic": "Total Traffic",
|
"totalTraffic": "Total Traffic",
|
||||||
"tradeNotifications": "Trade Notifications",
|
"tradeNotifications": "Trade Notifications",
|
||||||
"trafficDetails": "Traffic Details",
|
"trafficDetails": "Traffic Details",
|
||||||
"trafficLimit": "Traffic Limit",
|
"trafficLimit": "Traffic Limit",
|
||||||
"trafficStats": "Traffic Stats",
|
"trafficStats": "Traffic Stats",
|
||||||
"trafficUsage": "trafficUsage",
|
"trafficUsage": "Traffic Usage",
|
||||||
|
"remainingTraffic": "Remaining Traffic",
|
||||||
"unlimited": "unlimited",
|
"unlimited": "unlimited",
|
||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateSuccess": "Updated successfully",
|
"updateSuccess": "Updated successfully",
|
||||||
|
"groupUpdated": "Group updated successfully",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadTraffic": "Upload Traffic",
|
"uploadTraffic": "Upload Traffic",
|
||||||
|
"userStatus": "User Status",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "User Agent",
|
||||||
"userEmail": "Email",
|
"userEmail": "Email",
|
||||||
"userEmailPlaceholder": "Enter email",
|
"userEmailPlaceholder": "Enter email",
|
||||||
@@ -134,5 +200,21 @@
|
|||||||
"userList": "User List",
|
"userList": "User List",
|
||||||
"userName": "Username",
|
"userName": "Username",
|
||||||
"userProfile": "User Profile",
|
"userProfile": "User Profile",
|
||||||
"verified": "Verified"
|
"userGroup": "User Group",
|
||||||
|
"active": "Active",
|
||||||
|
"verified": "Verified",
|
||||||
|
"viewDeviceGroup": "View Device Group",
|
||||||
|
"viewOwner": "View Owner",
|
||||||
|
"locked": "Locked",
|
||||||
|
"lockGroup": "Lock Group",
|
||||||
|
"lockGroupDescription": "Prevent automatic grouping from changing this user's group",
|
||||||
|
"groupLocked": "Group Locked",
|
||||||
|
"previewNodes": "Preview Nodes",
|
||||||
|
"availableNodes": "Available Nodes",
|
||||||
|
"name": "Name",
|
||||||
|
"address": "Address",
|
||||||
|
"noNodesAvailable": "No nodes available",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"publicNodes": "Public Nodes",
|
||||||
|
"subscriptionNodes": "Subscription Nodes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,8 @@
|
|||||||
"title": "邮箱设置",
|
"title": "邮箱设置",
|
||||||
"trafficExceedEmailTemplate": "流量超额邮件模板",
|
"trafficExceedEmailTemplate": "流量超额邮件模板",
|
||||||
"trafficTemplate": "流量模板",
|
"trafficTemplate": "流量模板",
|
||||||
|
"deleteAccountEmailTemplate": "注销账户邮件模板",
|
||||||
|
"deleteAccountTemplate": "注销模版",
|
||||||
"verifyEmailTemplate": "验证邮件模板",
|
"verifyEmailTemplate": "验证邮件模板",
|
||||||
"verifyTemplate": "验证模板",
|
"verifyTemplate": "验证模板",
|
||||||
"whitelistSuffixes": "白名单后缀",
|
"whitelistSuffixes": "白名单后缀",
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
{
|
{
|
||||||
|
"captcha": {
|
||||||
|
"clickToRefresh": "点击刷新",
|
||||||
|
"noImage": "无图片",
|
||||||
|
"placeholder": "请输入验证码...",
|
||||||
|
"refresh": "刷新验证码",
|
||||||
|
"required": "请输入验证码",
|
||||||
|
"sliderRequired": "请完成滑块验证",
|
||||||
|
"slider": {
|
||||||
|
"clickToVerify": "点击进行验证",
|
||||||
|
"fail": "请重试",
|
||||||
|
"hint": "拖动拼图块到对应位置",
|
||||||
|
"success": "验证成功",
|
||||||
|
"title": "安全验证"
|
||||||
|
},
|
||||||
|
"turnstile": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"clickToVerify": "点击进行验证",
|
||||||
|
"success": "验证成功",
|
||||||
|
"title": "安全验证"
|
||||||
|
}
|
||||||
|
},
|
||||||
"check": {
|
"check": {
|
||||||
"description": "验证您的身份",
|
"description": "验证您的身份",
|
||||||
"title": "验证"
|
"title": "验证"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"40005": "您没有访问权限,如有疑问请联系管理员。",
|
"40005": "您没有访问权限,如有疑问请联系管理员。",
|
||||||
"50001": "找不到对应的优惠券信息,请检查后重试。",
|
"50001": "找不到对应的优惠券信息,请检查后重试。",
|
||||||
"50002": "该优惠券已被使用,无法再次使用。",
|
"50002": "该优惠券已被使用,无法再次使用。",
|
||||||
|
"50003": "",
|
||||||
|
"50004": "",
|
||||||
"60001": "订阅已过期,请续费后使用。",
|
"60001": "订阅已过期,请续费后使用。",
|
||||||
"60002": "暂时无法使用该订阅,请稍后再试。",
|
"60002": "暂时无法使用该订阅,请稍后再试。",
|
||||||
"60003": "检测到现有订阅,请先取消后再继续。",
|
"60003": "检测到现有订阅,请先取消后再继续。",
|
||||||
|
|||||||
@@ -29,5 +29,6 @@
|
|||||||
"users": "用户",
|
"users": "用户",
|
||||||
"userTitle": "用户统计",
|
"userTitle": "用户统计",
|
||||||
"userTraffic": "用户流量",
|
"userTraffic": "用户流量",
|
||||||
|
"withdrawalManagement": "提现管理",
|
||||||
"yesterday": "昨日"
|
"yesterday": "昨日"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
{
|
||||||
|
"actions": "操作",
|
||||||
|
"autoTrigger": "自动",
|
||||||
|
"averageMode": "平均分组",
|
||||||
|
"cancel": "取消",
|
||||||
|
"completed": "已完成",
|
||||||
|
"confirm": "确认",
|
||||||
|
"confirmDelete": "确认删除",
|
||||||
|
"config": "配置",
|
||||||
|
"create": "创建",
|
||||||
|
"created": "创建成功",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"createNodeGroup": "创建节点组",
|
||||||
|
"createUserGroup": "创建用户组",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleted": "删除成功",
|
||||||
|
"deleteNodeGroupConfirm": "此操作将删除节点组。该组中的节点将被重新分配。",
|
||||||
|
"deleteUserGroupConfirm": "此操作将删除用户组。该组中的用户将被重新分配到默认组。",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "输入描述",
|
||||||
|
"edit": "编辑",
|
||||||
|
"editNodeGroup": "编辑节点组",
|
||||||
|
"editUserGroup": "编辑用户组",
|
||||||
|
"editUserGroupDescription": "编辑用户组分配和锁定状态",
|
||||||
|
"selectGroup": "选择一个组",
|
||||||
|
"endTime": "结束时间",
|
||||||
|
"errorMessage": "错误信息",
|
||||||
|
"export": "导出",
|
||||||
|
"failed": "失败",
|
||||||
|
"failedCount": "失败",
|
||||||
|
"groupConfig": "分组配置",
|
||||||
|
"groupConfigDescription": "管理节点组并自动为用户订阅分配节点组",
|
||||||
|
"groupDetails": "分组详情",
|
||||||
|
"groupEnabled": "启用分组管理",
|
||||||
|
"groupEnabledDescription": "启用分组管理以控制用户对节点的访问",
|
||||||
|
"groupHistory": "分组计算历史",
|
||||||
|
"groupHistoryDescription": "查看分组重算历史和结果",
|
||||||
|
"groupHistoryDetail": "分组计算详情",
|
||||||
|
"groupId": "分组ID",
|
||||||
|
"groupIdPlaceholder": "输入唯一的分组ID",
|
||||||
|
"groupMode": "分组模式",
|
||||||
|
"groupModeDescription": "选择分组算法以将用户分配到组",
|
||||||
|
"groupName": "分组名称",
|
||||||
|
"groupNamePlaceholder": "输入分组名称",
|
||||||
|
"groupRecalculation": "分组重新计算",
|
||||||
|
"groupRecalculationDescription": "根据当前配置手动触发所有有效用户订阅的节点组重新分配",
|
||||||
|
"history": "历史记录",
|
||||||
|
"historyId": "历史ID",
|
||||||
|
"id": "ID",
|
||||||
|
"idPrefix": "#",
|
||||||
|
"idle": "空闲",
|
||||||
|
"separator": ",",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadFailed": "加载配置失败",
|
||||||
|
"locked": "已锁定",
|
||||||
|
"manualTrigger": "手动",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "输入名称",
|
||||||
|
"nodeCount": "节点数",
|
||||||
|
"nodeGroup": "节点组",
|
||||||
|
"nodeGroupFormDescription": "配置节点组设置",
|
||||||
|
"nodeGroups": "节点组",
|
||||||
|
"nodeGroupsDescription": "管理节点组以控制用户访问权限",
|
||||||
|
"noDetails": "暂无详情",
|
||||||
|
"operator": "操作人",
|
||||||
|
"progress": "进度",
|
||||||
|
"recalculate": "重新计算",
|
||||||
|
"recalculateAll": "重新分配节点组",
|
||||||
|
"recalculationCompleted": "重新计算成功完成",
|
||||||
|
"recalculationFailed": "重新计算失败,请重试。",
|
||||||
|
"recalculationStarted": "重新计算已启动",
|
||||||
|
"recalculationWarning": "重新计算将根据当前配置重新分配所有有效用户订阅的节点组。此操作无法撤消。",
|
||||||
|
"running": "运行中",
|
||||||
|
"save": "保存",
|
||||||
|
"scheduleTrigger": "定时",
|
||||||
|
"sort": "排序",
|
||||||
|
"sortOrder": "排序顺序",
|
||||||
|
"startTime": "开始时间",
|
||||||
|
"subscribeMode": "套餐分组",
|
||||||
|
"successCount": "成功",
|
||||||
|
"title": "分组管理",
|
||||||
|
"totalUsers": "总用户数",
|
||||||
|
"totalNodes": "总节点数",
|
||||||
|
"totalGroups": "总分组数",
|
||||||
|
"trafficMode": "流量分组",
|
||||||
|
"triggerType": "触发类型",
|
||||||
|
"userGroup": "用户组",
|
||||||
|
"userGroups": "用户组",
|
||||||
|
"userGroupsDescription": "管理用户组以控制节点访问权限",
|
||||||
|
"updated": "更新成功",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"userCount": "用户数",
|
||||||
|
"viewDetail": "查看详情",
|
||||||
|
"warning": "警告",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"enableGrouping": "启用分组",
|
||||||
|
"enableGroupingDescription": "启用后,用户订阅将根据分配模式自动分配节点组",
|
||||||
|
"groupingMode": "分组方式",
|
||||||
|
"averageModeConfig": "平均模式配置",
|
||||||
|
"subscribeModeConfig": "订阅模式配置",
|
||||||
|
"trafficModeConfig": "流量模式配置",
|
||||||
|
"averageModeDescription": "为有效用户订阅随机分配可用节点组",
|
||||||
|
"subscribeModeDescription": "根据订阅套餐设置用户组的默认节点组",
|
||||||
|
"trafficModeDescription": "根据用户订阅的流量使用情况分配节点组",
|
||||||
|
"defaultUserGroupId": "默认用户组ID",
|
||||||
|
"defaultUserGroupDescription": "新用户将被分配到此组",
|
||||||
|
"defaultUserGroupForExpiredDescription": "订阅过期的用户将被分配到此组",
|
||||||
|
"autoCreateGroup": "自动创建组",
|
||||||
|
"autoCreateGroupDescription": "添加新订阅计划时自动创建新的用户组",
|
||||||
|
"lockGroup": "锁定分组",
|
||||||
|
"lockGroupDescription": "防止自动重新计算更改此用户的分组",
|
||||||
|
"trafficRangesComingSoon": "流量区间配置即将推出...",
|
||||||
|
"currentStatus": "当前状态",
|
||||||
|
"trafficRangesConfig": "流量区间配置",
|
||||||
|
"trafficRangesDescription": "配置用户流量分组区间。流量根据用户计费周期计算。",
|
||||||
|
"minTrafficGB": "最小流量 (GB)",
|
||||||
|
"maxTrafficGB": "最大流量 (GB)",
|
||||||
|
"addRange": "添加区间",
|
||||||
|
"remove": "移除",
|
||||||
|
"note": "注意",
|
||||||
|
"trafficRangesNote": "区间不能重叠且必须覆盖所有值而不留空档。流量大于最后一个区间上限的用户将被分配到最后一个组。",
|
||||||
|
"defaultUserGroup": "默认用户组",
|
||||||
|
"defaultUserGroupForTrafficDescription": "超出所有定义区间的用户将被分配到此组",
|
||||||
|
"rangeError": "区间错误",
|
||||||
|
"overlapError": "区间重叠错误",
|
||||||
|
"gapError": "存在空档错误",
|
||||||
|
"groupByTraffic": "按流量分组",
|
||||||
|
"resetGroups": "重置所有分组",
|
||||||
|
"resetGroupsTitle": "重置所有分组",
|
||||||
|
"resetGroupsDescription": "此操作将删除所有节点组和用户组,将所有用户的组ID重置为0,清空所有商品的节点组ID,清空所有节点的节点组ID。此操作无法撤消。",
|
||||||
|
"resetSuccess": "所有分组已成功重置",
|
||||||
|
"resetFailed": "重置分组失败",
|
||||||
|
"saved": "配置保存成功",
|
||||||
|
"saveFailed": "保存配置失败",
|
||||||
|
"autoCalculated": "自动统计",
|
||||||
|
"userGroupCountAutoCalculated": "自动统计实际用户组数量",
|
||||||
|
"userGroupCount": "用户组数",
|
||||||
|
"nodeGroupCountAutoCalculated": "自动统计实际节点组数量",
|
||||||
|
"nodeGroupCount": "节点组数",
|
||||||
|
"arrow": " → ",
|
||||||
|
"availableNodeGroups": "可用节点组",
|
||||||
|
"currentGroupingResult": "当前分组结果",
|
||||||
|
"calculationInfo": "计算信息",
|
||||||
|
"groupingDetailsStatistics": "分组详情统计",
|
||||||
|
"successFailedCount": "成功/失败",
|
||||||
|
"latestGroupingCalculation": "最新分组计算详情",
|
||||||
|
"userList": "用户列表",
|
||||||
|
"email": "邮箱",
|
||||||
|
"noUsers": "未找到用户",
|
||||||
|
"showing": "显示",
|
||||||
|
"to": "至",
|
||||||
|
"of": "共",
|
||||||
|
"previous": "上一页",
|
||||||
|
"next": "下一页",
|
||||||
|
"result": "结果",
|
||||||
|
"bindNodeGroup": "绑定节点组",
|
||||||
|
"bindNodeGroupDescription": "选择一个节点组绑定到以下用户组:{{userGroups}}",
|
||||||
|
"selectNodeGroup": "选择节点组",
|
||||||
|
"selectNodeGroupPlaceholder": "请选择节点组...",
|
||||||
|
"selectNodeGroupRequired": "请选择一个节点组",
|
||||||
|
"unbound": "未绑定",
|
||||||
|
"bindSuccess": "成功将 {{userGroupCount}} 个用户组绑定到节点组",
|
||||||
|
"bindFailed": "绑定节点组失败",
|
||||||
|
"groupMapping": "分组对应关系",
|
||||||
|
"forCalculation": "参与计算",
|
||||||
|
"trafficRange": "流量区间 (GB)",
|
||||||
|
"configSaved": "配置保存成功",
|
||||||
|
"subscribeGroupMappingTitle": "套餐-节点组对应关系",
|
||||||
|
"subscribeName": "订阅计划",
|
||||||
|
"userGroupName": "用户组",
|
||||||
|
"nodeGroupName": "节点组",
|
||||||
|
"notMapped": "未映射",
|
||||||
|
"noMappingData": "暂无映射数据",
|
||||||
|
"forCalculationDescription": "此节点组是否参与分组计算",
|
||||||
|
"trafficRangeGB": "流量区间 (GB)",
|
||||||
|
"trafficRangeDescription": "流量大于等于最小值且小于最大值的用户将被分配到此节点组",
|
||||||
|
"minCannotExceedMax": "最小流量不能超过最大流量",
|
||||||
|
"rangeOverlap": "区间与节点组 \"{{name}}\" 重叠",
|
||||||
|
"nodeGroupNotFound": "未找到节点组",
|
||||||
|
"validationFailed": "验证失败",
|
||||||
|
"totalNodeGroups": "总节点组数",
|
||||||
|
"invalidRange": "最小流量必须小于最大流量",
|
||||||
|
"rangeConflict": "流量区间与节点组 \"{{name}}\" 冲突(区间:{{min}} - {{max}} GB)",
|
||||||
|
"isExpiredGroup": "过期节点组",
|
||||||
|
"isExpiredGroupDescription": "允许过期用户使用受限节点",
|
||||||
|
"expiredDaysLimit": "过期天数限制",
|
||||||
|
"expiredDaysLimitDescription": "用户订阅过期后仍可访问节点的天数",
|
||||||
|
"maxTrafficGBExpired": "过期用户最大流量 (GB)",
|
||||||
|
"maxTrafficGBExpiredDescription": "过期用户允许使用的最大流量(0 = 不限制)",
|
||||||
|
"speedLimit": "限速 (KB/s)",
|
||||||
|
"speedLimitDescription": "该节点组用户的速度限制(0 = 不限制)",
|
||||||
|
"expiredGroup": "过期专用",
|
||||||
|
"expiredSettings": "过期设置",
|
||||||
|
"days": "天",
|
||||||
|
"expiredGroupExists": "系统中已存在过期节点组:{{name}}",
|
||||||
|
"nodeGroupUsedBySubscribe": "该节点组已被订阅商品设置为默认节点组,不能设为过期节点组",
|
||||||
|
"expiredGroupForCalculationDescription": "过期专用节点组不能参与分组计算"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"commission": "佣金",
|
||||||
|
"days": "{{count}} 天",
|
||||||
|
"disabled": "禁用",
|
||||||
|
"empty": "暂无邀请记录",
|
||||||
|
"enabled": "启用",
|
||||||
|
"hasPurchased": "已购买",
|
||||||
|
"invitee": "被邀请人",
|
||||||
|
"inviteeGiftDays": "被邀请人赠送天数",
|
||||||
|
"inviteeId": "被邀请人 ID",
|
||||||
|
"invitedAt": "邀请时间",
|
||||||
|
"inviter": "邀请人",
|
||||||
|
"inviterGiftDays": "邀请人赠送天数",
|
||||||
|
"inviterId": "邀请人 ID",
|
||||||
|
"loadErrorDescription": "刷新表格或稍后重试。",
|
||||||
|
"loadErrorTitle": "邀请记录加载失败",
|
||||||
|
"no": "否",
|
||||||
|
"orderCount": "订单数",
|
||||||
|
"searchPlaceholder": "邮箱/手机号关键词",
|
||||||
|
"status": "状态",
|
||||||
|
"title": "邀请管理",
|
||||||
|
"yes": "是"
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"Document Management": "文档管理",
|
"Document Management": "文档管理",
|
||||||
"Email": "邮件",
|
"Email": "邮件",
|
||||||
"Gift": "赠送",
|
"Gift": "赠送",
|
||||||
|
"Group Management": "分组管理",
|
||||||
|
"Invite Management": "邀请管理",
|
||||||
"Login": "登录",
|
"Login": "登录",
|
||||||
"Logs & Analytics": "日志与分析",
|
"Logs & Analytics": "日志与分析",
|
||||||
"Maintenance": "维护",
|
"Maintenance": "维护",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"Order Management": "订单管理",
|
"Order Management": "订单管理",
|
||||||
"Payment Config": "支付配置",
|
"Payment Config": "支付配置",
|
||||||
"Product Management": "商品管理",
|
"Product Management": "商品管理",
|
||||||
|
"Promo Management": "促销管理",
|
||||||
"Redemption Management": "兑换码管理",
|
"Redemption Management": "兑换码管理",
|
||||||
"Register": "注册",
|
"Register": "注册",
|
||||||
"Reset Subscribe": "重置订阅",
|
"Reset Subscribe": "重置订阅",
|
||||||
@@ -31,6 +34,8 @@
|
|||||||
"System Config": "系统配置",
|
"System Config": "系统配置",
|
||||||
"Ticket Management": "工单管理",
|
"Ticket Management": "工单管理",
|
||||||
"Traffic Details": "流量详情",
|
"Traffic Details": "流量详情",
|
||||||
|
"Device Group": "设备组",
|
||||||
"User Management": "用户管理",
|
"User Management": "用户管理",
|
||||||
"Users & Support": "用户与支持"
|
"Users & Support": "用户与支持",
|
||||||
|
"Withdrawal Management": "提现管理"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
{
|
{
|
||||||
"address": "地址",
|
"address": "地址",
|
||||||
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDeleteDesc": "此操作无法撤销。",
|
"confirmDeleteDesc": "此操作无法撤销。",
|
||||||
"confirmDeleteTitle": "删除此节点?",
|
"confirmDeleteTitle": "删除此节点?",
|
||||||
"copied": "已复制",
|
"copied": "已复制",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"create": "创建",
|
"create": "创建落地节点",
|
||||||
"created": "已创建",
|
"created": "已创建",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
"drawerCreateTitle": "创建节点",
|
"drawerCreateTitle": "创建落地节点",
|
||||||
"drawerEditTitle": "编辑节点",
|
"drawerEditTitle": "编辑节点",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"enabled_off": "已禁用",
|
"enabled_off": "已禁用",
|
||||||
"enabled_on": "已启用",
|
"enabled_on": "已启用",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
"nodeGroup": "节点分组",
|
||||||
|
"nodeGroups": "节点分组",
|
||||||
|
"nodeGroup_description": "将此节点分配到多个分组以控制用户访问。",
|
||||||
"pageTitle": "节点",
|
"pageTitle": "节点",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"protocol": "协议",
|
"protocol": "协议",
|
||||||
|
"public": "公共",
|
||||||
|
"selectNodeGroup": "选择节点分组…",
|
||||||
"select_protocol": "选择协议…",
|
"select_protocol": "选择协议…",
|
||||||
"select_server": "选择服务器…",
|
"select_server": "选择服务器…",
|
||||||
"server": "服务器",
|
"server": "服务器",
|
||||||
"sorted_success": "排序成功",
|
"sorted_success": "排序成功",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
|
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
|
||||||
|
"tags_groupMode_description": "可选标签,用于显示和过滤(如果为空,节点组名称将作为标签使用)。",
|
||||||
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
|
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
|
||||||
"updated": "已更新"
|
"updated": "已更新"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
{
|
{
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"couponDiscount": "优惠券折扣",
|
"couponDiscount": "优惠券折扣",
|
||||||
|
"confirmRefund": "确认退费",
|
||||||
"discount": "折扣金额",
|
"discount": "折扣金额",
|
||||||
"feeAmount": "手续费",
|
"feeAmount": "手续费",
|
||||||
"method": "支付方式",
|
"method": "支付方式",
|
||||||
"orderNumber": "订单编号",
|
"orderNumber": "订单编号",
|
||||||
|
"refund": "退费",
|
||||||
|
"refundConfirmDescription": "该退费操作会使用户订阅立即失效,并扣减关联代理佣金。",
|
||||||
|
"refundConfirmTitle": "确认退费",
|
||||||
|
"refundConfirmWarning": "订单退费后不可再次重复执行,请谨慎操作。",
|
||||||
|
"refundSubmitting": "退费中...",
|
||||||
|
"refundSuccess": "退费成功。",
|
||||||
"status": {
|
"status": {
|
||||||
"0": "状态",
|
"0": "状态",
|
||||||
"1": "待支付",
|
"1": "待支付",
|
||||||
"2": "已支付",
|
"2": "已支付",
|
||||||
"3": "已取消",
|
"3": "已取消",
|
||||||
"4": "已关闭",
|
"4": "已关闭",
|
||||||
"5": "已完成"
|
"5": "已完成",
|
||||||
|
"6": "已退费"
|
||||||
},
|
},
|
||||||
"subscribe": "订阅",
|
"subscribe": "订阅",
|
||||||
"subscribePrice": "订阅价格",
|
"subscribePrice": "订阅价格",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDelete": "确定要删除吗?",
|
"confirmDelete": "确定要删除吗?",
|
||||||
@@ -7,13 +8,16 @@
|
|||||||
"create": "创建",
|
"create": "创建",
|
||||||
"createSubscribe": "创建订阅",
|
"createSubscribe": "创建订阅",
|
||||||
"createSuccess": "创建成功",
|
"createSuccess": "创建成功",
|
||||||
|
"currentUserGroup": "当前用户分组",
|
||||||
|
"defaultNodeGroup": "默认节点组",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
"nodeGroups": "节点组",
|
||||||
|
"nodes": "个节点",
|
||||||
"deleteSuccess": "删除成功",
|
"deleteSuccess": "删除成功",
|
||||||
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
||||||
"deviceLimit": "IP限制",
|
"deviceLimit": "IP限制",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"editSubscribe": "编辑订阅",
|
"editSubscribe": "编辑订阅",
|
||||||
"sortSuccess": "排序成功",
|
|
||||||
"form": {
|
"form": {
|
||||||
"annualReset": "年度重置",
|
"annualReset": "年度重置",
|
||||||
"basic": "基本",
|
"basic": "基本",
|
||||||
@@ -28,9 +32,9 @@
|
|||||||
"discount_price": "折扣价格",
|
"discount_price": "折扣价格",
|
||||||
"discountDescription": "根据单价设置折扣",
|
"discountDescription": "根据单价设置折扣",
|
||||||
"discountPercent": "折扣百分比",
|
"discountPercent": "折扣百分比",
|
||||||
|
"appleProductId": "苹果商品ID",
|
||||||
"Hour": "小时",
|
"Hour": "小时",
|
||||||
"inventory": "订阅库存",
|
"inventory": "订阅库存",
|
||||||
"unlimitedInventory": "无限制(输入 -1)",
|
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"languageDescription": "留空为默认无语言限制",
|
"languageDescription": "留空为默认无语言限制",
|
||||||
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
|
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
|
||||||
@@ -40,7 +44,19 @@
|
|||||||
"name": "名称",
|
"name": "名称",
|
||||||
"node": "节点",
|
"node": "节点",
|
||||||
"nodeGroup": "节点组",
|
"nodeGroup": "节点组",
|
||||||
"nodes": "节点",
|
"nodeGroups": "节点组",
|
||||||
|
"nodeGroupsDescription": "将此商品分配到多个节点分组。用户将可以从这些分组获取节点。",
|
||||||
|
"nodeGroupsFirstSelectionDescription": "为此商品选择节点组。第一个选中的组将被设置为默认节点组。",
|
||||||
|
"defaultNodeGroup": "默认节点组",
|
||||||
|
"defaultNodeGroupDescription": "为此商品选择默认节点组。将自动包含在备用节点组中。",
|
||||||
|
"selectDefaultNodeGroup": "选择默认节点组...",
|
||||||
|
"noDefaultNodeGroup": "无默认节点组",
|
||||||
|
"backupNodeGroups": "备用节点组",
|
||||||
|
"backupNodeGroupsDescription": "选择其他备用节点组。默认节点组会自动包含。",
|
||||||
|
"nodes": "关联节点",
|
||||||
|
"nodesDescription": "选择此订阅的节点",
|
||||||
|
"nodesInGroup": "分组中的节点:",
|
||||||
|
"nodesWithoutGroupsDescription": "未分配到分组的节点将在此处显示(属于分组的节点在上方的节点组部分管理)",
|
||||||
"noLimit": "无限制",
|
"noLimit": "无限制",
|
||||||
"NoLimit": "无限制",
|
"NoLimit": "无限制",
|
||||||
"noReset": "不重置",
|
"noReset": "不重置",
|
||||||
@@ -59,18 +75,55 @@
|
|||||||
"showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度",
|
"showOriginalPriceDescription": "开启后,在订阅卡片上将会显示原价和折后价,帮助用户了解优惠幅度",
|
||||||
"speedLimit": "速度限制",
|
"speedLimit": "速度限制",
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
|
"trafficLimit": "按量限速",
|
||||||
|
"trafficLimitRules": "按量限速规则",
|
||||||
|
"trafficLimitDescription": "配置基于流量的限速规则。当流量使用达到指定量时,将进行限速。",
|
||||||
|
"addTrafficLimitRule": "添加限速规则",
|
||||||
|
"statType": "统计类型",
|
||||||
|
"selectStatType": "选择类型...",
|
||||||
|
"statTypeHour": "小时",
|
||||||
|
"statTypeDay": "天",
|
||||||
|
"statValue": "时间值",
|
||||||
|
"trafficUsage": "使用流量(GB)",
|
||||||
|
"speedLimitKb": "限速(kb)",
|
||||||
"unitPrice": "单价",
|
"unitPrice": "单价",
|
||||||
"unitTime": "时间单位",
|
"unitTime": "时间单位",
|
||||||
|
"unlimitedInventory": "无限制(输入 -1)",
|
||||||
"Year": "年"
|
"Year": "年"
|
||||||
},
|
},
|
||||||
|
"groupMapping": "分组映射",
|
||||||
|
"groupMappingTitle": "分组映射",
|
||||||
|
"groupMappingUpdateFailed": "更新分组映射失败",
|
||||||
|
"groupMappingUpdateSuccess": "分组映射更新成功",
|
||||||
|
"migrateUsers": "迁移用户",
|
||||||
|
"migrateUsersTitle": "迁移用户",
|
||||||
|
"migrateUsersDescription": "将当前用户组的所有用户迁移到另一个用户组",
|
||||||
|
"migrateUsersWarning": "这将把 {count} 个用户从 \"{group}\" 迁移到目标用户组。此操作无法撤销。",
|
||||||
|
"migrateUsersSuccess": "成功将 {count} 个用户迁移到目标用户组",
|
||||||
|
"migrateUsersFailed": "迁移用户失败",
|
||||||
|
"targetUserGroup": "目标用户组",
|
||||||
|
"selectTargetGroup": "选择目标用户组...",
|
||||||
|
"selectTargetGroupFirst": "请先选择目标用户组",
|
||||||
|
"cannotMigrateToSameGroup": "无法迁移到相同的用户组",
|
||||||
|
"noSourceGroup": "没有可用的源用户组",
|
||||||
|
"selectedGroup": "已选择的分组",
|
||||||
|
"userCount": "用户数量",
|
||||||
|
"migrating": "迁移中...",
|
||||||
"inventory": "订阅库存",
|
"inventory": "订阅库存",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"loading": "加载中...",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
"noMapping": "未设置映射",
|
||||||
|
"noNodes": "该分组下没有节点",
|
||||||
"quota": "购买限制/次",
|
"quota": "购买限制/次",
|
||||||
"replacement": "重置价格/次",
|
"replacement": "重置价格/次",
|
||||||
|
"save": "保存",
|
||||||
|
"selectGroupPlaceholder": "选择分组...",
|
||||||
|
"selectUserGroup": "选择用户分组",
|
||||||
"sell": "销售",
|
"sell": "销售",
|
||||||
"show": "显示",
|
"show": "显示",
|
||||||
"sold": "订阅数量",
|
"sold": "订阅数量",
|
||||||
|
"sortSuccess": "排序成功",
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
"unitPrice": "单价",
|
"unitPrice": "单价",
|
||||||
"updateSuccess": "更新成功"
|
"updateSuccess": "更新成功"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"confirmDelete": "确定要删除吗?",
|
||||||
|
"createPrice": "创建促销价",
|
||||||
|
"createRule": "创建规则",
|
||||||
|
"createSuccess": "创建成功",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
||||||
|
"detail": "详情",
|
||||||
|
"edit": "编辑",
|
||||||
|
"editRule": "编辑规则",
|
||||||
|
"emptyPrices": "暂无商品促销价",
|
||||||
|
"emptyRules": "暂无促销规则",
|
||||||
|
"emptyUsage": "暂无促销使用记录",
|
||||||
|
"enabled": "启用",
|
||||||
|
"endTime": "结束时间",
|
||||||
|
"form": {
|
||||||
|
"enterPromoPrice": "输入价格",
|
||||||
|
"inactiveMonths": "未活跃月数",
|
||||||
|
"inactiveMonthsPlaceholder": "未购买的月数",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "规则名称",
|
||||||
|
"noDiscounts": "该订阅没有 discount 档位,不能配置促销价。",
|
||||||
|
"promoPriceLessThanOriginalPrice": "促销价必须低于原价",
|
||||||
|
"promoPriceLessThanUnitPrice": "促销价必须低于原价",
|
||||||
|
"selectEndTime": "选择时间",
|
||||||
|
"selectRule": "选择规则",
|
||||||
|
"selectStartTime": "选择时间",
|
||||||
|
"selectQuantity": "选择 discount 档位",
|
||||||
|
"selectSubscribe": "选择订阅",
|
||||||
|
"type": "规则类型",
|
||||||
|
"windowHours": "新用户窗口小时数",
|
||||||
|
"windowHoursPlaceholder": "注册后的小时数"
|
||||||
|
},
|
||||||
|
"loadErrorDescription": "请刷新表格或稍后重试。",
|
||||||
|
"loadPricesError": "促销价加载失败",
|
||||||
|
"loadRulesError": "促销规则加载失败",
|
||||||
|
"loadUsageError": "促销使用记录加载失败",
|
||||||
|
"name": "名称",
|
||||||
|
"no": "否",
|
||||||
|
"orderNo": "订单号",
|
||||||
|
"originalPriceHint": "原价",
|
||||||
|
"params": "参数",
|
||||||
|
"prices": "商品促销价",
|
||||||
|
"priority": "优先级",
|
||||||
|
"promoPrice": "促销价",
|
||||||
|
"quantity": "档位",
|
||||||
|
"rule": "规则",
|
||||||
|
"ruleDetail": "规则详情",
|
||||||
|
"rules": "促销规则",
|
||||||
|
"searchRule": "规则名称",
|
||||||
|
"startTime": "开始时间",
|
||||||
|
"subscribe": "订阅",
|
||||||
|
"type": "类型",
|
||||||
|
"types": {
|
||||||
|
"campaign": "活动",
|
||||||
|
"inactive_user": "回归用户",
|
||||||
|
"new_user": "新用户"
|
||||||
|
},
|
||||||
|
"unitPrice": "原价",
|
||||||
|
"unitPriceHint": "原价",
|
||||||
|
"updateSuccess": "更新成功",
|
||||||
|
"usage": "使用记录",
|
||||||
|
"usedAt": "使用时间",
|
||||||
|
"userId": "用户 ID",
|
||||||
|
"validityPeriod": "有效期",
|
||||||
|
"yes": "是"
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"active": "有效",
|
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"code": "兑换码",
|
"code": "兑换码",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
"duration": "兑换可用时长",
|
"duration": "兑换可用时长",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"editRedemptionCode": "编辑兑换码",
|
"editRedemptionCode": "编辑兑换码",
|
||||||
"exhausted": "已用尽",
|
|
||||||
"form": {
|
"form": {
|
||||||
"batchCount": "批次数量",
|
"batchCount": "批次数量",
|
||||||
"batchCountPlaceholder": "批次数量",
|
"batchCountPlaceholder": "批次数量",
|
||||||
@@ -26,16 +24,12 @@
|
|||||||
"halfYear": "半年",
|
"halfYear": "半年",
|
||||||
"month": "月",
|
"month": "月",
|
||||||
"quarter": "季度",
|
"quarter": "季度",
|
||||||
"quantityRequired": "数量为必填项",
|
|
||||||
"selectPlan": "选择兑换套餐",
|
"selectPlan": "选择兑换套餐",
|
||||||
"selectUnitTime": "选择兑换时长单位",
|
"selectUnitTime": "选择兑换时长单位",
|
||||||
"subscribePlan": "兑换套餐",
|
"subscribePlan": "兑换套餐",
|
||||||
"subscribePlanRequired": "兑换套餐为必填项",
|
|
||||||
"totalCount": "兑换码可用次数",
|
"totalCount": "兑换码可用次数",
|
||||||
"totalCountPlaceholder": "兑换码可用次数",
|
"totalCountPlaceholder": "兑换码可用次数",
|
||||||
"totalCountRequired": "兑换码可用次数为必填项",
|
|
||||||
"unitTime": "兑换时长单位",
|
"unitTime": "兑换时长单位",
|
||||||
"unitTimeRequired": "兑换时长单位为必填项",
|
|
||||||
"year": "年"
|
"year": "年"
|
||||||
},
|
},
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@@ -50,8 +44,8 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"subscribeId": "套餐ID",
|
"subscribeId": "套餐ID",
|
||||||
"subscribePlan": "兑换套餐",
|
"subscribePlan": "兑换套餐",
|
||||||
"totalCount": "兑换码可用次数",
|
|
||||||
"total": "总计",
|
"total": "总计",
|
||||||
|
"totalCount": "兑换码可用次数",
|
||||||
"unitTime": "兑换时长单位",
|
"unitTime": "兑换时长单位",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
"usedCount": "已使用数量",
|
"usedCount": "已使用数量",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存设置",
|
"save": "保存设置",
|
||||||
|
"saving": "保存中...",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"saveSuccess": "保存成功"
|
"saveSuccess": "保存成功"
|
||||||
},
|
},
|
||||||
@@ -19,10 +20,15 @@
|
|||||||
"description": "配置货币单位、符号和汇率 API 设置",
|
"description": "配置货币单位、符号和汇率 API 设置",
|
||||||
"title": "货币配置"
|
"title": "货币配置"
|
||||||
},
|
},
|
||||||
|
"groupSettings": "",
|
||||||
"invite": {
|
"invite": {
|
||||||
"description": "配置用户邀请和推荐奖励设置",
|
"description": "配置用户邀请和推荐奖励设置",
|
||||||
"forcedInvite": "强制邀请注册",
|
"forcedInvite": "强制邀请注册",
|
||||||
"forcedInviteDescription": "启用后,用户必须通过邀请链接注册",
|
"forcedInviteDescription": "启用后,用户必须通过邀请链接注册",
|
||||||
|
"giftDays": "邀请赠送天数",
|
||||||
|
"giftDaysDescription": "当推荐佣金比例为 0 时,被邀请人完成购买后,邀请人和被邀请人各自获得的订阅延长天数",
|
||||||
|
"giftDaysPlaceholder": "请输入天数",
|
||||||
|
"giftDaysSuffix": "天",
|
||||||
"inputPlaceholder": "请输入",
|
"inputPlaceholder": "请输入",
|
||||||
"onlyFirstPurchase": "仅首次购买奖励",
|
"onlyFirstPurchase": "仅首次购买奖励",
|
||||||
"onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励",
|
"onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励",
|
||||||
@@ -42,6 +48,22 @@
|
|||||||
"title": "日志清理设置"
|
"title": "日志清理设置"
|
||||||
},
|
},
|
||||||
"logSettings": "日志设置",
|
"logSettings": "日志设置",
|
||||||
|
"signature": {
|
||||||
|
"title": "请求签名",
|
||||||
|
"description": "启用或禁用公共 API 的请求签名验证",
|
||||||
|
"enable": "启用签名验证",
|
||||||
|
"enableDescription": "启用后,客户端可以通过发送 X-Signature-Enabled: 1 来触发严格的签名验证",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"saveFailed": "保存失败"
|
||||||
|
},
|
||||||
|
"subscribeMode": {
|
||||||
|
"title": "订阅模式",
|
||||||
|
"description": "配置单订阅或多订阅购买行为",
|
||||||
|
"singleSubscriptionMode": "单订阅模式",
|
||||||
|
"singleSubscriptionModeDescription": "启用后,用户在同一账户中只能购买/续费一个订阅",
|
||||||
|
"saveSuccess": "订阅模式更新成功",
|
||||||
|
"saveFailed": "更新设置失败"
|
||||||
|
},
|
||||||
"privacyPolicy": {
|
"privacyPolicy": {
|
||||||
"description": "编辑和管理隐私政策内容",
|
"description": "编辑和管理隐私政策内容",
|
||||||
"title": "隐私政策"
|
"title": "隐私政策"
|
||||||
@@ -103,13 +125,21 @@
|
|||||||
},
|
},
|
||||||
"userSecuritySettings": "用户与安全",
|
"userSecuritySettings": "用户与安全",
|
||||||
"verify": {
|
"verify": {
|
||||||
"description": "配置 Turnstile 验证码和验证设置",
|
"captchaType": "验证码类型",
|
||||||
"enableLoginVerify": "登录验证",
|
"captchaTypeDescription": "选择本地图形验证码、本地滑块验证码(均可离线)或 Cloudflare Turnstile",
|
||||||
"enableLoginVerifyDescription": "启用后,用户登录时必须通过人机验证",
|
"captchaTypeLocal": "本地图形验证码",
|
||||||
"enablePasswordVerify": "密码重置验证",
|
"captchaTypePlaceholder": "选择验证码类型",
|
||||||
"enablePasswordVerifyDescription": "启用后,用户重置密码时必须通过人机验证",
|
"captchaTypeSlider": "本地滑块验证码",
|
||||||
"enableRegisterVerify": "注册验证",
|
"captchaTypeTurnstile": "Cloudflare Turnstile",
|
||||||
"enableRegisterVerifyDescription": "启用后,用户注册时必须通过人机验证",
|
"description": "配置验证码类型和验证设置",
|
||||||
|
"enableAdminLoginCaptcha": "启用管理端认证验证码",
|
||||||
|
"enableAdminLoginCaptchaDescription": "启用后,管理员登录或重置密码时必须通过验证码验证",
|
||||||
|
"enableUserLoginCaptcha": "启用用户端登录验证码",
|
||||||
|
"enableUserLoginCaptchaDescription": "启用后,用户登录时必须通过验证码验证",
|
||||||
|
"enableUserRegisterCaptcha": "启用用户端注册验证码",
|
||||||
|
"enableUserRegisterCaptchaDescription": "启用后,用户注册时必须通过验证码验证",
|
||||||
|
"enableUserResetPasswordCaptcha": "启用用户重置密码验证码",
|
||||||
|
"enableUserResetPasswordCaptchaDescription": "启用后,用户重置密码时必须通过验证码验证",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"saveSuccess": "保存成功",
|
"saveSuccess": "保存成功",
|
||||||
"title": "安全验证",
|
"title": "安全验证",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"0": "状态",
|
"0": "状态",
|
||||||
"1": "待跟进",
|
"1": "待跟进",
|
||||||
"2": "待回复",
|
"2": "待回复",
|
||||||
"3": "已解决",
|
"3": "已取消",
|
||||||
"4": "已关闭"
|
"4": "已关闭"
|
||||||
},
|
},
|
||||||
"ticketList": "工单列表",
|
"ticketList": "工单列表",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"systemReboot": "系统重启",
|
"systemReboot": "系统重启",
|
||||||
"systemServices": "系统服务",
|
"systemServices": "系统服务",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
|
"updateDescription": "",
|
||||||
"updateFailed": "更新失败",
|
"updateFailed": "更新失败",
|
||||||
"updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
|
"updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
"serverRequired": "请选择服务器"
|
"serverRequired": "请选择服务器"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
|
"quantityRequired": "",
|
||||||
|
"subscribePlanRequired": "",
|
||||||
|
"totalCountRequired": "",
|
||||||
|
"unitTimeRequired": "",
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "客户端名称必填",
|
"nameRequired": "客户端名称必填",
|
||||||
"userAgentRequiredSuffix": "是必填项"
|
"userAgentRequiredSuffix": "是必填项"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"accountEnable": "账户启用",
|
"accountEnable": "账户启用",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"administrator": "管理员",
|
"administrator": "管理员",
|
||||||
|
"all": "全部",
|
||||||
"areaCodePlaceholder": "区号",
|
"areaCodePlaceholder": "区号",
|
||||||
"authMethodsTitle": "认证方式",
|
"authMethodsTitle": "认证方式",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDelete": "确认删除",
|
"confirmDelete": "确认删除",
|
||||||
"confirmOffline": "确认下线",
|
"confirmOffline": "确认下线",
|
||||||
|
"confirmResetToken": "确认重置订阅地址",
|
||||||
|
"confirmResumeSubscribe": "确认恢复订阅",
|
||||||
|
"confirmStopSubscribe": "确认暂停订阅",
|
||||||
"copySubscription": "复制订阅",
|
"copySubscription": "复制订阅",
|
||||||
"copySuccess": "复制成功",
|
"copySuccess": "复制成功",
|
||||||
"create": "创建",
|
"create": "创建",
|
||||||
@@ -24,26 +28,69 @@
|
|||||||
"createSubscription": "创建订阅",
|
"createSubscription": "创建订阅",
|
||||||
"createSuccess": "创建成功",
|
"createSuccess": "创建成功",
|
||||||
"createUser": "创建用户",
|
"createUser": "创建用户",
|
||||||
|
"currentCommission": "当前佣金",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
"deleteDescription": "此操作无法撤销。",
|
"deleteDescription": "此操作无法撤销。",
|
||||||
"deleteSubscriptionDescription": "此操作无法撤销。",
|
"deleteSubscriptionDescription": "此操作无法撤销。",
|
||||||
"deleteSuccess": "删除成功",
|
"deleteSuccess": "删除成功",
|
||||||
"isDeleted": "状态",
|
"deviceGroup": "设备组",
|
||||||
"deviceLimit": "IP限制",
|
"deviceLimit": "IP限制",
|
||||||
|
"deviceNo": "设备编号",
|
||||||
|
"deviceSearch": "设备",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTraffic": "下载流量",
|
"downloadTraffic": "下载流量",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"email": "邮箱",
|
||||||
|
"editGroup": "编辑分组",
|
||||||
"editSubscription": "编辑订阅",
|
"editSubscription": "编辑订阅",
|
||||||
|
"editUserGroup": "编辑用户组",
|
||||||
|
"editUserGroupDescription": "编辑用户组分配和锁定状态",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
|
"enabled": "启用",
|
||||||
|
"disabled": "禁用",
|
||||||
"expiredAt": "过期时间",
|
"expiredAt": "过期时间",
|
||||||
"expireTime": "过期时间",
|
"expireTime": "过期时间",
|
||||||
|
"familyActions": "操作",
|
||||||
|
"familyConfirmDissolve": "确认注销账号",
|
||||||
|
"familyConfirmRemoveMember": "确认移除成员",
|
||||||
|
"familyDetail": "设备组详情",
|
||||||
|
"familyDisabled": "已禁用",
|
||||||
|
"familyDissolve": "注销账号",
|
||||||
|
"familyDissolved": "设备组已解散",
|
||||||
|
"familyDissolveDescription": "此操作将注销账号并移除所有活跃成员。",
|
||||||
|
"familyId": "设备组 ID",
|
||||||
|
"familyInvalidMaxMembers": "最大成员数无效",
|
||||||
|
"familyJoinSource": "加入方式",
|
||||||
|
"familyJoinSourceOwnerInit": "创建者初始化",
|
||||||
|
"familyJoinedAt": "加入时间",
|
||||||
|
"familyLeftAt": "离开时间",
|
||||||
|
"familyManagement": "设备组管理",
|
||||||
|
"familyMaxMembers": "最大成员数",
|
||||||
|
"familyMaxMembersTooSmall": "最大成员数不能小于当前活跃成员数",
|
||||||
|
"familyMember": "成员",
|
||||||
|
"familyMemberLeft": "已离开",
|
||||||
|
"familyMemberRemoved": "已移除",
|
||||||
|
"familyMembers": "设备组成员",
|
||||||
|
"familyNoData": "暂无设备组数据",
|
||||||
|
"familyNoMembers": "暂无成员",
|
||||||
|
"familyOwnerUserId": "所有者用户 ID",
|
||||||
|
"familyRemoveMemberDescription": "此操作将从活跃设备组中移除该成员。",
|
||||||
|
"familyStatus": "设备组状态",
|
||||||
|
"familySummary": "设备组概览",
|
||||||
|
"familyUpdateMaxMembers": "更新最大成员数",
|
||||||
|
"firstPurchaseOnly": "仅首次购买",
|
||||||
"giftAmount": "赠送金额",
|
"giftAmount": "赠送金额",
|
||||||
"giftAmountPlaceholder": "输入赠送金额",
|
"giftAmountPlaceholder": "输入赠送金额",
|
||||||
"giftLogs": "赠送日志",
|
"giftLogs": "赠送日志",
|
||||||
|
"globalDefault": "全局默认",
|
||||||
"invalidEmailFormat": "邮箱格式无效",
|
"invalidEmailFormat": "邮箱格式无效",
|
||||||
"inviteCode": "邀请码",
|
"inviteCode": "邀请码",
|
||||||
"inviteCodePlaceholder": "输入邀请码",
|
"inviteCodePlaceholder": "输入邀请码",
|
||||||
|
"inviteCount": "邀请用户数",
|
||||||
|
"inviteStats": "邀请统计",
|
||||||
|
"invitedUsers": "已邀请用户",
|
||||||
|
"isDeleted": "状态",
|
||||||
"kickOfflineConfirm": "确认踢下线",
|
"kickOfflineConfirm": "确认踢下线",
|
||||||
"kickOfflineSuccess": "设备已踢下线",
|
"kickOfflineSuccess": "设备已踢下线",
|
||||||
"lastSeen": "最后上线",
|
"lastSeen": "最后上线",
|
||||||
@@ -52,17 +99,24 @@
|
|||||||
"loginNotifications": "登录通知",
|
"loginNotifications": "登录通知",
|
||||||
"loginStatus": "登录状态",
|
"loginStatus": "登录状态",
|
||||||
"manager": "管理员",
|
"manager": "管理员",
|
||||||
|
"memberCount": "成员数量",
|
||||||
"more": "更多",
|
"more": "更多",
|
||||||
|
"neverLoggedIn": "从未登录",
|
||||||
"normal": "正常",
|
"normal": "正常",
|
||||||
|
"next": "下一页",
|
||||||
|
"noInvitedUsers": "暂无邀请用户",
|
||||||
"notifySettingsTitle": "通知设置",
|
"notifySettingsTitle": "通知设置",
|
||||||
|
"inactive": "未活跃",
|
||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"onlineDevices": "在线设备",
|
"onlineDevices": "在线设备",
|
||||||
"onlyFirstPurchase": "仅首次购买",
|
"onlyFirstPurchase": "仅首次购买",
|
||||||
"orderList": "订单列表",
|
"orderList": "订单列表",
|
||||||
|
"owner": "所有者",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"passwordPlaceholder": "输入密码",
|
"passwordPlaceholder": "输入密码",
|
||||||
"permanent": "永久",
|
"permanent": "永久",
|
||||||
|
"prev": "上一页",
|
||||||
"pleaseEnterEmail": "输入邮箱",
|
"pleaseEnterEmail": "输入邮箱",
|
||||||
"referer": "推荐人",
|
"referer": "推荐人",
|
||||||
"refererId": "推荐人 ID",
|
"refererId": "推荐人 ID",
|
||||||
@@ -71,7 +125,9 @@
|
|||||||
"referralPercentage": "推荐百分比",
|
"referralPercentage": "推荐百分比",
|
||||||
"referralPercentagePlaceholder": "输入百分比",
|
"referralPercentagePlaceholder": "输入百分比",
|
||||||
"referrerUserId": "推荐人用户 ID",
|
"referrerUserId": "推荐人用户 ID",
|
||||||
|
"registeredAt": "注册时间",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
|
"removeSuccess": "移除成功",
|
||||||
"resetLogs": "重置日志",
|
"resetLogs": "重置日志",
|
||||||
"resetTraffic": "重置流量",
|
"resetTraffic": "重置流量",
|
||||||
"toggleStatus": "切换状态",
|
"toggleStatus": "切换状态",
|
||||||
@@ -81,29 +137,36 @@
|
|||||||
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
|
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
|
||||||
"toggleSubscriptionStatus": "切换状态",
|
"toggleSubscriptionStatus": "切换状态",
|
||||||
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
|
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
|
||||||
|
"resetSearch": "重置",
|
||||||
"resetTime": "重置时间",
|
"resetTime": "重置时间",
|
||||||
"resetToken": "重置订阅地址",
|
"resetToken": "重置订阅地址",
|
||||||
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
||||||
|
"saving": "保存中...",
|
||||||
"resetTokenSuccess": "订阅地址重置成功",
|
"resetTokenSuccess": "订阅地址重置成功",
|
||||||
"confirmResetToken": "确认重置订阅地址",
|
"resumeSubscribe": "恢复订阅",
|
||||||
|
"selectGroup": "选择一个组",
|
||||||
|
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
|
||||||
|
"resumeSubscribeSuccess": "订阅已恢复",
|
||||||
|
"save": "保存",
|
||||||
|
"shortCode": "短码",
|
||||||
|
"speedLimit": "速度限制",
|
||||||
|
"startTime": "开始时间",
|
||||||
|
"status": "状态",
|
||||||
|
"statusActive": "活跃",
|
||||||
|
"statusDeducted": "已扣除",
|
||||||
|
"statusExpired": "已过期",
|
||||||
|
"statusFinished": "已完成",
|
||||||
|
"statusPending": "待处理",
|
||||||
|
"statusStopped": "已停止",
|
||||||
"stopSubscribe": "暂停订阅",
|
"stopSubscribe": "暂停订阅",
|
||||||
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
|
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
|
||||||
"stopSubscribeSuccess": "订阅已暂停",
|
"stopSubscribeSuccess": "订阅已暂停",
|
||||||
"confirmStopSubscribe": "确认暂停订阅",
|
"search": "搜索",
|
||||||
"resumeSubscribe": "恢复订阅",
|
"searchPlaceholder": "邮箱 / 邀请码 / 设备ID",
|
||||||
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
|
"searchInputPlaceholder": "请输入搜索内容",
|
||||||
"resumeSubscribeSuccess": "订阅已恢复",
|
"sharedSubscription": "共享",
|
||||||
"confirmResumeSubscribe": "确认恢复订阅",
|
"sharedSubscriptionInfo": "该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅",
|
||||||
"status": "状态",
|
"sharedSubscriptionList": "共享订阅列表",
|
||||||
"statusPending": "待处理",
|
|
||||||
"statusActive": "活跃",
|
|
||||||
"statusFinished": "已完成",
|
|
||||||
"statusExpired": "已过期",
|
|
||||||
"statusDeducted": "已扣除",
|
|
||||||
"statusStopped": "已停止",
|
|
||||||
"save": "保存",
|
|
||||||
"speedLimit": "速度限制",
|
|
||||||
"startTime": "开始时间",
|
|
||||||
"subscription": "订阅",
|
"subscription": "订阅",
|
||||||
"subscriptionId": "订阅 ID",
|
"subscriptionId": "订阅 ID",
|
||||||
"subscriptionInfo": "订阅信息",
|
"subscriptionInfo": "订阅信息",
|
||||||
@@ -114,18 +177,22 @@
|
|||||||
"telephone": "电话",
|
"telephone": "电话",
|
||||||
"telephonePlaceholder": "输入电话号码",
|
"telephonePlaceholder": "输入电话号码",
|
||||||
"token": "令牌",
|
"token": "令牌",
|
||||||
|
"totalCommission": "总佣金",
|
||||||
"totalTraffic": "总流量",
|
"totalTraffic": "总流量",
|
||||||
"tradeNotifications": "交易通知",
|
"tradeNotifications": "交易通知",
|
||||||
"trafficDetails": "流量详情",
|
"trafficDetails": "流量详情",
|
||||||
"trafficLimit": "流量限制",
|
"trafficLimit": "流量限制",
|
||||||
"trafficStats": "流量统计",
|
"trafficStats": "流量统计",
|
||||||
"trafficUsage": "流量使用",
|
"trafficUsage": "流量使用",
|
||||||
|
"remainingTraffic": "剩余流量",
|
||||||
"unlimited": "无限制",
|
"unlimited": "无限制",
|
||||||
"unverified": "未验证",
|
"unverified": "未验证",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
|
"groupUpdated": "分组更新成功",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uploadTraffic": "上传流量",
|
"uploadTraffic": "上传流量",
|
||||||
|
"userStatus": "用户状态",
|
||||||
"userAgent": "用户代理",
|
"userAgent": "用户代理",
|
||||||
"userEmail": "邮箱",
|
"userEmail": "邮箱",
|
||||||
"userEmailPlaceholder": "输入邮箱",
|
"userEmailPlaceholder": "输入邮箱",
|
||||||
@@ -134,5 +201,21 @@
|
|||||||
"userList": "用户列表",
|
"userList": "用户列表",
|
||||||
"userName": "用户名",
|
"userName": "用户名",
|
||||||
"userProfile": "用户资料",
|
"userProfile": "用户资料",
|
||||||
"verified": "已验证"
|
"userGroup": "用户分组",
|
||||||
|
"active": "活跃",
|
||||||
|
"verified": "已验证",
|
||||||
|
"viewDeviceGroup": "查看设备组",
|
||||||
|
"viewOwner": "查看所有者",
|
||||||
|
"locked": "锁定",
|
||||||
|
"lockGroup": "锁定分组",
|
||||||
|
"lockGroupDescription": "防止自动分组更改此用户的分组",
|
||||||
|
"groupLocked": "分组已锁定",
|
||||||
|
"previewNodes": "预览节点",
|
||||||
|
"availableNodes": "可用节点",
|
||||||
|
"name": "名称",
|
||||||
|
"address": "地址",
|
||||||
|
"noNodesAvailable": "无可用节点",
|
||||||
|
"nodeGroup": "节点组",
|
||||||
|
"publicNodes": "公共节点",
|
||||||
|
"subscriptionNodes": "套餐节点"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function Display<T extends number | undefined | null>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "trafficSpeed") {
|
if (type === "trafficSpeed") {
|
||||||
return value ? `${formatBytes(value).replace("B", "b")}ps` : "0";
|
return value ? `${value} Mbps` : "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "number") {
|
if (type === "number") {
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export function useNavs() {
|
|||||||
url: "/dashboard/nodes",
|
url: "/dashboard/nodes",
|
||||||
icon: "flat-color-icons:mind-map",
|
icon: "flat-color-icons:mind-map",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("Group Management", "Group Management"),
|
||||||
|
url: "/dashboard/group",
|
||||||
|
icon: "flat-color-icons:department",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("Subscribe Config", "Subscribe Config"),
|
title: t("Subscribe Config", "Subscribe Config"),
|
||||||
url: "/dashboard/subscribe",
|
url: "/dashboard/subscribe",
|
||||||
@@ -44,6 +49,11 @@ export function useNavs() {
|
|||||||
url: "/dashboard/product",
|
url: "/dashboard/product",
|
||||||
icon: "flat-color-icons:shop",
|
icon: "flat-color-icons:shop",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("Promo Management", "Promo Management"),
|
||||||
|
url: "/dashboard/promo",
|
||||||
|
icon: "flat-color-icons:briefcase",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -88,6 +98,21 @@ 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("Invite Management", "Invite Management"),
|
||||||
|
url: "/dashboard/invite-management",
|
||||||
|
icon: "flat-color-icons:add-user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Device Group", "Device Group"),
|
||||||
|
url: "/dashboard/family",
|
||||||
|
icon: "flat-color-icons:home",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("Ticket Management", "Ticket Management"),
|
title: t("Ticket Management", "Ticket Management"),
|
||||||
url: "/dashboard/ticket",
|
url: "/dashboard/ticket",
|
||||||
|
|||||||
@@ -35,12 +35,14 @@ initializeI18n({
|
|||||||
"coupon",
|
"coupon",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"document",
|
"document",
|
||||||
|
"invite",
|
||||||
"log",
|
"log",
|
||||||
"marketing",
|
"marketing",
|
||||||
"menu",
|
"menu",
|
||||||
"nodes",
|
"nodes",
|
||||||
"order",
|
"order",
|
||||||
"payment",
|
"payment",
|
||||||
|
"promo",
|
||||||
"product",
|
"product",
|
||||||
"servers",
|
"servers",
|
||||||
"subscribe",
|
"subscribe",
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ 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 DashboardWithdrawalIndexLazyRouteImport = createFileRoute(
|
||||||
|
'/dashboard/withdrawal/',
|
||||||
|
)()
|
||||||
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
|
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
|
||||||
const DashboardTicketIndexLazyRouteImport =
|
const DashboardTicketIndexLazyRouteImport =
|
||||||
createFileRoute('/dashboard/ticket/')()
|
createFileRoute('/dashboard/ticket/')()
|
||||||
@@ -28,6 +34,8 @@ const DashboardSubscribeIndexLazyRouteImport = createFileRoute(
|
|||||||
const DashboardRedemptionIndexLazyRouteImport = createFileRoute(
|
const DashboardRedemptionIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/redemption/',
|
'/dashboard/redemption/',
|
||||||
)()
|
)()
|
||||||
|
const DashboardPromoIndexLazyRouteImport =
|
||||||
|
createFileRoute('/dashboard/promo/')()
|
||||||
const DashboardProductIndexLazyRouteImport = createFileRoute(
|
const DashboardProductIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/product/',
|
'/dashboard/product/',
|
||||||
)()
|
)()
|
||||||
@@ -39,6 +47,13 @@ const DashboardOrderIndexLazyRouteImport =
|
|||||||
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/marketing/',
|
'/dashboard/marketing/',
|
||||||
)()
|
)()
|
||||||
|
const DashboardInviteManagementIndexLazyRouteImport = createFileRoute(
|
||||||
|
'/dashboard/invite-management/',
|
||||||
|
)()
|
||||||
|
const DashboardGroupIndexLazyRouteImport =
|
||||||
|
createFileRoute('/dashboard/group/')()
|
||||||
|
const DashboardFamilyIndexLazyRouteImport =
|
||||||
|
createFileRoute('/dashboard/family/')()
|
||||||
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/document/',
|
'/dashboard/document/',
|
||||||
)()
|
)()
|
||||||
@@ -105,6 +120,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',
|
||||||
@@ -119,6 +141,14 @@ const DashboardNodesLazyRoute = DashboardNodesLazyRouteImport.update({
|
|||||||
} as any).lazy(() =>
|
} as any).lazy(() =>
|
||||||
import('./routes/dashboard/nodes.lazy').then((d) => d.Route),
|
import('./routes/dashboard/nodes.lazy').then((d) => d.Route),
|
||||||
)
|
)
|
||||||
|
const DashboardWithdrawalIndexLazyRoute =
|
||||||
|
DashboardWithdrawalIndexLazyRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => DashboardWithdrawalLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/withdrawal/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
const DashboardUserIndexLazyRoute = DashboardUserIndexLazyRouteImport.update({
|
const DashboardUserIndexLazyRoute = DashboardUserIndexLazyRouteImport.update({
|
||||||
id: '/user/',
|
id: '/user/',
|
||||||
path: '/user/',
|
path: '/user/',
|
||||||
@@ -158,6 +188,13 @@ const DashboardRedemptionIndexLazyRoute =
|
|||||||
} as any).lazy(() =>
|
} as any).lazy(() =>
|
||||||
import('./routes/dashboard/redemption/index.lazy').then((d) => d.Route),
|
import('./routes/dashboard/redemption/index.lazy').then((d) => d.Route),
|
||||||
)
|
)
|
||||||
|
const DashboardPromoIndexLazyRoute = DashboardPromoIndexLazyRouteImport.update({
|
||||||
|
id: '/promo/',
|
||||||
|
path: '/promo/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/promo/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
const DashboardProductIndexLazyRoute =
|
const DashboardProductIndexLazyRoute =
|
||||||
DashboardProductIndexLazyRouteImport.update({
|
DashboardProductIndexLazyRouteImport.update({
|
||||||
id: '/product/',
|
id: '/product/',
|
||||||
@@ -189,6 +226,31 @@ const DashboardMarketingIndexLazyRoute =
|
|||||||
} as any).lazy(() =>
|
} as any).lazy(() =>
|
||||||
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
||||||
)
|
)
|
||||||
|
const DashboardInviteManagementIndexLazyRoute =
|
||||||
|
DashboardInviteManagementIndexLazyRouteImport.update({
|
||||||
|
id: '/invite-management/',
|
||||||
|
path: '/invite-management/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/invite-management/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const DashboardGroupIndexLazyRoute = DashboardGroupIndexLazyRouteImport.update({
|
||||||
|
id: '/group/',
|
||||||
|
path: '/group/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/group/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
|
const DashboardFamilyIndexLazyRoute =
|
||||||
|
DashboardFamilyIndexLazyRouteImport.update({
|
||||||
|
id: '/family/',
|
||||||
|
path: '/family/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/family/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
const DashboardDocumentIndexLazyRoute =
|
const DashboardDocumentIndexLazyRoute =
|
||||||
DashboardDocumentIndexLazyRouteImport.update({
|
DashboardDocumentIndexLazyRouteImport.update({
|
||||||
id: '/document/',
|
id: '/document/',
|
||||||
@@ -327,6 +389,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 DashboardWithdrawalLazyRouteWithChildren
|
||||||
'/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
|
||||||
@@ -345,15 +408,20 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
|
||||||
|
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
|
||||||
|
'/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
'/dashboard/product': typeof DashboardProductIndexLazyRoute
|
'/dashboard/product': typeof DashboardProductIndexLazyRoute
|
||||||
|
'/dashboard/promo': typeof DashboardPromoIndexLazyRoute
|
||||||
'/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute
|
'/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute
|
||||||
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
|
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
|
||||||
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
|
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
|
||||||
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
|
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
|
||||||
'/dashboard/user': typeof DashboardUserIndexLazyRoute
|
'/dashboard/user': typeof DashboardUserIndexLazyRoute
|
||||||
|
'/dashboard/withdrawal/': typeof DashboardWithdrawalIndexLazyRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexLazyRoute
|
'/': typeof IndexLazyRoute
|
||||||
@@ -377,15 +445,20 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
|
||||||
|
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
|
||||||
|
'/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
'/dashboard/product': typeof DashboardProductIndexLazyRoute
|
'/dashboard/product': typeof DashboardProductIndexLazyRoute
|
||||||
|
'/dashboard/promo': typeof DashboardPromoIndexLazyRoute
|
||||||
'/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute
|
'/dashboard/redemption': typeof DashboardRedemptionIndexLazyRoute
|
||||||
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
|
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
|
||||||
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
|
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
|
||||||
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
|
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
|
||||||
'/dashboard/user': typeof DashboardUserIndexLazyRoute
|
'/dashboard/user': typeof DashboardUserIndexLazyRoute
|
||||||
|
'/dashboard/withdrawal': typeof DashboardWithdrawalIndexLazyRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -393,6 +466,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 DashboardWithdrawalLazyRouteWithChildren
|
||||||
'/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
|
||||||
@@ -411,15 +485,20 @@ export interface FileRoutesById {
|
|||||||
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
|
||||||
|
'/dashboard/group/': typeof DashboardGroupIndexLazyRoute
|
||||||
|
'/dashboard/invite-management/': typeof DashboardInviteManagementIndexLazyRoute
|
||||||
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
||||||
'/dashboard/product/': typeof DashboardProductIndexLazyRoute
|
'/dashboard/product/': typeof DashboardProductIndexLazyRoute
|
||||||
|
'/dashboard/promo/': typeof DashboardPromoIndexLazyRoute
|
||||||
'/dashboard/redemption/': typeof DashboardRedemptionIndexLazyRoute
|
'/dashboard/redemption/': typeof DashboardRedemptionIndexLazyRoute
|
||||||
'/dashboard/subscribe/': typeof DashboardSubscribeIndexLazyRoute
|
'/dashboard/subscribe/': typeof DashboardSubscribeIndexLazyRoute
|
||||||
'/dashboard/system/': typeof DashboardSystemIndexLazyRoute
|
'/dashboard/system/': typeof DashboardSystemIndexLazyRoute
|
||||||
'/dashboard/ticket/': typeof DashboardTicketIndexLazyRoute
|
'/dashboard/ticket/': typeof DashboardTicketIndexLazyRoute
|
||||||
'/dashboard/user/': typeof DashboardUserIndexLazyRoute
|
'/dashboard/user/': typeof DashboardUserIndexLazyRoute
|
||||||
|
'/dashboard/withdrawal/': typeof DashboardWithdrawalIndexLazyRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -428,6 +507,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'
|
||||||
@@ -446,15 +526,20 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/family'
|
||||||
|
| '/dashboard/group'
|
||||||
|
| '/dashboard/invite-management'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
| '/dashboard/product'
|
| '/dashboard/product'
|
||||||
|
| '/dashboard/promo'
|
||||||
| '/dashboard/redemption'
|
| '/dashboard/redemption'
|
||||||
| '/dashboard/subscribe'
|
| '/dashboard/subscribe'
|
||||||
| '/dashboard/system'
|
| '/dashboard/system'
|
||||||
| '/dashboard/ticket'
|
| '/dashboard/ticket'
|
||||||
| '/dashboard/user'
|
| '/dashboard/user'
|
||||||
|
| '/dashboard/withdrawal/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -478,21 +563,27 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/family'
|
||||||
|
| '/dashboard/group'
|
||||||
|
| '/dashboard/invite-management'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
| '/dashboard/product'
|
| '/dashboard/product'
|
||||||
|
| '/dashboard/promo'
|
||||||
| '/dashboard/redemption'
|
| '/dashboard/redemption'
|
||||||
| '/dashboard/subscribe'
|
| '/dashboard/subscribe'
|
||||||
| '/dashboard/system'
|
| '/dashboard/system'
|
||||||
| '/dashboard/ticket'
|
| '/dashboard/ticket'
|
||||||
| '/dashboard/user'
|
| '/dashboard/user'
|
||||||
|
| '/dashboard/withdrawal'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/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'
|
||||||
@@ -511,15 +602,20 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control/'
|
| '/dashboard/auth-control/'
|
||||||
| '/dashboard/coupon/'
|
| '/dashboard/coupon/'
|
||||||
| '/dashboard/document/'
|
| '/dashboard/document/'
|
||||||
|
| '/dashboard/family/'
|
||||||
|
| '/dashboard/group/'
|
||||||
|
| '/dashboard/invite-management/'
|
||||||
| '/dashboard/marketing/'
|
| '/dashboard/marketing/'
|
||||||
| '/dashboard/order/'
|
| '/dashboard/order/'
|
||||||
| '/dashboard/payment/'
|
| '/dashboard/payment/'
|
||||||
| '/dashboard/product/'
|
| '/dashboard/product/'
|
||||||
|
| '/dashboard/promo/'
|
||||||
| '/dashboard/redemption/'
|
| '/dashboard/redemption/'
|
||||||
| '/dashboard/subscribe/'
|
| '/dashboard/subscribe/'
|
||||||
| '/dashboard/system/'
|
| '/dashboard/system/'
|
||||||
| '/dashboard/ticket/'
|
| '/dashboard/ticket/'
|
||||||
| '/dashboard/user/'
|
| '/dashboard/user/'
|
||||||
|
| '/dashboard/withdrawal/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -550,6 +646,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'
|
||||||
@@ -564,6 +667,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardNodesLazyRouteImport
|
preLoaderRoute: typeof DashboardNodesLazyRouteImport
|
||||||
parentRoute: typeof DashboardRouteLazyRoute
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/withdrawal/': {
|
||||||
|
id: '/dashboard/withdrawal/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/dashboard/withdrawal/'
|
||||||
|
preLoaderRoute: typeof DashboardWithdrawalIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardWithdrawalLazyRoute
|
||||||
|
}
|
||||||
'/dashboard/user/': {
|
'/dashboard/user/': {
|
||||||
id: '/dashboard/user/'
|
id: '/dashboard/user/'
|
||||||
path: '/user'
|
path: '/user'
|
||||||
@@ -599,6 +709,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardRedemptionIndexLazyRouteImport
|
preLoaderRoute: typeof DashboardRedemptionIndexLazyRouteImport
|
||||||
parentRoute: typeof DashboardRouteLazyRoute
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/promo/': {
|
||||||
|
id: '/dashboard/promo/'
|
||||||
|
path: '/promo'
|
||||||
|
fullPath: '/dashboard/promo'
|
||||||
|
preLoaderRoute: typeof DashboardPromoIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
'/dashboard/product/': {
|
'/dashboard/product/': {
|
||||||
id: '/dashboard/product/'
|
id: '/dashboard/product/'
|
||||||
path: '/product'
|
path: '/product'
|
||||||
@@ -627,6 +744,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
||||||
parentRoute: typeof DashboardRouteLazyRoute
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/invite-management/': {
|
||||||
|
id: '/dashboard/invite-management/'
|
||||||
|
path: '/invite-management'
|
||||||
|
fullPath: '/dashboard/invite-management'
|
||||||
|
preLoaderRoute: typeof DashboardInviteManagementIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
|
'/dashboard/group/': {
|
||||||
|
id: '/dashboard/group/'
|
||||||
|
path: '/group'
|
||||||
|
fullPath: '/dashboard/group'
|
||||||
|
preLoaderRoute: typeof DashboardGroupIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
|
'/dashboard/family/': {
|
||||||
|
id: '/dashboard/family/'
|
||||||
|
path: '/family'
|
||||||
|
fullPath: '/dashboard/family'
|
||||||
|
preLoaderRoute: typeof DashboardFamilyIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
'/dashboard/document/': {
|
'/dashboard/document/': {
|
||||||
id: '/dashboard/document/'
|
id: '/dashboard/document/'
|
||||||
path: '/document'
|
path: '/document'
|
||||||
@@ -749,9 +887,24 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DashboardWithdrawalLazyRouteChildren {
|
||||||
|
DashboardWithdrawalIndexLazyRoute: typeof DashboardWithdrawalIndexLazyRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardWithdrawalLazyRouteChildren: DashboardWithdrawalLazyRouteChildren =
|
||||||
|
{
|
||||||
|
DashboardWithdrawalIndexLazyRoute: DashboardWithdrawalIndexLazyRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardWithdrawalLazyRouteWithChildren =
|
||||||
|
DashboardWithdrawalLazyRoute._addFileChildren(
|
||||||
|
DashboardWithdrawalLazyRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface DashboardRouteLazyRouteChildren {
|
interface DashboardRouteLazyRouteChildren {
|
||||||
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
|
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
|
||||||
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
|
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
|
||||||
|
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRouteWithChildren
|
||||||
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
|
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
|
||||||
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
|
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
|
||||||
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
|
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
|
||||||
@@ -770,10 +923,14 @@ interface DashboardRouteLazyRouteChildren {
|
|||||||
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
||||||
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
||||||
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
||||||
|
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
|
||||||
|
DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute
|
||||||
|
DashboardInviteManagementIndexLazyRoute: typeof DashboardInviteManagementIndexLazyRoute
|
||||||
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
||||||
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
||||||
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
||||||
DashboardProductIndexLazyRoute: typeof DashboardProductIndexLazyRoute
|
DashboardProductIndexLazyRoute: typeof DashboardProductIndexLazyRoute
|
||||||
|
DashboardPromoIndexLazyRoute: typeof DashboardPromoIndexLazyRoute
|
||||||
DashboardRedemptionIndexLazyRoute: typeof DashboardRedemptionIndexLazyRoute
|
DashboardRedemptionIndexLazyRoute: typeof DashboardRedemptionIndexLazyRoute
|
||||||
DashboardSubscribeIndexLazyRoute: typeof DashboardSubscribeIndexLazyRoute
|
DashboardSubscribeIndexLazyRoute: typeof DashboardSubscribeIndexLazyRoute
|
||||||
DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute
|
DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute
|
||||||
@@ -784,6 +941,7 @@ interface DashboardRouteLazyRouteChildren {
|
|||||||
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
|
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
|
||||||
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
|
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
|
||||||
DashboardServersLazyRoute: DashboardServersLazyRoute,
|
DashboardServersLazyRoute: DashboardServersLazyRoute,
|
||||||
|
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRouteWithChildren,
|
||||||
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
|
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
|
||||||
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
|
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
|
||||||
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
|
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
|
||||||
@@ -802,10 +960,15 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
|
|||||||
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
||||||
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
||||||
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
||||||
|
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
|
||||||
|
DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute,
|
||||||
|
DashboardInviteManagementIndexLazyRoute:
|
||||||
|
DashboardInviteManagementIndexLazyRoute,
|
||||||
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
||||||
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
||||||
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
||||||
DashboardProductIndexLazyRoute: DashboardProductIndexLazyRoute,
|
DashboardProductIndexLazyRoute: DashboardProductIndexLazyRoute,
|
||||||
|
DashboardPromoIndexLazyRoute: DashboardPromoIndexLazyRoute,
|
||||||
DashboardRedemptionIndexLazyRoute: DashboardRedemptionIndexLazyRoute,
|
DashboardRedemptionIndexLazyRoute: DashboardRedemptionIndexLazyRoute,
|
||||||
DashboardSubscribeIndexLazyRoute: DashboardSubscribeIndexLazyRoute,
|
DashboardSubscribeIndexLazyRoute: DashboardSubscribeIndexLazyRoute,
|
||||||
DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute,
|
DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import FamilyManagement from "@/sections/user/family";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/family/")({
|
||||||
|
component: FamilyManagement,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import Group from "@/sections/group";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/group/")({
|
||||||
|
component: Group,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import InviteManagement from "@/sections/invite";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/invite-management/")({
|
||||||
|
component: InviteManagement,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import PromoPage from "@/sections/promo";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/promo/")({
|
||||||
|
component: PromoPage,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import WithdrawalManagementPage from "@/sections/withdrawal";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/withdrawal")({
|
||||||
|
component: WithdrawalManagementPage,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import WithdrawalPage from "@/sections/withdrawal";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/withdrawal/")({
|
||||||
|
component: WithdrawalPage,
|
||||||
|
});
|
||||||
@@ -54,6 +54,7 @@ const emailSettingsSchema = z.object({
|
|||||||
expiration_email_template: z.string().optional(),
|
expiration_email_template: z.string().optional(),
|
||||||
maintenance_email_template: z.string().optional(),
|
maintenance_email_template: z.string().optional(),
|
||||||
traffic_exceed_email_template: z.string().optional(),
|
traffic_exceed_email_template: z.string().optional(),
|
||||||
|
delete_account_email_template: z.string().optional(),
|
||||||
platform: z.string(),
|
platform: z.string(),
|
||||||
platform_config: z
|
platform_config: z
|
||||||
.object({
|
.object({
|
||||||
@@ -102,6 +103,7 @@ export default function EmailSettingsForm() {
|
|||||||
expiration_email_template: "",
|
expiration_email_template: "",
|
||||||
maintenance_email_template: "",
|
maintenance_email_template: "",
|
||||||
traffic_exceed_email_template: "",
|
traffic_exceed_email_template: "",
|
||||||
|
delete_account_email_template: "",
|
||||||
platform: "smtp",
|
platform: "smtp",
|
||||||
platform_config: {
|
platform_config: {
|
||||||
host: "",
|
host: "",
|
||||||
@@ -195,6 +197,12 @@ export default function EmailSettingsForm() {
|
|||||||
<TabsTrigger value="traffic">
|
<TabsTrigger value="traffic">
|
||||||
{t("email.trafficTemplate", "Traffic Template")}
|
{t("email.trafficTemplate", "Traffic Template")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="delete_account">
|
||||||
|
{t(
|
||||||
|
"email.deleteAccountTemplate",
|
||||||
|
"Delete Account Template"
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent className="space-y-2" value="basic">
|
<TabsContent className="space-y-2" value="basic">
|
||||||
@@ -840,6 +848,88 @@ export default function EmailSettingsForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="space-y-2" value="delete_account">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.delete_account_email_template"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"email.deleteAccountEmailTemplate",
|
||||||
|
"Delete Account Email Template"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HTMLEditor
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={t(
|
||||||
|
"email.inputPlaceholder",
|
||||||
|
"Please enter"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="mt-4 space-y-2 border-t pt-4">
|
||||||
|
<p className="font-medium text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.title",
|
||||||
|
"Template Variables"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-muted-foreground text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.SiteLogo}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.siteLogo.description",
|
||||||
|
"Site logo URL"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.SiteName}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.siteName.description",
|
||||||
|
"Site name"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.Code}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.code.description",
|
||||||
|
"Verification code"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.Expire}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.expire.description",
|
||||||
|
"Code expiration time"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
resetPassword,
|
adminLogin,
|
||||||
userLogin,
|
adminResetPassword,
|
||||||
userRegister,
|
} from "@workspace/ui/services/admin/auth";
|
||||||
} from "@workspace/ui/services/common/auth";
|
import { userRegister } from "@workspace/ui/services/common/auth";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -42,7 +42,7 @@ export default function EmailAuthForm() {
|
|||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "login": {
|
case "login": {
|
||||||
const login = await userLogin(params);
|
const login = await adminLogin(params);
|
||||||
toast.success(t("login.success", "Login successful!"));
|
toast.success(t("login.success", "Login successful!"));
|
||||||
onLogin(login.data.data?.token);
|
onLogin(login.data.data?.token);
|
||||||
break;
|
break;
|
||||||
@@ -54,7 +54,7 @@ export default function EmailAuthForm() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "reset":
|
case "reset":
|
||||||
await resetPassword(params);
|
await adminResetPassword(params);
|
||||||
toast.success(t("reset.success", "Password reset successful!"));
|
toast.success(t("reset.success", "Password reset successful!"));
|
||||||
setType("login");
|
setType("login");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
import { Input } from "@workspace/ui/components/input";
|
import { Input } from "@workspace/ui/components/input";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useGlobalStore } from "@/stores/global";
|
import { useGlobalStore } from "@/stores/global";
|
||||||
|
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||||
|
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||||
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
||||||
|
|
||||||
export default function LoginForm({
|
export default function LoginForm({
|
||||||
@@ -33,26 +35,57 @@ export default function LoginForm({
|
|||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
const { common } = useGlobalStore();
|
const { common } = useGlobalStore();
|
||||||
const { verify } = common;
|
const { verify } = common;
|
||||||
|
const [captchaId, setCaptchaId] = useState("");
|
||||||
|
|
||||||
|
const isTurnstile = verify.captcha_type === "turnstile";
|
||||||
|
const isLocal = verify.captcha_type === "local";
|
||||||
|
const isSlider = verify.captcha_type === "slider";
|
||||||
|
const captchaEnabled = verify.enable_admin_login_captcha;
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.email(t("login.email", "Email")),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email(t("login.email", "Please enter a valid email address")),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
cf_token:
|
cf_token:
|
||||||
verify.enable_login_verify && verify.turnstile_site_key
|
captchaEnabled && isTurnstile && verify.turnstile_site_key
|
||||||
? z.string()
|
? z.string()
|
||||||
: z.string().optional(),
|
: z.string().optional(),
|
||||||
|
captcha_code:
|
||||||
|
captchaEnabled && isLocal
|
||||||
|
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
|
||||||
|
: z.string().optional(),
|
||||||
|
slider_token:
|
||||||
|
captchaEnabled && isSlider
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
|
||||||
|
: z.string().optional(),
|
||||||
});
|
});
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: initialValues,
|
defaultValues: {
|
||||||
|
cf_token: "",
|
||||||
|
captcha_code: "",
|
||||||
|
slider_token: "",
|
||||||
|
...initialValues,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const turnstile = useRef<TurnstileRef>(null);
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const localCaptcha = useRef<LocalCaptchaRef>(null);
|
||||||
|
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
try {
|
try {
|
||||||
|
// Add captcha_id for local captcha
|
||||||
|
if (isLocal && captchaEnabled) {
|
||||||
|
(data as any).captcha_id = captchaId;
|
||||||
|
}
|
||||||
onSubmit(data);
|
onSubmit(data);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
turnstile.current?.reset();
|
turnstile.current?.reset();
|
||||||
|
localCaptcha.current?.reset();
|
||||||
|
sliderCaptcha.current?.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +131,7 @@ export default function LoginForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{verify.enable_login_verify && (
|
{captchaEnabled && isTurnstile && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cf_token"
|
name="cf_token"
|
||||||
@@ -116,6 +149,38 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{captchaEnabled && isLocal && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="captcha_code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<LocalCaptcha
|
||||||
|
{...field}
|
||||||
|
onCaptchaIdChange={setCaptchaId}
|
||||||
|
ref={localCaptcha}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{captchaEnabled && isSlider && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slider_token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SliderCaptcha {...field} ref={sliderCaptcha} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button disabled={loading} type="submit">
|
<Button disabled={loading} type="submit">
|
||||||
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
||||||
{t("login.title", "Login")}
|
{t("login.title", "Login")}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
import { Input } from "@workspace/ui/components/input";
|
import { Input } from "@workspace/ui/components/input";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useGlobalStore } from "@/stores/global";
|
import { useGlobalStore } from "@/stores/global";
|
||||||
|
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
|
||||||
import SendCode from "../send-code";
|
import SendCode from "../send-code";
|
||||||
|
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
|
||||||
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
|
||||||
|
|
||||||
export default function ResetForm({
|
export default function ResetForm({
|
||||||
@@ -35,27 +37,58 @@ export default function ResetForm({
|
|||||||
|
|
||||||
const { common } = useGlobalStore();
|
const { common } = useGlobalStore();
|
||||||
const { verify, auth } = common;
|
const { verify, auth } = common;
|
||||||
|
const [captchaId, setCaptchaId] = useState("");
|
||||||
|
|
||||||
|
const isTurnstile = verify.captcha_type === "turnstile";
|
||||||
|
const isLocal = verify.captcha_type === "local";
|
||||||
|
const isSlider = verify.captcha_type === "slider";
|
||||||
|
const captchaEnabled = verify.enable_user_reset_password_captcha;
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.email(t("reset.email", "Email")),
|
email: z
|
||||||
|
.string()
|
||||||
|
.email(t("reset.email", "Please enter a valid email address")),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
|
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
|
||||||
cf_token:
|
cf_token:
|
||||||
verify.enable_register_verify && verify.turnstile_site_key
|
captchaEnabled && isTurnstile && verify.turnstile_site_key
|
||||||
? z.string()
|
? z.string()
|
||||||
: z.string().nullish(),
|
: z.string().nullish(),
|
||||||
|
captcha_code:
|
||||||
|
captchaEnabled && isLocal
|
||||||
|
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
|
||||||
|
: z.string().nullish(),
|
||||||
|
slider_token:
|
||||||
|
captchaEnabled && isSlider
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
|
||||||
|
: z.string().optional(),
|
||||||
});
|
});
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: initialValues,
|
defaultValues: {
|
||||||
|
cf_token: "",
|
||||||
|
captcha_code: "",
|
||||||
|
slider_token: "",
|
||||||
|
...initialValues,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const turnstile = useRef<TurnstileRef>(null);
|
const turnstile = useRef<TurnstileRef>(null);
|
||||||
|
const localCaptcha = useRef<LocalCaptchaRef>(null);
|
||||||
|
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
try {
|
try {
|
||||||
|
// Add captcha_id for local captcha
|
||||||
|
if (isLocal && captchaEnabled) {
|
||||||
|
(data as any).captcha_id = captchaId;
|
||||||
|
}
|
||||||
onSubmit(data);
|
onSubmit(data);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
turnstile.current?.reset();
|
turnstile.current?.reset();
|
||||||
|
localCaptcha.current?.reset();
|
||||||
|
sliderCaptcha.current?.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +161,7 @@ export default function ResetForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{verify.enable_reset_password_verify && (
|
{captchaEnabled && isTurnstile && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="cf_token"
|
name="cf_token"
|
||||||
@@ -146,6 +179,38 @@ export default function ResetForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{captchaEnabled && isLocal && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="captcha_code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<LocalCaptcha
|
||||||
|
{...field}
|
||||||
|
onCaptchaIdChange={setCaptchaId}
|
||||||
|
ref={localCaptcha}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{captchaEnabled && isSlider && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slider_token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SliderCaptcha {...field} ref={sliderCaptcha} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button disabled={loading} type="submit">
|
<Button disabled={loading} type="submit">
|
||||||
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
|
||||||
{t("reset.title", "Reset Password")}
|
{t("reset.title", "Reset Password")}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import { adminGenerateCaptcha } from "@workspace/ui/services/admin/auth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface LocalCaptchaRef {
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalCaptchaProps {
|
||||||
|
value?: string | null;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onCaptchaIdChange?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocalCaptcha = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCaptchaIdChange,
|
||||||
|
ref,
|
||||||
|
}: LocalCaptchaProps & { ref?: RefObject<LocalCaptchaRef | null> }) => {
|
||||||
|
const { t } = useTranslation("auth");
|
||||||
|
const [captchaImage, setCaptchaImage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchCaptcha = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminGenerateCaptcha();
|
||||||
|
const captchaData = res.data?.data;
|
||||||
|
if (captchaData) {
|
||||||
|
setCaptchaImage(captchaData.image);
|
||||||
|
onCaptchaIdChange?.(captchaData.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate captcha:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCaptcha();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
reset: () => {
|
||||||
|
onChange?.("");
|
||||||
|
fetchCaptcha();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
placeholder={t("captcha.placeholder", "Enter captcha code...")}
|
||||||
|
value={value || ""}
|
||||||
|
/>
|
||||||
|
<div className="relative h-10 w-32 flex-shrink-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center bg-muted">
|
||||||
|
<Icon className="animate-spin" icon="mdi:loading" />
|
||||||
|
</div>
|
||||||
|
) : captchaImage ? (
|
||||||
|
<img
|
||||||
|
alt="captcha"
|
||||||
|
className="h-full w-full cursor-pointer object-contain"
|
||||||
|
height={40}
|
||||||
|
onClick={fetchCaptcha}
|
||||||
|
src={captchaImage}
|
||||||
|
title={t("captcha.clickToRefresh", "Click to refresh")}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground text-xs">
|
||||||
|
{t("captcha.noImage", "No Image")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={fetchCaptcha}
|
||||||
|
size="icon"
|
||||||
|
title={t("captcha.refresh", "Refresh captcha")}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:refresh" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalCaptcha.displayName = "LocalCaptcha";
|
||||||
|
|
||||||
|
export default LocalCaptcha;
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import {
|
||||||
|
adminGenerateCaptcha,
|
||||||
|
adminVerifyCaptchaSlider,
|
||||||
|
} from "@workspace/ui/services/admin/auth";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export interface SliderCaptchaRef {
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SliderCaptchaProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrailPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
t: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_SIZE = 100;
|
||||||
|
const BG_NATURAL_WIDTH = 560;
|
||||||
|
const BG_NATURAL_HEIGHT = 280;
|
||||||
|
|
||||||
|
const SliderCaptcha = ({
|
||||||
|
onChange,
|
||||||
|
ref,
|
||||||
|
}: SliderCaptchaProps & { ref?: RefObject<SliderCaptchaRef | null> }) => {
|
||||||
|
const { t } = useTranslation("auth");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [captchaId, setCaptchaId] = useState("");
|
||||||
|
const [bgImage, setBgImage] = useState("");
|
||||||
|
const [blockImage, setBlockImage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
const [status, setStatus] = useState<"idle" | "success" | "fail">("idle");
|
||||||
|
const [blockPos, setBlockPos] = useState({ x: 0, y: 50 });
|
||||||
|
const [shaking, setShaking] = useState(false);
|
||||||
|
|
||||||
|
const dragging = useRef(false);
|
||||||
|
const startPointer = useRef({ x: 0, y: 0 });
|
||||||
|
const startBlock = useRef({ x: 0, y: 50 });
|
||||||
|
const dragStartTime = useRef(0);
|
||||||
|
const trail = useRef<TrailPoint[]>([]);
|
||||||
|
|
||||||
|
const getContainerSize = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT };
|
||||||
|
return { w: el.clientWidth, h: el.clientHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCaptcha = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setStatus("idle");
|
||||||
|
setBlockPos({ x: 0, y: 50 });
|
||||||
|
trail.current = [];
|
||||||
|
try {
|
||||||
|
const res = await adminGenerateCaptcha();
|
||||||
|
const data = res.data?.data;
|
||||||
|
if (data) {
|
||||||
|
setCaptchaId(data.id);
|
||||||
|
setBgImage(data.image);
|
||||||
|
setBlockImage(data.block_image ?? "");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to generate slider captcha:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (verified) return;
|
||||||
|
setOpen(true);
|
||||||
|
fetchCaptcha();
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
reset: () => {
|
||||||
|
setVerified(false);
|
||||||
|
onChange?.("");
|
||||||
|
setOpen(false);
|
||||||
|
trail.current = [];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (status === "success" || loading) return;
|
||||||
|
dragging.current = true;
|
||||||
|
dragStartTime.current = Date.now();
|
||||||
|
trail.current = [];
|
||||||
|
startPointer.current = { x: e.clientX, y: e.clientY };
|
||||||
|
startBlock.current = { ...blockPos };
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const { w: _w, h: _h } = getContainerSize();
|
||||||
|
trail.current.push({
|
||||||
|
x: Math.round(startBlock.current.x),
|
||||||
|
y: Math.round(startBlock.current.y),
|
||||||
|
t: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
const { w, h } = getContainerSize();
|
||||||
|
const scaleX = BG_NATURAL_WIDTH / w;
|
||||||
|
const scaleY = BG_NATURAL_HEIGHT / h;
|
||||||
|
const dx = (e.clientX - startPointer.current.x) * scaleX;
|
||||||
|
const dy = (e.clientY - startPointer.current.y) * scaleY;
|
||||||
|
const newX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
|
||||||
|
);
|
||||||
|
const newY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
|
||||||
|
);
|
||||||
|
setBlockPos({ x: newX, y: newY });
|
||||||
|
|
||||||
|
trail.current.push({
|
||||||
|
x: Math.round(newX),
|
||||||
|
y: Math.round(newY),
|
||||||
|
t: Date.now() - dragStartTime.current,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = async (e: React.PointerEvent) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
dragging.current = false;
|
||||||
|
const { w, h } = getContainerSize();
|
||||||
|
const scaleX = BG_NATURAL_WIDTH / w;
|
||||||
|
const scaleY = BG_NATURAL_HEIGHT / h;
|
||||||
|
const dx = (e.clientX - startPointer.current.x) * scaleX;
|
||||||
|
const dy = (e.clientY - startPointer.current.y) * scaleY;
|
||||||
|
const finalX = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const finalY = Math.round(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setBlockPos({ x: finalX, y: finalY });
|
||||||
|
|
||||||
|
trail.current.push({
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
t: Date.now() - dragStartTime.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminVerifyCaptchaSlider({
|
||||||
|
id: captchaId,
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
trail: JSON.stringify(trail.current),
|
||||||
|
});
|
||||||
|
const token = res.data?.data?.token;
|
||||||
|
if (token) {
|
||||||
|
setStatus("success");
|
||||||
|
setTimeout(() => {
|
||||||
|
setVerified(true);
|
||||||
|
onChange?.(token);
|
||||||
|
setOpen(false);
|
||||||
|
}, 600);
|
||||||
|
} else {
|
||||||
|
triggerFail();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
triggerFail();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFail = () => {
|
||||||
|
setStatus("fail");
|
||||||
|
setShaking(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShaking(false);
|
||||||
|
fetchCaptcha();
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100;
|
||||||
|
const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100;
|
||||||
|
const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100;
|
||||||
|
const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Trigger button */}
|
||||||
|
<button
|
||||||
|
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
|
||||||
|
verified
|
||||||
|
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
|
||||||
|
: "border-input bg-background hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
onClick={handleOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
verified ? "bg-green-500" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{verified ? (
|
||||||
|
<Icon className="text-white text-xs" icon="mdi:check" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={verified ? "font-medium" : "text-muted-foreground"}>
|
||||||
|
{verified
|
||||||
|
? t("captcha.slider.success", "Verified")
|
||||||
|
: t("captcha.slider.clickToVerify", "Click to verify")}
|
||||||
|
</span>
|
||||||
|
{verified && (
|
||||||
|
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Slider dialog */}
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setOpen(false);
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<DialogContent className="select-none p-6 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("captcha.slider.title", "Security Verification")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`relative w-full overflow-hidden rounded-md bg-muted ${
|
||||||
|
shaking ? "animate-[shake_0.4s_ease-in-out]" : ""
|
||||||
|
}`}
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
paddingTop: `${(BG_NATURAL_HEIGHT / BG_NATURAL_WIDTH) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Icon className="animate-spin text-2xl" icon="mdi:loading" />
|
||||||
|
</div>
|
||||||
|
) : bgImage ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
alt="captcha background"
|
||||||
|
className="absolute inset-0 h-full w-full"
|
||||||
|
draggable={false}
|
||||||
|
height={BG_NATURAL_HEIGHT}
|
||||||
|
src={bgImage}
|
||||||
|
width={BG_NATURAL_WIDTH}
|
||||||
|
/>
|
||||||
|
{blockImage && (
|
||||||
|
<img
|
||||||
|
alt="captcha block"
|
||||||
|
className="absolute cursor-grab active:cursor-grabbing"
|
||||||
|
draggable={false}
|
||||||
|
height={BG_NATURAL_HEIGHT}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
src={blockImage}
|
||||||
|
style={{
|
||||||
|
filter:
|
||||||
|
status === "success"
|
||||||
|
? "drop-shadow(0 0 3px rgba(74,222,128,0.9))"
|
||||||
|
: status === "fail"
|
||||||
|
? "drop-shadow(0 0 3px rgba(248,113,113,0.9))"
|
||||||
|
: "drop-shadow(0 0 2px rgba(255,255,255,0.7))",
|
||||||
|
left: `${blockLeftPct}%`,
|
||||||
|
top: `${blockTopPct}%`,
|
||||||
|
width: `${blockSizeWPct}%`,
|
||||||
|
height: `${blockSizeHPct}%`,
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
width={BG_NATURAL_WIDTH}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status !== "idle" && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center font-medium text-sm ${
|
||||||
|
status === "success"
|
||||||
|
? "bg-green-500/20 text-green-700"
|
||||||
|
: "bg-red-500/20 text-red-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status === "success"
|
||||||
|
? t("captcha.slider.success", "Verified")
|
||||||
|
: t("captcha.slider.fail", "Try again")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-muted-foreground text-xs">
|
||||||
|
{t("captcha.slider.hint", "Drag the piece to fit the puzzle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={fetchCaptcha}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:refresh" />
|
||||||
|
{t("captcha.clickToRefresh", "Refresh")}
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-8px); }
|
||||||
|
40% { transform: translateX(8px); }
|
||||||
|
60% { transform: translateX(-6px); }
|
||||||
|
80% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SliderCaptcha.displayName = "SliderCaptcha";
|
||||||
|
|
||||||
|
export default SliderCaptcha;
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Turnstile, { useTurnstile } from "react-turnstile";
|
import Turnstile, { useTurnstile } from "react-turnstile";
|
||||||
|
|
||||||
@@ -9,55 +22,134 @@ export type TurnstileRef = {
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CloudFlareTurnstile = forwardRef<
|
const CloudFlareTurnstile = function CloudFlareTurnstile({
|
||||||
TurnstileRef,
|
id,
|
||||||
{
|
value,
|
||||||
id?: string;
|
onChange,
|
||||||
value?: null | string;
|
ref,
|
||||||
onChange: (value?: string) => void;
|
}: {
|
||||||
}
|
id?: string;
|
||||||
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
|
value?: null | string;
|
||||||
|
onChange: (value?: string) => void;
|
||||||
|
ref?: RefObject<TurnstileRef | null>;
|
||||||
|
}) {
|
||||||
const { common } = useGlobalStore();
|
const { common } = useGlobalStore();
|
||||||
const { verify } = common;
|
const { verify } = common;
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { i18n } = useTranslation();
|
const { i18n, t } = useTranslation("auth");
|
||||||
const locale = i18n.language;
|
const locale = i18n.language;
|
||||||
const turnstile = useTurnstile();
|
const turnstile = useTurnstile();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
reset: () => turnstile.reset(),
|
reset: () => {
|
||||||
|
setVerified(false);
|
||||||
|
onChange("");
|
||||||
|
turnstile.reset();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[turnstile]
|
[turnstile, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === "") {
|
if (value === "") {
|
||||||
|
setVerified(false);
|
||||||
turnstile.reset();
|
turnstile.reset();
|
||||||
}
|
}
|
||||||
}, [turnstile, value]);
|
}, [turnstile, value]);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (verified) return;
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!verify.turnstile_site_key) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
verify.turnstile_site_key && (
|
<>
|
||||||
<Turnstile
|
{/* Trigger button */}
|
||||||
fixedSize
|
<button
|
||||||
id={id}
|
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
|
||||||
language={locale.toLowerCase()}
|
verified
|
||||||
onExpire={() => {
|
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
|
||||||
onChange();
|
: "border-input bg-background hover:bg-muted"
|
||||||
turnstile.reset();
|
}`}
|
||||||
|
onClick={handleOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
verified ? "bg-green-500" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{verified ? (
|
||||||
|
<Icon className="text-white text-xs" icon="mdi:check" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={verified ? "font-medium" : "text-muted-foreground"}>
|
||||||
|
{verified
|
||||||
|
? t("captcha.turnstile.success", "Verified")
|
||||||
|
: t("captcha.turnstile.clickToVerify", "Click to verify")}
|
||||||
|
</span>
|
||||||
|
{verified && (
|
||||||
|
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Turnstile dialog */}
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setOpen(false);
|
||||||
}}
|
}}
|
||||||
onTimeout={() => {
|
open={open}
|
||||||
onChange();
|
>
|
||||||
turnstile.reset();
|
<DialogContent className="flex w-auto flex-col items-center gap-4 p-6">
|
||||||
}}
|
<DialogHeader>
|
||||||
onVerify={(token) => onChange(token)}
|
<DialogTitle>
|
||||||
sitekey={verify.turnstile_site_key}
|
{t("captcha.turnstile.title", "Security Verification")}
|
||||||
theme={resolvedTheme as "light" | "dark"}
|
</DialogTitle>
|
||||||
/>
|
</DialogHeader>
|
||||||
)
|
<Turnstile
|
||||||
|
fixedSize
|
||||||
|
id={id}
|
||||||
|
language={locale.toLowerCase()}
|
||||||
|
onExpire={() => {
|
||||||
|
onChange("");
|
||||||
|
turnstile.reset();
|
||||||
|
}}
|
||||||
|
onTimeout={() => {
|
||||||
|
onChange("");
|
||||||
|
turnstile.reset();
|
||||||
|
}}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setVerified(true);
|
||||||
|
onChange(token);
|
||||||
|
setTimeout(() => setOpen(false), 400);
|
||||||
|
}}
|
||||||
|
sitekey={verify.turnstile_site_key}
|
||||||
|
theme={resolvedTheme as "light" | "dark"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("captcha.turnstile.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export default CloudFlareTurnstile;
|
export default CloudFlareTurnstile;
|
||||||
|
|||||||
@@ -13,13 +13,7 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@workspace/ui/components/chart";
|
} from "@workspace/ui/components/chart";
|
||||||
import {
|
// (Select imports removed)
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@workspace/ui/components/select";
|
|
||||||
import { Separator } from "@workspace/ui/components/separator";
|
import { Separator } from "@workspace/ui/components/separator";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||||
import Empty from "@workspace/ui/composed/empty";
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
@@ -62,7 +56,6 @@ export default function Statistics() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dataType, setDataType] = useState<string | "nodes" | "users">("nodes");
|
|
||||||
const [timeFrame, setTimeFrame] = useState<string | "today" | "yesterday">(
|
const [timeFrame, setTimeFrame] = useState<string | "today" | "yesterday">(
|
||||||
"today"
|
"today"
|
||||||
);
|
);
|
||||||
@@ -93,10 +86,112 @@ export default function Statistics() {
|
|||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const currentData =
|
|
||||||
trafficData[dataType as "nodes" | "users"][
|
const TrafficRankCard = ({ type }: { type: "nodes" | "users" }) => {
|
||||||
timeFrame as "today" | "yesterday"
|
const currentData = trafficData[type][timeFrame as "today" | "yesterday"];
|
||||||
];
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="!flex-row flex items-center justify-between">
|
||||||
|
<CardTitle>
|
||||||
|
{type === "nodes"
|
||||||
|
? t("nodeTraffic", "Node Traffic")
|
||||||
|
: t("userTraffic", "User Traffic")}
|
||||||
|
</CardTitle>
|
||||||
|
<Tabs onValueChange={setTimeFrame} value={timeFrame}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="yesterday">
|
||||||
|
{t("yesterday", "Yesterday")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-80">
|
||||||
|
{currentData.length > 0 ? (
|
||||||
|
<ChartContainer
|
||||||
|
className="max-h-80"
|
||||||
|
config={{
|
||||||
|
traffic: {
|
||||||
|
label: t("traffic", "Traffic"),
|
||||||
|
color: "var(--primary)",
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
label: t("type", "Type"),
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: "var(--foreground)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BarChart data={currentData} height={400} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => formatBytes(value || 0)}
|
||||||
|
tickLine={false}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
dataKey="name"
|
||||||
|
interval={0}
|
||||||
|
tickFormatter={(_value, index) => String(index + 1)}
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={0}
|
||||||
|
type="category"
|
||||||
|
width={15}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
formatter={(value) => formatBytes(Number(value) || 0)}
|
||||||
|
label={true}
|
||||||
|
labelFormatter={(label, [payload]) =>
|
||||||
|
type === "nodes" ? (
|
||||||
|
`${t("nodes", "Nodes")}: ${label}`
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-80">
|
||||||
|
<UserSubscribeDetail
|
||||||
|
enabled={true}
|
||||||
|
id={payload?.payload.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div>{`${t("users", "Users")}: ${label}`}</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
trigger="hover"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="traffic"
|
||||||
|
fill="var(--primary)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
className="fill-foreground"
|
||||||
|
dataKey="name"
|
||||||
|
fontSize={12}
|
||||||
|
offset={8}
|
||||||
|
position="insideLeft"
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Empty />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -146,11 +241,11 @@ export default function Statistics() {
|
|||||||
iconBg: "bg-green-100 dark:bg-green-900/30",
|
iconBg: "bg-green-100 dark:bg-green-900/30",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("pendingTickets", "Pending Tickets"),
|
title: t("withdrawalManagement", "提现管理"),
|
||||||
value: TicketTotal || 0,
|
value: TicketTotal || 0,
|
||||||
subtitle: t("pending", "Pending"),
|
subtitle: t("pending", "Pending"),
|
||||||
icon: "uil:clipboard-notes",
|
icon: "uil:clipboard-notes",
|
||||||
href: "/dashboard/ticket",
|
href: "/dashboard/withdrawal",
|
||||||
color: "text-red-600 dark:text-red-400",
|
color: "text-red-600 dark:text-red-400",
|
||||||
iconBg: "bg-red-100 dark:bg-red-900/30",
|
iconBg: "bg-red-100 dark:bg-red-900/30",
|
||||||
},
|
},
|
||||||
@@ -189,122 +284,14 @@ export default function Statistics() {
|
|||||||
))}
|
))}
|
||||||
<SystemVersionCard />
|
<SystemVersionCard />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-2">
|
||||||
<RevenueStatisticsCard />
|
<RevenueStatisticsCard />
|
||||||
<UserStatisticsCard />
|
<UserStatisticsCard />
|
||||||
<Card>
|
</div>
|
||||||
<CardHeader className="!flex-row flex items-center justify-between">
|
|
||||||
<CardTitle>{t("trafficRank", "Traffic Rank")}</CardTitle>
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<Tabs onValueChange={setTimeFrame} value={timeFrame}>
|
<TrafficRankCard type="nodes" />
|
||||||
<TabsList>
|
<TrafficRankCard type="users" />
|
||||||
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
|
|
||||||
<TabsTrigger value="yesterday">
|
|
||||||
{t("yesterday", "Yesterday")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-80">
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h4 className="font-semibold">
|
|
||||||
{dataType === "nodes"
|
|
||||||
? t("nodeTraffic", "Node Traffic")
|
|
||||||
: t("userTraffic", "User Traffic")}
|
|
||||||
</h4>
|
|
||||||
<Select defaultValue="nodes" onValueChange={setDataType}>
|
|
||||||
<SelectTrigger className="w-28">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("selectTypePlaceholder", "Select Type")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="nodes">{t("nodes", "Nodes")}</SelectItem>
|
|
||||||
<SelectItem value="users">{t("users", "Users")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{currentData.length > 0 ? (
|
|
||||||
<ChartContainer
|
|
||||||
className="max-h-80"
|
|
||||||
config={{
|
|
||||||
traffic: {
|
|
||||||
label: t("traffic", "Traffic"),
|
|
||||||
color: "var(--primary)",
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
label: t("type", "Type"),
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
color: "var(--foreground)",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BarChart data={currentData} height={400} layout="vertical">
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
axisLine={false}
|
|
||||||
tickFormatter={(value) => formatBytes(value || 0)}
|
|
||||||
tickLine={false}
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
axisLine={false}
|
|
||||||
dataKey="name"
|
|
||||||
interval={0}
|
|
||||||
tickFormatter={(_value, index) => String(index + 1)}
|
|
||||||
tickLine={false}
|
|
||||||
tickMargin={0}
|
|
||||||
type="category"
|
|
||||||
width={15}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
formatter={(value) => formatBytes(Number(value) || 0)}
|
|
||||||
label={true}
|
|
||||||
labelFormatter={(label, [payload]) =>
|
|
||||||
dataType === "nodes" ? (
|
|
||||||
`${t("nodes", "Nodes")}: ${label}`
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="w-80">
|
|
||||||
<UserSubscribeDetail
|
|
||||||
enabled={true}
|
|
||||||
id={payload?.payload.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator className="my-2" />
|
|
||||||
<div>{`${t("users", "Users")}: ${label}`}</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
trigger="hover"
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="traffic"
|
|
||||||
fill="var(--primary)"
|
|
||||||
radius={[0, 4, 4, 0]}
|
|
||||||
>
|
|
||||||
<LabelList
|
|
||||||
className="fill-foreground"
|
|
||||||
dataKey="name"
|
|
||||||
fontSize={12}
|
|
||||||
offset={8}
|
|
||||||
position="insideLeft"
|
|
||||||
/>
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ChartContainer>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Empty />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
getNodeGroupList,
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function AverageModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [averageConfig, setAverageConfig] = useState({
|
||||||
|
node_group_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const hasLoadedConfig = useRef(true);
|
||||||
|
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
if (data.data?.config?.average_config) {
|
||||||
|
setAverageConfig(data.data.config.average_config as any);
|
||||||
|
}
|
||||||
|
hasLoadedConfig.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load group config:", error);
|
||||||
|
toast.error(t("loadFailed", "Failed to load configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeGroupsData) {
|
||||||
|
const nodeGroupCount = nodeGroupsData?.length || 0;
|
||||||
|
|
||||||
|
if (averageConfig.node_group_count !== nodeGroupCount) {
|
||||||
|
setAverageConfig({
|
||||||
|
...averageConfig,
|
||||||
|
node_group_count: nodeGroupCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodeGroupsData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "average" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("averageModeConfig", "Average Mode Configuration")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"averageModeDescription",
|
||||||
|
"Randomly assign node groups to user subscriptions based on subscribe configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node_group_count">
|
||||||
|
{t("availableNodeGroups", "Available Node Groups")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-muted"
|
||||||
|
id="node_group_count"
|
||||||
|
min={1}
|
||||||
|
readOnly
|
||||||
|
type="number"
|
||||||
|
value={averageConfig.node_group_count}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"nodeGroupCountAutoCalculated",
|
||||||
|
"Auto-calculated from actual node groups"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("groupRecalculation", "Group Recalculation")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationCompleted",
|
||||||
|
"Recalculation completed successfully"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationFailed",
|
||||||
|
"Recalculation failed. Please try again."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@workspace/ui/components/select";
|
||||||
|
import {
|
||||||
|
bindNodeGroups,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface BindNodeGroupsDialogProps {
|
||||||
|
userGroupIds: number[];
|
||||||
|
userGroupNames: string[];
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BindNodeGroupsDialog({
|
||||||
|
userGroupIds,
|
||||||
|
userGroupNames,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}: BindNodeGroupsDialogProps) {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedNodeGroupId, setSelectedNodeGroupId] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const { data: nodeGroupsData, isLoading } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && nodeGroupsData) {
|
||||||
|
// Load current binding when dialog opens
|
||||||
|
loadCurrentBinding();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadCurrentBinding = () => {
|
||||||
|
// Get first user group's current node group binding
|
||||||
|
// For batch binding, we'll default to unbound
|
||||||
|
setSelectedNodeGroupId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBind = async () => {
|
||||||
|
if (selectedNodeGroupId === undefined) {
|
||||||
|
toast.error(t("selectNodeGroupRequired", "Please select a node group"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await bindNodeGroups({
|
||||||
|
user_group_ids: userGroupIds,
|
||||||
|
node_group_id: selectedNodeGroupId === 0 ? null : selectedNodeGroupId,
|
||||||
|
} as API.BindNodeGroupsRequest);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
t(
|
||||||
|
"bindSuccess",
|
||||||
|
"Successfully bound {{userGroupCount}} user groups to node group"
|
||||||
|
).replace(/{{userGroupCount}}/g, String(userGroupIds.length))
|
||||||
|
);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
onOpenChange?.(false);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to bind node group:", error);
|
||||||
|
toast.error(t("bindFailed", "Failed to bind node group"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayNames =
|
||||||
|
userGroupNames.length > 2
|
||||||
|
? `${userGroupNames.slice(0, 2).join(", ")}... (${userGroupIds.length})`
|
||||||
|
: userGroupNames.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(newOpen) => {
|
||||||
|
setOpen(newOpen);
|
||||||
|
onOpenChange?.(newOpen);
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
{t("bindNodeGroup", "Bind Node Group")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("bindNodeGroup", "Bind Node Group")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"bindNodeGroupDescription",
|
||||||
|
"Select a node group to bind to user groups: {{userGroups}}",
|
||||||
|
{ userGroups: displayNames }
|
||||||
|
).replace(/{{userGroups}}/g, displayNames)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node-group">
|
||||||
|
{t("selectNodeGroup", "Select Node Group")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setSelectedNodeGroupId(Number.parseInt(val, 10) || undefined)
|
||||||
|
}
|
||||||
|
value={selectedNodeGroupId?.toString() || ""}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full" id="node-group">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"selectNodeGroupPlaceholder",
|
||||||
|
"Select a node group..."
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">{t("unbound", "Unbound")}</SelectItem>
|
||||||
|
{nodeGroupsData?.map((nodeGroup) => (
|
||||||
|
<SelectItem key={nodeGroup.id} value={String(nodeGroup.id)}>
|
||||||
|
{nodeGroup.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || selectedNodeGroupId === undefined}
|
||||||
|
onClick={handleBind}
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t("confirm", "Confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import {
|
||||||
|
getGroupHistory,
|
||||||
|
getGroupHistoryDetail,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function CurrentGroupResults() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
const [latestDetails, setLatestDetails] = useState<any[]>([]);
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||||
|
|
||||||
|
// User list dialog state
|
||||||
|
const [userListOpen, setUserListOpen] = useState(false);
|
||||||
|
const [selectedNodeGroupName, setSelectedNodeGroupName] =
|
||||||
|
useState<string>("");
|
||||||
|
const [userList, setUserList] = useState<any[]>([]);
|
||||||
|
const [userListLoading, setUserListLoading] = useState(false);
|
||||||
|
const [userListTotal, setUserListTotal] = useState(0);
|
||||||
|
|
||||||
|
// Fetch node groups
|
||||||
|
const { data: nodeGroups } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
// Load latest result
|
||||||
|
const { data: historyData } = await getGroupHistory({
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (historyData.data?.list && historyData.data.list.length > 0) {
|
||||||
|
const latest = historyData.data.list[0];
|
||||||
|
if (!latest) return;
|
||||||
|
setLatestResult(latest);
|
||||||
|
|
||||||
|
// Fetch details
|
||||||
|
setDetailsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: detailData } = await getGroupHistoryDetail({
|
||||||
|
id: latest.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailData.data?.config_snapshot?.group_details) {
|
||||||
|
setLatestDetails(detailData.data.config_snapshot.group_details);
|
||||||
|
} else {
|
||||||
|
setLatestDetails([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch latest result details:", error);
|
||||||
|
setLatestDetails([]);
|
||||||
|
} finally {
|
||||||
|
setDetailsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowUserList = async (
|
||||||
|
nodeGroupId: number,
|
||||||
|
nodeGroupName: string
|
||||||
|
) => {
|
||||||
|
setSelectedNodeGroupName(nodeGroupName);
|
||||||
|
setUserListOpen(true);
|
||||||
|
setUserListLoading(true);
|
||||||
|
|
||||||
|
// 从历史详情记录中获取用户数据
|
||||||
|
const detail = latestDetails.find((d: any) => {
|
||||||
|
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
|
||||||
|
return detailNodeGroupId === nodeGroupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
const userDataJSON = detail.UserData || detail.user_data;
|
||||||
|
if (userDataJSON) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(userDataJSON);
|
||||||
|
setUserList(userData);
|
||||||
|
setUserListTotal(userData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse user data:", error);
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
setUserListLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("currentGroupingResult", "Current Grouping Result")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("loading", "Loading...")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Latest Result Card */}
|
||||||
|
{latestResult ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("currentGroupingResult", "Current Grouping Result")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"latestGroupingCalculation",
|
||||||
|
"Latest grouping calculation details"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Calculation Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
{t("calculationInfo", "Calculation Information")}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("groupMode", "Group Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.GroupMode || latestResult.group_mode) ===
|
||||||
|
"average"
|
||||||
|
? t("averageMode", "Average Mode")
|
||||||
|
: (latestResult.GroupMode || latestResult.group_mode) ===
|
||||||
|
"subscribe"
|
||||||
|
? t("subscribeMode", "Subscribe Mode")
|
||||||
|
: t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("state", "State")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.State || latestResult.state) === "completed"
|
||||||
|
? t("completed", "Completed")
|
||||||
|
: (latestResult.State || latestResult.state) === "running"
|
||||||
|
? t("running", "Running")
|
||||||
|
: (latestResult.State || latestResult.state) ===
|
||||||
|
"failed"
|
||||||
|
? t("failed", "Failed")
|
||||||
|
: t("idle", "Idle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("triggerType", "Trigger Type")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.TriggerType || latestResult.trigger_type) ===
|
||||||
|
"manual"
|
||||||
|
? t("manualTrigger", "Manual")
|
||||||
|
: (latestResult.TriggerType ||
|
||||||
|
latestResult.trigger_type) === "auto"
|
||||||
|
? t("autoTrigger", "Auto")
|
||||||
|
: t("scheduleTrigger", "Schedule")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("successFailedCount", "Success/Failed")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.SuccessCount ||
|
||||||
|
latestResult.success_count ||
|
||||||
|
0}{" "}
|
||||||
|
/{" "}
|
||||||
|
{latestResult.FailedCount || latestResult.failed_count || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("startTime", "Start Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.StartTime || latestResult.start_time
|
||||||
|
? new Date(
|
||||||
|
(latestResult.StartTime || latestResult.start_time) *
|
||||||
|
1000
|
||||||
|
).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("endTime", "End Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.EndTime || latestResult.end_time
|
||||||
|
? new Date(
|
||||||
|
(latestResult.EndTime || latestResult.end_time) * 1000
|
||||||
|
).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grouping Details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
{t("groupingDetailsStatistics", "Grouping Details Statistics")}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">
|
||||||
|
{latestDetails.reduce(
|
||||||
|
(sum: number, d: any) =>
|
||||||
|
sum + (d.UserCount || d.user_count || 0),
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">
|
||||||
|
{latestDetails.reduce(
|
||||||
|
(sum: number, d: any) =>
|
||||||
|
sum + (d.NodeCount || d.node_count || 0),
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalNodes", "Total Nodes")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">
|
||||||
|
{latestDetails.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalNodeGroups", "Total Node Groups")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : latestDetails.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Details Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b px-4 py-2 text-left">
|
||||||
|
{t("nodeGroup", "Node Group")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("userCount", "User Count")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("nodeCount", "Node Count")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{latestDetails.map((detail: any, index: number) => {
|
||||||
|
const nodeGroupId =
|
||||||
|
detail.NodeGroupId || detail.node_group_id;
|
||||||
|
const nodeGroup = nodeGroups?.find(
|
||||||
|
(ng) => ng.id === nodeGroupId
|
||||||
|
);
|
||||||
|
const nodeGroupName =
|
||||||
|
nodeGroup?.name ||
|
||||||
|
`${t("idPrefix", "#")}${nodeGroupId}`;
|
||||||
|
const userCount =
|
||||||
|
detail.UserCount || detail.user_count || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{nodeGroupName}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("id", "ID")}: {nodeGroupId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
className={`font-semibold hover:underline ${
|
||||||
|
userCount === 0
|
||||||
|
? "cursor-not-allowed text-muted-foreground"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}`}
|
||||||
|
disabled={userCount === 0}
|
||||||
|
onClick={() =>
|
||||||
|
handleShowUserList(nodeGroupId, nodeGroupName)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{userCount}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
{detail.NodeCount || detail.node_count || 0}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("currentGroupingResult", "Current Grouping Result")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User List Dialog */}
|
||||||
|
<Dialog onOpenChange={setUserListOpen} open={userListOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedNodeGroupName} - {t("userList", "User List")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("totalUsers", "Total Users")}: {userListTotal}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{userListLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : userList.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("id", "ID")}</TableHead>
|
||||||
|
<TableHead>{t("email", "Email")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{userList.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.id}</TableCell>
|
||||||
|
<TableCell>{user.email || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noUsers", "No users found")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@workspace/ui/components/alert-dialog";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
resetGroups,
|
||||||
|
updateGroupConfig,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function GroupConfig() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [config, setConfig] = useState<{
|
||||||
|
enabled: boolean;
|
||||||
|
mode: "average" | "subscribe" | "traffic";
|
||||||
|
}>({
|
||||||
|
enabled: false,
|
||||||
|
mode: "average",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
if (data.data) {
|
||||||
|
setConfig({
|
||||||
|
enabled: data.data.enabled,
|
||||||
|
mode: (data.data.mode || "average") as
|
||||||
|
| "average"
|
||||||
|
| "subscribe"
|
||||||
|
| "traffic",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load group config:", error);
|
||||||
|
toast.error(t("loadFailed", "Failed to load configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateEnabled = async (enabled: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
enabled,
|
||||||
|
mode: config.mode,
|
||||||
|
};
|
||||||
|
await updateGroupConfig(payload);
|
||||||
|
setConfig({ ...config, enabled });
|
||||||
|
toast.success(t("saved", "Configuration saved successfully"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update group config:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateMode = async (
|
||||||
|
mode: "average" | "subscribe" | "traffic"
|
||||||
|
) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
enabled: config.enabled,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
await updateGroupConfig(payload);
|
||||||
|
setConfig({ ...config, mode });
|
||||||
|
toast.success(t("saved", "Configuration saved successfully"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update group config:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetGroups = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await resetGroups({ confirm: true });
|
||||||
|
toast.success(
|
||||||
|
t("resetSuccess", "All groups have been reset successfully")
|
||||||
|
);
|
||||||
|
setShowResetDialog(false);
|
||||||
|
// Reload config after reset
|
||||||
|
await loadConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset groups:", error);
|
||||||
|
toast.error(t("resetFailed", "Failed to reset groups"));
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupConfig", "Group Configuration")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupConfigDescription",
|
||||||
|
"Configure user group and node group settings"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Enable/Disable */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="font-medium" htmlFor="enabled">
|
||||||
|
{t("enableGrouping", "Enable Grouping")}
|
||||||
|
</label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"enableGroupingDescription",
|
||||||
|
"When enabled, users will only see nodes from their assigned group"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
checked={config.enabled}
|
||||||
|
className="h-4 w-4"
|
||||||
|
disabled={saving}
|
||||||
|
id="enabled"
|
||||||
|
onChange={(e) => handleUpdateEnabled(e.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
{config.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t("groupingMode", "Grouping Mode")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "average"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => handleUpdateMode("average")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"averageModeDescription",
|
||||||
|
"Distribute users evenly across groups"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "subscribe"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => handleUpdateMode("subscribe")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"subscribeModeDescription",
|
||||||
|
"Group users by their subscription plan"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "traffic"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "cursor-not-allowed opacity-50" : ""}`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => handleUpdateMode("traffic")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"trafficModeDescription",
|
||||||
|
"Group users by their traffic usage"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
<div className="flex justify-end border-t pt-4">
|
||||||
|
<AlertDialog
|
||||||
|
onOpenChange={setShowResetDialog}
|
||||||
|
open={showResetDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
{t("resetGroups", "Reset All Groups")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("resetGroupsTitle", "Reset All Groups")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
"resetGroupsDescription",
|
||||||
|
"This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone."
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={handleResetGroups}
|
||||||
|
>
|
||||||
|
{resetting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("confirm", "Confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
getGroupHistory,
|
||||||
|
getGroupHistoryDetail,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
|
||||||
|
export default function GroupHistory() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedHistory, setSelectedHistory] =
|
||||||
|
useState<API.GroupHistory | null>(null);
|
||||||
|
const [details, setDetails] = useState<any[]>([]);
|
||||||
|
const [nodeGroupMap, setNodeGroupMap] = useState<Map<number, string>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
// User list dialog state
|
||||||
|
const [userListOpen, setUserListOpen] = useState(false);
|
||||||
|
const [selectedNodeGroupName, setSelectedNodeGroupName] =
|
||||||
|
useState<string>("");
|
||||||
|
const [userList, setUserList] = useState<any[]>([]);
|
||||||
|
const [userListTotal, setUserListTotal] = useState(0);
|
||||||
|
|
||||||
|
// Fetch all node groups
|
||||||
|
const { data: nodeGroups } = useQuery({
|
||||||
|
queryKey: ["getNodeGroupListForDetail"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build ID to name maps when groups are loaded
|
||||||
|
if (nodeGroups) {
|
||||||
|
const newNodeGroupMap = new Map<number, string>();
|
||||||
|
nodeGroups.forEach((ng: API.NodeGroup) => {
|
||||||
|
newNodeGroupMap.set(ng.id, ng.name);
|
||||||
|
});
|
||||||
|
if (newNodeGroupMap.size !== nodeGroupMap.size) {
|
||||||
|
setNodeGroupMap(newNodeGroupMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModeLabel = (mode: string) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "average":
|
||||||
|
return t("averageMode", "Average");
|
||||||
|
case "subscribe":
|
||||||
|
return t("subscribeMode", "Subscribe");
|
||||||
|
case "traffic":
|
||||||
|
return t("trafficMode", "Traffic");
|
||||||
|
default:
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTriggerTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "manual":
|
||||||
|
return t("manualTrigger", "Manual");
|
||||||
|
case "auto":
|
||||||
|
return t("autoTrigger", "Auto");
|
||||||
|
case "schedule":
|
||||||
|
return t("scheduleTrigger", "Schedule");
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = async (record: API.GroupHistory) => {
|
||||||
|
setSelectedHistory(record);
|
||||||
|
setDetailOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupHistoryDetail({
|
||||||
|
id: record.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Group history detail response:", data);
|
||||||
|
|
||||||
|
// 从返回的数据中获取详情列表
|
||||||
|
// data.data.config_snapshot.group_details 包含分组详情
|
||||||
|
if (data.data?.config_snapshot?.group_details) {
|
||||||
|
setDetails(data.data.config_snapshot.group_details);
|
||||||
|
} else {
|
||||||
|
console.warn("No group_details found in response:", data);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch history details:", error);
|
||||||
|
setDetails([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowUserList = async (
|
||||||
|
nodeGroupId: number,
|
||||||
|
nodeGroupName: string
|
||||||
|
) => {
|
||||||
|
setSelectedNodeGroupName(nodeGroupName);
|
||||||
|
setUserListOpen(true);
|
||||||
|
|
||||||
|
// 从历史详情记录中获取用户数据
|
||||||
|
const detail = details.find((d: any) => {
|
||||||
|
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
|
||||||
|
return detailNodeGroupId === nodeGroupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
const userDataJSON = detail.UserData || detail.user_data;
|
||||||
|
if (userDataJSON) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(userDataJSON);
|
||||||
|
setUserList(userData);
|
||||||
|
setUserListTotal(userData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse user data:", error);
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("groupHistory", "Group Calculation History")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupHistoryDescription",
|
||||||
|
"View group recalculation history and results"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProTable<API.GroupHistory, API.GetGroupHistoryRequest>
|
||||||
|
action={ref}
|
||||||
|
actions={{
|
||||||
|
render: (row: any) => [
|
||||||
|
<Button
|
||||||
|
key="detail"
|
||||||
|
onClick={() => handleViewDetail(row)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("viewDetail", "View Detail")}
|
||||||
|
</Button>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "id",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("id", "ID"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("idPrefix", "#")}
|
||||||
|
{row.getValue("id")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "group_mode",
|
||||||
|
accessorKey: "group_mode",
|
||||||
|
header: t("groupMode", "Group Mode"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{getModeLabel(row.getValue("group_mode"))}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trigger_type",
|
||||||
|
accessorKey: "trigger_type",
|
||||||
|
header: t("triggerType", "Trigger Type"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getTriggerTypeLabel(row.getValue("trigger_type"))}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "total_users",
|
||||||
|
accessorKey: "total_users",
|
||||||
|
header: t("totalUsers", "Total Users"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<span className="font-semibold">
|
||||||
|
{row.getValue("total_users")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "result",
|
||||||
|
accessorKey: "error_log",
|
||||||
|
header: t("result", "Result"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const record = row.original;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("successCount", "Success")}: {record.success_count}{" "}
|
||||||
|
{t("separator", "/")} {t("failedCount", "Failed")}:{" "}
|
||||||
|
{record.failed_count}
|
||||||
|
</div>
|
||||||
|
{record.error_log && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{t("failed", "Failed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!record.error_log && record.failed_count === 0 && (
|
||||||
|
<Badge variant="default">
|
||||||
|
{t("completed", "Completed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created_at",
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: t("createdAt", "Created At"),
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
formatDate(row.getValue("created_at")),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
title: t("groupHistory", "Group Calculation History"),
|
||||||
|
}}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getGroupHistory({
|
||||||
|
page: params.page || 1,
|
||||||
|
size: params.size || 10,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail Dialog */}
|
||||||
|
<Dialog onOpenChange={setDetailOpen} open={detailOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("groupHistoryDetail", "Group Calculation Detail")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("historyId", "History ID")}: {selectedHistory?.id}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedHistory && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("groupMode", "Group Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{getModeLabel(selectedHistory.group_mode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("triggerType", "Trigger Type")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{getTriggerTypeLabel(selectedHistory.trigger_type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{selectedHistory.total_users}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("result", "Result")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("successCount", "Success")}:{" "}
|
||||||
|
{selectedHistory.success_count} {t("separator", "/")}{" "}
|
||||||
|
{t("failedCount", "Failed")}:{" "}
|
||||||
|
{selectedHistory.failed_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedHistory.start_time && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("startTime", "Start Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(selectedHistory.start_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedHistory.end_time && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("endTime", "End Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(selectedHistory.end_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedHistory.error_log && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("errorMessage", "Error Message")}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||||
|
{selectedHistory.error_log}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 font-medium text-sm">
|
||||||
|
{t("groupDetails", "Group Details")}
|
||||||
|
</div>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : details.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">
|
||||||
|
{details.reduce(
|
||||||
|
(sum: number, d: any) =>
|
||||||
|
sum + (d.UserCount || d.user_count || 0),
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">
|
||||||
|
{details.reduce(
|
||||||
|
(sum: number, d: any) =>
|
||||||
|
sum + (d.NodeCount || d.node_count || 0),
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalNodes", "Total Nodes")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-bold text-2xl">{details.length}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("totalNodeGroups", "Total Node Groups")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详情表格 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b px-4 py-2 text-left">
|
||||||
|
{t("nodeGroup", "Node Group")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("userCount", "User Count")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("nodeCount", "Node Count")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.map((detail: any, index: number) => {
|
||||||
|
const nodeGroupId =
|
||||||
|
detail.NodeGroupId || detail.node_group_id;
|
||||||
|
const nodeGroupName =
|
||||||
|
nodeGroupMap.get(nodeGroupId) ||
|
||||||
|
`${t("idPrefix", "#")}${nodeGroupId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{nodeGroupName}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("id", "ID")}: {nodeGroupId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
className="cursor-pointer font-semibold hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={
|
||||||
|
(detail.UserCount ||
|
||||||
|
detail.user_count ||
|
||||||
|
0) === 0
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleShowUserList(
|
||||||
|
nodeGroupId,
|
||||||
|
nodeGroupName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{detail.UserCount || detail.user_count || 0}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
{detail.NodeCount || detail.node_count || 0}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* User List Dialog */}
|
||||||
|
<Dialog onOpenChange={setUserListOpen} open={userListOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[700px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedNodeGroupName} - {t("userList", "User List")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("totalUsers", "Total Users")}: {userListTotal}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{userList.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("id", "ID")}</TableHead>
|
||||||
|
<TableHead>{t("email", "Email")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{userList.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.id}</TableCell>
|
||||||
|
<TableCell>{user.email || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noUsers", "No users found")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function GroupRecalculate() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState<string | null>(null);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
|
||||||
|
// Poll status every 2 seconds when recalculating
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async (
|
||||||
|
mode: "average" | "subscribe" | "traffic"
|
||||||
|
) => {
|
||||||
|
setRecalculating(mode);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("groupRecalculation", "Group Recalculation")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationCompleted",
|
||||||
|
"Recalculation completed successfully"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationFailed",
|
||||||
|
"Recalculation failed. Please try again."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Buttons */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Average Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={
|
||||||
|
recalculating === "average" || status?.state === "running"
|
||||||
|
}
|
||||||
|
onClick={() => handleRecalculate("average")}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "average" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribe Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={
|
||||||
|
recalculating === "subscribe" || status?.state === "running"
|
||||||
|
}
|
||||||
|
onClick={() => handleRecalculate("subscribe")}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "subscribe" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traffic Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={
|
||||||
|
recalculating === "traffic" || status?.state === "running"
|
||||||
|
}
|
||||||
|
onClick={() => handleRecalculate("traffic")}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "traffic" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@workspace/ui/components/tabs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import AverageModeTab from "./average-mode-tab";
|
||||||
|
import CurrentGroupResults from "./current-group-results";
|
||||||
|
import GroupConfig from "./group-config";
|
||||||
|
import GroupHistory from "./group-history";
|
||||||
|
// import UserGroups from "./user-groups";
|
||||||
|
import NodeGroups from "./node-groups";
|
||||||
|
import SubscribeModeTab from "./subscribe-mode-tab";
|
||||||
|
import TrafficModeTab from "./traffic-mode-tab";
|
||||||
|
|
||||||
|
export default function Group() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold text-lg">
|
||||||
|
{t("title", "Group Management")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Tabs defaultValue="config">
|
||||||
|
<TabsList className="flex flex-wrap gap-2">
|
||||||
|
<TabsTrigger value="config">{t("config", "Config")}</TabsTrigger>
|
||||||
|
{/* <TabsTrigger value="user">
|
||||||
|
{t("userGroups", "User Groups")}
|
||||||
|
</TabsTrigger> */}
|
||||||
|
<TabsTrigger value="node">
|
||||||
|
{t("nodeGroups", "Node Groups")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="average">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="subscribe">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="traffic">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="results">
|
||||||
|
{t("currentGroupingResult", "Current Grouping Result")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">{t("history", "History")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="config">
|
||||||
|
<GroupConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* <TabsContent value="user" className="mt-4">
|
||||||
|
<UserGroups />
|
||||||
|
</TabsContent> */}
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="node">
|
||||||
|
<NodeGroups />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="average">
|
||||||
|
<AverageModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="subscribe">
|
||||||
|
<SubscribeModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="traffic">
|
||||||
|
<TrafficModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="results">
|
||||||
|
<CurrentGroupResults />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="mt-4" value="history">
|
||||||
|
<GroupHistory />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { Textarea } from "@workspace/ui/components/textarea";
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { type RefObject, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface NodeGroupFormProps {
|
||||||
|
initialValues?: Partial<API.NodeGroup>;
|
||||||
|
allNodeGroups?: API.NodeGroup[];
|
||||||
|
currentGroupId?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
|
||||||
|
title: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeGroupForm = ({
|
||||||
|
initialValues,
|
||||||
|
allNodeGroups = [],
|
||||||
|
currentGroupId,
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
trigger,
|
||||||
|
ref,
|
||||||
|
}: NodeGroupFormProps & { ref?: RefObject<HTMLButtonElement | null> }) => {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [conflictError, setConflictError] = useState<string>("");
|
||||||
|
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
is_expired_group: false,
|
||||||
|
expired_days_limit: 7,
|
||||||
|
max_traffic_gb_expired: 0,
|
||||||
|
speed_limit: 0,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setConflictError(""); // 重置冲突错误
|
||||||
|
if (initialValues) {
|
||||||
|
setValues({
|
||||||
|
name: initialValues.name || "",
|
||||||
|
description: initialValues.description || "",
|
||||||
|
sort: initialValues.sort ?? 0,
|
||||||
|
for_calculation: initialValues.for_calculation ?? true,
|
||||||
|
is_expired_group: initialValues.is_expired_group ?? false,
|
||||||
|
expired_days_limit: initialValues.expired_days_limit ?? 7,
|
||||||
|
max_traffic_gb_expired: initialValues.max_traffic_gb_expired ?? 0,
|
||||||
|
speed_limit: initialValues.speed_limit ?? 0,
|
||||||
|
min_traffic_gb: initialValues.min_traffic_gb ?? 0,
|
||||||
|
max_traffic_gb: initialValues.max_traffic_gb ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
is_expired_group: false,
|
||||||
|
expired_days_limit: 7,
|
||||||
|
max_traffic_gb_expired: 0,
|
||||||
|
speed_limit: 0,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialValues, open]);
|
||||||
|
|
||||||
|
// 检测流量区间冲突
|
||||||
|
const checkTrafficRangeConflict = (
|
||||||
|
minTraffic: number,
|
||||||
|
maxTraffic: number
|
||||||
|
): string => {
|
||||||
|
// 如果 min=0 且 max=0,表示不参与流量分组,跳过所有验证
|
||||||
|
if (minTraffic === 0 && maxTraffic === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证区间有效性:min 必须 < max(除非 max=0 表示无上限)
|
||||||
|
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||||||
|
return t("invalidRange", "Min traffic must be less than max traffic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 max=0 的情况,表示无上限,使用一个很大的数代替
|
||||||
|
const actualMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
|
||||||
|
|
||||||
|
// 检查与其他节点组的冲突
|
||||||
|
for (const group of allNodeGroups) {
|
||||||
|
// 跳过当前编辑的节点组
|
||||||
|
if (currentGroupId && group.id === currentGroupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过没有设置流量区间的节点组(min=0 且 max=0 表示未配置)
|
||||||
|
const existingMin = group.min_traffic_gb ?? 0;
|
||||||
|
const existingMax = group.max_traffic_gb ?? 0;
|
||||||
|
if (existingMin === 0 && existingMax === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理现有节点组 max=0 的情况
|
||||||
|
const actualExistingMax =
|
||||||
|
existingMax === 0 ? Number.MAX_VALUE : existingMax;
|
||||||
|
|
||||||
|
// 检测区间重叠
|
||||||
|
// 两个区间 [min1, max1] 和 [min2, max2] 重叠的条件:
|
||||||
|
// max1 > min2 && max2 > min1
|
||||||
|
const hasOverlap =
|
||||||
|
actualMax > existingMin && actualExistingMax > minTraffic;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
return t("rangeConflict", {
|
||||||
|
name: group.name,
|
||||||
|
min: existingMin.toString(),
|
||||||
|
max: existingMax === 0 ? "∞" : existingMax.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检测过期节点组冲突
|
||||||
|
const checkExpiredGroupConflict = async (
|
||||||
|
isExpiredGroup: boolean
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!isExpiredGroup) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在其他过期节点组
|
||||||
|
const existingExpiredGroup = allNodeGroups.find(
|
||||||
|
(group) => group.is_expired_group && group.id !== currentGroupId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingExpiredGroup) {
|
||||||
|
return t(
|
||||||
|
"expiredGroupExists",
|
||||||
|
`System already has an expired node group: ${existingExpiredGroup.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查当前节点组是否被订阅商品使用
|
||||||
|
if (currentGroupId) {
|
||||||
|
try {
|
||||||
|
const { getSubscribeList } = await import(
|
||||||
|
"@workspace/ui/services/admin/subscribe"
|
||||||
|
);
|
||||||
|
const { data } = await getSubscribeList({
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
node_group_id: currentGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.data && data.data.total > 0) {
|
||||||
|
return t(
|
||||||
|
"nodeGroupUsedBySubscribe",
|
||||||
|
"This node group is used as default node group in subscription products, cannot set as expired group"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check subscribe usage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否存在其他过期节点组(用于隐藏开关)
|
||||||
|
const hasOtherExpiredGroup = allNodeGroups.some(
|
||||||
|
(group) => group.is_expired_group && group.id !== currentGroupId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前是否是过期节点组(编辑模式下)
|
||||||
|
const isCurrentExpiredGroup = initialValues?.is_expired_group ?? false;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 检测过期节点组冲突
|
||||||
|
const expiredGroupConflict = await checkExpiredGroupConflict(
|
||||||
|
values.is_expired_group
|
||||||
|
);
|
||||||
|
if (expiredGroupConflict) {
|
||||||
|
setConflictError(expiredGroupConflict);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在非过期节点组时检测流量区间冲突
|
||||||
|
if (!values.is_expired_group) {
|
||||||
|
const conflict = checkTrafficRangeConflict(
|
||||||
|
values.min_traffic_gb,
|
||||||
|
values.max_traffic_gb
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
setConflictError(conflict);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const success = await onSubmit(values);
|
||||||
|
setSubmitting(false);
|
||||||
|
if (success) {
|
||||||
|
setOpen(false);
|
||||||
|
setConflictError("");
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
is_expired_group: false,
|
||||||
|
expired_days_limit: 7,
|
||||||
|
max_traffic_gb_expired: 0,
|
||||||
|
speed_limit: 0,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={setOpen} open={open}>
|
||||||
|
<DialogTrigger asChild ref={ref}>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("nodeGroupFormDescription", "Configure node group settings")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">{t("name", "Name")} *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
onChange={(e) => setValues({ ...values, name: e.target.value })}
|
||||||
|
placeholder={t("namePlaceholder", "Enter name")}
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">
|
||||||
|
{t("description", "Description")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("descriptionPlaceholder", "Enter description")}
|
||||||
|
rows={3}
|
||||||
|
value={values.description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
|
||||||
|
<Input
|
||||||
|
id="sort"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
sort: Number.parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={values.sort}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="for_calculation">
|
||||||
|
{t("forCalculation", "For Calculation")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{values.is_expired_group
|
||||||
|
? t(
|
||||||
|
"expiredGroupForCalculationDescription",
|
||||||
|
"Expired-only node groups cannot participate in group calculation"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"forCalculationDescription",
|
||||||
|
"Whether this node group participates in grouping calculation"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={values.for_calculation}
|
||||||
|
disabled={values.is_expired_group}
|
||||||
|
id="for_calculation"
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setValues({ ...values, for_calculation: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 仅在没有其他过期节点组或当前就是过期节点组时显示 */}
|
||||||
|
{(!hasOtherExpiredGroup || isCurrentExpiredGroup) && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="is_expired_group">
|
||||||
|
{t("isExpiredGroup", "Expired Node Group")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"isExpiredGroupDescription",
|
||||||
|
"Allow expired users to use limited nodes"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={values.is_expired_group}
|
||||||
|
id="is_expired_group"
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
is_expired_group: checked,
|
||||||
|
for_calculation: checked ? false : values.for_calculation,
|
||||||
|
min_traffic_gb: checked ? 0 : values.min_traffic_gb,
|
||||||
|
max_traffic_gb: checked ? 0 : values.max_traffic_gb,
|
||||||
|
});
|
||||||
|
// 实时检测过期节点组冲突
|
||||||
|
const conflict = await checkExpiredGroupConflict(checked);
|
||||||
|
setConflictError(conflict);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.is_expired_group && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expired_days_limit">
|
||||||
|
{t("expiredDaysLimit", "Expired Days Limit")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"expiredDaysLimitDescription",
|
||||||
|
"Number of days after expiration that users can still access nodes"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="expired_days_limit"
|
||||||
|
min={1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
expired_days_limit:
|
||||||
|
Number.parseInt(e.target.value, 10) || 7,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={values.expired_days_limit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_traffic_gb_expired">
|
||||||
|
{t(
|
||||||
|
"maxTrafficGBExpired",
|
||||||
|
"Max Traffic for Expired Users (GB)"
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"maxTrafficGBExpiredDescription",
|
||||||
|
"Maximum traffic allowed for expired users (0 = unlimited)"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="max_traffic_gb_expired"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
max_traffic_gb_expired:
|
||||||
|
Number.parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={values.max_traffic_gb_expired}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="speed_limit">
|
||||||
|
{t("speedLimit", "Speed Limit (Mbps)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="speed_limit"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
speed_limit: Number.parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
value={values.speed_limit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!values.is_expired_group && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{t("trafficRangeGB", "Traffic Range (GB)")}</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"trafficRangeDescription",
|
||||||
|
"Users with traffic >= Min and < Max will be assigned to this node group"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="min_traffic_gb">
|
||||||
|
{t("minTrafficGB", "Min Traffic (GB)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="min_traffic_gb"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||||
|
setValues({ ...values, min_traffic_gb: newValue });
|
||||||
|
// 实时检测冲突
|
||||||
|
const conflict = checkTrafficRangeConflict(
|
||||||
|
newValue,
|
||||||
|
values.max_traffic_gb
|
||||||
|
);
|
||||||
|
setConflictError(conflict);
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={values.min_traffic_gb}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_traffic_gb">
|
||||||
|
{t("maxTrafficGB", "Max Traffic (GB)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max_traffic_gb"
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||||
|
setValues({ ...values, max_traffic_gb: newValue });
|
||||||
|
// 实时检测冲突
|
||||||
|
const conflict = checkTrafficRangeConflict(
|
||||||
|
values.min_traffic_gb,
|
||||||
|
newValue
|
||||||
|
);
|
||||||
|
setConflictError(conflict);
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={values.max_traffic_gb}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 显示冲突错误 */}
|
||||||
|
{conflictError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{conflictError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border px-4 py-2 text-sm"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm disabled:opacity-50"
|
||||||
|
disabled={submitting || loading || !!conflictError}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("save", "Save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NodeGroupForm.displayName = "NodeGroupForm";
|
||||||
|
|
||||||
|
export default NodeGroupForm;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
createNodeGroup,
|
||||||
|
deleteNodeGroup,
|
||||||
|
getNodeGroupList,
|
||||||
|
updateNodeGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import NodeGroupForm from "./node-group-form";
|
||||||
|
|
||||||
|
export default function NodeGroups() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [allNodeGroups, setAllNodeGroups] = useState<API.NodeGroup[]>([]);
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
// 获取所有节点组数据(用于冲突检测)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllNodeGroups = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch node groups:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAllNodeGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("nodeGroups", "Node Groups")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"nodeGroupsDescription",
|
||||||
|
"Manage node groups for user access control"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProTable<API.NodeGroup, API.GetNodeGroupListRequest>
|
||||||
|
action={ref}
|
||||||
|
actions={{
|
||||||
|
render: (row: any) => [
|
||||||
|
<NodeGroupForm
|
||||||
|
allNodeGroups={allNodeGroups}
|
||||||
|
currentGroupId={row.id}
|
||||||
|
initialValues={row}
|
||||||
|
key={`edit-${row.id}`}
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateNodeGroup({
|
||||||
|
id: row.id,
|
||||||
|
...values,
|
||||||
|
} as API.UpdateNodeGroupRequest);
|
||||||
|
toast.success(t("updated", "Updated successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("editNodeGroup", "Edit Node Group")}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
{t("edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteNodeGroupConfirm",
|
||||||
|
"This will delete the node group. Nodes in this group will be reassigned."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteNodeGroup({ id: row.id });
|
||||||
|
toast.success(t("deleted", "Deleted successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Confirm Delete")}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "id",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("id", "ID"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
#{row.getValue("id")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const isExpiredGroup = row.original.is_expired_group;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{row.getValue("name")}</span>
|
||||||
|
{isExpiredGroup && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{t("expiredGroup", "Expired")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "description",
|
||||||
|
accessorKey: "description",
|
||||||
|
header: t("description", "Description"),
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
row.getValue("description") || "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "for_calculation",
|
||||||
|
accessorKey: "for_calculation",
|
||||||
|
header: t("forCalculation", "For Calculation"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const value = row.getValue("for_calculation");
|
||||||
|
return value ? (
|
||||||
|
<Badge variant="default">{t("yes", "Yes")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{t("no", "No")}</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "traffic_range",
|
||||||
|
header: t("trafficRange", "Traffic Range (GB)"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const min = row.original.min_traffic_gb;
|
||||||
|
const max = row.original.max_traffic_gb;
|
||||||
|
if (min !== undefined && max !== undefined) {
|
||||||
|
return `${min} - ${max}`;
|
||||||
|
}
|
||||||
|
if (min !== undefined) {
|
||||||
|
return `≥ ${min}`;
|
||||||
|
}
|
||||||
|
if (max !== undefined) {
|
||||||
|
return `≤ ${max}`;
|
||||||
|
}
|
||||||
|
return "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sort",
|
||||||
|
accessorKey: "sort",
|
||||||
|
header: t("sort", "Sort"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
title: t("nodeGroups", "Node Groups"),
|
||||||
|
toolbar: (
|
||||||
|
<NodeGroupForm
|
||||||
|
allNodeGroups={allNodeGroups}
|
||||||
|
currentGroupId={undefined}
|
||||||
|
initialValues={undefined}
|
||||||
|
key="create"
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createNodeGroup(
|
||||||
|
values as API.CreateNodeGroupRequest
|
||||||
|
);
|
||||||
|
toast.success(t("created", "Created successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createNodeGroup", "Create Node Group")}
|
||||||
|
trigger={<Button>{t("create", "Create")}</Button>}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: params.page || 1,
|
||||||
|
size: params.size || 10,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import {
|
||||||
|
getRecalculationStatus,
|
||||||
|
getSubscribeGroupMapping,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface SubscribeGroupMapping {
|
||||||
|
subscribe_name: string;
|
||||||
|
node_group_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscribeModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Fetch subscribe group mapping
|
||||||
|
const { data: mappingData, isLoading: mappingLoading } = useQuery({
|
||||||
|
queryKey: ["subscribeGroupMapping"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSubscribeGroupMapping();
|
||||||
|
return (data.data?.list || []) as SubscribeGroupMapping[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "subscribe" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("subscribeModeConfig", "Subscribe Mode Configuration")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"subscribeModeDescription",
|
||||||
|
"Group users by their purchased subscription plan"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Subscribe Group Mapping Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("subscribeGroupMappingTitle", "套餐-节点组对应关系")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mappingLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{mappingData && mappingData.length > 0 ? (
|
||||||
|
mappingData
|
||||||
|
.filter(
|
||||||
|
(item: SubscribeGroupMapping) =>
|
||||||
|
item.subscribe_name && item.node_group_name
|
||||||
|
)
|
||||||
|
.map((item: SubscribeGroupMapping, index: number) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.subscribe_name}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 text-muted-foreground">
|
||||||
|
{t("arrow", "→")}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.node_group_name}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-center text-muted-foreground">
|
||||||
|
{t("noMappingData", "No mapping data available")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("groupRecalculation", "Group Recalculation")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationCompleted",
|
||||||
|
"Recalculation completed successfully"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationFailed",
|
||||||
|
"Recalculation failed. Please try again."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
getNodeGroupList,
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
updateNodeGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import TrafficRangeConfig from "./traffic-ranges-config";
|
||||||
|
|
||||||
|
export default function TrafficModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Fetch node groups
|
||||||
|
const {
|
||||||
|
data: nodeGroupsData,
|
||||||
|
isLoading: isLoadingNodeGroups,
|
||||||
|
refetch: refetchNodeGroups,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrafficUpdate = async (
|
||||||
|
nodeGroupId: number,
|
||||||
|
fields: { min_traffic_gb?: number; max_traffic_gb?: number }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await updateNodeGroup({
|
||||||
|
id: nodeGroupId,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
toast.success(t("configSaved", "Configuration saved successfully"));
|
||||||
|
// Refetch node groups to get updated data
|
||||||
|
refetchNodeGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update node group:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "traffic" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Node Groups Traffic Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("trafficModeConfig", "Traffic Mode Configuration")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"trafficModeDescription",
|
||||||
|
"Configure traffic ranges for node groups. Users will be assigned to node groups based on their traffic usage."
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoadingNodeGroups ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TrafficRangeConfig
|
||||||
|
nodeGroups={nodeGroupsData || []}
|
||||||
|
onTrafficUpdate={handleTrafficUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("groupRecalculation", "Group Recalculation")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationCompleted",
|
||||||
|
"Recalculation completed successfully"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
{t(
|
||||||
|
"recalculationFailed",
|
||||||
|
"Recalculation failed. Please try again."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface NodeGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficRangeConfigProps {
|
||||||
|
nodeGroups: NodeGroup[];
|
||||||
|
onTrafficUpdate: (
|
||||||
|
nodeGroupId: number,
|
||||||
|
fields: { min_traffic_gb?: number; max_traffic_gb?: number }
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatingNode {
|
||||||
|
nodeGroupId: number;
|
||||||
|
field: "min_traffic_gb" | "max_traffic_gb";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeGroupTempValues {
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrafficRangeConfig({
|
||||||
|
nodeGroups,
|
||||||
|
onTrafficUpdate,
|
||||||
|
}: TrafficRangeConfigProps) {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [updatingNodes, setUpdatingNodes] = useState<UpdatingNode[]>([]);
|
||||||
|
// 使用对象存储每个节点组的临时值
|
||||||
|
const [temporaryValues, setTemporaryValues] = useState<
|
||||||
|
Record<number, NodeGroupTempValues>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// Get the display value (temporary or actual)
|
||||||
|
const getDisplayValue = (
|
||||||
|
nodeGroupId: number,
|
||||||
|
field: "min_traffic_gb" | "max_traffic_gb"
|
||||||
|
): number => {
|
||||||
|
const temp = temporaryValues[nodeGroupId];
|
||||||
|
if (temp && temp[field] !== undefined) {
|
||||||
|
return temp[field]!;
|
||||||
|
}
|
||||||
|
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
|
||||||
|
return field === "min_traffic_gb"
|
||||||
|
? (nodeGroup?.min_traffic_gb ?? 0)
|
||||||
|
: (nodeGroup?.max_traffic_gb ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate traffic ranges: no overlaps
|
||||||
|
const validateTrafficRange = (
|
||||||
|
nodeGroupId: number,
|
||||||
|
minTraffic: number,
|
||||||
|
maxTraffic: number
|
||||||
|
): { valid: boolean; error?: string } => {
|
||||||
|
// 如果 min=0 且 max=0,表示不参与流量分组,跳过验证
|
||||||
|
if (minTraffic === 0 && maxTraffic === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if min >= max (both > 0)
|
||||||
|
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: t(
|
||||||
|
"minCannotExceedMax",
|
||||||
|
"Minimum traffic cannot exceed maximum traffic"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlaps with other node groups
|
||||||
|
const otherGroups = nodeGroups
|
||||||
|
.filter((ng) => ng.id !== nodeGroupId)
|
||||||
|
.map((ng) => {
|
||||||
|
const temp = temporaryValues[ng.id];
|
||||||
|
return {
|
||||||
|
id: ng.id,
|
||||||
|
name: ng.name,
|
||||||
|
min:
|
||||||
|
temp?.min_traffic_gb !== undefined
|
||||||
|
? temp.min_traffic_gb
|
||||||
|
: (ng.min_traffic_gb ?? 0),
|
||||||
|
max:
|
||||||
|
temp?.max_traffic_gb !== undefined
|
||||||
|
? temp.max_traffic_gb
|
||||||
|
: (ng.max_traffic_gb ?? 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((ng) => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
|
||||||
|
.sort((a, b) => a.min - b.min);
|
||||||
|
|
||||||
|
for (const other of otherGroups) {
|
||||||
|
// Handle max=0 as no limit (infinity)
|
||||||
|
const otherMax = other.max === 0 ? Number.MAX_VALUE : other.max;
|
||||||
|
const currentMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
|
||||||
|
|
||||||
|
// Check for overlap: two ranges [min1, max1] and [min2, max2] overlap if:
|
||||||
|
// max1 > min2 && max2 > min1
|
||||||
|
if (currentMax > other.min && otherMax > minTraffic) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: t(
|
||||||
|
"rangeOverlap",
|
||||||
|
'Range overlaps with node group "{{name}}"',
|
||||||
|
{ name: other.name }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrafficBlur = async (nodeGroupId: number) => {
|
||||||
|
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
|
||||||
|
if (!nodeGroup) return;
|
||||||
|
|
||||||
|
const tempValues = temporaryValues[nodeGroupId];
|
||||||
|
if (!tempValues) return;
|
||||||
|
|
||||||
|
// 获取当前的临时值或实际值
|
||||||
|
const currentMin =
|
||||||
|
tempValues.min_traffic_gb !== undefined
|
||||||
|
? tempValues.min_traffic_gb
|
||||||
|
: (nodeGroup.min_traffic_gb ?? 0);
|
||||||
|
const currentMax =
|
||||||
|
tempValues.max_traffic_gb !== undefined
|
||||||
|
? tempValues.max_traffic_gb
|
||||||
|
: (nodeGroup.max_traffic_gb ?? 0);
|
||||||
|
|
||||||
|
// 只要有一个字段被修改了就保存
|
||||||
|
const hasMinChange = tempValues.min_traffic_gb !== undefined;
|
||||||
|
const hasMaxChange = tempValues.max_traffic_gb !== undefined;
|
||||||
|
if (!(hasMinChange || hasMaxChange)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
const validation = validateTrafficRange(
|
||||||
|
nodeGroupId,
|
||||||
|
currentMin,
|
||||||
|
currentMax
|
||||||
|
);
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast.error(
|
||||||
|
validation.error || t("validationFailed", "Validation failed")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查值是否真的改变了
|
||||||
|
const originalMin = nodeGroup.min_traffic_gb ?? 0;
|
||||||
|
const originalMax = nodeGroup.max_traffic_gb ?? 0;
|
||||||
|
if (currentMin === originalMin && currentMax === originalMax) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为更新中(只标记被修改的字段)
|
||||||
|
if (hasMinChange) {
|
||||||
|
setUpdatingNodes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ nodeGroupId, field: "min_traffic_gb" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (hasMaxChange) {
|
||||||
|
setUpdatingNodes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ nodeGroupId, field: "max_traffic_gb" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 一次性传递两个字段
|
||||||
|
const fieldsToUpdate: {
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
} = {};
|
||||||
|
if (currentMin !== originalMin) {
|
||||||
|
fieldsToUpdate.min_traffic_gb = currentMin;
|
||||||
|
}
|
||||||
|
if (currentMax !== originalMax) {
|
||||||
|
fieldsToUpdate.max_traffic_gb = currentMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fieldsToUpdate).length > 0) {
|
||||||
|
await onTrafficUpdate(nodeGroupId, fieldsToUpdate);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 移除更新状态
|
||||||
|
setUpdatingNodes((prev) =>
|
||||||
|
prev.filter((u) => !(u.nodeGroupId === nodeGroupId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpdating = (nodeGroupId: number) =>
|
||||||
|
updatingNodes.some((u) => u.nodeGroupId === nodeGroupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-12 gap-2 font-medium text-muted-foreground text-sm">
|
||||||
|
<div className="col-span-6">{t("nodeGroup", "Node Group")}</div>
|
||||||
|
<div className="col-span-3">{t("minTrafficGB", "Min (GB)")}</div>
|
||||||
|
<div className="col-span-3">{t("maxTrafficGB", "Max (GB)")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodeGroups.map((nodeGroup) => (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-12 items-center gap-2"
|
||||||
|
key={nodeGroup.id}
|
||||||
|
>
|
||||||
|
<div className="col-span-6">
|
||||||
|
<div className="font-medium">{nodeGroup.name}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t("id", "ID")}: {nodeGroup.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative col-span-3">
|
||||||
|
<Input
|
||||||
|
disabled={isUpdating(nodeGroup.id)}
|
||||||
|
min={0}
|
||||||
|
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||||
|
// 更新临时状态
|
||||||
|
setTemporaryValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeGroup.id]: {
|
||||||
|
...prev[nodeGroup.id],
|
||||||
|
min_traffic_gb: newValue,
|
||||||
|
max_traffic_gb: prev[nodeGroup.id]?.max_traffic_gb,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
|
||||||
|
/>
|
||||||
|
{isUpdating(nodeGroup.id) && (
|
||||||
|
<div className="-translate-y-1/2 absolute top-1/2 right-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative col-span-3">
|
||||||
|
<Input
|
||||||
|
disabled={isUpdating(nodeGroup.id)}
|
||||||
|
min={0}
|
||||||
|
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||||
|
// 更新临时状态
|
||||||
|
setTemporaryValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[nodeGroup.id]: {
|
||||||
|
...prev[nodeGroup.id],
|
||||||
|
min_traffic_gb: prev[nodeGroup.id]?.min_traffic_gb,
|
||||||
|
max_traffic_gb: newValue,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
step={1}
|
||||||
|
type="number"
|
||||||
|
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
|
||||||
|
/>
|
||||||
|
{isUpdating(nodeGroup.id) && (
|
||||||
|
<div className="-translate-y-1/2 absolute top-1/2 right-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-4 text-muted-foreground text-sm">
|
||||||
|
<strong>{t("note", "Note")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"trafficRangesNote",
|
||||||
|
"Set traffic ranges for each node group. Users will be assigned to node groups based on their traffic usage. Leave both values as 0 to not use this node group for traffic-based assignment."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { getAdminInviteList } from "@workspace/ui/services/admin/invite";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import InviteManagement from ".";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (
|
||||||
|
_key: string,
|
||||||
|
fallback: string,
|
||||||
|
options?: { count?: number }
|
||||||
|
): string => {
|
||||||
|
if (typeof options?.count === "number") {
|
||||||
|
return `${options.count} days`;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/global", () => ({
|
||||||
|
useGlobalStore: () => ({
|
||||||
|
common: {
|
||||||
|
currency: {
|
||||||
|
currency_symbol: "$",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/common", () => ({
|
||||||
|
formatDate: (timestamp: number) => `date-${timestamp}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@workspace/ui/services/admin/invite", () => ({
|
||||||
|
getAdminInviteList: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetAdminInviteList = vi.mocked(getAdminInviteList);
|
||||||
|
|
||||||
|
function createInviteListResponse(
|
||||||
|
data: API.GetAdminInviteListResponse
|
||||||
|
): AxiosResponse<API.Response & { data?: API.GetAdminInviteListResponse }> {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
code: 200,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosResponse["config"]["headers"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem("timezone", "UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InviteManagement", () => {
|
||||||
|
it("renders invite rows with status, purchase state, commission, and gift days", async () => {
|
||||||
|
mockedGetAdminInviteList.mockResolvedValue(
|
||||||
|
createInviteListResponse({
|
||||||
|
total: 1,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
inviter_id: 10,
|
||||||
|
inviter_identifier: "agent@example.com",
|
||||||
|
invitee_id: 123,
|
||||||
|
invitee_identifier: "user@example.com",
|
||||||
|
invitee_avatar: "",
|
||||||
|
invitee_enable: true,
|
||||||
|
invited_at: 1_716_800_000,
|
||||||
|
order_count: 3,
|
||||||
|
has_purchased: true,
|
||||||
|
inviter_commission: 1500,
|
||||||
|
inviter_gift_days: 30,
|
||||||
|
invitee_gift_days: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<InviteManagement />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedGetAdminInviteList).toHaveBeenCalledWith({
|
||||||
|
page: 1,
|
||||||
|
size: 200,
|
||||||
|
search: undefined,
|
||||||
|
inviter_id: undefined,
|
||||||
|
invitee_id: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("agent@example.com")).not.toBeNull();
|
||||||
|
expect(screen.getByText("user@example.com")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Enabled")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Yes")).not.toBeNull();
|
||||||
|
expect(screen.getByText("$15.00")).not.toBeNull();
|
||||||
|
expect(screen.getByText("30 days")).not.toBeNull();
|
||||||
|
expect(screen.getByText("7 days")).not.toBeNull();
|
||||||
|
expect(screen.getByText("date-1716800000")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty state when the API returns no rows", async () => {
|
||||||
|
mockedGetAdminInviteList.mockResolvedValue(
|
||||||
|
createInviteListResponse({
|
||||||
|
total: 0,
|
||||||
|
list: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<InviteManagement />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("No invite records")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading state while invites are being fetched", () => {
|
||||||
|
let resolveRequest:
|
||||||
|
| ((
|
||||||
|
value: AxiosResponse<
|
||||||
|
API.Response & { data?: API.GetAdminInviteListResponse }
|
||||||
|
>
|
||||||
|
) => void)
|
||||||
|
| undefined;
|
||||||
|
mockedGetAdminInviteList.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<InviteManagement />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
|
||||||
|
expect(resolveRequest).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state when the request fails", async () => {
|
||||||
|
mockedGetAdminInviteList.mockRejectedValue(new Error("network failed"));
|
||||||
|
|
||||||
|
render(<InviteManagement />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedGetAdminInviteList).toHaveBeenCalled());
|
||||||
|
expect(await screen.findByText("Failed to load invites")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("No invite records")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@workspace/ui/components/alert";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@workspace/ui/components/avatar";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import { getAdminInviteList } from "@workspace/ui/services/admin/invite";
|
||||||
|
import { CircleAlert } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Display } from "@/components/display";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
|
||||||
|
type InviteFilters = {
|
||||||
|
search?: string;
|
||||||
|
inviter_id?: string;
|
||||||
|
invitee_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toOptionalNumber(value?: string) {
|
||||||
|
if (!value) return;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteUserCell({
|
||||||
|
id,
|
||||||
|
identifier,
|
||||||
|
avatar,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
avatar?: string;
|
||||||
|
}) {
|
||||||
|
const fallback = identifier?.charAt(0)?.toUpperCase() || "U";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-48 items-center gap-2">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage alt={identifier || `#${id}`} src={avatar} />
|
||||||
|
<AvatarFallback className="text-xs">{fallback}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-sm">#{id}</div>
|
||||||
|
<div className="max-w-52 truncate text-muted-foreground text-xs">
|
||||||
|
{identifier || "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteIdentifierCell({
|
||||||
|
id,
|
||||||
|
identifier,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-44">
|
||||||
|
<div className="font-medium text-sm">#{id}</div>
|
||||||
|
<div className="max-w-52 truncate text-muted-foreground text-xs">
|
||||||
|
{identifier || "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteManagement() {
|
||||||
|
const { t } = useTranslation("invite");
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProTable<API.AdminInviteRelation, InviteFilters>
|
||||||
|
action={ref}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "inviter_id",
|
||||||
|
header: t("inviter", "Inviter"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<InviteIdentifierCell
|
||||||
|
id={row.original.inviter_id}
|
||||||
|
identifier={row.original.inviter_identifier}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invitee_id",
|
||||||
|
header: t("invitee", "Invitee"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<InviteUserCell
|
||||||
|
avatar={row.original.invitee_avatar}
|
||||||
|
id={row.original.invitee_id}
|
||||||
|
identifier={row.original.invitee_identifier}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invitee_enable",
|
||||||
|
header: t("status", "Status"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={row.original.invitee_enable ? "default" : "secondary"}
|
||||||
|
>
|
||||||
|
{row.original.invitee_enable
|
||||||
|
? t("enabled", "Enabled")
|
||||||
|
: t("disabled", "Disabled")}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invited_at",
|
||||||
|
header: t("invitedAt", "Invited At"),
|
||||||
|
cell: ({ row }) => formatDate(row.original.invited_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "order_count",
|
||||||
|
header: t("orderCount", "Orders"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "has_purchased",
|
||||||
|
header: t("hasPurchased", "Purchased"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.has_purchased ? "default" : "outline"}>
|
||||||
|
{row.original.has_purchased ? t("yes", "Yes") : t("no", "No")}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inviter_commission",
|
||||||
|
header: t("commission", "Commission"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Display type="currency" value={row.original.inviter_commission} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "inviter_gift_days",
|
||||||
|
header: t("inviterGiftDays", "Inviter Gift Days"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
t("days", "{{count}} days", {
|
||||||
|
count: row.original.inviter_gift_days,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invitee_gift_days",
|
||||||
|
header: t("inviteeGiftDays", "Invitee Gift Days"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
t("days", "{{count}} days", {
|
||||||
|
count: row.original.invitee_gift_days,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
empty={<Empty description={t("empty", "No invite records")} />}
|
||||||
|
error={
|
||||||
|
<Alert className="mx-auto max-w-md" variant="destructive">
|
||||||
|
<CircleAlert aria-hidden="true" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("loadErrorTitle", "Failed to load invites")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("loadErrorDescription", "Refresh the table or try again later.")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
header={{ title: t("title", "Invite Management") }}
|
||||||
|
params={[
|
||||||
|
{
|
||||||
|
key: "search",
|
||||||
|
placeholder: t("searchPlaceholder", "Email or phone"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inviter_id",
|
||||||
|
placeholder: t("inviterId", "Inviter ID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invitee_id",
|
||||||
|
placeholder: t("inviteeId", "Invitee ID"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
request={async (pagination, filters) => {
|
||||||
|
const { data } = await getAdminInviteList({
|
||||||
|
page: pagination.page,
|
||||||
|
size: pagination.size,
|
||||||
|
search: filters.search?.trim() || undefined,
|
||||||
|
inviter_id: toOptionalNumber(filters.inviter_id),
|
||||||
|
invitee_id: toOptionalNumber(filters.invitee_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
list:
|
||||||
|
data.data?.list.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: `${item.inviter_id}-${item.invitee_id}-${item.invited_at}`,
|
||||||
|
})) || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,14 +25,12 @@ export default function ResetSubscribeLogPage() {
|
|||||||
|
|
||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<
|
<ProTable<
|
||||||
API.ResetSubscribeLog,
|
API.ResetSubscribeLog,
|
||||||
{ date?: string; user_subscribe_id?: number }
|
{ date?: string; user_subscribe_id?: string }
|
||||||
>
|
>
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@@ -83,7 +81,9 @@ export default function ResetSubscribeLogPage() {
|
|||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id
|
||||||
|
? Number((filter as any)?.user_subscribe_id)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const list = (data?.data?.list || []) as any[];
|
const list = (data?.data?.list || []) as any[];
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
|
|||||||
@@ -17,14 +17,12 @@ export default function SubscribeTrafficLogPage() {
|
|||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<
|
<ProTable<
|
||||||
API.UserSubscribeTrafficLog,
|
API.UserSubscribeTrafficLog,
|
||||||
{ date?: string; user_id?: number; user_subscribe_id?: number }
|
{ date?: string; user_id?: number; user_subscribe_id?: string }
|
||||||
>
|
>
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
@@ -95,7 +93,9 @@ export default function SubscribeTrafficLogPage() {
|
|||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_id: (filter as any)?.user_id,
|
user_id: (filter as any)?.user_id,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id
|
||||||
|
? Number((filter as any)?.user_subscribe_id)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const list =
|
const list =
|
||||||
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
|
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ export default function SubscribeLogPage() {
|
|||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
|
<ProTable<
|
||||||
|
API.SubscribeLog,
|
||||||
|
{ date?: string; user_id?: number; user_subscribe_id?: string }
|
||||||
|
>
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
accessorKey: "user",
|
accessorKey: "user",
|
||||||
@@ -96,7 +97,9 @@ export default function SubscribeLogPage() {
|
|||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_id: (filter as any)?.user_id,
|
user_id: (filter as any)?.user_id,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id
|
||||||
|
? Number((filter as any)?.user_subscribe_id)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const list = (data?.data?.list || []) as any[];
|
const list = (data?.data?.list || []) as any[];
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 { Switch } from "@workspace/ui/components/switch";
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
@@ -8,6 +9,10 @@ import {
|
|||||||
ProTable,
|
ProTable,
|
||||||
type ProTableActions,
|
type ProTableActions,
|
||||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
@@ -16,11 +21,12 @@ import {
|
|||||||
toggleNodeStatus,
|
toggleNodeStatus,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from "@workspace/ui/services/admin/server";
|
} from "@workspace/ui/services/admin/server";
|
||||||
import { useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
||||||
@@ -32,13 +38,139 @@ export default function Nodes() {
|
|||||||
const { getServerName, getServerAddress, getProtocolPort } = useServer();
|
const { getServerName, getServerAddress, getProtocolPort } = useServer();
|
||||||
const { fetchNodes, fetchTags } = useNode();
|
const { fetchNodes, fetchTags } = useNode();
|
||||||
|
|
||||||
|
// Fetch node groups for display
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch group config to check if group feature is enabled
|
||||||
|
const { data: groupConfigData } = useQuery({
|
||||||
|
queryKey: ["groupConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroupEnabled = groupConfigData?.enabled;
|
||||||
|
|
||||||
|
// Dynamic columns based on group feature status
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
header: t("enabled", "Enabled"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Switch
|
||||||
|
checked={row.original.enabled}
|
||||||
|
onCheckedChange={async (v) => {
|
||||||
|
await toggleNodeStatus({ id: row.original.id, enable: v });
|
||||||
|
toast.success(
|
||||||
|
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
|
||||||
|
);
|
||||||
|
ref.current?.refresh();
|
||||||
|
fetchNodes();
|
||||||
|
fetchTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "address_port",
|
||||||
|
header: `${t("address", "Address")}:${t("port", "Port")}`,
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${row.original.address || "—"}:${row.original.port || "—"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server_id",
|
||||||
|
header: t("server", "Server"),
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "protocol",
|
||||||
|
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
header: t("tags", "Tags"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(row.original.tags || []).length === 0
|
||||||
|
? "—"
|
||||||
|
: row.original.tags.map((tg: string) => (
|
||||||
|
<Badge key={tg} variant="outline">
|
||||||
|
{tg}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add Node Groups column when group feature is enabled
|
||||||
|
if (isGroupEnabled) {
|
||||||
|
baseColumns.push({
|
||||||
|
id: "node_group_ids",
|
||||||
|
header: t("nodeGroups", "Node Groups"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const groupIds = (row.original.node_group_ids as number[]) || [];
|
||||||
|
|
||||||
|
// Public node indicator (when node_group_ids is empty)
|
||||||
|
if (groupIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge className="text-xs" variant="secondary">
|
||||||
|
{t("public", "Public")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{groupIds.map((groupId) => {
|
||||||
|
const group = nodeGroupsData?.find((g) => g.id === groupId);
|
||||||
|
return (
|
||||||
|
<Badge key={groupId} variant="outline">
|
||||||
|
{group?.name || String(groupId)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
|
}, [
|
||||||
|
isGroupEnabled,
|
||||||
|
nodeGroupsData,
|
||||||
|
t,
|
||||||
|
getServerName,
|
||||||
|
getServerAddress,
|
||||||
|
getProtocolPort,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.Node, { search: string }>
|
<ProTable<API.Node, { search: string; node_group_id?: number }>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
<NodeForm
|
<NodeForm
|
||||||
initialValues={row}
|
initialValues={row as any}
|
||||||
key="edit"
|
key="edit"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -47,6 +179,10 @@ export default function Nodes() {
|
|||||||
const body: API.UpdateNodeRequest = {
|
const body: API.UpdateNodeRequest = {
|
||||||
...row,
|
...row,
|
||||||
...values,
|
...values,
|
||||||
|
node_group_ids:
|
||||||
|
values.node_group_ids?.map((id: string | number) =>
|
||||||
|
Number(id)
|
||||||
|
) || [],
|
||||||
} as any;
|
} as any;
|
||||||
await updateNode(body);
|
await updateNode(body);
|
||||||
toast.success(t("updated", "Updated"));
|
toast.success(t("updated", "Updated"));
|
||||||
@@ -110,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")}
|
||||||
@@ -135,62 +280,7 @@ export default function Nodes() {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
id: "enabled",
|
|
||||||
header: t("enabled", "Enabled"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Switch
|
|
||||||
checked={row.original.enabled}
|
|
||||||
onCheckedChange={async (v) => {
|
|
||||||
await toggleNodeStatus({ id: row.original.id, enable: v });
|
|
||||||
toast.success(
|
|
||||||
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
|
|
||||||
);
|
|
||||||
ref.current?.refresh();
|
|
||||||
fetchNodes();
|
|
||||||
fetchTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ accessorKey: "name", header: t("name", "Name") },
|
|
||||||
|
|
||||||
{
|
|
||||||
id: "address_port",
|
|
||||||
header: `${t("address", "Address")}:${t("port", "Port")}`,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${row.original.address || "—"}:${row.original.port || "—"}`,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
id: "server_id",
|
|
||||||
header: t("server", "Server"),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "protocol",
|
|
||||||
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "tags",
|
|
||||||
header: t("tags", "Tags"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(row.original.tags || []).length === 0
|
|
||||||
? "—"
|
|
||||||
: row.original.tags.map((tg) => (
|
|
||||||
<Badge key={tg} variant="outline">
|
|
||||||
{tg}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
header={{
|
header={{
|
||||||
title: t("pageTitle", "Nodes"),
|
title: t("pageTitle", "Nodes"),
|
||||||
toolbar: (
|
toolbar: (
|
||||||
@@ -199,15 +289,19 @@ export default function Nodes() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: API.CreateNodeRequest = {
|
const body: any = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
server_id: Number(values.server_id!),
|
server_id: Number(values.server_id!),
|
||||||
protocol: values.protocol,
|
protocol: values.protocol,
|
||||||
address: values.address,
|
address: values.address,
|
||||||
port: Number(values.port!),
|
port: Number(values.port!),
|
||||||
tags: values.tags || [],
|
tags: values.tags || [],
|
||||||
enabled: false,
|
|
||||||
};
|
};
|
||||||
|
if (values.node_group_ids) {
|
||||||
|
body.node_group_ids = values.node_group_ids.map(
|
||||||
|
(id: string | number) => Number(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
await createNode(body);
|
await createNode(body);
|
||||||
toast.success(t("created", "Created"));
|
toast.success(t("created", "Created"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
@@ -220,12 +314,14 @@ export default function Nodes() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={t("drawerCreateTitle", "Create Node")}
|
title={t("drawerCreateTitle", "Create Landing Node")}
|
||||||
trigger={t("create", "Create")}
|
trigger={t("create", "Create Landing Node")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
onSort={async (source, target, items) => {
|
onSort={async (source, target, items) => {
|
||||||
|
// NOTE: `items` is the current page's items from ProTable.
|
||||||
|
// Avoid mutating it in-place, and persist sort changes reliably.
|
||||||
const sourceIndex = items.findIndex(
|
const sourceIndex = items.findIndex(
|
||||||
(item) => String(item.id) === source
|
(item) => String(item.id) === source
|
||||||
);
|
);
|
||||||
@@ -233,23 +329,38 @@ export default function Nodes() {
|
|||||||
(item) => String(item.id) === target
|
(item) => String(item.id) === target
|
||||||
);
|
);
|
||||||
|
|
||||||
const originalSorts = items.map((item) => item.sort);
|
if (sourceIndex === -1 || targetIndex === -1) return items;
|
||||||
|
|
||||||
const [movedItem] = items.splice(sourceIndex, 1);
|
const prevSortById = new Map(items.map((it) => [it.id, it.sort]));
|
||||||
items.splice(targetIndex, 0, movedItem!);
|
|
||||||
|
|
||||||
const updatedItems = items.map((item, index) => {
|
const next = items.slice();
|
||||||
const originalSort = originalSorts[index];
|
const [movedItem] = next.splice(sourceIndex, 1);
|
||||||
const newSort = originalSort !== undefined ? originalSort : item.sort;
|
next.splice(targetIndex, 0, movedItem!);
|
||||||
return { ...item, sort: newSort };
|
|
||||||
});
|
// IMPORTANT:
|
||||||
|
// Some installations have duplicate / empty `sort` values (commonly 0 or null)
|
||||||
|
// which makes the order appear "random" after refresh and also makes
|
||||||
|
// "swap sort values" strategies a no-op.
|
||||||
|
//
|
||||||
|
// To make the ordering stable, we re-index the current page to a strictly
|
||||||
|
// increasing sequence.
|
||||||
|
const numericSorts = items
|
||||||
|
.map((it) => (typeof it.sort === "number" ? it.sort : Number.NaN))
|
||||||
|
.filter((v) => Number.isFinite(v)) as number[];
|
||||||
|
const baseSort = numericSorts.length ? Math.min(...numericSorts) : 0;
|
||||||
|
|
||||||
|
const updatedItems = next.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
sort: baseSort + index,
|
||||||
|
}));
|
||||||
|
|
||||||
const changedItems = updatedItems.filter(
|
const changedItems = updatedItems.filter(
|
||||||
(item, index) => item.sort !== items[index]?.sort
|
(item) => item.sort !== prevSortById.get(item.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (changedItems.length > 0) {
|
if (changedItems.length > 0) {
|
||||||
resetSortWithNode({
|
await resetSortWithNode({
|
||||||
|
// Send all changed rows (within the current page) so backend can persist.
|
||||||
sort: changedItems.map((item) => ({
|
sort: changedItems.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
sort: item.sort,
|
sort: item.sort,
|
||||||
@@ -257,16 +368,52 @@ export default function Nodes() {
|
|||||||
});
|
});
|
||||||
toast.success(t("sorted_success", "Sorted successfully"));
|
toast.success(t("sorted_success", "Sorted successfully"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedItems;
|
return updatedItems;
|
||||||
}}
|
}}
|
||||||
params={[{ key: "search" }]}
|
params={[
|
||||||
|
{
|
||||||
|
key: "search",
|
||||||
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "node_group_id",
|
||||||
|
placeholder: t("nodeGroups", "Node Groups"),
|
||||||
|
options: [
|
||||||
|
{ label: t("all", "All"), value: "" },
|
||||||
|
...(nodeGroupsData?.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
request={async (pagination, filter) => {
|
request={async (pagination, filter) => {
|
||||||
const { data } = await filterNodeList({
|
const filters = {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
search: filter?.search || undefined,
|
search: filter?.search || undefined,
|
||||||
|
node_group_id: filter?.node_group_id
|
||||||
|
? Number(filter.node_group_id)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await filterNodeList(filters);
|
||||||
|
const rawList = (data?.data?.list || []) as API.Node[];
|
||||||
|
// Backend should ideally return nodes already sorted, but we also sort on the
|
||||||
|
// frontend to keep the UI stable (and avoid "random" order after refresh).
|
||||||
|
const list = rawList.slice().sort((a, b) => {
|
||||||
|
const as = a.sort;
|
||||||
|
const bs = b.sort;
|
||||||
|
const an = typeof as === "number" ? as : Number.POSITIVE_INFINITY;
|
||||||
|
const bn = typeof bs === "number" ? bs : Number.POSITIVE_INFINITY;
|
||||||
|
if (an !== bn) return an - bn;
|
||||||
|
// Tie-breaker to keep a stable order.
|
||||||
|
return Number(a.id) - Number(b.id);
|
||||||
});
|
});
|
||||||
const list = (data?.data?.list || []) as API.Node[];
|
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
return { list, total };
|
return { list, total };
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@workspace/ui/components/form";
|
} from "@workspace/ui/components/form";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -23,6 +26,10 @@ import {
|
|||||||
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 TagInput from "@workspace/ui/composed/tag-input";
|
import TagInput from "@workspace/ui/composed/tag-input";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -54,7 +61,7 @@ const buildSchema = (t: TFunction) =>
|
|||||||
server_id: z
|
server_id: z
|
||||||
.number({ message: t("errors.serverRequired", "Please select a server") })
|
.number({ message: t("errors.serverRequired", "Please select a server") })
|
||||||
.int()
|
.int()
|
||||||
.gt(0, t("errors.serverRequired", "Please select a server"))
|
.positive(t("errors.serverRequired", "Please select a server"))
|
||||||
.optional(),
|
.optional(),
|
||||||
protocol: z
|
protocol: z
|
||||||
.string()
|
.string()
|
||||||
@@ -71,6 +78,7 @@ const buildSchema = (t: TFunction) =>
|
|||||||
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
|
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
|
||||||
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
|
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
|
node_group_ids: z.optional(z.array(z.string()).default([])),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
|
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
|
||||||
@@ -112,8 +120,10 @@ export default function NodeForm(props: {
|
|||||||
address: "",
|
address: "",
|
||||||
port: 0,
|
port: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
node_group_ids: [],
|
||||||
...initialValues,
|
...initialValues,
|
||||||
},
|
},
|
||||||
|
mode: "onSubmit", // Only validate on form submission
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverId = form.watch("server_id");
|
const serverId = form.watch("server_id");
|
||||||
@@ -125,17 +135,60 @@ export default function NodeForm(props: {
|
|||||||
|
|
||||||
const availableProtocols = getAvailableProtocols(serverId);
|
const availableProtocols = getAvailableProtocols(serverId);
|
||||||
|
|
||||||
|
// Fetch node groups
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch group config to check if group feature is enabled
|
||||||
|
const { data: groupConfigData } = useQuery({
|
||||||
|
queryKey: ["groupConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroupEnabled = groupConfigData?.enabled;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
form.reset({
|
const resetValues: NodeFormValues = {
|
||||||
name: "",
|
name: "",
|
||||||
server_id: undefined,
|
server_id: undefined,
|
||||||
protocol: "",
|
protocol: "",
|
||||||
address: "",
|
address: "",
|
||||||
port: 0,
|
port: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
...initialValues,
|
node_group_ids: [],
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Copy only the values we need from initialValues
|
||||||
|
if (initialValues.name) resetValues.name = initialValues.name;
|
||||||
|
if (initialValues.server_id)
|
||||||
|
resetValues.server_id = initialValues.server_id;
|
||||||
|
if (initialValues.protocol) resetValues.protocol = initialValues.protocol;
|
||||||
|
if (initialValues.address) resetValues.address = initialValues.address;
|
||||||
|
if (initialValues.port) resetValues.port = initialValues.port;
|
||||||
|
if (initialValues.tags) resetValues.tags = initialValues.tags;
|
||||||
|
|
||||||
|
// Convert node_group_ids from number[] to string[], ensure it's always an array
|
||||||
|
if (
|
||||||
|
initialValues.node_group_ids &&
|
||||||
|
Array.isArray(initialValues.node_group_ids)
|
||||||
|
) {
|
||||||
|
resetValues.node_group_ids = initialValues.node_group_ids.map(
|
||||||
|
(id: string | number) => String(id)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resetValues.node_group_ids = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(resetValues);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialValues]);
|
}, [initialValues]);
|
||||||
@@ -360,6 +413,7 @@ export default function NodeForm(props: {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Tags field - always shown */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="tags"
|
name="tags"
|
||||||
@@ -378,15 +432,86 @@ export default function NodeForm(props: {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{isGroupEnabled
|
||||||
"tags_description",
|
? t(
|
||||||
"Permission grouping tag (incl. plan binding and delivery policies)."
|
"tags_groupMode_description",
|
||||||
)}
|
"Optional tags for display and filtering (node group will be used as tag if empty)."
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"tags_description",
|
||||||
|
"Permission grouping tag (incl. plan binding and delivery policies)."
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Show Node Group field only when group feature is enabled */}
|
||||||
|
{isGroupEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="node_group_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("nodeGroup", "Node Group")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{nodeGroupsData?.map((g) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
key={g.id}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value?.includes(String(g.id))}
|
||||||
|
id={`node-group-${g.id}`}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
// Ensure field.value is always an array
|
||||||
|
const currentValue = Array.isArray(
|
||||||
|
field.value
|
||||||
|
)
|
||||||
|
? field.value
|
||||||
|
: [];
|
||||||
|
if (checked) {
|
||||||
|
const newValue = [
|
||||||
|
...currentValue,
|
||||||
|
String(g.id),
|
||||||
|
];
|
||||||
|
form.setValue(field.name, newValue, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newValue = currentValue.filter(
|
||||||
|
(v: string) => v !== String(g.id)
|
||||||
|
);
|
||||||
|
form.setValue(field.name, newValue, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor={`node-group-${g.id}`}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"nodeGroup_description",
|
||||||
|
"Assign this node to multiple groups for user access control."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useSearch } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@workspace/ui/components/alert-dialog";
|
||||||
import { Badge } from "@workspace/ui/components/badge";
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,18 +26,35 @@ import {
|
|||||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
import { cn } from "@workspace/ui/lib/utils";
|
import { cn } from "@workspace/ui/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
activateOrder,
|
||||||
getOrderList,
|
getOrderList,
|
||||||
|
refundOrder,
|
||||||
updateOrderStatus,
|
updateOrderStatus,
|
||||||
} from "@workspace/ui/services/admin/order";
|
} from "@workspace/ui/services/admin/order";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
|
import { useGlobalStore } from "@/stores/global";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
import { UserDetail } from "../user/user-detail";
|
import { UserDetail } from "../user/user-detail";
|
||||||
|
|
||||||
|
const REFUNDED_ORDER_STATUS = 6;
|
||||||
|
|
||||||
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 user = useGlobalStore((state) => state.user);
|
||||||
|
const [confirmingOrderId, setConfirmingOrderId] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const initialFilters = {
|
||||||
|
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||||
|
search: sp.search || undefined,
|
||||||
|
status: sp.status || undefined,
|
||||||
|
subscribe_id: sp.subscribe_id || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{
|
{
|
||||||
@@ -44,6 +74,11 @@ export default function Order() {
|
|||||||
label: t("status.5", "Completed"),
|
label: t("status.5", "Completed"),
|
||||||
className: "bg-green-500",
|
className: "bg-green-500",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: REFUNDED_ORDER_STATUS,
|
||||||
|
label: t("status.6", "Refunded"),
|
||||||
|
className: "bg-red-500",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
@@ -56,10 +91,101 @@ export default function Order() {
|
|||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
const { subscribes, getSubscribeName } = useSubscribe();
|
const { subscribes, getSubscribeName } = useSubscribe();
|
||||||
|
const canRefundOrders = Boolean(user?.is_admin);
|
||||||
|
|
||||||
|
const refundMutation = useMutation({
|
||||||
|
mutationFn: async (order: API.Order) => {
|
||||||
|
await refundOrder({ id: order.id });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("refundSuccess", "Refund completed."));
|
||||||
|
setConfirmingOrderId(null);
|
||||||
|
ref.current?.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Refund order failed", error);
|
||||||
|
setConfirmingOrderId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRefundedOrder = (order: API.Order) =>
|
||||||
|
order.status === REFUNDED_ORDER_STATUS ||
|
||||||
|
order.status_name === t("status.6", "Refunded") ||
|
||||||
|
order.status_name?.toLowerCase() === "refunded";
|
||||||
|
|
||||||
|
const canRefundOrder = (order: API.Order) =>
|
||||||
|
canRefundOrders && !isRefundedOrder(order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.Order, any>
|
<ProTable<API.Order, any>
|
||||||
action={ref}
|
action={ref}
|
||||||
|
actions={{
|
||||||
|
render: (order) => {
|
||||||
|
if (!canRefundOrder(order)) return [];
|
||||||
|
|
||||||
|
const isPending =
|
||||||
|
refundMutation.isPending && confirmingOrderId === order.id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
<AlertDialog
|
||||||
|
key={`refund-${order.id}`}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setConfirmingOrderId(open ? order.id : null);
|
||||||
|
}}
|
||||||
|
open={confirmingOrderId === order.id}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button disabled={isPending} size="sm" variant="destructive">
|
||||||
|
{isPending
|
||||||
|
? t("refundSubmitting", "Refunding...")
|
||||||
|
: t("refund", "Refund")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("refundConfirmTitle", "Confirm refund")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"refundConfirmDescription",
|
||||||
|
"This refund will immediately invalidate the user's subscription and deduct the related agent commission."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{t(
|
||||||
|
"refundConfirmWarning",
|
||||||
|
"This action cannot be repeated after the order is refunded."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isPending) return;
|
||||||
|
await refundMutation.mutateAsync(order);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? t("refundSubmitting", "Refunding...")
|
||||||
|
: t("confirmRefund", "Confirm refund")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
accessorKey: "order_no",
|
accessorKey: "order_no",
|
||||||
@@ -180,6 +306,14 @@ export default function Order() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "payment",
|
||||||
|
header: t("method", "Payment Method"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const order = row.original as API.Order;
|
||||||
|
return order.payment?.name || order.payment?.platform || "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "user_id",
|
accessorKey: "user_id",
|
||||||
header: t("user", "User"),
|
header: t("user", "User"),
|
||||||
@@ -206,29 +340,49 @@ export default function Order() {
|
|||||||
);
|
);
|
||||||
if ([1, 3, 4].includes(row.getValue("status"))) {
|
if ([1, 3, 4].includes(row.getValue("status"))) {
|
||||||
return (
|
return (
|
||||||
<Combobox<number, false>
|
<div className="flex items-center gap-1">
|
||||||
className={cn(option?.className)}
|
<Combobox<number, false>
|
||||||
onChange={async (value) => {
|
className={cn(option?.className)}
|
||||||
await updateOrderStatus({
|
onChange={async (value) => {
|
||||||
id: order.id,
|
await updateOrderStatus({
|
||||||
status: value,
|
id: order.id,
|
||||||
});
|
status: value,
|
||||||
ref.current?.refresh();
|
});
|
||||||
}}
|
ref.current?.refresh();
|
||||||
options={statusOptions}
|
}}
|
||||||
placeholder={t("status.0", "Status")}
|
options={statusOptions}
|
||||||
value={order.status}
|
placeholder={t("status.0", "Status")}
|
||||||
/>
|
value={order.status}
|
||||||
|
/>
|
||||||
|
{[1, 3].includes(order.status) && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await activateOrder({ order_no: order.order_no });
|
||||||
|
ref.current?.refresh();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("activate", "Activate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge>
|
<Badge
|
||||||
{option?.label || t(`status.${row.getValue("status")}`)}
|
variant={isRefundedOrder(order) ? "destructive" : "default"}
|
||||||
|
>
|
||||||
|
{option?.label ||
|
||||||
|
(order.status_name
|
||||||
|
? t(`status.${row.getValue("status")}`, order.status_name)
|
||||||
|
: t(`status.${row.getValue("status")}`))}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
initialFilters={initialFilters}
|
||||||
params={[
|
params={[
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@workspace/ui/components/accordion";
|
} from "@workspace/ui/components/accordion";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Card } from "@workspace/ui/components/card";
|
||||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -36,10 +38,14 @@ 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 { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
|
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
|
||||||
import { JSONEditor } from "@workspace/ui/composed/editor/index";
|
import { JSONEditor } from "@workspace/ui/composed/editor/index";
|
||||||
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";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
import {
|
import {
|
||||||
evaluateWithPrecision,
|
evaluateWithPrecision,
|
||||||
unitConversion,
|
unitConversion,
|
||||||
@@ -72,6 +78,8 @@ const defaultValues = {
|
|||||||
language: "",
|
language: "",
|
||||||
node_tags: [],
|
node_tags: [],
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
node_group_id: "",
|
||||||
|
node_group_ids: [],
|
||||||
unit_time: "Month",
|
unit_time: "Month",
|
||||||
deduction_ratio: 0,
|
deduction_ratio: 0,
|
||||||
purchase_with_discount: false,
|
purchase_with_discount: false,
|
||||||
@@ -79,6 +87,7 @@ const defaultValues = {
|
|||||||
renewal_reset: false,
|
renewal_reset: false,
|
||||||
show_original_price: false,
|
show_original_price: false,
|
||||||
deduction_mode: "auto",
|
deduction_mode: "auto",
|
||||||
|
traffic_limit: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SubscribeForm<T extends Record<string, any>>({
|
export default function SubscribeForm<T extends Record<string, any>>({
|
||||||
@@ -106,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(),
|
||||||
@@ -117,11 +127,23 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
node_tags: z.array(z.string()).optional(),
|
node_tags: z.array(z.string()).optional(),
|
||||||
nodes: z.array(z.number()).optional(),
|
nodes: z.array(z.number()).optional(),
|
||||||
|
node_group_id: z.string().optional(),
|
||||||
|
node_group_ids: z.optional(z.array(z.string()).default([])),
|
||||||
deduction_ratio: z.number().optional(),
|
deduction_ratio: z.number().optional(),
|
||||||
allow_deduction: z.boolean().optional(),
|
allow_deduction: z.boolean().optional(),
|
||||||
reset_cycle: z.number().optional(),
|
reset_cycle: z.number().optional(),
|
||||||
renewal_reset: z.boolean().optional(),
|
renewal_reset: z.boolean().optional(),
|
||||||
show_original_price: z.boolean().optional(),
|
show_original_price: z.boolean().optional(),
|
||||||
|
traffic_limit: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
stat_type: z.string(),
|
||||||
|
stat_value: z.number().int(),
|
||||||
|
traffic_usage: z.number(),
|
||||||
|
speed_limit: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@@ -234,12 +256,27 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form?.reset(
|
const processedValues = assign(
|
||||||
assign(
|
defaultValues,
|
||||||
defaultValues,
|
shake(initialValues, (value) => value === null) as Record<string, any>
|
||||||
shake(initialValues, (value) => value === null) as Record<string, any>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Convert node_group_id from number to string (including 0)
|
||||||
|
if (initialValues?.node_group_id !== undefined) {
|
||||||
|
processedValues.node_group_id = String(initialValues.node_group_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert node_group_ids from number[] to string[]
|
||||||
|
if (
|
||||||
|
initialValues?.node_group_ids &&
|
||||||
|
Array.isArray(initialValues.node_group_ids)
|
||||||
|
) {
|
||||||
|
processedValues.node_group_ids = (
|
||||||
|
initialValues.node_group_ids as any[]
|
||||||
|
).map((id) => String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
form?.reset(processedValues);
|
||||||
const discount = form.getValues("discount") || [];
|
const discount = form.getValues("discount") || [];
|
||||||
if (discount.length > 0) {
|
if (discount.length > 0) {
|
||||||
debouncedCalculateDiscount(discount, "discount");
|
debouncedCalculateDiscount(discount, "discount");
|
||||||
@@ -256,15 +293,68 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(data: { [x: string]: any }) {
|
async function handleSubmit(data: { [x: string]: any }) {
|
||||||
|
// Don't process node_group_id - submit as-is
|
||||||
|
|
||||||
const bool = await onSubmit(data as T);
|
const bool = await onSubmit(data as T);
|
||||||
if (bool) setOpen(false);
|
if (bool) setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags } = useNode();
|
const {
|
||||||
|
getAllAvailableTags,
|
||||||
|
getNodesByTag,
|
||||||
|
getNodesWithoutTags,
|
||||||
|
getNodesWithoutGroups,
|
||||||
|
nodes,
|
||||||
|
} = useNode();
|
||||||
|
|
||||||
const tagGroups = getAllAvailableTags();
|
const tagGroups = getAllAvailableTags();
|
||||||
|
|
||||||
|
// Fetch node groups (exclude expired groups)
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
const allGroups = data.data?.list || [];
|
||||||
|
// Filter out expired node groups
|
||||||
|
return allGroups.filter((group) => !group.is_expired_group);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch group config to check if group feature is enabled
|
||||||
|
const { data: groupConfigData } = useQuery({
|
||||||
|
queryKey: ["groupConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroupEnabled = groupConfigData?.enabled;
|
||||||
|
|
||||||
const unit_time = form.watch("unit_time");
|
const unit_time = form.watch("unit_time");
|
||||||
|
const node_group_id = form.watch("node_group_id");
|
||||||
|
const node_group_ids = form.watch("node_group_ids");
|
||||||
|
|
||||||
|
// Watch node_group_id and automatically include it in node_group_ids
|
||||||
|
useEffect(() => {
|
||||||
|
if (node_group_id) {
|
||||||
|
const currentGroupIds = form.getValues("node_group_ids") || [];
|
||||||
|
if (!currentGroupIds.includes(node_group_id)) {
|
||||||
|
form.setValue("node_group_ids", [...currentGroupIds, node_group_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [node_group_id, form]);
|
||||||
|
|
||||||
|
// If node_group_id is empty or 0, automatically set it to the first item in node_group_ids
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
(!node_group_id || node_group_id === "0") &&
|
||||||
|
node_group_ids &&
|
||||||
|
node_group_ids.length > 0
|
||||||
|
) {
|
||||||
|
form.setValue("node_group_id", node_group_ids[0]);
|
||||||
|
}
|
||||||
|
}, [node_group_ids, node_group_id, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet onOpenChange={setOpen} open={open}>
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
@@ -286,7 +376,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form className="pt-4" onSubmit={form.handleSubmit(handleSubmit)}>
|
<form className="pt-4" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||||
<Tabs className="w-full" defaultValue="basic">
|
<Tabs className="w-full" defaultValue="basic">
|
||||||
<TabsList className="mb-6 grid w-full grid-cols-3">
|
<TabsList className="mb-6 grid w-full grid-cols-4">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
value="basic"
|
value="basic"
|
||||||
@@ -308,6 +398,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
{t("form.nodes")}
|
{t("form.nodes")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
value="traffic-limit"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" icon="uil:tachometer-fast" />
|
||||||
|
{t("form.trafficLimit")}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent className="space-y-4" value="basic">
|
<TabsContent className="space-y-4" value="basic">
|
||||||
@@ -397,12 +494,6 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
placeholder={t("form.noLimit")}
|
placeholder={t("form.noLimit")}
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
formatInput={(value) =>
|
|
||||||
unitConversion("bitsToMb", value)
|
|
||||||
}
|
|
||||||
formatOutput={(value) =>
|
|
||||||
unitConversion("mbToBits", value)
|
|
||||||
}
|
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
form.setValue(field.name, value);
|
form.setValue(field.name, value);
|
||||||
}}
|
}}
|
||||||
@@ -723,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 & {
|
||||||
@@ -932,80 +1028,88 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
|
|
||||||
<TabsContent className="space-y-4" value="servers">
|
<TabsContent className="space-y-4" value="servers">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FormField
|
{/* Show node_tags field only when group feature is disabled */}
|
||||||
control={form.control}
|
{!isGroupEnabled && (
|
||||||
name="node_tags"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="node_tags"
|
||||||
<FormLabel>{t("form.nodeGroup")}</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Accordion
|
<FormLabel>{t("form.nodeGroup")}</FormLabel>
|
||||||
className="w-full"
|
<FormControl>
|
||||||
collapsible
|
<Accordion
|
||||||
type="single"
|
className="w-full"
|
||||||
>
|
collapsible
|
||||||
{tagGroups.map((tag) => {
|
type="single"
|
||||||
const value = field.value || [];
|
>
|
||||||
const tagId = tag;
|
{tagGroups.map((tag) => {
|
||||||
const nodesWithTag = getNodesByTag(tag);
|
const value = field.value || [];
|
||||||
|
const tagId = tag;
|
||||||
|
const nodesWithTag = getNodesByTag(tag);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem key={tag} value={String(tag)}>
|
<AccordionItem
|
||||||
<AccordionTrigger>
|
key={tag}
|
||||||
<div className="flex items-center gap-2">
|
value={String(tag)}
|
||||||
<Checkbox
|
>
|
||||||
checked={value.includes(tagId as any)}
|
<AccordionTrigger>
|
||||||
onCheckedChange={(checked) =>
|
<div className="flex items-center gap-2">
|
||||||
checked
|
<Checkbox
|
||||||
? form.setValue(field.name, [
|
checked={value.includes(
|
||||||
...value,
|
tagId as any
|
||||||
tagId,
|
)}
|
||||||
] as any)
|
onCheckedChange={(checked) =>
|
||||||
: form.setValue(
|
checked
|
||||||
field.name,
|
? form.setValue(field.name, [
|
||||||
value.filter(
|
...value,
|
||||||
(v: any) => v !== tagId
|
tagId,
|
||||||
|
] as any)
|
||||||
|
: form.setValue(
|
||||||
|
field.name,
|
||||||
|
value.filter(
|
||||||
|
(v: any) => v !== tagId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<Label>
|
||||||
<Label>
|
{tag}
|
||||||
{tag}
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
<span className="ml-2 text-muted-foreground text-xs">
|
({nodesWithTag.length})
|
||||||
({nodesWithTag.length})
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{getNodesByTag(tag).map((node) => (
|
|
||||||
<li
|
|
||||||
className="flex items-center justify-between gap-3"
|
|
||||||
key={node.id}
|
|
||||||
>
|
|
||||||
<span className="flex-1">
|
|
||||||
{node.name}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
</Label>
|
||||||
{node.address}:{node.port}
|
</div>
|
||||||
</span>
|
</AccordionTrigger>
|
||||||
<span className="flex-1 text-right">
|
<AccordionContent>
|
||||||
{node.protocol}
|
<ul className="space-y-1">
|
||||||
</span>
|
{getNodesByTag(tag).map((node) => (
|
||||||
</li>
|
<li
|
||||||
))}
|
className="flex items-center justify-between gap-3"
|
||||||
</ul>
|
key={node.id}
|
||||||
</AccordionContent>
|
>
|
||||||
</AccordionItem>
|
<span className="flex-1">
|
||||||
);
|
{node.name}
|
||||||
})}
|
</span>
|
||||||
</Accordion>
|
<span className="flex-1">
|
||||||
</FormControl>
|
{node.address}:{node.port}
|
||||||
<FormMessage />
|
</span>
|
||||||
</FormItem>
|
<span className="flex-1 text-right">
|
||||||
)}
|
{node.protocol}
|
||||||
/>
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -1015,7 +1119,12 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
<FormLabel>{t("form.node")}</FormLabel>
|
<FormLabel>{t("form.node")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{getNodesWithoutTags().map((item) => {
|
{/* When group feature is enabled, show nodes without groups */}
|
||||||
|
{/* When group feature is disabled, show nodes without tags */}
|
||||||
|
{(isGroupEnabled
|
||||||
|
? getNodesWithoutGroups()
|
||||||
|
: getNodesWithoutTags()
|
||||||
|
).map((item: API.Node) => {
|
||||||
const value = field.value || [];
|
const value = field.value || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1056,6 +1165,512 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{isGroupEnabled
|
||||||
|
? t(
|
||||||
|
"form.nodesWithoutGroupsDescription",
|
||||||
|
"Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"form.nodesDescription",
|
||||||
|
"Select nodes for this subscription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Show node_group_ids field only when group feature is enabled */}
|
||||||
|
{isGroupEnabled && (
|
||||||
|
<>
|
||||||
|
{/* When no default node group is set, show simple node group selection */}
|
||||||
|
{node_group_id ? (
|
||||||
|
<>
|
||||||
|
{/* Default Node Group Selection - shown when default is set */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="node_group_id"
|
||||||
|
render={({ field }) => {
|
||||||
|
// Find the selected node group
|
||||||
|
const selectedNodeGroup = nodeGroupsData?.find(
|
||||||
|
(g) => String(g.id) === field.value
|
||||||
|
);
|
||||||
|
// Filter nodes that belong to this group
|
||||||
|
const nodesInGroup = selectedNodeGroup
|
||||||
|
? (nodes || []).filter((node) => {
|
||||||
|
const nodeGroupIds =
|
||||||
|
(node as any).node_group_ids || [];
|
||||||
|
return nodeGroupIds.includes(
|
||||||
|
selectedNodeGroup.id
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"form.defaultNodeGroup",
|
||||||
|
"Default Node Group"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Card className="p-4">
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
onChange={(value) => {
|
||||||
|
form.setValue(
|
||||||
|
field.name,
|
||||||
|
value || ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
"form.noDefaultNodeGroup",
|
||||||
|
"No Default Node Group"
|
||||||
|
),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
...(nodeGroupsData?.map((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
value: String(g.id),
|
||||||
|
})) || []),
|
||||||
|
]}
|
||||||
|
placeholder={t(
|
||||||
|
"form.selectDefaultNodeGroup",
|
||||||
|
"Select a default node group..."
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription className="mt-2">
|
||||||
|
{t(
|
||||||
|
"form.defaultNodeGroupDescription",
|
||||||
|
"The default node group for this product."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
{/* Show nodes in the selected default node group */}
|
||||||
|
{nodesInGroup.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 mb-2 text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"form.nodesInGroup",
|
||||||
|
"Nodes in this group:"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{nodesInGroup.map((node) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
||||||
|
key={node.id}
|
||||||
|
>
|
||||||
|
<span className="flex-1 font-medium">
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-muted-foreground">
|
||||||
|
{node.address}:{node.port}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-right text-muted-foreground">
|
||||||
|
{node.protocol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Backup Node Groups Selection - filter out default node group */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="node_group_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"form.backupNodeGroups",
|
||||||
|
"Backup Node Groups"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{nodeGroupsData
|
||||||
|
?.filter(
|
||||||
|
(g) => String(g.id) !== node_group_id
|
||||||
|
)
|
||||||
|
?.map((g) => {
|
||||||
|
// Filter nodes that belong to this group
|
||||||
|
const nodesInGroup = (
|
||||||
|
nodes || []
|
||||||
|
).filter((node) => {
|
||||||
|
const nodeGroupIds =
|
||||||
|
(node as any).node_group_ids ||
|
||||||
|
[];
|
||||||
|
return nodeGroupIds.includes(g.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
key={g.id}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value?.includes(
|
||||||
|
String(g.id)
|
||||||
|
)}
|
||||||
|
id={`subscribe-backup-node-group-${g.id}`}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
const currentValue =
|
||||||
|
field.value || [];
|
||||||
|
if (checked) {
|
||||||
|
form.setValue(
|
||||||
|
field.name,
|
||||||
|
[
|
||||||
|
...currentValue,
|
||||||
|
String(g.id),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setValue(
|
||||||
|
field.name,
|
||||||
|
currentValue.filter(
|
||||||
|
(v: string) =>
|
||||||
|
v !== String(g.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
htmlFor={`subscribe-backup-node-group-${g.id}`}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
({nodesInGroup.length}{" "}
|
||||||
|
{t("form.nodes", "nodes")})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show nodes in this group */}
|
||||||
|
{nodesInGroup.length > 0 && (
|
||||||
|
<div className="mt-3 ml-6">
|
||||||
|
<div className="mb-2 text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"form.nodesInGroup",
|
||||||
|
"Nodes in this group:"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{nodesInGroup.map(
|
||||||
|
(node) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
||||||
|
key={node.id}
|
||||||
|
>
|
||||||
|
<span className="flex-1 font-medium">
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-muted-foreground">
|
||||||
|
{node.address}:
|
||||||
|
{node.port}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-right text-muted-foreground">
|
||||||
|
{node.protocol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"form.backupNodeGroupsDescription",
|
||||||
|
"Select additional backup node groups."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="node_group_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.nodeGroups", "Node Groups")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{nodeGroupsData?.map((g) => {
|
||||||
|
// Filter nodes that belong to this group
|
||||||
|
const nodesInGroup = (nodes || []).filter(
|
||||||
|
(node) => {
|
||||||
|
const nodeGroupIds =
|
||||||
|
(node as any).node_group_ids || [];
|
||||||
|
return nodeGroupIds.includes(g.id);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
key={g.id}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value?.includes(
|
||||||
|
String(g.id)
|
||||||
|
)}
|
||||||
|
id={`subscribe-node-group-${g.id}`}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const currentValue =
|
||||||
|
field.value || [];
|
||||||
|
const currentDefaultGroupId =
|
||||||
|
form.getValues(
|
||||||
|
"node_group_id"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
const newValue = [
|
||||||
|
...currentValue,
|
||||||
|
String(g.id),
|
||||||
|
];
|
||||||
|
form.setValue(
|
||||||
|
field.name,
|
||||||
|
newValue
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no default node group is set, set this one as default
|
||||||
|
if (!currentDefaultGroupId) {
|
||||||
|
form.setValue(
|
||||||
|
"node_group_id",
|
||||||
|
String(g.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.setValue(
|
||||||
|
field.name,
|
||||||
|
currentValue.filter(
|
||||||
|
(v: string) =>
|
||||||
|
v !== String(g.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
htmlFor={`subscribe-node-group-${g.id}`}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
<span className="ml-2 text-muted-foreground text-sm">
|
||||||
|
({nodesInGroup.length}{" "}
|
||||||
|
{t("form.nodes", "nodes")})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show nodes in this group */}
|
||||||
|
{nodesInGroup.length > 0 && (
|
||||||
|
<div className="mt-3 ml-6">
|
||||||
|
<div className="mb-2 text-muted-foreground text-xs">
|
||||||
|
{t(
|
||||||
|
"form.nodesInGroup",
|
||||||
|
"Nodes in this group:"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{nodesInGroup.map((node) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded border bg-muted/30 p-2 text-sm"
|
||||||
|
key={node.id}
|
||||||
|
>
|
||||||
|
<span className="flex-1 font-medium">
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-muted-foreground">
|
||||||
|
{node.address}:{node.port}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-right text-muted-foreground">
|
||||||
|
{node.protocol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"form.nodeGroupsFirstSelectionDescription",
|
||||||
|
"Select node groups for this product. The first selected group will be set as the default node group."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Traffic Limit Tab */}
|
||||||
|
<TabsContent className="space-y-4" value="traffic-limit">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="traffic_limit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.trafficLimitRules", "Traffic Limit Rules")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ArrayInput
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: "stat_type",
|
||||||
|
type: "select",
|
||||||
|
placeholder: t(
|
||||||
|
"form.statType",
|
||||||
|
"Statistics Type"
|
||||||
|
),
|
||||||
|
value: "day",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: t("form.statTypeHour", "Hour"),
|
||||||
|
value: "hour",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("form.statTypeDay", "Day"),
|
||||||
|
value: "day",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stat_value",
|
||||||
|
type: "number",
|
||||||
|
placeholder: t(
|
||||||
|
"form.statValue",
|
||||||
|
"Time Value"
|
||||||
|
),
|
||||||
|
min: 1,
|
||||||
|
onKeyDown: (
|
||||||
|
e: React.KeyboardEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
if (e.key === "." || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatOutput: (value: string | number) => {
|
||||||
|
const num = Number(value);
|
||||||
|
return String(
|
||||||
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "traffic_usage",
|
||||||
|
type: "number",
|
||||||
|
placeholder: t(
|
||||||
|
"form.trafficUsage",
|
||||||
|
"Traffic Usage (GB)"
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
onKeyDown: (
|
||||||
|
e: React.KeyboardEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
if (e.key === "." || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatOutput: (value: string | number) => {
|
||||||
|
const num = Number(value);
|
||||||
|
return String(
|
||||||
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "speed_limit",
|
||||||
|
type: "number",
|
||||||
|
placeholder: t(
|
||||||
|
"form.speedLimitMbps",
|
||||||
|
"Speed Limit (Mbps)"
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
onKeyDown: (
|
||||||
|
e: React.KeyboardEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
if (e.key === "." || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatOutput: (value: string | number) => {
|
||||||
|
const num = Number(value);
|
||||||
|
return String(
|
||||||
|
Number.isNaN(num) ? 0 : Math.floor(num)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(items: any[]) => {
|
||||||
|
field.onChange(
|
||||||
|
items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
stat_value: Number(item.stat_value) || 0,
|
||||||
|
traffic_usage:
|
||||||
|
Number(item.traffic_usage) || 0,
|
||||||
|
speed_limit: Number(item.speed_limit) || 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={
|
||||||
|
field.value && field.value.length > 0
|
||||||
|
? field.value
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
stat_type: "day",
|
||||||
|
stat_value: 1,
|
||||||
|
traffic_usage: 0,
|
||||||
|
speed_limit: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"form.trafficLimitDescription",
|
||||||
|
"Configure traffic-based speed limit rules. When traffic usage reaches the specified amount, the speed will be limited."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 { Switch } from "@workspace/ui/components/switch";
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
ProTable,
|
ProTable,
|
||||||
type ProTableActions,
|
type ProTableActions,
|
||||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import { getNodeGroupList } from "@workspace/ui/services/admin/group";
|
||||||
import {
|
import {
|
||||||
batchDeleteSubscribe,
|
batchDeleteSubscribe,
|
||||||
createSubscribe,
|
createSubscribe,
|
||||||
@@ -28,8 +30,36 @@ export default function SubscribeTable() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
const { fetchSubscribes } = useSubscribe();
|
const { fetchSubscribes } = useSubscribe();
|
||||||
|
|
||||||
|
// Fetch node groups for filtering (exclude expired groups)
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
const allGroups = data.data?.list || [];
|
||||||
|
// Filter out expired node groups
|
||||||
|
return allGroups.filter((group) => !group.is_expired_group);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch group config to check if group feature is enabled
|
||||||
|
const { data: groupConfigData } = useQuery({
|
||||||
|
queryKey: ["groupConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await (
|
||||||
|
await import("@workspace/ui/services/admin/group")
|
||||||
|
).getGroupConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroupEnabled = groupConfigData?.enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
|
<ProTable<
|
||||||
|
API.SubscribeItem,
|
||||||
|
{ group_id: number; query: string; node_group_id?: number }
|
||||||
|
>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
@@ -40,10 +70,18 @@ export default function SubscribeTable() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateSubscribe({
|
const updateBody: any = {
|
||||||
...row,
|
...row,
|
||||||
...values,
|
...values,
|
||||||
} as API.UpdateSubscribeRequest);
|
};
|
||||||
|
// Add node_group_ids if it exists in values
|
||||||
|
const vals = values as any;
|
||||||
|
if (vals.node_group_ids) {
|
||||||
|
updateBody.node_group_ids = vals.node_group_ids.map(
|
||||||
|
(id: string | number) => Number(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await updateSubscribe(updateBody as API.UpdateSubscribeRequest);
|
||||||
toast.success(t("updateSuccess"));
|
toast.success(t("updateSuccess"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
fetchSubscribes();
|
fetchSubscribes();
|
||||||
@@ -210,10 +248,12 @@ export default function SubscribeTable() {
|
|||||||
header: t("inventory"),
|
header: t("inventory"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const inventory = row.getValue("inventory") as number;
|
const inventory = row.getValue("inventory") as number;
|
||||||
return inventory === -1 ? (
|
return (
|
||||||
<Display type="number" unlimited value={0} />
|
<Display
|
||||||
) : (
|
type="number"
|
||||||
<Display type="number" unlimited value={inventory} />
|
unlimited={inventory === -1}
|
||||||
|
value={inventory === -1 ? 0 : inventory}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -243,6 +283,28 @@ export default function SubscribeTable() {
|
|||||||
<Badge variant="outline">{row.getValue("sold")}</Badge>
|
<Badge variant="outline">{row.getValue("sold")}</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "node_group",
|
||||||
|
header: t("defaultNodeGroup", "Default Node Group"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const nodeGroupId = row.original.node_group_id;
|
||||||
|
const nodeGroup = nodeGroupsData?.find(
|
||||||
|
(g) => g.id === nodeGroupId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nodeGroup ? (
|
||||||
|
<Badge variant="outline">{nodeGroup.name}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
header={{
|
header={{
|
||||||
toolbar: (
|
toolbar: (
|
||||||
@@ -251,11 +313,19 @@ export default function SubscribeTable() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createSubscribe({
|
const createBody: any = {
|
||||||
...values,
|
...values,
|
||||||
show: false,
|
show: false,
|
||||||
sell: false,
|
sell: false,
|
||||||
});
|
};
|
||||||
|
// Add node_group_ids if it exists in values
|
||||||
|
const vals = values as any;
|
||||||
|
if (vals.node_group_ids) {
|
||||||
|
createBody.node_group_ids = vals.node_group_ids.map(
|
||||||
|
(id: string | number) => Number(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await createSubscribe(createBody);
|
||||||
toast.success(t("createSuccess"));
|
toast.success(t("createSuccess"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
fetchSubscribes();
|
fetchSubscribes();
|
||||||
@@ -312,12 +382,32 @@ export default function SubscribeTable() {
|
|||||||
{
|
{
|
||||||
key: "search",
|
key: "search",
|
||||||
},
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "node_group_id",
|
||||||
|
placeholder: t("nodeGroups", "Node Groups"),
|
||||||
|
options: [
|
||||||
|
{ label: t("all", "All"), value: "" },
|
||||||
|
...(nodeGroupsData?.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
request={async (pagination, filters) => {
|
request={async (pagination, filters) => {
|
||||||
const { data } = await getSubscribeList({
|
const params = {
|
||||||
...pagination,
|
...pagination,
|
||||||
...filters,
|
...filters,
|
||||||
});
|
node_group_id: filters?.node_group_id
|
||||||
|
? Number(filters.node_group_id)
|
||||||
|
: undefined,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const { data } = await getSubscribeList(params);
|
||||||
return {
|
return {
|
||||||
list: data.data?.list || [],
|
list: data.data?.list || [],
|
||||||
total: data.data?.total || 0,
|
total: data.data?.total || 0,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { getPromoRuleList } from "@workspace/ui/services/admin/promo";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import PromoPage from ".";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback: string): string => fallback,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/subscribe", () => ({
|
||||||
|
useSubscribe: () => ({
|
||||||
|
subscribes: [
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "Monthly Plan",
|
||||||
|
unit_price: 1200,
|
||||||
|
sold: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getSubscribeName: (id?: number) => (id === 8 ? "Monthly Plan" : "--"),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/common", () => ({
|
||||||
|
formatDate: (timestamp: number) => `date-${timestamp}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@workspace/ui/services/admin/promo", () => ({
|
||||||
|
createPromoPrice: vi.fn(),
|
||||||
|
createPromoRule: vi.fn(),
|
||||||
|
deletePromoPrice: vi.fn(),
|
||||||
|
deletePromoRule: vi.fn(),
|
||||||
|
getPromoPriceList: vi.fn(),
|
||||||
|
getPromoRuleList: vi.fn(),
|
||||||
|
getPromoUsageList: vi.fn(),
|
||||||
|
updatePromoRule: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetPromoRuleList = vi.mocked(getPromoRuleList);
|
||||||
|
|
||||||
|
function createRuleListResponse(
|
||||||
|
data: API.GetPromoRuleListResponse
|
||||||
|
): AxiosResponse<API.Response & { data?: API.GetPromoRuleListResponse }> {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
code: 200,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosResponse["config"]["headers"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem("timezone", "UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PromoPage", () => {
|
||||||
|
it("renders promo rules with type, priority, status, and validity period", async () => {
|
||||||
|
mockedGetPromoRuleList.mockResolvedValue(
|
||||||
|
createRuleListResponse({
|
||||||
|
total: 1,
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Return User Price",
|
||||||
|
type: "inactive_user",
|
||||||
|
params: { inactive_months: 3 },
|
||||||
|
priority: 5,
|
||||||
|
enabled: true,
|
||||||
|
start_time: 1_716_800_000,
|
||||||
|
end_time: 1_716_900_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PromoPage />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedGetPromoRuleList).toHaveBeenCalledWith({
|
||||||
|
page: 1,
|
||||||
|
size: 200,
|
||||||
|
search: undefined,
|
||||||
|
type: undefined,
|
||||||
|
enabled: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("Return User Price")).not.toBeNull();
|
||||||
|
expect(screen.getByText("inactive_user")).not.toBeNull();
|
||||||
|
expect(screen.getByText("5")).not.toBeNull();
|
||||||
|
expect(screen.getByText("date-1716800000")).not.toBeNull();
|
||||||
|
expect(screen.getByText("date-1716900000")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty state when there are no promo rules", async () => {
|
||||||
|
mockedGetPromoRuleList.mockResolvedValue(
|
||||||
|
createRuleListResponse({
|
||||||
|
total: 0,
|
||||||
|
list: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PromoPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("No promo rules")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a loading state while promo rules are being fetched", () => {
|
||||||
|
let resolveRequest:
|
||||||
|
| ((
|
||||||
|
value: AxiosResponse<
|
||||||
|
API.Response & { data?: API.GetPromoRuleListResponse }
|
||||||
|
>
|
||||||
|
) => void)
|
||||||
|
| undefined;
|
||||||
|
mockedGetPromoRuleList.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PromoPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
|
||||||
|
expect(resolveRequest).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state when promo rules fail to load", async () => {
|
||||||
|
mockedGetPromoRuleList.mockRejectedValue(new Error("network failed"));
|
||||||
|
|
||||||
|
render(<PromoPage />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedGetPromoRuleList).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Failed to load promo rules")
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(screen.queryByText("No promo rules")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@workspace/ui/components/alert";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@workspace/ui/components/tabs";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
createPromoPrice,
|
||||||
|
createPromoRule,
|
||||||
|
deletePromoPrice,
|
||||||
|
deletePromoRule,
|
||||||
|
getPromoPriceList,
|
||||||
|
getPromoRuleList,
|
||||||
|
getPromoUsageList,
|
||||||
|
updatePromoRule,
|
||||||
|
} from "@workspace/ui/services/admin/promo";
|
||||||
|
import { CircleAlert } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
import PriceForm from "./price-form";
|
||||||
|
import RuleDetail from "./rule-detail";
|
||||||
|
import RuleForm from "./rule-form";
|
||||||
|
import { formatCurrency, parseOptionalNumber, promoRuleTypes } from "./utils";
|
||||||
|
|
||||||
|
type RuleFilters = {
|
||||||
|
search?: string;
|
||||||
|
type?: API.PromoRuleType;
|
||||||
|
enabled?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PriceFilters = {
|
||||||
|
promo_rule_id?: string;
|
||||||
|
subscribe_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageFilters = PriceFilters & {
|
||||||
|
user_id?: string;
|
||||||
|
order_no?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ErrorState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert className="mx-auto max-w-md" variant="destructive">
|
||||||
|
<CircleAlert aria-hidden="true" />
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription>{description}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromoPage() {
|
||||||
|
const { t } = useTranslation("promo");
|
||||||
|
const { subscribes, getSubscribeName } = useSubscribe();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rules, setRules] = useState<API.PromoRule[]>([]);
|
||||||
|
const [detailRule, setDetailRule] = useState<API.PromoRule>();
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const ruleRef = useRef<ProTableActions>(null);
|
||||||
|
const priceRef = useRef<ProTableActions>(null);
|
||||||
|
const usageRef = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs className="space-y-4" defaultValue="rules">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 md:w-fit">
|
||||||
|
<TabsTrigger value="rules">{t("rules", "Rules")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="prices">{t("prices", "Prices")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="usage">{t("usage", "Usage")}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="rules">
|
||||||
|
<ProTable<API.PromoRule, RuleFilters>
|
||||||
|
action={ruleRef}
|
||||||
|
actions={{
|
||||||
|
render: (row) => [
|
||||||
|
<Button
|
||||||
|
key="detail"
|
||||||
|
onClick={() => {
|
||||||
|
setDetailRule(row);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("detail", "Detail")}
|
||||||
|
</Button>,
|
||||||
|
<RuleForm
|
||||||
|
initialValues={row}
|
||||||
|
key="edit"
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePromoRule({ id: row.id }, values);
|
||||||
|
toast.success(t("updateSuccess", "Update Success"));
|
||||||
|
ruleRef.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("editRule", "Edit Rule")}
|
||||||
|
trigger={t("edit", "Edit")}
|
||||||
|
/>,
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteWarning",
|
||||||
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deletePromoRule({ id: row.id });
|
||||||
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
|
ruleRef.current?.refresh();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: t("type", "Type"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`types.${row.original.type}`, row.original.type)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: t("priority", "Priority"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: t("enabled", "Enabled"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.original.enabled}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
await updatePromoRule(
|
||||||
|
{ id: row.original.id },
|
||||||
|
{ ...row.original, enabled: checked }
|
||||||
|
);
|
||||||
|
toast.success(t("updateSuccess", "Update Success"));
|
||||||
|
ruleRef.current?.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "start_time",
|
||||||
|
header: t("startTime", "Start Time"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.start_time
|
||||||
|
? formatDate(row.original.start_time)
|
||||||
|
: "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "end_time",
|
||||||
|
header: t("endTime", "End Time"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.end_time
|
||||||
|
? formatDate(row.original.end_time)
|
||||||
|
: "--",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
empty={<Empty description={t("emptyRules", "No promo rules")} />}
|
||||||
|
error={
|
||||||
|
<ErrorState
|
||||||
|
description={t(
|
||||||
|
"loadErrorDescription",
|
||||||
|
"Refresh the table or try again later."
|
||||||
|
)}
|
||||||
|
title={t("loadRulesError", "Failed to load promo rules")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
header={{
|
||||||
|
toolbar: (
|
||||||
|
<RuleForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createPromoRule(values);
|
||||||
|
toast.success(t("createSuccess", "Create Success"));
|
||||||
|
ruleRef.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createRule", "Create Rule")}
|
||||||
|
trigger={t("createRule", "Create Rule")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
params={[
|
||||||
|
{ key: "search", placeholder: t("searchRule", "Rule name") },
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
placeholder: t("type", "Type"),
|
||||||
|
options: promoRuleTypes.map((type) => ({
|
||||||
|
label: t(`types.${type}`, type),
|
||||||
|
value: type,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "enabled",
|
||||||
|
placeholder: t("enabled", "Enabled"),
|
||||||
|
options: [
|
||||||
|
{ label: t("yes", "Yes"), value: "true" },
|
||||||
|
{ label: t("no", "No"), value: "false" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
request={async (pagination, filters) => {
|
||||||
|
const { data } = await getPromoRuleList({
|
||||||
|
...pagination,
|
||||||
|
search: filters.search?.trim() || undefined,
|
||||||
|
type: filters.type,
|
||||||
|
enabled:
|
||||||
|
filters.enabled === undefined || filters.enabled === ""
|
||||||
|
? undefined
|
||||||
|
: filters.enabled === "true",
|
||||||
|
});
|
||||||
|
const list = data.data?.list || [];
|
||||||
|
setRules(list);
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="prices">
|
||||||
|
<ProTable<API.PromoPrice, PriceFilters>
|
||||||
|
action={priceRef}
|
||||||
|
actions={{
|
||||||
|
render: (row) => [
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteWarning",
|
||||||
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deletePromoPrice({ id: row.id });
|
||||||
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
|
priceRef.current?.refresh();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "promo_rule_id",
|
||||||
|
header: t("rule", "Rule"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.promo_rule_id
|
||||||
|
? rules.find(
|
||||||
|
(rule) => rule.id === row.original.promo_rule_id
|
||||||
|
)?.name || `#${row.original.promo_rule_id}`
|
||||||
|
: "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subscribe_id",
|
||||||
|
header: t("subscribe", "Subscribe"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.subscribe_name ||
|
||||||
|
getSubscribeName(row.original.subscribe_id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: t("quantity", "Quantity"),
|
||||||
|
cell: ({ row }) => row.original.quantity || "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "unit_price",
|
||||||
|
header: t("unitPrice", "Unit Price"),
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.unit_price),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "promo_price",
|
||||||
|
header: t("promoPrice", "Promo Price"),
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.promo_price),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
empty={<Empty description={t("emptyPrices", "No promo prices")} />}
|
||||||
|
error={
|
||||||
|
<ErrorState
|
||||||
|
description={t(
|
||||||
|
"loadErrorDescription",
|
||||||
|
"Refresh the table or try again later."
|
||||||
|
)}
|
||||||
|
title={t("loadPricesError", "Failed to load promo prices")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
header={{
|
||||||
|
toolbar: (
|
||||||
|
<PriceForm
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createPromoPrice(values);
|
||||||
|
toast.success(t("createSuccess", "Create Success"));
|
||||||
|
priceRef.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rules={rules}
|
||||||
|
subscribes={subscribes}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
params={[
|
||||||
|
{
|
||||||
|
key: "promo_rule_id",
|
||||||
|
placeholder: t("rule", "Rule"),
|
||||||
|
options: rules.map((rule) => ({
|
||||||
|
label: rule.name,
|
||||||
|
value: String(rule.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subscribe_id",
|
||||||
|
placeholder: t("subscribe", "Subscribe"),
|
||||||
|
options: subscribes
|
||||||
|
.filter((item) => typeof item.id === "number")
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.name || `#${item.id}`,
|
||||||
|
value: String(item.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
request={async (pagination, filters) => {
|
||||||
|
const { data } = await getPromoPriceList({
|
||||||
|
...pagination,
|
||||||
|
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
|
||||||
|
subscribe_id: parseOptionalNumber(filters.subscribe_id),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="usage">
|
||||||
|
<ProTable<API.PromoUsage, UsageFilters>
|
||||||
|
action={usageRef}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "promo_rule_id",
|
||||||
|
header: t("rule", "Rule"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.rule_name ||
|
||||||
|
rules.find((rule) => rule.id === row.original.promo_rule_id)
|
||||||
|
?.name ||
|
||||||
|
`#${row.original.promo_rule_id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "user_id",
|
||||||
|
header: t("userId", "User ID"),
|
||||||
|
cell: ({ row }) => `#${row.original.user_id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subscribe_id",
|
||||||
|
header: t("subscribe", "Subscribe"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.subscribe_name ||
|
||||||
|
getSubscribeName(row.original.subscribe_id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: t("quantity", "Quantity"),
|
||||||
|
cell: ({ row }) => row.original.quantity || "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "order_no",
|
||||||
|
header: t("orderNo", "Order No."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "promo_price",
|
||||||
|
header: t("promoPrice", "Promo Price"),
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.promo_price),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "used_at",
|
||||||
|
header: t("usedAt", "Used At"),
|
||||||
|
cell: ({ row }) => formatDate(row.original.used_at),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
empty={
|
||||||
|
<Empty description={t("emptyUsage", "No promo usage records")} />
|
||||||
|
}
|
||||||
|
error={
|
||||||
|
<ErrorState
|
||||||
|
description={t(
|
||||||
|
"loadErrorDescription",
|
||||||
|
"Refresh the table or try again later."
|
||||||
|
)}
|
||||||
|
title={t("loadUsageError", "Failed to load promo usage")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
params={[
|
||||||
|
{
|
||||||
|
key: "promo_rule_id",
|
||||||
|
placeholder: t("rule", "Rule"),
|
||||||
|
options: rules.map((rule) => ({
|
||||||
|
label: rule.name,
|
||||||
|
value: String(rule.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "subscribe_id",
|
||||||
|
placeholder: t("subscribe", "Subscribe"),
|
||||||
|
options: subscribes
|
||||||
|
.filter((item) => typeof item.id === "number")
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.name || `#${item.id}`,
|
||||||
|
value: String(item.id),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ key: "user_id", placeholder: t("userId", "User ID") },
|
||||||
|
{ key: "order_no", placeholder: t("orderNo", "Order No.") },
|
||||||
|
]}
|
||||||
|
request={async (pagination, filters) => {
|
||||||
|
const { data } = await getPromoUsageList({
|
||||||
|
...pagination,
|
||||||
|
promo_rule_id: parseOptionalNumber(filters.promo_rule_id),
|
||||||
|
subscribe_id: parseOptionalNumber(filters.subscribe_id),
|
||||||
|
user_id: parseOptionalNumber(filters.user_id),
|
||||||
|
order_no: filters.order_no?.trim() || undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<RuleDetail
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
open={detailOpen}
|
||||||
|
rule={detailRule}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import PriceForm from "./price-form";
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unobserve() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Element.prototype.scrollIntoView = function scrollIntoView() {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback: string): string => fallback,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rules: API.PromoRule[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Campaign Rule",
|
||||||
|
type: "campaign",
|
||||||
|
params: {},
|
||||||
|
priority: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const subscribes: API.SubscribeItem[] = [
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "Monthly Plan",
|
||||||
|
unit_price: 1200,
|
||||||
|
unit_time: "Month",
|
||||||
|
sold: 0,
|
||||||
|
discount: [
|
||||||
|
{
|
||||||
|
quantity: 3,
|
||||||
|
discount: 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const subscribesWithoutDiscounts: API.SubscribeItem[] = [
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "Monthly Plan",
|
||||||
|
unit_price: 1200,
|
||||||
|
unit_time: "Month",
|
||||||
|
sold: 0,
|
||||||
|
discount: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function selectCombobox(index: number, option: string) {
|
||||||
|
const combobox = screen.getAllByRole("combobox")[index];
|
||||||
|
if (!combobox) throw new Error(`Combobox ${index} not found`);
|
||||||
|
fireEvent.click(combobox);
|
||||||
|
fireEvent.click(await screen.findByText(option));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PriceForm", () => {
|
||||||
|
it("submits promo price with promo_rule_id and selected quantity item", async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(true);
|
||||||
|
render(
|
||||||
|
<PriceForm onSubmit={onSubmit} rules={rules} subscribes={subscribes} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
|
||||||
|
await selectCombobox(0, "Campaign Rule");
|
||||||
|
await selectCombobox(1, "Monthly Plan");
|
||||||
|
await selectCombobox(2, "3 x Month");
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Enter price"), {
|
||||||
|
target: { value: "20" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
promo_rule_id: 1,
|
||||||
|
items: [{ subscribe_id: 8, quantity: 3, promo_price: 2000 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks submit when subscribe has no discount quantities", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<PriceForm
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
rules={rules}
|
||||||
|
subscribes={subscribesWithoutDiscounts}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
|
||||||
|
await selectCombobox(1, "Monthly Plan");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"This subscribe has no discount quantities and cannot be configured."
|
||||||
|
)
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Confirm" })).toHaveProperty(
|
||||||
|
"disabled",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the form open and shows an error when promo price reaches original price", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<PriceForm onSubmit={onSubmit} rules={rules} subscribes={subscribes} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Create Promo Price" }));
|
||||||
|
await selectCombobox(0, "Campaign Rule");
|
||||||
|
await selectCombobox(1, "Monthly Plan");
|
||||||
|
await selectCombobox(2, "3 x Month");
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Enter price"), {
|
||||||
|
target: { value: "36" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Promo price must be lower than original price")
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@workspace/ui/components/form";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
|
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
getSubscribeDiscounts,
|
||||||
|
getSubscribeUnitPrice,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
const priceSchema = z.object({
|
||||||
|
promo_rule_id: z.number().min(1),
|
||||||
|
subscribe_id: z.number().min(1),
|
||||||
|
quantity: z.number().min(1),
|
||||||
|
promo_price: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PriceFormValues = z.infer<typeof priceSchema>;
|
||||||
|
|
||||||
|
interface PriceFormProps {
|
||||||
|
loading?: boolean;
|
||||||
|
rules: API.PromoRule[];
|
||||||
|
subscribes: API.SubscribeItem[];
|
||||||
|
onSubmit: (data: API.CreatePromoPriceRequest) => Promise<boolean> | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PriceForm({
|
||||||
|
loading,
|
||||||
|
rules,
|
||||||
|
subscribes,
|
||||||
|
onSubmit,
|
||||||
|
}: PriceFormProps) {
|
||||||
|
const { t } = useTranslation("promo");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm<PriceFormValues>({
|
||||||
|
resolver: zodResolver(priceSchema),
|
||||||
|
defaultValues: {
|
||||||
|
promo_rule_id: 0,
|
||||||
|
subscribe_id: 0,
|
||||||
|
quantity: 0,
|
||||||
|
promo_price: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const subscribeId = form.watch("subscribe_id");
|
||||||
|
const quantity = form.watch("quantity");
|
||||||
|
const subscribe = subscribes.find((item) => item.id === subscribeId);
|
||||||
|
const discounts = getSubscribeDiscounts(subscribe);
|
||||||
|
const unitPrice = getSubscribeUnitPrice(subscribe);
|
||||||
|
const originalPrice = unitPrice * quantity;
|
||||||
|
const hasSelectedSubscribe = subscribeId > 0;
|
||||||
|
const hasDiscounts = discounts.length > 0;
|
||||||
|
|
||||||
|
async function handleSubmit(values: PriceFormValues) {
|
||||||
|
const maxPrice = unitPrice * values.quantity;
|
||||||
|
if (maxPrice > 0 && values.promo_price >= maxPrice) {
|
||||||
|
form.setError("promo_price", {
|
||||||
|
message: t(
|
||||||
|
"form.promoPriceLessThanOriginalPrice",
|
||||||
|
"Promo price must be lower than original price"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const success = await onSubmit({
|
||||||
|
promo_rule_id: values.promo_rule_id,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
subscribe_id: values.subscribe_id,
|
||||||
|
quantity: values.quantity,
|
||||||
|
promo_price: values.promo_price,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (success) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
promo_rule_id: 0,
|
||||||
|
subscribe_id: 0,
|
||||||
|
quantity: 0,
|
||||||
|
promo_price: 0,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("createPrice", "Create Promo Price")}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[520px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{t("createPrice", "Create Promo Price")}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4 px-6 pt-4"
|
||||||
|
id="promo-price-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="promo_rule_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("rule", "Rule")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox<number>
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={rules.map((rule) => ({
|
||||||
|
label: rule.name,
|
||||||
|
value: rule.id,
|
||||||
|
}))}
|
||||||
|
placeholder={t("form.selectRule", "Select rule")}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="subscribe_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("subscribe", "Subscribe")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox<number>
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("quantity", 0, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
form.clearErrors(["quantity", "promo_price"]);
|
||||||
|
}}
|
||||||
|
options={subscribes
|
||||||
|
.filter((item) => typeof item.id === "number")
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.name || `#${item.id}`,
|
||||||
|
value: item.id as number,
|
||||||
|
}))}
|
||||||
|
placeholder={t(
|
||||||
|
"form.selectSubscribe",
|
||||||
|
"Select subscribe"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="quantity"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("quantity", "Quantity")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox<number>
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={discounts.map((item) => ({
|
||||||
|
label: `${item.quantity} x ${t(
|
||||||
|
subscribe?.unit_time || "Month",
|
||||||
|
subscribe?.unit_time || "Month"
|
||||||
|
)}`,
|
||||||
|
value: item.quantity,
|
||||||
|
}))}
|
||||||
|
placeholder={t(
|
||||||
|
"form.selectQuantity",
|
||||||
|
"Select discount quantity"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{hasSelectedSubscribe && !hasDiscounts && (
|
||||||
|
<p className="text-destructive text-xs">
|
||||||
|
{t(
|
||||||
|
"form.noDiscounts",
|
||||||
|
"This subscribe has no discount quantities and cannot be configured."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="promo_price"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("promoPrice", "Promo Price")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
formatInput={(value) =>
|
||||||
|
unitConversion("centsToDollars", value)
|
||||||
|
}
|
||||||
|
formatOutput={(value) =>
|
||||||
|
unitConversion("dollarsToCents", value)
|
||||||
|
}
|
||||||
|
min={0.01}
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t("form.enterPromoPrice", "Enter price")}
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{originalPrice > 0 && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t("originalPriceHint", "Original price")}:{" "}
|
||||||
|
{formatCurrency(originalPrice)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
disabled={loading || (hasSelectedSubscribe && !hasDiscounts)}
|
||||||
|
form="promo-price-form"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("confirm", "Confirm")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
|
||||||
|
interface RuleDetailProps {
|
||||||
|
open: boolean;
|
||||||
|
rule?: API.PromoRule;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleDetail({
|
||||||
|
open,
|
||||||
|
rule,
|
||||||
|
onOpenChange,
|
||||||
|
}: RuleDetailProps) {
|
||||||
|
const { t } = useTranslation("promo");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||||
|
<SheetContent className="w-[520px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>
|
||||||
|
{rule?.name || t("ruleDetail", "Rule Detail")}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
{rule && (
|
||||||
|
<div className="px-6 pt-4">
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("type", "Type")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`types.${rule.type}`, rule.type)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("priority", "Priority")}</TableCell>
|
||||||
|
<TableCell>{rule.priority}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("enabled", "Enabled")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{rule.enabled ? t("yes", "Yes") : t("no", "No")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>{t("params", "Params")}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="whitespace-pre-wrap rounded bg-muted px-2 py-1 text-xs">
|
||||||
|
{JSON.stringify(rule.params || {}, null, 2)}
|
||||||
|
</code>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
{t("validityPeriod", "Validity Period")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{rule.start_time ? formatDate(rule.start_time) : "--"} -{" "}
|
||||||
|
{rule.end_time ? formatDate(rule.end_time) : "--"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@workspace/ui/components/form";
|
||||||
|
import {
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem,
|
||||||
|
} from "@workspace/ui/components/radio-group";
|
||||||
|
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 { DatePicker } from "@workspace/ui/composed/date-picker";
|
||||||
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { promoRuleTypes } from "./utils";
|
||||||
|
|
||||||
|
const ruleSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.enum(["new_user", "inactive_user", "campaign"]),
|
||||||
|
inactive_months: z.number().optional(),
|
||||||
|
window_hours: z.number().optional(),
|
||||||
|
priority: z.number().min(0),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
start_time: z.number().optional(),
|
||||||
|
end_time: z.number().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (
|
||||||
|
data.start_time !== undefined &&
|
||||||
|
data.end_time !== undefined &&
|
||||||
|
data.start_time >= data.end_time
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "End time must be later than start time",
|
||||||
|
path: ["end_time"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.type === "inactive_user" &&
|
||||||
|
(!data.inactive_months || data.inactive_months <= 0)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Inactive months must be greater than 0",
|
||||||
|
path: ["inactive_months"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.type === "new_user" &&
|
||||||
|
(!data.window_hours || data.window_hours <= 0)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Window hours must be greater than 0",
|
||||||
|
path: ["window_hours"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type RuleFormValues = z.infer<typeof ruleSchema>;
|
||||||
|
|
||||||
|
interface RuleFormProps {
|
||||||
|
initialValues?: API.PromoRule;
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (data: API.CreatePromoRuleRequest) => Promise<boolean> | boolean;
|
||||||
|
title: string;
|
||||||
|
trigger: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(rule?: API.PromoRule): RuleFormValues {
|
||||||
|
return {
|
||||||
|
name: rule?.name ?? "",
|
||||||
|
type: rule?.type ?? "inactive_user",
|
||||||
|
inactive_months: rule?.params?.inactive_months,
|
||||||
|
window_hours: rule?.params?.window_hours,
|
||||||
|
priority: rule?.priority ?? 0,
|
||||||
|
enabled: rule?.enabled ?? false,
|
||||||
|
start_time: rule?.start_time,
|
||||||
|
end_time: rule?.end_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest {
|
||||||
|
const params: API.PromoRuleParams =
|
||||||
|
values.type === "inactive_user"
|
||||||
|
? { inactive_months: values.inactive_months }
|
||||||
|
: values.type === "new_user"
|
||||||
|
? { window_hours: values.window_hours }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
type: values.type,
|
||||||
|
params,
|
||||||
|
priority: values.priority,
|
||||||
|
enabled: values.enabled,
|
||||||
|
start_time: values.start_time || undefined,
|
||||||
|
end_time: values.end_time || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RuleForm({
|
||||||
|
initialValues,
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
trigger,
|
||||||
|
}: RuleFormProps) {
|
||||||
|
const { t } = useTranslation("promo");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm<RuleFormValues>({
|
||||||
|
resolver: zodResolver(ruleSchema),
|
||||||
|
defaultValues: toFormValues(initialValues),
|
||||||
|
});
|
||||||
|
const type = form.watch("type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(toFormValues(initialValues));
|
||||||
|
}, [form, initialValues]);
|
||||||
|
|
||||||
|
async function handleSubmit(values: RuleFormValues) {
|
||||||
|
const success = await onSubmit(toRequest(values));
|
||||||
|
if (success) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.reset(toFormValues(initialValues));
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[520px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-4 px-6 pt-4"
|
||||||
|
id="promo-rule-form"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("form.name", "Name")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t("form.namePlaceholder", "Rule name")}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("form.type", "Rule Type")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
className="grid gap-2 sm:grid-cols-3"
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value as API.PromoRuleType)
|
||||||
|
}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
{promoRuleTypes.map((item) => (
|
||||||
|
<FormItem
|
||||||
|
className="flex items-center space-x-2 space-y-0"
|
||||||
|
key={item}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem value={item} />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">
|
||||||
|
{t(`types.${item}`, item)}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{type === "inactive_user" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="inactive_months"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.inactiveMonths", "Inactive Months")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={1}
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t(
|
||||||
|
"form.inactiveMonthsPlaceholder",
|
||||||
|
"Months without purchase"
|
||||||
|
)}
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{type === "new_user" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="window_hours"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("form.windowHours", "Window Hours")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={1}
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t(
|
||||||
|
"form.windowHoursPlaceholder",
|
||||||
|
"Hours after registration"
|
||||||
|
)}
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("priority", "Priority")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={0}
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
placeholder="0"
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="start_time"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("startTime", "Start Time")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<DatePicker
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t("form.selectStartTime", "Select time")}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="end_time"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("endTime", "End Time")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<DatePicker
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
placeholder={t("form.selectEndTime", "Select time")}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel>{t("enabled", "Enabled")}</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</ScrollArea>
|
||||||
|
<SheetFooter>
|
||||||
|
<Button disabled={loading} form="promo-rule-form" type="submit">
|
||||||
|
{t("confirm", "Confirm")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||||
|
|
||||||
|
export const promoRuleTypes: API.PromoRuleType[] = [
|
||||||
|
"inactive_user",
|
||||||
|
"new_user",
|
||||||
|
"campaign",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function formatCurrency(value?: number) {
|
||||||
|
if (typeof value !== "number") return "--";
|
||||||
|
return `$${unitConversion("centsToDollars", value)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalNumber(value?: string) {
|
||||||
|
if (!value) return;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscribeUnitPrice(subscribe?: API.SubscribeItem) {
|
||||||
|
return typeof subscribe?.unit_price === "number" ? subscribe.unit_price : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubscribeDiscounts(subscribe?: API.SubscribeItem) {
|
||||||
|
return (subscribe?.discount || []).filter(
|
||||||
|
(item) => typeof item.quantity === "number" && item.quantity > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,220 +29,223 @@ export default function Redemption() {
|
|||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
|
<ProTable<
|
||||||
action={ref}
|
API.RedemptionCode,
|
||||||
actions={{
|
{ subscribe_plan: number; unit_time: string; code: string }
|
||||||
render: (row) => [
|
>
|
||||||
<Button
|
action={ref}
|
||||||
key="records"
|
actions={{
|
||||||
variant="outline"
|
render: (row) => [
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => {
|
key="records"
|
||||||
setSelectedCodeId(row.id);
|
onClick={() => {
|
||||||
setRecordsOpen(true);
|
setSelectedCodeId(row.id);
|
||||||
}}
|
setRecordsOpen(true);
|
||||||
>
|
}}
|
||||||
{t("records", "Records")}
|
size="sm"
|
||||||
</Button>,
|
variant="outline"
|
||||||
<RedemptionForm<API.UpdateRedemptionCodeRequest>
|
>
|
||||||
initialValues={row}
|
{t("records", "Records")}
|
||||||
key="edit"
|
</Button>,
|
||||||
loading={loading}
|
<RedemptionForm<API.UpdateRedemptionCodeRequest>
|
||||||
onSubmit={async (values) => {
|
initialValues={row}
|
||||||
setLoading(true);
|
key="edit"
|
||||||
try {
|
loading={loading}
|
||||||
await updateRedemptionCode({ ...values });
|
onSubmit={async (values) => {
|
||||||
toast.success(t("updateSuccess", "Update Success"));
|
setLoading(true);
|
||||||
ref.current?.refresh();
|
try {
|
||||||
setLoading(false);
|
await updateRedemptionCode({ ...values });
|
||||||
return true;
|
toast.success(t("updateSuccess", "Update Success"));
|
||||||
} catch (_error) {
|
ref.current?.refresh();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return false;
|
return true;
|
||||||
}
|
} catch (_error) {
|
||||||
}}
|
setLoading(false);
|
||||||
title={t("editRedemptionCode", "Edit Redemption Code")}
|
return false;
|
||||||
trigger={t("edit", "Edit")}
|
}
|
||||||
/>,
|
}}
|
||||||
<ConfirmButton
|
title={t("editRedemptionCode", "Edit Redemption Code")}
|
||||||
cancelText={t("cancel", "Cancel")}
|
trigger={t("edit", "Edit")}
|
||||||
confirmText={t("confirm", "Confirm")}
|
/>,
|
||||||
description={t(
|
<ConfirmButton
|
||||||
"deleteWarning",
|
cancelText={t("cancel", "Cancel")}
|
||||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
confirmText={t("confirm", "Confirm")}
|
||||||
)}
|
description={t(
|
||||||
key="delete"
|
"deleteWarning",
|
||||||
onConfirm={async () => {
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
await deleteRedemptionCode({ id: row.id });
|
)}
|
||||||
toast.success(t("deleteSuccess", "Delete Success"));
|
key="delete"
|
||||||
ref.current?.refresh();
|
onConfirm={async () => {
|
||||||
}}
|
await deleteRedemptionCode({ id: row.id });
|
||||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
trigger={
|
|
||||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
],
|
|
||||||
batchRender: (rows) => [
|
|
||||||
<ConfirmButton
|
|
||||||
cancelText={t("cancel", "Cancel")}
|
|
||||||
confirmText={t("confirm", "Confirm")}
|
|
||||||
description={t(
|
|
||||||
"deleteWarning",
|
|
||||||
"Once deleted, data cannot be recovered. Please proceed with caution."
|
|
||||||
)}
|
|
||||||
key="delete"
|
|
||||||
onConfirm={async () => {
|
|
||||||
await batchDeleteRedemptionCode({
|
|
||||||
ids: rows.map((item) => item.id),
|
|
||||||
});
|
|
||||||
toast.success(t("deleteSuccess", "Delete Success"));
|
|
||||||
ref.current?.reset();
|
|
||||||
}}
|
|
||||||
title={t("confirmDelete", "Are you sure you want to delete?")}
|
|
||||||
trigger={
|
|
||||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
accessorKey: "code",
|
|
||||||
header: t("code", "Code"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "subscribe_plan",
|
|
||||||
header: t("subscribePlan", "Subscribe Plan"),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const plan = subscribes?.find(
|
|
||||||
(s) => s.id === row.getValue("subscribe_plan")
|
|
||||||
);
|
|
||||||
return plan?.name || "--";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "unit_time",
|
|
||||||
header: t("unitTime", "Unit Time"),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const unitTime = row.getValue("unit_time") as string;
|
|
||||||
const unitTimeMap: Record<string, string> = {
|
|
||||||
day: t("form.day", "Day"),
|
|
||||||
month: t("form.month", "Month"),
|
|
||||||
quarter: t("form.quarter", "Quarter"),
|
|
||||||
half_year: t("form.halfYear", "Half Year"),
|
|
||||||
year: t("form.year", "Year"),
|
|
||||||
};
|
|
||||||
return unitTimeMap[unitTime] || unitTime;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "quantity",
|
|
||||||
header: t("duration", "Duration"),
|
|
||||||
cell: ({ row }) => `${row.original.quantity}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "total_count",
|
|
||||||
header: t("totalCount", "Total Count"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>
|
|
||||||
{t("totalCount", "Total")}: {row.original.total_count}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t("remainingCount", "Remaining")}:{" "}
|
|
||||||
{row.original.total_count - (row.original.used_count || 0)}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t("usedCount", "Used")}: {row.original.used_count || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: t("status", "Status"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Switch
|
|
||||||
defaultChecked={row.getValue("status") === 1}
|
|
||||||
onCheckedChange={async (checked) => {
|
|
||||||
await toggleRedemptionCodeStatus({
|
|
||||||
id: row.original.id,
|
|
||||||
status: checked ? 1 : 0,
|
|
||||||
});
|
|
||||||
toast.success(
|
|
||||||
checked
|
|
||||||
? t("updateSuccess", "Update Success")
|
|
||||||
: t("updateSuccess", "Update Success")
|
|
||||||
);
|
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
}}
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
batchRender: (rows) => [
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteWarning",
|
||||||
|
"Once deleted, data cannot be recovered. Please proceed with caution."
|
||||||
|
)}
|
||||||
|
key="delete"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await batchDeleteRedemptionCode({
|
||||||
|
ids: rows.map((item) => item.id),
|
||||||
|
});
|
||||||
|
toast.success(t("deleteSuccess", "Delete Success"));
|
||||||
|
ref.current?.reset();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Are you sure you want to delete?")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "code",
|
||||||
|
header: t("code", "Code"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "subscribe_plan",
|
||||||
|
header: t("subscribePlan", "Subscribe Plan"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const plan = subscribes?.find(
|
||||||
|
(s) => s.id === row.getValue("subscribe_plan")
|
||||||
|
);
|
||||||
|
return plan?.name || "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "unit_time",
|
||||||
|
header: t("unitTime", "Unit Time"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const unitTime = row.getValue("unit_time") as string;
|
||||||
|
const unitTimeMap: Record<string, string> = {
|
||||||
|
day: t("form.day", "Day"),
|
||||||
|
month: t("form.month", "Month"),
|
||||||
|
quarter: t("form.quarter", "Quarter"),
|
||||||
|
half_year: t("form.halfYear", "Half Year"),
|
||||||
|
year: t("form.year", "Year"),
|
||||||
|
};
|
||||||
|
return unitTimeMap[unitTime] || unitTime;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "quantity",
|
||||||
|
header: t("duration", "Duration"),
|
||||||
|
cell: ({ row }) => `${row.original.quantity}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "total_count",
|
||||||
|
header: t("totalCount", "Total Count"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>
|
||||||
|
{t("totalCount", "Total")}: {row.original.total_count}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("remainingCount", "Remaining")}:{" "}
|
||||||
|
{row.original.total_count - (row.original.used_count || 0)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("usedCount", "Used")}: {row.original.used_count || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: t("status", "Status"),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.getValue("status") === 1}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
await toggleRedemptionCodeStatus({
|
||||||
|
id: row.original.id,
|
||||||
|
status: checked ? 1 : 0,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
checked
|
||||||
|
? t("updateSuccess", "Update Success")
|
||||||
|
: t("updateSuccess", "Update Success")
|
||||||
|
);
|
||||||
|
ref.current?.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
toolbar: (
|
||||||
|
<RedemptionForm<API.CreateRedemptionCodeRequest>
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createRedemptionCode(values);
|
||||||
|
toast.success(t("createSuccess", "Create Success"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createRedemptionCode", "Create Redemption Code")}
|
||||||
|
trigger={t("create", "Create")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
}}
|
||||||
]}
|
params={[
|
||||||
header={{
|
{
|
||||||
toolbar: (
|
key: "subscribe_plan",
|
||||||
<RedemptionForm<API.CreateRedemptionCodeRequest>
|
placeholder: t("subscribePlan", "Subscribe Plan"),
|
||||||
loading={loading}
|
options: subscribes?.map((item) => ({
|
||||||
onSubmit={async (values) => {
|
label: item.name!,
|
||||||
setLoading(true);
|
value: String(item.id),
|
||||||
try {
|
})),
|
||||||
await createRedemptionCode(values);
|
},
|
||||||
toast.success(t("createSuccess", "Create Success"));
|
{
|
||||||
ref.current?.refresh();
|
key: "unit_time",
|
||||||
setLoading(false);
|
placeholder: t("unitTime", "Unit Time"),
|
||||||
return true;
|
options: [
|
||||||
} catch (_error) {
|
{ label: t("form.day", "Day"), value: "day" },
|
||||||
setLoading(false);
|
{ label: t("form.month", "Month"), value: "month" },
|
||||||
return false;
|
{ label: t("form.quarter", "Quarter"), value: "quarter" },
|
||||||
}
|
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
|
||||||
}}
|
{ label: t("form.year", "Year"), value: "year" },
|
||||||
title={t("createRedemptionCode", "Create Redemption Code")}
|
],
|
||||||
trigger={t("create", "Create")}
|
},
|
||||||
/>
|
{
|
||||||
),
|
key: "code",
|
||||||
}}
|
},
|
||||||
params={[
|
]}
|
||||||
{
|
request={async (pagination, filters) => {
|
||||||
key: "subscribe_plan",
|
const { data } = await getRedemptionCodeList({
|
||||||
placeholder: t("subscribePlan", "Subscribe Plan"),
|
...pagination,
|
||||||
options: subscribes?.map((item) => ({
|
...filters,
|
||||||
label: item.name!,
|
});
|
||||||
value: String(item.id),
|
return {
|
||||||
})),
|
list: data.data?.list || [],
|
||||||
},
|
total: data.data?.total || 0,
|
||||||
{
|
};
|
||||||
key: "unit_time",
|
}}
|
||||||
placeholder: t("unitTime", "Unit Time"),
|
/>
|
||||||
options: [
|
<RedemptionRecords
|
||||||
{ label: t("form.day", "Day"), value: "day" },
|
codeId={selectedCodeId}
|
||||||
{ label: t("form.month", "Month"), value: "month" },
|
onOpenChange={setRecordsOpen}
|
||||||
{ label: t("form.quarter", "Quarter"), value: "quarter" },
|
open={recordsOpen}
|
||||||
{ label: t("form.halfYear", "Half Year"), value: "half_year" },
|
/>
|
||||||
{ label: t("form.year", "Year"), value: "year" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "code",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
request={async (pagination, filters) => {
|
|
||||||
const { data } = await getRedemptionCodeList({
|
|
||||||
...pagination,
|
|
||||||
...filters,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
list: data.data?.list || [],
|
|
||||||
total: data.data?.total || 0,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RedemptionRecords
|
|
||||||
codeId={selectedCodeId}
|
|
||||||
open={recordsOpen}
|
|
||||||
onOpenChange={setRecordsOpen}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,15 +26,24 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
|
|
||||||
const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({
|
const getFormSchema = (t: (key: string, defaultValue: string) => string) =>
|
||||||
id: z.number().optional(),
|
z.object({
|
||||||
code: z.string().optional(),
|
id: z.number().optional(),
|
||||||
batch_count: z.number().optional(),
|
code: z.string().optional(),
|
||||||
total_count: z.number().min(1, t("form.totalCountRequired", "Total count is required")),
|
batch_count: z.number().optional(),
|
||||||
subscribe_plan: z.number().min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
total_count: z
|
||||||
unit_time: z.string().min(1, t("form.unitTimeRequired", "Unit time is required")),
|
.number()
|
||||||
quantity: z.number().min(1, t("form.quantityRequired", "Quantity is required")),
|
.min(1, t("form.totalCountRequired", "Total count is required")),
|
||||||
});
|
subscribe_plan: z
|
||||||
|
.number()
|
||||||
|
.min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
||||||
|
unit_time: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("form.unitTimeRequired", "Unit time is required")),
|
||||||
|
quantity: z
|
||||||
|
.number()
|
||||||
|
.min(1, t("form.quantityRequired", "Quantity is required")),
|
||||||
|
});
|
||||||
|
|
||||||
interface RedemptionFormProps<T> {
|
interface RedemptionFormProps<T> {
|
||||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||||
@@ -184,9 +193,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="unit_time"
|
name="unit_time"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.unitTime", "Unit Time")}</FormLabel>
|
||||||
{t("form.unitTime", "Unit Time")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Combobox<string, false>
|
<Combobox<string, false>
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -195,8 +202,14 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "day", label: t("form.day", "Day") },
|
{ value: "day", label: t("form.day", "Day") },
|
||||||
{ value: "month", label: t("form.month", "Month") },
|
{ value: "month", label: t("form.month", "Month") },
|
||||||
{ value: "quarter", label: t("form.quarter", "Quarter") },
|
{
|
||||||
{ value: "half_year", label: t("form.halfYear", "Half Year") },
|
value: "quarter",
|
||||||
|
label: t("form.quarter", "Quarter"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "half_year",
|
||||||
|
label: t("form.halfYear", "Half Year"),
|
||||||
|
},
|
||||||
{ value: "year", label: t("form.year", "Year") },
|
{ value: "year", label: t("form.year", "Year") },
|
||||||
]}
|
]}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
@@ -215,16 +228,11 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="quantity"
|
name="quantity"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.duration", "Duration")}</FormLabel>
|
||||||
{t("form.duration", "Duration")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<EnhancedInput
|
<EnhancedInput
|
||||||
min={1}
|
min={1}
|
||||||
placeholder={t(
|
placeholder={t("form.durationPlaceholder", "Duration")}
|
||||||
"form.durationPlaceholder",
|
|
||||||
"Duration"
|
|
||||||
)}
|
|
||||||
step={1}
|
step={1}
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
@@ -242,9 +250,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="total_count"
|
name="total_count"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.totalCount", "Total Count")}</FormLabel>
|
||||||
{t("form.totalCount", "Total Count")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<EnhancedInput
|
<EnhancedInput
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function RedemptionRecords({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [pagination, setPagination] = useState({ page: 1, size: 10 });
|
const [pagination, setPagination] = useState({ page: 1, size: 200 });
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
if (!codeId) return;
|
if (!codeId) return;
|
||||||
@@ -59,12 +59,10 @@ export default function RedemptionRecords({
|
|||||||
}, [open, codeId, pagination]);
|
}, [open, codeId, pagination]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{t("records", "Redemption Records")}</DialogTitle>
|
||||||
{t("records", "Redemption Records")}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -72,7 +70,7 @@ export default function RedemptionRecords({
|
|||||||
<span>{t("loading", "Loading...")}</span>
|
<span>{t("loading", "Loading...")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
{t("noRecords", "No records found")}
|
{t("noRecords", "No records found")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -102,7 +100,9 @@ export default function RedemptionRecords({
|
|||||||
<TableCell>{record.id}</TableCell>
|
<TableCell>{record.id}</TableCell>
|
||||||
<TableCell>{record.user_id}</TableCell>
|
<TableCell>{record.user_id}</TableCell>
|
||||||
<TableCell>{record.subscribe_id}</TableCell>
|
<TableCell>{record.subscribe_id}</TableCell>
|
||||||
<TableCell>{unitTimeMap[record.unit_time] || record.unit_time}</TableCell>
|
<TableCell>
|
||||||
|
{unitTimeMap[record.unit_time] || record.unit_time}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{record.quantity}</TableCell>
|
<TableCell>{record.quantity}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{record.redeemed_at
|
{record.redeemed_at
|
||||||
@@ -115,26 +115,28 @@ export default function RedemptionRecords({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{total > pagination.size && (
|
{total > pagination.size && (
|
||||||
<div className="flex justify-between items-center mt-4">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{t("total", "Total")}: {total}
|
{t("total", "Total")}: {total}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||||
disabled={pagination.page === 1}
|
disabled={pagination.page === 1}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
||||||
}
|
}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{t("previous", "Previous")}
|
{t("previous", "Previous")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||||
disabled={pagination.page * pagination.size >= total}
|
disabled={pagination.page * pagination.size >= total}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
||||||
}
|
}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{t("next", "Next")}
|
{t("next", "Next")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -66,18 +66,13 @@ export const SECURITY = {
|
|||||||
trojan: ["tls"] as const,
|
trojan: ["tls"] as const,
|
||||||
hysteria: ["tls"] as const,
|
hysteria: ["tls"] as const,
|
||||||
tuic: ["tls"] as const,
|
tuic: ["tls"] as const,
|
||||||
anytls: ["tls"] as const,
|
anytls: ["none", "tls", "reality"] as const,
|
||||||
naive: ["none", "tls"] as const,
|
naive: ["none", "tls"] as const,
|
||||||
http: ["none", "tls"] as const,
|
http: ["none", "tls"] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const FLOWS = {
|
export const FLOWS = {
|
||||||
vless: [
|
vless: ["none", "xtls-rprx-vision"] as const,
|
||||||
"none",
|
|
||||||
"xtls-rprx-direct",
|
|
||||||
"xtls-rprx-splice",
|
|
||||||
"xtls-rprx-vision",
|
|
||||||
] as const,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const TUIC_UDP_RELAY_MODES = ["native", "quic"] as const;
|
export const TUIC_UDP_RELAY_MODES = ["native", "quic"] as const;
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ export function getProtocolDefaultConfig(proto: ProtocolType) {
|
|||||||
cert_mode: "none",
|
cert_mode: "none",
|
||||||
cert_dns_provider: null,
|
cert_dns_provider: null,
|
||||||
cert_dns_env: null,
|
cert_dns_env: null,
|
||||||
|
reality_server_addr: null,
|
||||||
|
reality_server_port: null,
|
||||||
|
reality_private_key: null,
|
||||||
|
reality_public_key: null,
|
||||||
|
reality_short_id: null,
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
} as any;
|
} as any;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ const anytls = z.object({
|
|||||||
cert_mode: z.enum(CERT_MODES).nullish(),
|
cert_mode: z.enum(CERT_MODES).nullish(),
|
||||||
cert_dns_provider: nullableString,
|
cert_dns_provider: nullableString,
|
||||||
cert_dns_env: nullableString,
|
cert_dns_env: nullableString,
|
||||||
|
reality_server_addr: nullableString,
|
||||||
|
reality_server_port: nullablePort,
|
||||||
|
reality_private_key: nullableString,
|
||||||
|
reality_public_key: nullableString,
|
||||||
|
reality_short_id: nullableString,
|
||||||
});
|
});
|
||||||
|
|
||||||
const socks = z.object({
|
const socks = z.object({
|
||||||
|
|||||||
@@ -279,7 +279,10 @@ export function useProtocolFields() {
|
|||||||
options: FLOWS.vless,
|
options: FLOWS.vless,
|
||||||
defaultValue: "none",
|
defaultValue: "none",
|
||||||
group: "transport",
|
group: "transport",
|
||||||
condition: (p) => p.transport === "tcp",
|
condition: (p) =>
|
||||||
|
p.encryption === "mlkem768x25519plus" ||
|
||||||
|
(p.transport === "tcp" &&
|
||||||
|
(p.security === "tls" || p.security === "reality")),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "security",
|
name: "security",
|
||||||
@@ -441,7 +444,7 @@ export function useProtocolFields() {
|
|||||||
name: "encryption_ticket",
|
name: "encryption_ticket",
|
||||||
type: "input",
|
type: "input",
|
||||||
label: t("encryption_ticket", "Ticket Time"),
|
label: t("encryption_ticket", "Ticket Time"),
|
||||||
placeholder: "e.g. 600s",
|
placeholder: "e.g. 600",
|
||||||
group: "encryption",
|
group: "encryption",
|
||||||
condition: (p) =>
|
condition: (p) =>
|
||||||
p.encryption === "mlkem768x25519plus" &&
|
p.encryption === "mlkem768x25519plus" &&
|
||||||
@@ -1093,6 +1096,14 @@ export function useProtocolFields() {
|
|||||||
placeholder: "1-65535",
|
placeholder: "1-65535",
|
||||||
group: "basic",
|
group: "basic",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "security",
|
||||||
|
type: "select",
|
||||||
|
label: t("security", "Security"),
|
||||||
|
options: SECURITY.anytls,
|
||||||
|
defaultValue: "tls",
|
||||||
|
group: "security",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "padding_scheme",
|
name: "padding_scheme",
|
||||||
type: "textarea",
|
type: "textarea",
|
||||||
@@ -1108,12 +1119,14 @@ export function useProtocolFields() {
|
|||||||
type: "input",
|
type: "input",
|
||||||
label: t("security_sni", "SNI"),
|
label: t("security_sni", "SNI"),
|
||||||
group: "security",
|
group: "security",
|
||||||
|
condition: (p) => p.security !== "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "allow_insecure",
|
name: "allow_insecure",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
label: t("security_allow_insecure", "Allow Insecure"),
|
label: t("security_allow_insecure", "Allow Insecure"),
|
||||||
group: "security",
|
group: "security",
|
||||||
|
condition: (p) => p.security !== "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fingerprint",
|
name: "fingerprint",
|
||||||
@@ -1122,6 +1135,7 @@ export function useProtocolFields() {
|
|||||||
options: FINGERPRINTS,
|
options: FINGERPRINTS,
|
||||||
defaultValue: "chrome",
|
defaultValue: "chrome",
|
||||||
group: "security",
|
group: "security",
|
||||||
|
condition: (p) => p.security !== "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cert_mode",
|
name: "cert_mode",
|
||||||
@@ -1130,6 +1144,7 @@ export function useProtocolFields() {
|
|||||||
options: CERT_MODES,
|
options: CERT_MODES,
|
||||||
defaultValue: "none",
|
defaultValue: "none",
|
||||||
group: "security",
|
group: "security",
|
||||||
|
condition: (p) => p.security === "tls",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cert_dns_provider",
|
name: "cert_dns_provider",
|
||||||
@@ -1148,6 +1163,63 @@ export function useProtocolFields() {
|
|||||||
group: "security",
|
group: "security",
|
||||||
condition: (p) => p.cert_mode === "dns",
|
condition: (p) => p.cert_mode === "dns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "reality_server_addr",
|
||||||
|
type: "input",
|
||||||
|
label: t("security_server_address", "Reality Server Address"),
|
||||||
|
placeholder: t(
|
||||||
|
"security_server_address_placeholder",
|
||||||
|
"e.g. 1.2.3.4 or domain"
|
||||||
|
),
|
||||||
|
group: "reality",
|
||||||
|
condition: (p) => p.security === "reality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reality_server_port",
|
||||||
|
type: "number",
|
||||||
|
label: t("security_server_port", "Reality Server Port"),
|
||||||
|
min: 1,
|
||||||
|
max: 65_535,
|
||||||
|
placeholder: "1-65535",
|
||||||
|
group: "reality",
|
||||||
|
condition: (p) => p.security === "reality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reality_private_key",
|
||||||
|
type: "input",
|
||||||
|
label: t("security_private_key", "Reality Private Key"),
|
||||||
|
placeholder: t(
|
||||||
|
"security_private_key_placeholder",
|
||||||
|
"Enter private key"
|
||||||
|
),
|
||||||
|
group: "reality",
|
||||||
|
generate: {
|
||||||
|
function: generateRealityKeyPair,
|
||||||
|
updateFields: {
|
||||||
|
reality_private_key: "privateKey",
|
||||||
|
reality_public_key: "publicKey",
|
||||||
|
} as Record<string, string>,
|
||||||
|
},
|
||||||
|
condition: (p) => p.security === "reality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reality_public_key",
|
||||||
|
type: "input",
|
||||||
|
label: t("security_public_key", "Reality Public Key"),
|
||||||
|
placeholder: t("security_public_key_placeholder", "Enter public key"),
|
||||||
|
group: "reality",
|
||||||
|
condition: (p) => p.security === "reality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reality_short_id",
|
||||||
|
type: "input",
|
||||||
|
label: t("security_short_id", "Reality Short ID"),
|
||||||
|
group: "reality",
|
||||||
|
generate: {
|
||||||
|
function: generateRealityShortId,
|
||||||
|
},
|
||||||
|
condition: (p) => p.security === "reality",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user