Compare commits

...

10 Commits

Author SHA1 Message Date
6b92979c7c feat: 自定义版本功能更新
Some checks failed
Build and Release / Build (push) Has been cancelled
- 新增家庭共享订阅管理
- 新增用户邀请统计
- 新增签名和订阅模式设置表单
- 更新 API 服务层和国际化文件
- UI 组件优化(enhanced-input、pro-table)
2026-03-19 01:56:13 -07:00
31803a1d24 feat: 后台订单列表新增手动激活按钮
- API 层新增 activateOrder 方法 (POST /v1/admin/order/activate)
- pending(1) 和 cancelled(3) 状态的订单显示 Activate 按钮
- 点击后调用后端手动激活接口,强制发放订阅权益
2026-03-09 04:08:23 -07:00
web@ppanel
778e78ac68 fix(user): show expire time & improve renewal dialog on mobile (#22)
* 🐛 fix(admin): prioritize follow-up tickets by updated time

*  feat(dashboard): show node/user traffic ranks side-by-side

* 🐛 fix(user): show expire time & improve renewal dialog on mobile
2026-03-04 08:16:33 -08:00
semantic-release-bot
8317e93d6b 🚀 chore(release): Release 1.3.12 / 发布版本 1.3.12 [skip ci]
## [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](a07d1ca48e))
2026-03-04 08:16:32 -08:00
web@ppanel
116f6e5360 fix(admin): prioritize follow-up tickets (#18)
* 🐛 fix(admin): prioritize follow-up tickets by updated time

*  feat(dashboard): show node/user traffic ranks side-by-side
2026-03-04 08:16:31 -08:00
semantic-release-bot
370d59d5ad 🚀 chore(release): Release 1.3.11 / 发布版本 1.3.11 [skip ci]
## [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](15fc37db9e))
2026-03-04 08:16:30 -08:00
ppanel-web
08702fb643 fix(admin): stabilize node sorting with duplicate sort values 2026-03-04 08:16:29 -08:00
ppanel-web
bbea15dea4 Fix unstable node sorting order 2026-03-04 08:16:28 -08:00
ppanel-web
98da7b1476 🐛 fix node sort persistence on drag reorder 2026-03-04 08:16:26 -08:00
EUForest
dc55d85056 fix(admin): handle int64 precision loss in getUserSubscribe API
Add transformResponse to convert large integers to strings before JSON parsing
to prevent precision loss for int64 values like user IDs that exceed
JavaScript's MAX_SAFE_INTEGER.
2026-02-14 00:44:28 +08:00
93 changed files with 3526 additions and 870 deletions

View File

@ -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

2
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

135
.serena/project.yml Normal file
View File

@ -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 readonly.
# 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:

View File

@ -19,6 +19,18 @@ This document records all notable changes to ShadCN Admin.
---
## [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)
### 🐛 Bug Fixes / 问题修复

View File

@ -99,6 +99,8 @@
"title": "Email Settings",
"trafficExceedEmailTemplate": "Traffic Exceed Email Template",
"trafficTemplate": "Traffic Template",
"deleteAccountEmailTemplate": "Delete Account Email Template",
"deleteAccountTemplate": "Delete Account Template",
"verifyEmailTemplate": "Verify Email Template",
"verifyTemplate": "Verify Template",
"whitelistSuffixes": "Whitelist Suffixes",

View File

@ -31,6 +31,7 @@
"System Config": "System Config",
"Ticket Management": "Ticket Management",
"Traffic Details": "Traffic Details",
"Device Group": "Device Group",
"User Management": "User Management",
"Users & Support": "Users & Support"
}

View File

@ -3,6 +3,7 @@
"common": {
"cancel": "Cancel",
"save": "Save Settings",
"saving": "Saving...",
"saveFailed": "Save Failed",
"saveSuccess": "Save Successful"
},
@ -23,6 +24,10 @@
"description": "Configure user invitation and referral reward settings",
"forcedInvite": "Require Invitation to Register",
"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",
"onlyFirstPurchase": "First Purchase Reward Only",
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
@ -42,6 +47,22 @@
"title": "Log Cleanup 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": {
"description": "Edit and manage privacy policy content",
"title": "Privacy Policy"

View File

@ -24,6 +24,7 @@
"createSubscription": "Create Subscription",
"createSuccess": "Created successfully",
"createUser": "Create User",
"currentCommission": "Current Commission",
"delete": "Delete",
"deleted": "Deleted",
"deleteDescription": "This action cannot be undone.",
@ -31,19 +32,57 @@
"deleteSuccess": "Deleted successfully",
"isDeleted": "Status",
"deviceLimit": "Device Limit",
"deviceGroup": "Device Group",
"deviceNo": "Device No.",
"deviceSearch": "Device",
"download": "Download",
"downloadTraffic": "Download Traffic",
"edit": "Edit",
"editSubscription": "Edit Subscription",
"enable": "Enable",
"enabled": "Enabled",
"disabled": "Disabled",
"expiredAt": "Expired At",
"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",
"giftAmountPlaceholder": "Enter gift amount",
"giftLogs": "Gift Logs",
"globalDefault": "Global Default",
"invalidEmailFormat": "Invalid email format",
"inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter invite code",
"inviteCount": "Invited Users",
"inviteStats": "Invite Statistics",
"invitedUsers": "Invited Users",
"kickOfflineConfirm": "kickOfflineConfirm",
"kickOfflineSuccess": "Device kicked offline",
"lastSeen": "Last Seen",
@ -52,17 +91,22 @@
"loginNotifications": "Login Notifications",
"loginStatus": "Login Status",
"manager": "Administrator",
"memberCount": "Member Count",
"more": "More",
"normal": "Normal",
"next": "Next",
"noInvitedUsers": "No invited users yet",
"notifySettingsTitle": "Notify Settings",
"offline": "Offline",
"online": "Online",
"onlineDevices": "Online Devices",
"onlyFirstPurchase": "First Purchase Only",
"orderList": "Order List",
"owner": "Owner",
"password": "Password",
"passwordPlaceholder": "Enter password",
"permanent": "Permanent",
"prev": "Prev",
"pleaseEnterEmail": "Enter email",
"referer": "Referer",
"refererId": "Referer ID",
@ -71,7 +115,9 @@
"referralPercentage": "Referral Percentage",
"referralPercentagePlaceholder": "Enter percentage",
"referrerUserId": "Referrer User ID",
"registeredAt": "Registered At",
"remove": "Remove",
"removeSuccess": "Removed successfully",
"resetLogs": "Reset Logs",
"resetTraffic": "Reset Traffic",
"toggleStatus": "Toggle Status",
@ -81,6 +127,7 @@
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
"toggleSubscriptionStatus": "Toggle Status",
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
"resetSearch": "Reset",
"resetTime": "Reset Time",
"resetToken": "Reset Subscription Address",
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
@ -102,6 +149,12 @@
"statusDeducted": "Deducted",
"statusStopped": "Stopped",
"save": "Save",
"search": "Search",
"searchPlaceholder": "Email / Invite Code / Device ID",
"searchInputPlaceholder": "Enter search term",
"sharedSubscription": "Shared",
"sharedSubscriptionInfo": "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
"sharedSubscriptionList": "Shared Subscription List",
"speedLimit": "Speed Limit",
"startTime": "startTime",
"subscription": "Subscription",
@ -114,6 +167,7 @@
"telephone": "Phone",
"telephonePlaceholder": "Enter phone number",
"token": "token",
"totalCommission": "Total Commission",
"totalTraffic": "Total Traffic",
"tradeNotifications": "Trade Notifications",
"trafficDetails": "Traffic Details",
@ -134,5 +188,7 @@
"userList": "User List",
"userName": "Username",
"userProfile": "User Profile",
"verified": "Verified"
"verified": "Verified",
"viewDeviceGroup": "View Device Group",
"viewOwner": "View Owner"
}

View File

@ -99,6 +99,8 @@
"title": "邮箱设置",
"trafficExceedEmailTemplate": "流量超额邮件模板",
"trafficTemplate": "流量模板",
"deleteAccountEmailTemplate": "注销账户邮件模板",
"deleteAccountTemplate": "注销模版",
"verifyEmailTemplate": "验证邮件模板",
"verifyTemplate": "验证模板",
"whitelistSuffixes": "白名单后缀",

View File

@ -31,6 +31,7 @@
"System Config": "系统配置",
"Ticket Management": "工单管理",
"Traffic Details": "流量详情",
"Device Group": "设备组",
"User Management": "用户管理",
"Users & Support": "用户与支持"
}

View File

@ -3,6 +3,7 @@
"common": {
"cancel": "取消",
"save": "保存设置",
"saving": "保存中...",
"saveFailed": "保存失败",
"saveSuccess": "保存成功"
},
@ -23,6 +24,10 @@
"description": "配置用户邀请和推荐奖励设置",
"forcedInvite": "强制邀请注册",
"forcedInviteDescription": "启用后,用户必须通过邀请链接注册",
"giftDays": "邀请赠送天数",
"giftDaysDescription": "当推荐佣金比例为 0 时,被邀请人完成购买后,邀请人和被邀请人各自获得的订阅延长天数",
"giftDaysPlaceholder": "请输入天数",
"giftDaysSuffix": "天",
"inputPlaceholder": "请输入",
"onlyFirstPurchase": "仅首次购买奖励",
"onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励",
@ -42,6 +47,22 @@
"title": "日志清理设置"
},
"logSettings": "日志设置",
"signature": {
"title": "请求签名",
"description": "启用或禁用公共 API 的请求签名验证",
"enable": "启用签名验证",
"enableDescription": "启用后,客户端可以通过发送 X-Signature-Enabled: 1 来触发严格的签名验证",
"saveSuccess": "保存成功",
"saveFailed": "保存失败"
},
"subscribeMode": {
"title": "订阅模式",
"description": "配置单订阅或多订阅购买行为",
"singleSubscriptionMode": "单订阅模式",
"singleSubscriptionModeDescription": "启用后,用户在同一账户中只能购买/续费一个订阅",
"saveSuccess": "订阅模式更新成功",
"saveFailed": "更新设置失败"
},
"privacyPolicy": {
"description": "编辑和管理隐私政策内容",
"title": "隐私政策"

View File

@ -24,26 +24,66 @@
"createSubscription": "创建订阅",
"createSuccess": "创建成功",
"createUser": "创建用户",
"currentCommission": "当前佣金",
"delete": "删除",
"deleted": "已删除",
"deleteDescription": "此操作无法撤销。",
"deleteSubscriptionDescription": "此操作无法撤销。",
"deleteSuccess": "删除成功",
"isDeleted": "状态",
"deviceGroup": "设备组",
"deviceLimit": "IP限制",
"deviceNo": "设备编号",
"deviceSearch": "设备",
"download": "下载",
"downloadTraffic": "下载流量",
"edit": "编辑",
"email": "邮箱",
"editSubscription": "编辑订阅",
"enable": "启用",
"enabled": "启用",
"disabled": "禁用",
"expiredAt": "过期时间",
"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": "赠送金额",
"giftAmountPlaceholder": "输入赠送金额",
"giftLogs": "赠送日志",
"globalDefault": "全局默认",
"invalidEmailFormat": "邮箱格式无效",
"inviteCode": "邀请码",
"inviteCodePlaceholder": "输入邀请码",
"inviteCount": "邀请用户数",
"inviteStats": "邀请统计",
"invitedUsers": "已邀请用户",
"kickOfflineConfirm": "确认踢下线",
"kickOfflineSuccess": "设备已踢下线",
"lastSeen": "最后上线",
@ -52,17 +92,22 @@
"loginNotifications": "登录通知",
"loginStatus": "登录状态",
"manager": "管理员",
"memberCount": "成员数量",
"more": "更多",
"normal": "正常",
"next": "下一页",
"noInvitedUsers": "暂无邀请用户",
"notifySettingsTitle": "通知设置",
"offline": "离线",
"online": "在线",
"onlineDevices": "在线设备",
"onlyFirstPurchase": "仅首次购买",
"orderList": "订单列表",
"owner": "所有者",
"password": "密码",
"passwordPlaceholder": "输入密码",
"permanent": "永久",
"prev": "上一页",
"pleaseEnterEmail": "输入邮箱",
"referer": "推荐人",
"refererId": "推荐人 ID",
@ -71,7 +116,9 @@
"referralPercentage": "推荐百分比",
"referralPercentagePlaceholder": "输入百分比",
"referrerUserId": "推荐人用户 ID",
"registeredAt": "注册时间",
"remove": "移除",
"removeSuccess": "移除成功",
"resetLogs": "重置日志",
"resetTraffic": "重置流量",
"toggleStatus": "切换状态",
@ -81,6 +128,7 @@
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
"toggleSubscriptionStatus": "切换状态",
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
"resetSearch": "重置",
"resetTime": "重置时间",
"resetToken": "重置订阅地址",
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
@ -102,6 +150,12 @@
"statusDeducted": "已扣除",
"statusStopped": "已停止",
"save": "保存",
"search": "搜索",
"searchPlaceholder": "邮箱 / 邀请码 / 设备ID",
"searchInputPlaceholder": "请输入搜索内容",
"sharedSubscription": "共享",
"sharedSubscriptionInfo": "该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅",
"sharedSubscriptionList": "共享订阅列表",
"speedLimit": "速度限制",
"startTime": "开始时间",
"subscription": "订阅",
@ -114,6 +168,7 @@
"telephone": "电话",
"telephonePlaceholder": "输入电话号码",
"token": "令牌",
"totalCommission": "总佣金",
"totalTraffic": "总流量",
"tradeNotifications": "交易通知",
"trafficDetails": "流量详情",
@ -134,5 +189,7 @@
"userList": "用户列表",
"userName": "用户名",
"userProfile": "用户资料",
"verified": "已验证"
"verified": "已验证",
"viewDeviceGroup": "查看设备组",
"viewOwner": "查看所有者"
}

View File

@ -88,6 +88,11 @@ export function useNavs() {
url: "/dashboard/user",
icon: "flat-color-icons:conference-call",
},
{
title: t("Device Group", "Device Group"),
url: "/dashboard/family",
icon: "flat-color-icons:home",
},
{
title: t("Ticket Management", "Ticket Management"),
url: "/dashboard/ticket",

View File

@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport =
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
'/dashboard/marketing/',
)()
const DashboardFamilyIndexLazyRouteImport =
createFileRoute('/dashboard/family/')()
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
'/dashboard/document/',
)()
@ -189,6 +191,14 @@ const DashboardMarketingIndexLazyRoute =
} as any).lazy(() =>
import('./routes/dashboard/marketing/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 =
DashboardDocumentIndexLazyRouteImport.update({
id: '/document/',
@ -345,6 +355,7 @@ export interface FileRoutesByFullPath {
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@ -377,6 +388,7 @@ export interface FileRoutesByTo {
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@ -411,6 +423,7 @@ export interface FileRoutesById {
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
@ -446,6 +459,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control'
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@ -478,6 +492,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control'
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/family'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
@ -511,6 +526,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control/'
| '/dashboard/coupon/'
| '/dashboard/document/'
| '/dashboard/family/'
| '/dashboard/marketing/'
| '/dashboard/order/'
| '/dashboard/payment/'
@ -627,6 +643,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/family/': {
id: '/dashboard/family/'
path: '/family'
fullPath: '/dashboard/family'
preLoaderRoute: typeof DashboardFamilyIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/document/': {
id: '/dashboard/document/'
path: '/document'
@ -770,6 +793,7 @@ interface DashboardRouteLazyRouteChildren {
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
@ -802,6 +826,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,

View File

@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import FamilyManagement from "@/sections/user/family";
export const Route = createLazyFileRoute("/dashboard/family/")({
component: FamilyManagement,
});

View File

@ -54,6 +54,7 @@ const emailSettingsSchema = z.object({
expiration_email_template: z.string().optional(),
maintenance_email_template: z.string().optional(),
traffic_exceed_email_template: z.string().optional(),
delete_account_email_template: z.string().optional(),
platform: z.string(),
platform_config: z
.object({
@ -102,6 +103,7 @@ export default function EmailSettingsForm() {
expiration_email_template: "",
maintenance_email_template: "",
traffic_exceed_email_template: "",
delete_account_email_template: "",
platform: "smtp",
platform_config: {
host: "",
@ -195,6 +197,12 @@ export default function EmailSettingsForm() {
<TabsTrigger value="traffic">
{t("email.trafficTemplate", "Traffic Template")}
</TabsTrigger>
<TabsTrigger value="delete_account">
{t(
"email.deleteAccountTemplate",
"Delete Account Template"
)}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-2" value="basic">
@ -840,6 +848,88 @@ export default function EmailSettingsForm() {
)}
/>
</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>
</form>
</Form>

View File

@ -13,13 +13,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
// (Select imports removed)
import { Separator } from "@workspace/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
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">(
"today"
);
@ -93,10 +86,112 @@ export default function Statistics() {
})) || [],
},
};
const currentData =
trafficData[dataType as "nodes" | "users"][
timeFrame as "today" | "yesterday"
];
const TrafficRankCard = ({ type }: { type: "nodes" | "users" }) => {
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 (
<>
@ -189,122 +284,14 @@ export default function Statistics() {
))}
<SystemVersionCard />
</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 />
<UserStatisticsCard />
<Card>
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("trafficRank", "Traffic Rank")}</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">
<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 className="grid gap-3 md:grid-cols-2">
<TrafficRankCard type="nodes" />
<TrafficRankCard type="users" />
</div>
</>
);

View File

@ -226,6 +226,8 @@ export default function Nodes() {
),
}}
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(
(item) => String(item.id) === source
);
@ -233,23 +235,38 @@ export default function Nodes() {
(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);
items.splice(targetIndex, 0, movedItem!);
const prevSortById = new Map(items.map((it) => [it.id, it.sort]));
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const next = items.slice();
const [movedItem] = next.splice(sourceIndex, 1);
next.splice(targetIndex, 0, movedItem!);
// 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(
(item, index) => item.sort !== items[index]?.sort
(item) => item.sort !== prevSortById.get(item.id)
);
if (changedItems.length > 0) {
resetSortWithNode({
await resetSortWithNode({
// Send all changed rows (within the current page) so backend can persist.
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
@ -257,6 +274,7 @@ export default function Nodes() {
});
toast.success(t("sorted_success", "Sorted successfully"));
}
return updatedItems;
}}
params={[{ key: "search" }]}
@ -266,7 +284,18 @@ export default function Nodes() {
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Node[];
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 total = Number(data?.data?.total || list.length);
return { list, total };
}}

View File

@ -13,6 +13,7 @@ import {
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
activateOrder,
getOrderList,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
@ -180,6 +181,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",
header: t("user", "User"),
@ -206,19 +215,33 @@ export default function Order() {
);
if ([1, 3, 4].includes(row.getValue("status"))) {
return (
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={statusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
<div className="flex items-center gap-1">
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={statusOptions}
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 (

View File

@ -29,220 +29,223 @@ export default function Redemption() {
const ref = useRef<ProTableActions>(null);
return (
<>
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
action={ref}
actions={{
render: (row) => [
<Button
key="records"
variant="outline"
size="sm"
onClick={() => {
setSelectedCodeId(row.id);
setRecordsOpen(true);
}}
>
{t("records", "Records")}
</Button>,
<RedemptionForm<API.UpdateRedemptionCodeRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateRedemptionCode({ ...values });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editRedemptionCode", "Edit Redemption Code")}
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 deleteRedemptionCode({ id: row.id });
toast.success(t("deleteSuccess", "Delete Success"));
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")
);
<ProTable<
API.RedemptionCode,
{ subscribe_plan: number; unit_time: string; code: string }
>
action={ref}
actions={{
render: (row) => [
<Button
key="records"
onClick={() => {
setSelectedCodeId(row.id);
setRecordsOpen(true);
}}
size="sm"
variant="outline"
>
{t("records", "Records")}
</Button>,
<RedemptionForm<API.UpdateRedemptionCodeRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateRedemptionCode({ ...values });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editRedemptionCode", "Edit Redemption Code")}
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 deleteRedemptionCode({ id: row.id });
toast.success(t("deleteSuccess", "Delete Success"));
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")}
/>
),
},
]}
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={[
{
key: "subscribe_plan",
placeholder: t("subscribePlan", "Subscribe Plan"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{
key: "unit_time",
placeholder: t("unitTime", "Unit Time"),
options: [
{ label: t("form.day", "Day"), value: "day" },
{ label: t("form.month", "Month"), value: "month" },
{ label: t("form.quarter", "Quarter"), value: "quarter" },
{ 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}
/>
}}
params={[
{
key: "subscribe_plan",
placeholder: t("subscribePlan", "Subscribe Plan"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{
key: "unit_time",
placeholder: t("unitTime", "Unit Time"),
options: [
{ label: t("form.day", "Day"), value: "day" },
{ label: t("form.month", "Month"), value: "month" },
{ label: t("form.quarter", "Quarter"), value: "quarter" },
{ 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}
onOpenChange={setRecordsOpen}
open={recordsOpen}
/>
</>
);
}

View File

@ -26,15 +26,24 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useSubscribe } from "@/stores/subscribe";
const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({
id: z.number().optional(),
code: z.string().optional(),
batch_count: z.number().optional(),
total_count: z.number().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")),
});
const getFormSchema = (t: (key: string, defaultValue: string) => string) =>
z.object({
id: z.number().optional(),
code: z.string().optional(),
batch_count: z.number().optional(),
total_count: z
.number()
.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> {
onSubmit: (data: T) => Promise<boolean> | boolean;
@ -184,9 +193,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="unit_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.unitTime", "Unit Time")}
</FormLabel>
<FormLabel>{t("form.unitTime", "Unit Time")}</FormLabel>
<FormControl>
<Combobox<string, false>
onChange={(value) => {
@ -195,8 +202,14 @@ export default function RedemptionForm<T extends Record<string, any>>({
options={[
{ value: "day", label: t("form.day", "Day") },
{ 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") },
]}
placeholder={t(
@ -215,16 +228,11 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.duration", "Duration")}
</FormLabel>
<FormLabel>{t("form.duration", "Duration")}</FormLabel>
<FormControl>
<EnhancedInput
min={1}
placeholder={t(
"form.durationPlaceholder",
"Duration"
)}
placeholder={t("form.durationPlaceholder", "Duration")}
step={1}
type="number"
{...field}
@ -242,9 +250,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="total_count"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.totalCount", "Total Count")}
</FormLabel>
<FormLabel>{t("form.totalCount", "Total Count")}</FormLabel>
<FormControl>
<EnhancedInput
min={1}

View File

@ -32,7 +32,7 @@ export default function RedemptionRecords({
const [loading, setLoading] = useState(false);
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
const [total, setTotal] = useState(0);
const [pagination, setPagination] = useState({ page: 1, size: 10 });
const [pagination, setPagination] = useState({ page: 1, size: 200 });
const fetchRecords = async () => {
if (!codeId) return;
@ -59,12 +59,10 @@ export default function RedemptionRecords({
}, [open, codeId, pagination]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("records", "Redemption Records")}
</DialogTitle>
<DialogTitle>{t("records", "Redemption Records")}</DialogTitle>
</DialogHeader>
<div className="mt-4">
{loading ? (
@ -72,7 +70,7 @@ export default function RedemptionRecords({
<span>{t("loading", "Loading...")}</span>
</div>
) : 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")}
</div>
) : (
@ -102,7 +100,9 @@ export default function RedemptionRecords({
<TableCell>{record.id}</TableCell>
<TableCell>{record.user_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.redeemed_at
@ -115,26 +115,28 @@ export default function RedemptionRecords({
</TableBody>
</Table>
{total > pagination.size && (
<div className="flex justify-between items-center mt-4">
<span className="text-sm text-muted-foreground">
<div className="mt-4 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{t("total", "Total")}: {total}
</span>
<div className="flex gap-2">
<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}
onClick={() =>
setPagination((p) => ({ ...p, page: p.page - 1 }))
}
type="button"
>
{t("previous", "Previous")}
</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}
onClick={() =>
setPagination((p) => ({ ...p, page: p.page + 1 }))
}
type="button"
>
{t("next", "Next")}
</button>

View File

@ -12,6 +12,8 @@ import TosForm from "./basic-settings/tos-form";
import LogCleanupForm from "./log-cleanup/log-cleanup-form";
import InviteForm from "./user-security/invite-form";
import RegisterForm from "./user-security/register-form";
import SignatureForm from "./user-security/signature-form";
import SubscribeModeForm from "./user-security/subscribe-mode-form";
import VerifyCodeForm from "./user-security/verify-code-form";
import VerifyForm from "./user-security/verify-form";
@ -35,6 +37,8 @@ export default function System() {
{ component: InviteForm },
{ component: VerifyForm },
{ component: VerifyCodeForm },
{ component: SignatureForm },
{ component: SubscribeModeForm },
],
},
{

View File

@ -36,6 +36,7 @@ const inviteSchema = z.object({
forced_invite: z.boolean().optional(),
referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(),
gift_days: z.number().optional(),
});
type InviteFormData = z.infer<typeof inviteSchema>;
@ -60,6 +61,7 @@ export default function InviteConfig() {
forced_invite: false,
referral_percentage: 0,
only_first_purchase: false,
gift_days: 0,
},
});
@ -185,6 +187,38 @@ export default function InviteConfig() {
)}
/>
<FormField
control={form.control}
name="gift_days"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("invite.giftDays", "Invite Gift Days")}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueBlur={(value) => field.onChange(Number(value))}
placeholder={t(
"invite.giftDaysPlaceholder",
"Enter days"
)}
suffix={t("invite.giftDaysSuffix", "day(s)")}
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"invite.giftDaysDescription",
"When referral percentage is 0, both the inviter and invitee receive extra subscription days after a purchase"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="only_first_purchase"

View File

@ -0,0 +1,165 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Icon } from "@workspace/ui/composed/icon";
import {
getSignatureConfig,
updateSignatureConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const signatureSchema = z.object({
enable_signature: z.boolean().optional(),
});
type SignatureFormData = z.infer<typeof signatureSchema>;
export default function SignatureForm() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getSignatureConfig"],
queryFn: async () => {
const { data } = await getSignatureConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SignatureFormData>({
resolver: zodResolver(signatureSchema),
defaultValues: {
enable_signature: false,
},
});
useEffect(() => {
if (data) {
form.reset({ enable_signature: data.enable_signature });
}
}, [data, form]);
async function onSubmit(values: SignatureFormData) {
setLoading(true);
try {
await updateSignatureConfig({
enable_signature: values.enable_signature ?? false,
});
toast.success(t("signature.saveSuccess", "Save Successful"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("signature.saveFailed", "Save Failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:shield-lock-outline"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("signature.title", "Request Signature")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"signature.description",
"Enable or disable request signature verification for public APIs"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("signature.title", "Request Signature")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="signature-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enable_signature"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("signature.enable", "Enable Signature Verification")}
</FormLabel>
<FormControl>
<Switch
checked={field.value ?? false}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"signature.enableDescription",
"When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="px-6">
<Button
onClick={() => setOpen(false)}
type="button"
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="signature-form" type="submit">
{loading
? t("common.saving", "Saving...")
: t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,176 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
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 { Icon } from "@workspace/ui/composed/icon";
import {
getSubscribeConfig,
updateSubscribeConfig,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const subscribeModeSchema = z.object({
single_model: z.boolean().optional(),
});
type SubscribeModeFormData = z.infer<typeof subscribeModeSchema>;
export default function SubscribeModeForm() {
const { t } = useTranslation("system");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getSubscribeConfig"],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
enabled: open,
});
const form = useForm<SubscribeModeFormData>({
resolver: zodResolver(subscribeModeSchema),
defaultValues: {
single_model: false,
},
});
useEffect(() => {
if (data) {
form.reset({ single_model: data.single_model });
}
}, [data, form]);
async function onSubmit(values: SubscribeModeFormData) {
if (!data) return;
setLoading(true);
try {
await updateSubscribeConfig({
...data,
single_model: values.single_model ?? false,
});
toast.success(
t("subscribeMode.saveSuccess", "Subscription mode updated successfully")
);
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("subscribeMode.saveFailed", "Failed to update settings"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:toggle-switch" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("subscribeMode.title", "Subscription Mode")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"subscribeMode.description",
"Configure single or multiple subscription purchase behavior"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>
{t("subscribeMode.title", "Subscription Mode")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="subscribe-mode-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="single_model"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"subscribeMode.singleSubscriptionMode",
"Single Subscription Mode"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value ?? false}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"subscribeMode.singleSubscriptionModeDescription",
"After enabling, users can only purchase/renew one subscription in the same account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="px-6">
<Button
onClick={() => setOpen(false)}
type="button"
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button
disabled={loading || !data}
form="subscribe-mode-form"
type="submit"
>
{loading
? t("common.saving", "Saving...")
: t("common.save", "Save Settings")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -168,8 +168,31 @@ export default function Page() {
...pagination,
...filters,
});
const list = (data.data?.list || []) as API.Ticket[];
// Client-side ordering to improve triage efficiency:
// - Put "Pending Follow-up" (status=1) before "Pending Reply" (status=2)
// - Within each group, sort by updated_at desc
const statusPriority = (status: number) => {
if (status === 1) return 0;
if (status === 2) return 1;
return 2;
};
const toTime = (value: any) => {
const t = new Date(value).getTime();
return Number.isFinite(t) ? t : 0;
};
list.sort((a, b) => {
const pa = statusPriority(a.status);
const pb = statusPriority(b.status);
if (pa !== pb) return pa - pb;
return toTime(b.updated_at) - toTime(a.updated_at);
});
return {
list: data.data?.list || [],
list,
total: data.data?.total || 0,
};
}}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,78 @@
type TranslateFn = (...args: any[]) => any;
function normalizeEnumValue(value?: string) {
return (value || "").trim().toLowerCase();
}
export function isFamilyStatusActive(status?: string) {
return normalizeEnumValue(status) === "active";
}
export function isFamilyRoleOwner(role?: string) {
return normalizeEnumValue(role) === "owner";
}
export function isFamilyMemberStatusActive(status?: string) {
return normalizeEnumValue(status) === "active";
}
export function getFamilyStatusLabel(t: TranslateFn, status?: string) {
const normalized = normalizeEnumValue(status);
if (!normalized) return "--";
switch (normalized) {
case "active":
return t("statusActive", "Active");
case "disabled":
return t("familyDisabled", "Disabled");
default:
return status || "--";
}
}
export function getFamilyRoleLabel(t: TranslateFn, role?: string) {
const normalized = normalizeEnumValue(role);
if (!normalized) return "--";
switch (normalized) {
case "owner":
return t("owner", "Owner");
case "member":
return t("familyMember", "Member");
default:
return role || "--";
}
}
export function getFamilyMemberStatusLabel(t: TranslateFn, status?: string) {
const normalized = normalizeEnumValue(status);
if (!normalized) return "--";
switch (normalized) {
case "active":
return t("statusActive", "Active");
case "left":
return t("familyMemberLeft", "Left");
case "removed":
return t("familyMemberRemoved", "Removed");
default:
return status || "--";
}
}
export function getFamilyJoinSourceLabel(t: TranslateFn, joinSource?: string) {
const normalized = normalizeEnumValue(joinSource);
if (!normalized) return "--";
switch (normalized) {
case "owner_init":
return t("familyJoinSourceOwnerInit", "Owner Initialization");
case "bind_email_with_verification":
return t(
"familyJoinSourceBindEmailWithVerification",
"Bind Email With Verification"
);
default:
return joinSource || "--";
}
}

View File

@ -0,0 +1,349 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import {
dissolveFamily,
getFamilyDetail,
removeFamilyMember,
updateFamilyMaxMembers,
} from "@workspace/ui/services/admin/user";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import {
getFamilyJoinSourceLabel,
getFamilyMemberStatusLabel,
getFamilyRoleLabel,
getFamilyStatusLabel,
isFamilyMemberStatusActive,
isFamilyRoleOwner,
isFamilyStatusActive,
} from "./enums";
interface FamilyDetailSheetProps {
familyId?: number;
trigger: ReactNode;
onChanged?: () => void;
}
export function FamilyDetailSheet({
familyId,
trigger,
onChanged,
}: Readonly<FamilyDetailSheetProps>) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const [maxMembersInput, setMaxMembersInput] = useState("");
const queryClient = useQueryClient();
const validFamilyId = Number(familyId || 0);
const { data, isLoading, refetch } = useQuery({
enabled: open && validFamilyId > 0,
queryKey: ["familyDetail", validFamilyId],
queryFn: async () => {
const { data } = await getFamilyDetail({ id: validFamilyId });
return data.data;
},
});
useEffect(() => {
if (!(open && data?.summary)) return;
setMaxMembersInput(String(data.summary.max_members));
}, [data?.summary, open]);
const invalidateAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["familyList"] }),
queryClient.invalidateQueries({
queryKey: ["familyDetail", validFamilyId],
}),
queryClient.invalidateQueries({ queryKey: ["getUserList"] }),
]);
onChanged?.();
};
const updateMaxMutation = useMutation({
mutationFn: async (maxMembers: number) =>
updateFamilyMaxMembers({
family_id: validFamilyId,
max_members: maxMembers,
}),
onSuccess: async () => {
toast.success(t("updateSuccess", "Updated successfully"));
await invalidateAll();
await refetch();
},
});
const removeMemberMutation = useMutation({
mutationFn: async (userId: number) =>
removeFamilyMember({ family_id: validFamilyId, user_id: userId }),
onSuccess: async () => {
toast.success(t("removeSuccess", "Removed successfully"));
await invalidateAll();
await refetch();
},
});
const dissolveMutation = useMutation({
mutationFn: async () => dissolveFamily({ family_id: validFamilyId }),
onSuccess: async () => {
toast.success(t("familyDissolved", "Family dissolved"));
await invalidateAll();
await refetch();
},
});
const canDissolve = isFamilyStatusActive(data?.summary.status);
const summaryItems = useMemo(() => {
if (!data?.summary) return [];
return [
{ label: t("familyId", "Family ID"), value: data.summary.family_id },
{ label: t("owner", "Owner"), value: data.summary.owner_identifier },
{ label: t("userId", "User ID"), value: data.summary.owner_user_id },
{
label: t("familyStatus", "Family Status"),
value: getFamilyStatusLabel(t, data.summary.status),
},
{
label: t("memberCount", "Member Count"),
value: `${data.summary.active_member_count}/${data.summary.max_members}`,
},
{
label: t("createdAt", "Created At"),
value: formatDate(data.summary.created_at),
},
{
label: t("updatedAt", "Updated At"),
value: formatDate(data.summary.updated_at),
},
];
}, [data?.summary, t]);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="w-[920px] max-w-full md:max-w-6xl" side="right">
<SheetHeader>
<SheetTitle>
{t("familyDetail", "Family Detail")}
{validFamilyId ? ` · ID: ${validFamilyId}` : ""}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-120px)] pr-2">
{isLoading ? (
<div className="p-4">{t("loading", "Loading...")}</div>
) : data ? (
<div className="space-y-4 p-2">
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familySummary", "Family Summary")}
</h3>
<div className="grid grid-cols-2 gap-3">
{summaryItems.map((item) => (
<div
className="flex items-center justify-between rounded bg-muted/40 px-3 py-2"
key={item.label}
>
<span className="text-muted-foreground text-xs">
{item.label}
</span>
<span className="font-medium text-sm">{item.value}</span>
</div>
))}
</div>
</div>
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familyActions", "Family Actions")}
</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="w-40">
<EnhancedInput
onValueChange={(value) => {
setMaxMembersInput(value);
}}
type="number"
value={maxMembersInput}
/>
</div>
<Button
disabled={updateMaxMutation.isPending}
onClick={() => {
const maxMembers = Number(maxMembersInput);
if (
!maxMembers ||
Number.isNaN(maxMembers) ||
maxMembers <= 0
) {
toast.error(
t("familyInvalidMaxMembers", "Invalid max members")
);
return;
}
if (
data.summary.active_member_count &&
maxMembers < data.summary.active_member_count
) {
toast.error(
t(
"familyMaxMembersTooSmall",
"Max members cannot be lower than active member count"
)
);
return;
}
updateMaxMutation.mutate(maxMembers);
}}
variant="secondary"
>
{t("familyUpdateMaxMembers", "Update Max Members")}
</Button>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"familyDissolveDescription",
"This will dissolve the family and remove all active members."
)}
onConfirm={async () => {
await dissolveMutation.mutateAsync();
}}
title={t(
"familyConfirmDissolve",
"Confirm Dissolve Family"
)}
trigger={
<Button
disabled={!canDissolve || dissolveMutation.isPending}
variant="destructive"
>
{t("familyDissolve", "Dissolve Family")}
</Button>
}
/>
</div>
</div>
<div className="rounded-md border p-4">
<h3 className="mb-3 font-medium text-sm">
{t("familyMembers", "Family Members")}
</h3>
<div className="space-y-2">
{data.members?.length ? (
data.members.map((member) => {
const canRemove =
isFamilyStatusActive(data.summary.status) &&
!isFamilyRoleOwner(member.role_name) &&
isFamilyMemberStatusActive(member.status_name);
return (
<div
className="flex items-center justify-between rounded border px-3 py-2"
key={`${member.user_id}-${member.joined_at}`}
>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<Badge variant="outline">
ID: {member.user_id}
</Badge>
{member.device_no ? (
<Badge variant="outline">
<span className="font-mono">
{member.device_no}
</span>
</Badge>
) : null}
<span>{member.identifier}</span>
<Badge>
{getFamilyRoleLabel(t, member.role_name)}
</Badge>
<Badge
variant={
isFamilyMemberStatusActive(member.status_name)
? "default"
: "destructive"
}
>
{getFamilyMemberStatusLabel(
t,
member.status_name
)}
</Badge>
</div>
<div className="text-muted-foreground">
{t("familyJoinSource", "Join Source")}:{" "}
{getFamilyJoinSourceLabel(t, member.join_source)}{" "}
· {t("familyJoinedAt", "Joined At")}:{" "}
{member.joined_at
? formatDate(member.joined_at)
: "--"}{" "}
· {t("familyLeftAt", "Left At")}:{" "}
{member.left_at
? formatDate(member.left_at)
: "--"}
</div>
</div>
{canRemove ? (
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"familyRemoveMemberDescription",
"This will remove the member from the active family."
)}
onConfirm={async () => {
await removeMemberMutation.mutateAsync(
member.user_id
);
}}
title={t(
"familyConfirmRemoveMember",
"Confirm Remove Member"
)}
trigger={
<Button
disabled={removeMemberMutation.isPending}
size="sm"
variant="destructive"
>
{t("remove", "Remove")}
</Button>
}
/>
) : null}
</div>
);
})
) : (
<div className="text-muted-foreground text-sm">
{t("familyNoMembers", "No members")}
</div>
)}
</div>
</div>
</div>
) : (
<div className="p-4 text-muted-foreground">
{t("familyNoData", "No family data")}
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,138 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { getFamilyList } from "@workspace/ui/services/admin/user";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
import { getFamilyStatusLabel, isFamilyStatusActive } from "./enums";
import { FamilyDetailSheet } from "./family-detail-sheet";
interface FamilyManagementProps {
initialFamilyId?: number;
initialUserId?: number;
onChanged?: () => void;
}
export default function FamilyManagement({
initialFamilyId,
initialUserId,
onChanged,
}: Readonly<FamilyManagementProps>) {
const { t } = useTranslation("user");
const ref = useRef<ProTableActions>(null);
const initialFilters = {
family_id: initialFamilyId || undefined,
user_id: initialUserId || undefined,
};
return (
<ProTable<API.FamilySummary, API.GetFamilyListParams>
action={ref}
actions={{
render: (row) => [
<FamilyDetailSheet
familyId={row.family_id}
key={`detail-${row.family_id}`}
onChanged={() => {
ref.current?.refresh();
onChanged?.();
}}
trigger={<Button>{t("familyDetail", "Family Detail")}</Button>}
/>,
],
}}
columns={[
{
accessorKey: "family_id",
header: t("familyId", "Family ID"),
},
{
accessorKey: "owner_identifier",
header: t("owner", "Owner"),
cell: ({ row }) =>
`${row.original.owner_identifier} (ID: ${row.original.owner_user_id})`,
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const status = row.getValue("status") as string;
return isFamilyStatusActive(status) ? (
<Badge>{t("statusActive", "Active")}</Badge>
) : (
<Badge variant="secondary">
{getFamilyStatusLabel(t, status)}
</Badge>
);
},
},
{
accessorKey: "active_member_count",
header: t("memberCount", "Member Count"),
cell: ({ row }) =>
`${row.original.active_member_count}/${row.original.max_members}`,
},
{
accessorKey: "max_members",
header: t("familyMaxMembers", "Max Members"),
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
header={{
title: t("familyManagement", "Family Group Management"),
}}
initialFilters={initialFilters}
key={String(initialFamilyId || initialUserId || "all")}
params={[
{
key: "keyword",
placeholder: t("search", "Search"),
},
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: getFamilyStatusLabel(t, "active"), value: "active" },
{ label: getFamilyStatusLabel(t, "disabled"), value: "disabled" },
],
},
{
key: "owner_user_id",
placeholder: t("familyOwnerUserId", "Owner User ID"),
},
{
key: "family_id",
placeholder: t("familyId", "Family ID"),
},
{
key: "user_id",
placeholder: t("userId", "User ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await getFamilyList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}

View File

@ -8,7 +8,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Input } from "@workspace/ui/components/input";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
@ -23,6 +31,7 @@ import {
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
@ -35,14 +44,16 @@ import {
getUserList,
updateUserBasicInfo,
} from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import FamilyManagement from "./family";
import { UserDetail } from "./user-detail";
import UserForm from "./user-form";
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
@ -56,13 +67,15 @@ export default function User() {
const { subscribes } = useSubscribe();
const initialFilters = {
search: sp.search || undefined,
user_id: sp.user_id || undefined,
subscribe_id: sp.subscribe_id || undefined,
user_subscribe_id: sp.user_subscribe_id || undefined,
short_code: sp.short_code || undefined,
};
const searchRef = useRef({
type: sp.user_id ? "user_id" : "email",
value: sp.search || sp.user_id || "",
});
const handleSearch = useCallback((type: string, value: string) => {
searchRef.current = { type, value };
ref.current?.refresh();
}, []);
return (
<ProTable<API.User, API.GetUserListParams>
@ -75,6 +88,11 @@ export default function User() {
userId={row.id}
/>,
<SubscriptionSheet key="subscription" userId={row.id} />,
<DeviceGroupSheet
key="device-group"
onChanged={() => ref.current?.refresh()}
userId={row.id}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
@ -98,6 +116,7 @@ export default function User() {
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<InviteStatsMenuItem userId={row.id} />
<DropdownMenuItem asChild>
<Link
search={{ user_id: String(row.id) }}
@ -194,6 +213,10 @@ export default function User() {
header: t("userName", "Username"),
cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
const identifier = method?.auth_identifier || "";
const isDevice = method?.auth_type === "device";
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
const display = isDevice ? deviceNo || identifier : identifier;
return (
<div>
<Badge
@ -202,7 +225,7 @@ export default function User() {
>
{method?.auth_type}
</Badge>
{method?.auth_identifier}
<span title={isDevice ? display : undefined}>{display}</span>
</div>
);
},
@ -245,7 +268,14 @@ export default function User() {
},
]}
header={{
title: t("userList", "User List"),
title: (
<UserSearchBar
initialType={searchRef.current.type}
initialValue={searchRef.current.value}
onSearch={handleSearch}
subscribes={subscribes}
/>
),
toolbar: (
<UserForm<API.CreateUserRequest>
key="create"
@ -270,39 +300,19 @@ export default function User() {
/>
),
}}
initialFilters={initialFilters}
key={initialFilters.user_id}
params={[
{
key: "subscribe_id",
placeholder: t("subscription", "Subscription"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id!),
})),
},
{
key: "search",
placeholder: "Search",
},
{
key: "user_id",
placeholder: t("userId", "User ID"),
},
{
key: "user_subscribe_id",
placeholder: t("subscriptionId", "Subscription ID"),
},
{
key: "short_code",
placeholder: t("shortCode", "Short Code"),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserList({
...pagination,
...filter,
});
request={async (pagination) => {
const { type, value } = searchRef.current;
const params: Record<string, unknown> = { ...pagination };
if (value) {
if (type === "user_id") {
params.user_id = value;
} else if (type === "subscribe_id") {
params.subscribe_id = value;
} else {
params.search = value;
}
}
const { data } = await getUserList(params as API.GetUserListParams);
return {
list: data.data?.list || [],
total: data.data?.total || 0,
@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) {
</Sheet>
);
}
function InviteStatsMenuItem({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setOpen(true);
}}
>
{t("inviteStats", "Invite Statistics")}
</DropdownMenuItem>
<UserInviteStatsSheet
onOpenChange={setOpen}
open={open}
userId={userId}
/>
</>
);
}
function DeviceGroupSheet({
userId,
onChanged,
}: {
userId: number;
onChanged?: () => void;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button variant="outline">{t("deviceGroup", "Device Group")}</Button>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
<SheetHeader>
<SheetTitle>
{t("deviceGroup", "Device Group")} · ID: {userId}
</SheetTitle>
</SheetHeader>
<div className="mt-2 px-4">
<FamilyManagement initialUserId={userId} onChanged={onChanged} />
</div>
</SheetContent>
</Sheet>
);
}
function UserSearchBar({
initialType,
initialValue,
onSearch,
subscribes,
}: {
initialType: string;
initialValue: string;
onSearch: (type: string, value: string) => void;
subscribes?: API.SubscribeItem[];
}) {
const { t } = useTranslation("user");
const [searchType, setSearchType] = useState(initialType);
const [searchValue, setSearchValue] = useState(initialValue);
return (
<div className="flex items-center gap-2">
<Select
onValueChange={(v) => {
setSearchType(v);
setSearchValue("");
}}
value={searchType}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">{t("email", "Email")}</SelectItem>
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
<SelectItem value="subscribe_id">
{t("subscription", "Subscription")}
</SelectItem>
</SelectContent>
</Select>
{searchType === "subscribe_id" ? (
<Combobox
className="w-48"
onChange={(value) => {
setSearchValue(value);
onSearch("subscribe_id", value);
}}
options={subscribes?.map((item) => ({
label: item.name!,
value: String(item.id!),
}))}
placeholder={t("subscription", "Subscription")}
value={searchValue}
/>
) : (
<>
<Input
className="w-48"
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && onSearch(searchType, searchValue)
}
placeholder={t("searchInputPlaceholder", "Enter search term")}
value={searchValue}
/>
<Button
onClick={() => onSearch(searchType, searchValue)}
variant="default"
>
{t("search", "Search")}
</Button>
{searchValue && (
<Button
onClick={() => {
setSearchValue("");
onSearch(searchType, "");
}}
variant="outline"
>
{t("resetSearch", "Reset")}
</Button>
)}
</>
)}
</div>
);
}

View File

@ -12,6 +12,7 @@ import {
getUserDetail,
getUserSubscribeById,
} from "@workspace/ui/services/admin/user";
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
@ -163,9 +164,14 @@ export function UserDetail({ id }: { id: number }) {
if (!id) return "--";
const identifier =
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
data?.auth_methods[0]?.auth_identifier;
const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email");
const firstMethod = data?.auth_methods[0];
const rawIdentifier =
emailMethod?.auth_identifier || firstMethod?.auth_identifier || "";
const isDevice = !emailMethod && firstMethod?.auth_type === "device";
const identifier = isDevice
? shortenDeviceIdentifier(rawIdentifier)
: rawIdentifier;
return (
<HoverCard>

View File

@ -0,0 +1,228 @@
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Badge } from "@workspace/ui/components/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@workspace/ui/components/sheet";
import { Skeleton } from "@workspace/ui/components/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import {
getAdminUserInviteList,
getAdminUserInviteStats,
} from "@workspace/ui/services/admin/user";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { formatDate } from "@/utils/common";
interface UserInviteStatsSheetProps {
userId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UserInviteStatsSheet({
userId,
open,
onOpenChange,
}: UserInviteStatsSheetProps) {
const { t } = useTranslation("user");
const [page, setPage] = useState(1);
const pageSize = 200;
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["adminUserInviteStats", userId],
queryFn: async () => {
const { data } = await getAdminUserInviteStats({ user_id: userId });
return data.data;
},
enabled: open && !!userId,
});
const { data: listResult, isLoading: listLoading } = useQuery({
queryKey: ["adminUserInviteList", userId, page],
queryFn: async () => {
const { data } = await getAdminUserInviteList({
user_id: userId,
page,
size: pageSize,
});
return data.data;
},
enabled: open && !!userId,
});
const inviteList = listResult?.list ?? [];
const total = listResult?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent className="w-[560px] overflow-y-auto sm:max-w-[560px]">
<SheetHeader>
<SheetTitle>{t("inviteStats", "Invite Statistics")}</SheetTitle>
</SheetHeader>
{/* 概览卡片 */}
<div className="mt-4 grid grid-cols-2 gap-3">
<StatCard
label={t("inviteCount", "Invited Users")}
loading={statsLoading}
>
<span className="font-semibold text-2xl">
{stats?.invite_count ?? 0}
</span>
</StatCard>
<StatCard
label={t("totalCommission", "Total Commission")}
loading={statsLoading}
>
<Display type="currency" value={stats?.total_commission} />
</StatCard>
<StatCard
label={t("currentCommission", "Current Commission")}
loading={statsLoading}
>
<Display type="currency" value={stats?.current_commission} />
</StatCard>
<StatCard
label={t("referralPercentage", "Referral %")}
loading={statsLoading}
>
<span className="font-semibold text-2xl">
{stats?.referral_percentage
? `${stats.referral_percentage}%`
: t("globalDefault", "Global Default")}
</span>
{stats?.only_first_purchase && (
<span className="ml-1 text-muted-foreground text-xs">
({t("firstPurchaseOnly", "First purchase only")})
</span>
)}
</StatCard>
</div>
{/* 邀请用户列表 */}
<div className="mt-6">
<h3 className="mb-3 font-medium text-sm">
{t("invitedUsers", "Invited Users")} ({total})
</h3>
{listLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton className="h-10 w-full" key={i} />
))}
</div>
) : inviteList.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm">
{t("noInvitedUsers", "No invited users yet")}
</p>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("user", "User")}</TableHead>
<TableHead>{t("status", "Status")}</TableHead>
<TableHead>{t("registeredAt", "Registered At")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{inviteList.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-xs">
{user.identifier?.charAt(0)?.toUpperCase() ?? "U"}
</AvatarFallback>
</Avatar>
<span className="max-w-[160px] truncate text-sm">
{user.identifier || `#${user.id}`}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={user.enable ? "default" : "secondary"}>
{user.enable
? t("enabled", "Enabled")
: t("disabled", "Disabled")}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(user.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
type="button"
>
{t("prev", "Prev")}
</button>
<span className="px-3 py-1 text-sm">
{page} / {totalPages}
</span>
<button
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
type="button"
>
{t("next", "Next")}
</button>
</div>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
function StatCard({
label,
loading,
children,
}: {
label: string;
loading: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1 rounded-lg border p-4">
<p className="text-muted-foreground text-xs">{label}</p>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<div className="flex items-baseline gap-1 font-semibold text-2xl">
{children}
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription } from "@workspace/ui/components/alert";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
@ -15,12 +16,14 @@ import {
import {
createUserSubscribe,
deleteUserSubscribe,
getFamilyList,
getUserSubscribe,
resetUserSubscribeToken,
toggleUserSubscribeStatus,
updateUserSubscribe,
} from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react";
import { Info } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
@ -29,196 +32,420 @@ import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail";
import { SubscriptionForm } from "./subscription-form";
interface SharedInfo {
ownerUserId: number;
familyId: number;
}
export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
const [sharedInfo, setSharedInfo] = useState<SharedInfo | null>(null);
const request = useCallback(
async (pagination: { page: number; size: number }) => {
// 1. Fetch user's own subscriptions
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
const list = data.data?.list || [];
const total = data.data?.total || 0;
// 2. If user has own subscriptions, show them directly
if (list.length > 0) {
setSharedInfo(null);
return { list, total };
}
// 3. Check if user belongs to a device group
try {
const { data: familyData } = await getFamilyList({
user_id: userId,
page: 1,
size: 1,
});
const familyList = familyData.data?.list || [];
const family = familyList.find(
(f) => f.owner_user_id !== userId && f.status === "active"
);
if (family) {
// 4. Fetch owner's subscriptions
const { data: ownerData } = await getUserSubscribe({
user_id: family.owner_user_id,
...pagination,
});
const ownerList = ownerData.data?.list || [];
const ownerTotal = ownerData.data?.total || 0;
if (ownerList.length > 0) {
setSharedInfo({
ownerUserId: family.owner_user_id,
familyId: family.family_id,
});
return { list: ownerList, total: ownerTotal };
}
}
} catch {
// Silently fall through to show empty list
}
setSharedInfo(null);
return { list: [], total: 0 };
},
[userId]
);
const isSharedView = !!sharedInfo;
return (
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
actions={{
render: (row) => [
<SubscriptionForm
initialData={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")}
/>,
<RowMoreActions
key="more"
refresh={() => ref.current?.refresh()}
row={row}
token={row.token}
userId={userId}
/>,
],
}}
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "name",
header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => row.original.subscribe.name,
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const status = row.getValue("status") as number;
const expireTime = row.original.expire_time;
<div className="space-y-3">
{isSharedView && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>
{t("sharedSubscriptionInfo", {
defaultValue:
"This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
ownerId: sharedInfo.ownerUserId,
})}
</span>
<span className="flex gap-2">
<Button asChild size="sm" variant="outline">
<Link
search={{ user_id: String(sharedInfo.ownerUserId) }}
to="/dashboard/family"
>
{t("viewDeviceGroup", "View Device Group")}
</Link>
</Button>
<Button asChild size="sm" variant="outline">
<Link
search={{ user_id: String(sharedInfo.ownerUserId) }}
to="/dashboard/user"
>
{t("viewOwner", "View Owner")}
</Link>
</Button>
</span>
</AlertDescription>
</Alert>
)}
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
actions={{
render: (row) =>
isSharedView
? [
<Badge key="shared" variant="secondary">
{t("sharedSubscription", "Shared")}
</Badge>,
<RowReadOnlyActions
key="more"
refresh={() => ref.current?.refresh()}
row={row}
token={row.token}
userId={sharedInfo!.ownerUserId}
/>,
]
: [
<SubscriptionForm
initialData={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("editSubscription", "Edit Subscription")}
trigger={t("edit", "Edit")}
/>,
<RowMoreActions
key="more"
refresh={() => ref.current?.refresh()}
row={row}
token={row.token}
userId={userId}
/>,
],
}}
columns={[
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "name",
header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => (
<span className="flex items-center gap-2">
{row.original.subscribe.name}
{isSharedView && (
<Badge variant="secondary">
{t("sharedSubscription", "Shared")}
</Badge>
)}
</span>
),
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const status = row.getValue("status") as number;
const expireTime = row.original.expire_time;
// 如果过期时间为0说明是永久订阅应该显示为激活状态
const displayStatus = status === 3 && expireTime === 0 ? 1 : status;
// 如果过期时间为0说明是永久订阅应该显示为激活状态
const displayStatus =
status === 3 && expireTime === 0 ? 1 : status;
const statusMap: Record<
number,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
0: { label: t("statusPending", "Pending"), variant: "outline" },
1: { label: t("statusActive", "Active"), variant: "default" },
2: {
label: t("statusFinished", "Finished"),
variant: "secondary",
},
3: {
label: t("statusExpired", "Expired"),
variant: "destructive",
},
4: {
label: t("statusDeducted", "Deducted"),
variant: "secondary",
},
5: {
label: t("statusStopped", "Stopped"),
variant: "destructive",
},
};
const statusInfo = statusMap[displayStatus] || {
label: "Unknown",
variant: "outline",
};
return (
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
);
const statusMap: Record<
number,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
0: {
label: t("statusPending", "Pending"),
variant: "outline",
},
1: { label: t("statusActive", "Active"), variant: "default" },
2: {
label: t("statusFinished", "Finished"),
variant: "secondary",
},
3: {
label: t("statusExpired", "Expired"),
variant: "destructive",
},
4: {
label: t("statusDeducted", "Deducted"),
variant: "secondary",
},
5: {
label: t("statusStopped", "Stopped"),
variant: "destructive",
},
};
const statusInfo = statusMap[displayStatus] || {
label: "Unknown",
variant: "outline",
};
return (
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
);
},
},
},
{
accessorKey: "upload",
header: t("upload", "Upload"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("upload")} />
),
},
{
accessorKey: "download",
header: t("download", "Download"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("download")} />
),
},
{
accessorKey: "traffic",
header: t("totalTraffic", "Total Traffic"),
cell: ({ row }) => (
<Display type="traffic" unlimited value={row.getValue("traffic")} />
),
},
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
cell: ({ row }) => {
const speed = row.original?.subscribe?.speed_limit;
return <Display type="trafficSpeed" value={speed} />;
{
accessorKey: "upload",
header: t("upload", "Upload"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("upload")} />
),
},
},
{
accessorKey: "device_limit",
header: t("deviceLimit", "Device Limit"),
cell: ({ row }) => {
const limit = row.original?.subscribe?.device_limit;
return <Display type="number" unlimited value={limit} />;
{
accessorKey: "download",
header: t("download", "Download"),
cell: ({ row }) => (
<Display type="traffic" value={row.getValue("download")} />
),
},
},
{
accessorKey: "reset_time",
header: t("resetTime", "Reset Time"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={row.getValue("reset_time")}
{
accessorKey: "traffic",
header: t("totalTraffic", "Total Traffic"),
cell: ({ row }) => (
<Display
type="traffic"
unlimited
value={row.getValue("traffic")}
/>
),
},
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
cell: ({ row }) => {
const speed = row.original?.subscribe?.speed_limit;
return <Display type="trafficSpeed" value={speed} />;
},
},
{
accessorKey: "device_limit",
header: t("deviceLimit", "Device Limit"),
cell: ({ row }) => {
const limit = row.original?.subscribe?.device_limit;
return <Display type="number" unlimited value={limit} />;
},
},
{
accessorKey: "reset_time",
header: t("resetTime", "Reset Time"),
cell: ({ row }) => (
<Display
type="number"
unlimited
value={row.getValue("reset_time")}
/>
),
},
{
accessorKey: "expire_time",
header: t("expireTime", "Expire Time"),
cell: ({ row }) => {
const expireTime = row.getValue("expire_time") as number;
return expireTime && expireTime !== 0
? formatDate(expireTime)
: t("permanent", "Permanent");
},
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
]}
header={{
title: isSharedView
? t("sharedSubscriptionList", "Shared Subscription List")
: t("subscriptionList", "Subscription List"),
toolbar: isSharedView ? undefined : (
<SubscriptionForm
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...values,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
title={t("createSubscription", "Create Subscription")}
trigger={t("add", "Add")}
/>
),
},
{
accessorKey: "expire_time",
header: t("expireTime", "Expire Time"),
cell: ({ row }) => {
const expireTime = row.getValue("expire_time") as number;
return expireTime && expireTime !== 0
? formatDate(expireTime)
: t("permanent", "Permanent");
},
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => formatDate(row.getValue("created_at")),
},
]}
header={{
title: t("subscriptionList", "Subscription List"),
toolbar: (
<SubscriptionForm
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...values,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
}}
request={request}
/>
</div>
);
}
function RowReadOnlyActions({
userId,
row,
token,
refresh,
}: {
userId: number;
row: API.UserSubscribe;
token: string;
refresh?: () => void;
}) {
const { t } = useTranslation("user");
const triggerRef = useRef<HTMLButtonElement>(null);
const deleteRef = useRef<HTMLButtonElement>(null);
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
return (
<div className="inline-flex">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async (e) => {
e.preventDefault();
await navigator.clipboard.writeText(
getUserSubscribeUrls(row.short, token)[0] || ""
);
toast.success(t("copySuccess", "Copied successfully"));
}}
title={t("createSubscription", "Create Subscription")}
trigger={t("add", "Add")}
/>
),
}}
request={async (pagination) => {
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
>
{t("copySubscription", "Copy Subscription")}
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe"
>
{t("subscriptionLogs", "Subscription Logs")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, user_subscribe_id: row.id }}
to="/dashboard/log/subscribe-traffic"
>
{t("trafficStats", "Traffic Stats")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
search={{ user_id: userId, subscribe_id: row.id }}
to="/dashboard/log/traffic-details"
>
{t("trafficDetails", "Traffic Details")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
triggerRef.current?.click();
}}
>
{t("onlineDevices", "Online Devices")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onSelect={(e) => {
e.preventDefault();
deleteRef.current?.click();
}}
>
{t("delete", "Delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteSubscriptionDescription",
"This action cannot be undone."
)}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
refresh?.();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={<Button className="hidden" ref={deleteRef} />}
/>
<SubscriptionDetail
subscriptionId={row.id}
trigger={<Button className="hidden" ref={triggerRef} />}
userId={userId}
/>
</div>
);
}

View File

@ -14,6 +14,7 @@ import {
getUserSubscribeDevices,
kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user";
import { deviceIdToHash } from "@workspace/ui/utils/device";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@ -86,7 +87,18 @@ export function SubscriptionDetail({
),
},
{ accessorKey: "id", header: "ID" },
{ accessorKey: "identifier", header: "IMEI" },
{
accessorKey: "identifier",
header: t("deviceNo", "Device No."),
cell: ({ row }) => {
const id = row.original.id;
return (
<span className="font-mono" title={row.original.identifier}>
{deviceIdToHash(id)}
</span>
);
},
},
{
accessorKey: "user_agent",
header: t("userAgent", "User Agent"),

View File

@ -24,8 +24,11 @@ export function differenceInDays(date1: Date, date2: Date): number {
export function formatDate(date?: Date | number, showTime = true) {
if (!date) return;
// Backend returns Unix timestamps in seconds; convert to milliseconds for JS Date
const dateValue =
typeof date === "number" && date < 1e12 ? date * 1000 : date;
const timeZone = localStorage.getItem("timezone") || "UTC";
return intlFormat(date, {
return intlFormat(dateValue, {
year: "numeric",
month: "numeric",
day: "numeric",

View File

@ -6,6 +6,7 @@
"copySuccess": "Copy Success",
"deducted": "Deducted",
"download": "Download",
"expireAt": "Expires At",
"expirationDays": "Expiration Days",
"expired": "Expired",
"finished": "Finished",

View File

@ -6,6 +6,7 @@
"copySuccess": "复制成功",
"deducted": "已扣除",
"download": "下载",
"expireAt": "到期时间",
"expirationDays": "过期天数",
"expired": "已过期",
"finished": "已完成",

View File

@ -15,6 +15,7 @@ import {
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Icon } from "@workspace/ui/composed/icon";
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
import { useTranslation } from "react-i18next";
import { useNavs } from "@/layout/navs";
import { useGlobalStore } from "@/stores/global";
@ -32,6 +33,13 @@ export function UserNav() {
};
if (user) {
const method = user?.auth_methods?.[0];
const isDevice = method?.auth_type === "device";
const rawIdentifier = method?.auth_identifier ?? "";
const displayName = isDevice
? shortenDeviceIdentifier(rawIdentifier)
: rawIdentifier.split("@")[0] || "";
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@ -40,16 +48,14 @@ export function UserNav() {
<AvatarImage
alt={user?.avatar ?? ""}
className="object-cover"
src={user?.auth_methods?.[0]?.auth_identifier ?? ""}
src={isDevice ? "" : rawIdentifier}
/>
<AvatarFallback className="bg-linear-to-br from-primary/90 to-primary font-medium text-background">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
{displayName.toUpperCase().charAt(0)}
</AvatarFallback>
</Avatar>
<span className="max-w-10 truncate text-sm sm:max-w-[100px]">
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
{displayName}
</span>
<Icon
className="size-4 text-muted-foreground"

View File

@ -115,7 +115,7 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
<DialogTrigger asChild>
<Button size="sm">{t("renew", "Renew")}</Button>
</DialogTrigger>
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto md:max-w-screen-lg">
<DialogContent className="flex h-full flex-col overflow-y-auto md:h-auto md:max-w-screen-lg">
<DialogHeader>
<DialogTitle>
{t("renewSubscription", "Renew Subscription")}
@ -164,7 +164,7 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
/>
</div>
<Button
className="fixed bottom-0 left-0 w-full md:relative md:mt-6"
className="sticky bottom-0 left-0 w-full bg-background md:relative md:mt-6"
disabled={loading}
onClick={handleSubmit}
>

View File

@ -26,7 +26,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
const result = await queryAnnouncement(
{
page: 1,
size: 10,
size: 200,
pinned: type === "pinned",
popup: type === "popup",
},
@ -52,7 +52,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
<DialogHeader>
<DialogTitle>{data.title}</DialogTitle>
</DialogHeader>
<div className="prose prose-sm max-w-none dark:prose-invert">
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{data.content}</Markdown>
</div>
</DialogContent>
@ -69,7 +69,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
</h2>
<Card className="p-6">
{data.content ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{data.content}</Markdown>
</div>
) : (

View File

@ -13,12 +13,12 @@ import {
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import {
ProList,
type ProListActions,
} from "@workspace/ui/composed/pro-list/pro-list";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useRef, useState } from "react";
@ -60,13 +60,13 @@ export default function Announcement() {
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{item.title}</CardTitle>
{item.pinned && (
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary text-xs">
<Icon className="size-3" icon="uil:pin" />
{t("pinned", "Pinned")}
</span>
)}
{item.popup && (
<span className="flex items-center gap-1 rounded-full bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-500">
<span className="flex items-center gap-1 rounded-full bg-orange-500/10 px-2 py-0.5 font-medium text-orange-500 text-xs">
<Icon className="size-3" icon="uil:popcorn" />
{t("popup", "Popup")}
</span>
@ -80,7 +80,7 @@ export default function Announcement() {
</div>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none dark:prose-invert">
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{item.content}</Markdown>
</div>
</CardContent>
@ -95,9 +95,7 @@ export default function Announcement() {
</h2>
<Tabs defaultValue="all" onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all">
{t("all", "All")}
</TabsTrigger>
<TabsTrigger value="all">{t("all", "All")}</TabsTrigger>
<TabsTrigger value="pinned">
{t("pinnedOnly", "Pinned Only")}
</TabsTrigger>
@ -110,9 +108,7 @@ export default function Announcement() {
<ProList
action={normalRef}
renderItem={renderAnnouncement}
request={async (pagination) => {
return requestAnnouncements(pagination, {});
}}
request={async (pagination) => requestAnnouncements(pagination, {})}
/>
</TabsContent>
@ -120,9 +116,9 @@ export default function Announcement() {
<ProList
action={pinnedRef}
renderItem={renderAnnouncement}
request={async (pagination) => {
return requestAnnouncements(pagination, { pinned: true });
}}
request={async (pagination) =>
requestAnnouncements(pagination, { pinned: true })
}
/>
</TabsContent>
@ -130,9 +126,9 @@ export default function Announcement() {
<ProList
action={normalRef}
renderItem={renderAnnouncement}
request={async (pagination) => {
return requestAnnouncements(pagination, { popup: true });
}}
request={async (pagination) =>
requestAnnouncements(pagination, { popup: true })
}
/>
</TabsContent>
</Tabs>

View File

@ -277,7 +277,10 @@ export default function Content() {
<CardTitle className="font-medium">
{item.subscribe.name}
<p className="mt-1 text-foreground/50 text-sm">
{formatDate(item.start_time)}
{t("expireAt", "Expires At")}:{" "}
{item.expire_time
? formatDate(item.expire_time)
: t("noLimit", "No Limit")}
</p>
</CardTitle>
{item.status !== 4 && (

View File

@ -25,7 +25,9 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) {
const redeemMutation = useMutation({
mutationFn: (code: string) => redeemCode({ code }),
onSuccess: (response) => {
const message = (response.data as { message?: string })?.message || t("redeemSuccess", "兑换成功");
const message =
(response.data as { message?: string })?.message ||
t("redeemSuccess", "兑换成功");
toast.success(message);
setCode("");
onSuccess?.();
@ -64,10 +66,7 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) {
<Input
id="redemption-code"
onChange={(e) => setCode(e.target.value)}
placeholder={t(
"enterRedemptionCode",
"请输入兑换码"
)}
placeholder={t("enterRedemptionCode", "请输入兑换码")}
value={code}
/>
<Button

View File

@ -65,7 +65,11 @@ export default function Page() {
orderNo: order_no!,
returnUrl: window.location.href,
});
if (data.data?.type === "url" && data.data.checkout_url && !paymentOpened) {
if (
data.data?.type === "url" &&
data.data.checkout_url &&
!paymentOpened
) {
window.open(data.data.checkout_url, "_blank");
setPaymentOpened(true);
}

View File

@ -31,6 +31,7 @@ import {
updateBindEmail,
updateBindMobile,
} from "@workspace/ui/services/user/user";
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -287,6 +288,14 @@ export default function ThirdPartyAccounts() {
? currentValue
: method?.auth_identifier || "";
break;
case "device":
displayValue = method?.auth_identifier
? shortenDeviceIdentifier(method.auth_identifier)
: t(
`thirdParty.${account.id}.description`,
account.descriptionDefault
);
break;
default:
displayValue =
method?.auth_identifier ||

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "1.3.10",
"version": "1.3.12",
"private": true,
"homepage": "https://github.com/perfect-panel/frontend",
"bugs": {

View File

@ -30,7 +30,8 @@ export function EnhancedInput<T = string>({
...props
}: EnhancedInputProps<T>) {
const getProcessedValue = (inputValue: unknown) => {
if (inputValue === "" || inputValue === 0 || inputValue === "0") return "";
if (inputValue === "" || inputValue === undefined || inputValue === null)
return "";
const newValue = String(inputValue ?? "");
return formatInput ? formatInput(inputValue as T) : newValue;
};
@ -69,13 +70,6 @@ export function EnhancedInput<T = string>({
let inputValue = e.target.value;
if (props.type === "number") {
if (inputValue === "0") {
setValue("");
setInternalValue(0);
onValueChange?.(processValue(0));
return;
}
if (
/^-?\d*\.?\d*$/.test(inputValue) ||
inputValue === "-" ||
@ -99,7 +93,7 @@ export function EnhancedInput<T = string>({
} else {
setInternalValue(inputValue);
}
setValue(inputValue === "0" ? "" : inputValue);
setValue(inputValue);
}
} else {
setValue(inputValue);
@ -111,19 +105,15 @@ export function EnhancedInput<T = string>({
};
const handleBlur = () => {
if (props.type === "number" && value) {
if (value === "-" || value === ".") {
setValue("");
setInternalValue("");
onValueBlur?.("" as T);
return;
}
if (value === "0") {
setValue("");
onValueBlur?.(processValue(0));
return;
}
if (
props.type === "number" &&
value !== "" &&
(value === "-" || value === ".")
) {
setValue("");
setInternalValue("");
onValueBlur?.("" as T);
return;
}
const outputValue = processValue(value);

View File

@ -3,6 +3,7 @@
import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox";
import { useEffect, useState } from "react";
export interface IParams {
key: string;
@ -81,15 +82,52 @@ export function ColumnFilter<TData>({
);
}
return (
<Input
className="min-w-32"
<DebouncedInput
externalValue={filters[param.key] || ""}
key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)}
onSearch={(value) => updateFilter(param.key, value)}
placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/>
);
})}
</div>
);
}
function DebouncedInput({
externalValue,
onSearch,
placeholder,
}: {
externalValue: string;
onSearch: (value: string) => void;
placeholder: string;
}) {
const [localValue, setLocalValue] = useState(externalValue);
// Sync from external (e.g. reset)
useEffect(() => {
setLocalValue(externalValue);
}, [externalValue]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
onSearch(localValue);
}
};
return (
<Input
className="min-w-32"
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
value={localValue}
/>
);
}

View File

@ -123,7 +123,7 @@ export function ProTable<
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
pageSize: 200,
});
const loading = useRef(false);

View File

@ -35,10 +35,16 @@ export function ProTableWrapper<TData extends { id?: string | number }>({
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (onSort) {
const updatedData = await onSort(active.id, over?.id || null, data);
setData(updatedData);
}
// If the pointer is released outside of any droppable row, `over` can be null.
// In that case we should keep the current order (and avoid firing a sort API call).
if (!(onSort && over)) return;
// No-op when dropping onto itself.
if (String(active.id) === String(over.id)) return;
const updatedData = await onSort(active.id, over.id, data);
setData(updatedData);
};
if (!onSort) return children;
return (

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
// API 更新时间:
// API 唯一标识:
@ -13,6 +13,7 @@ import * as log from "./log";
import * as marketing from "./marketing";
import * as order from "./order";
import * as payment from "./payment";
import * as redemption from "./redemption";
import * as server from "./server";
import * as subscribe from "./subscribe";
import * as system from "./system";
@ -31,6 +32,7 @@ export default {
marketing,
order,
payment,
redemption,
server,
subscribe,
system,

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
@ -55,3 +55,27 @@ export async function updateOrderStatus(
}
);
}
/**
*
* POST /v1/admin/order/activate
* @param body - order_no
* @param options -
* @returns
*/
export async function activateOrder(
body: API.ActivateOrderRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/order/activate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,43 +1,14 @@
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Toggle redemption code status PUT /v1/admin/redemption/code/status */
export async function toggleRedemptionCodeStatus(
body: API.ToggleRedemptionCodeStatusRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code/status`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Update redemption code PUT /v1/admin/redemption/code */
export async function updateRedemptionCode(
body: API.UpdateRedemptionCodeRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Create redemption code POST /v1/admin/redemption/code */
/**
*
* POST /v1/admin/redemption/code
* @param body -
* @param options -
* @returns
*/
export async function createRedemptionCode(
body: API.CreateRedemptionCodeRequest,
options?: { [key: string]: any }
@ -55,7 +26,37 @@ export async function createRedemptionCode(
);
}
/** Delete redemption code DELETE /v1/admin/redemption/code */
/**
*
* PUT /v1/admin/redemption/code
* @param body -
* @param options -
* @returns
*/
export async function updateRedemptionCode(
body: API.UpdateRedemptionCodeRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/**
*
* DELETE /v1/admin/redemption/code
* @param body -
* @param options -
* @returns
*/
export async function deleteRedemptionCode(
body: API.DeleteRedemptionCodeRequest,
options?: { [key: string]: any }
@ -73,7 +74,13 @@ export async function deleteRedemptionCode(
);
}
/** Batch delete redemption code DELETE /v1/admin/redemption/code/batch */
/**
*
* DELETE /v1/admin/redemption/code/batch
* @param body - ids
* @param options -
* @returns
*/
export async function batchDeleteRedemptionCode(
body: API.BatchDeleteRedemptionCodeRequest,
options?: { [key: string]: any }
@ -91,9 +98,15 @@ export async function batchDeleteRedemptionCode(
);
}
/** Get redemption code list GET /v1/admin/redemption/code/list */
/**
*
* GET /v1/admin/redemption/code/list
* @param params -
* @param options -
* @returns
*/
export async function getRedemptionCodeList(
params: API.GetRedemptionCodeListRequest,
params: API.GetRedemptionCodeListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetRedemptionCodeListResponse }>(
@ -108,9 +121,39 @@ export async function getRedemptionCodeList(
);
}
/** Get redemption record list GET /v1/admin/redemption/record/list */
/**
* /
* PUT /v1/admin/redemption/code/status
* @param body -
* @param options -
* @returns
*/
export async function toggleRedemptionCodeStatus(
body: API.ToggleRedemptionCodeStatusRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code/status`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/**
*
* GET /v1/admin/redemption/record/list
* @param params -
* @param options -
* @returns
*/
export async function getRedemptionRecordList(
params: API.GetRedemptionRecordListRequest,
params: API.GetRedemptionRecordListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetRedemptionRecordListResponse }>(

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
@ -365,3 +365,43 @@ export async function updateVerifyConfig(
}
);
}
/**
*
* GET /v1/admin/system/signature_config
* @param options -
* @returns
*/
export async function getSignatureConfig(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.SignatureConfig }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/system/signature_config`,
{
method: "GET",
...(options || {}),
}
);
}
/**
*
* PUT /v1/admin/system/signature_config
* @param body -
* @param options -
* @returns
*/
export async function updateSignatureConfig(
body: API.SignatureConfig,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/system/signature_config`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -250,85 +250,6 @@ declare namespace API {
enable?: boolean;
};
type CreateRedemptionCodeRequest = {
total_count: number;
subscribe_plan: number;
unit_time: string;
quantity: number;
batch_count: number;
};
type UpdateRedemptionCodeRequest = {
id: number;
total_count?: number;
subscribe_plan?: number;
unit_time?: string;
quantity?: number;
status?: number;
};
type ToggleRedemptionCodeStatusRequest = {
id: number;
status: number;
};
type DeleteRedemptionCodeRequest = {
id: number;
};
type BatchDeleteRedemptionCodeRequest = {
ids: number[];
};
type GetRedemptionCodeListRequest = {
page: number;
size: number;
subscribe_plan?: number;
unit_time?: string;
code?: string;
};
type GetRedemptionCodeListResponse = {
total: number;
list: RedemptionCode[];
};
type GetRedemptionRecordListRequest = {
page: number;
size: number;
user_id?: number;
code_id?: number;
};
type GetRedemptionRecordListResponse = {
total: number;
list: RedemptionRecord[];
};
type RedemptionCode = {
id: number;
code: string;
total_count: number;
used_count: number;
subscribe_plan: number;
unit_time: string;
quantity: number;
status: number;
created_at: number;
updated_at: number;
};
type RedemptionRecord = {
id: number;
redemption_code_id: number;
user_id: number;
subscribe_id: number;
unit_time: string;
quantity: number;
redeemed_at: number;
created_at: number;
};
type CreateDocumentRequest = {
title: string;
content: string;
@ -1374,6 +1295,7 @@ declare namespace API {
forced_invite: boolean;
referral_percentage: number;
only_first_purchase: boolean;
gift_days?: number;
};
type KickOfflineRequest = {
@ -1864,7 +1786,6 @@ declare namespace API {
enable_ip_register_limit: boolean;
ip_register_limit: number;
ip_register_limit_duration: number;
device_limit: number;
};
type RegisterLog = {
@ -2659,4 +2580,186 @@ declare namespace API {
security: string;
security_config: SecurityConfig;
};
type SignatureConfig = {
enable_signature: boolean;
};
type FamilyDetail = {
summary: FamilySummary;
members: FamilyMemberItem[];
};
type FamilyMemberItem = {
user_id: number;
identifier: string;
device_no: string;
role: number;
role_name: string;
status: number;
status_name: string;
join_source: string;
joined_at: number;
left_at?: number;
};
type FamilySummary = {
family_id: number;
owner_user_id: number;
owner_identifier: string;
status: string;
active_member_count: number;
max_members: number;
created_at: number;
updated_at: number;
};
type GetFamilyDetailParams = {
id: number;
};
type GetFamilyListParams = {
page: number;
size: number;
keyword?: string;
status?: string;
owner_user_id?: number;
family_id?: number;
user_id?: number;
};
type GetFamilyListResponse = {
list: FamilySummary[];
total: number;
};
type DissolveFamilyRequest = {
family_id: number;
reason?: string;
};
type UpdateFamilyMaxMembersRequest = {
family_id: number;
max_members: number;
};
type RemoveFamilyMemberRequest = {
family_id: number;
user_id: number;
reason?: string;
};
type GetAdminUserInviteStatsParams = {
user_id: number;
};
type GetAdminUserInviteStatsResponse = {
invite_count: number;
total_commission: number;
current_commission: number;
referral_percentage: number;
only_first_purchase: boolean;
};
type GetAdminUserInviteListParams = {
user_id: number;
page: number;
size: number;
};
type AdminInvitedUser = {
id: number;
avatar: string;
identifier: string;
enable: boolean;
created_at: number;
};
type GetAdminUserInviteListResponse = {
total: number;
list: AdminInvitedUser[];
};
type ActivateOrderRequest = {
order_no: string;
};
type RedemptionCode = {
id: number;
code: string;
total_count: number;
used_count: number;
subscribe_plan: number;
unit_time: string;
quantity: number;
status: number;
created_at: number;
updated_at: number;
};
type RedemptionRecord = {
id: number;
redemption_code_id: number;
user_id: number;
subscribe_id: number;
unit_time: string;
quantity: number;
redeemed_at: number;
created_at: number;
};
type CreateRedemptionCodeRequest = {
total_count: number;
subscribe_plan: number;
unit_time: string;
quantity: number;
batch_count: number;
};
type UpdateRedemptionCodeRequest = {
id: number;
total_count?: number;
subscribe_plan?: number;
unit_time?: string;
quantity?: number;
status?: number;
};
type DeleteRedemptionCodeRequest = {
id: number;
};
type BatchDeleteRedemptionCodeRequest = {
ids: number[];
};
type GetRedemptionCodeListParams = {
page: number;
size: number;
subscribe_plan?: number;
unit_time?: string;
code?: string;
};
type GetRedemptionCodeListResponse = {
total: number;
list: RedemptionCode[];
};
type ToggleRedemptionCodeStatusRequest = {
id: number;
status: number;
};
type GetRedemptionRecordListParams = {
page: number;
size: number;
user_id?: number;
code_id?: number;
};
type GetRedemptionRecordListResponse = {
total: number;
list: RedemptionRecord[];
};
}

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
@ -505,3 +505,169 @@ export async function getUserSubscribeTrafficLogs(
}
);
}
/**
*
* GET /v1/admin/user/family/detail
* @param params - ID
* @param options -
* @returns
*/
export async function getFamilyDetail(
params: API.GetFamilyDetailParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.FamilyDetail }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/detail`,
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}
/**
*
* GET /v1/admin/user/family/list
* @param params -
* @param options -
* @returns
*/
export async function getFamilyList(
params: API.GetFamilyListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetFamilyListResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/list`,
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}
/**
*
* PUT /v1/admin/user/family/dissolve
* @param body -
* @param options -
* @returns
*/
export async function dissolveFamily(
body: API.DissolveFamilyRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/dissolve`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/**
*
* PUT /v1/admin/user/family/max_members
* @param body -
* @param options -
* @returns
*/
export async function updateFamilyMaxMembers(
body: API.UpdateFamilyMaxMembersRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/max_members`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/**
*
* PUT /v1/admin/user/family/member/remove
* @param body -
* @param options -
* @returns
*/
export async function removeFamilyMember(
body: API.RemoveFamilyMemberRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${
import.meta.env.VITE_API_PREFIX || ""
}/v1/admin/user/family/member/remove`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/**
*
* GET /v1/admin/user/invite/stats
* @param params - ID
* @param options -
* @returns
*/
export async function getAdminUserInviteStats(
params: API.GetAdminUserInviteStatsParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetAdminUserInviteStatsResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/invite/stats`,
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}
/**
*
* GET /v1/admin/user/invite/list
* @param params - ID
* @param options -
* @returns
*/
export async function getAdminUserInviteList(
params: API.GetAdminUserInviteListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.GetAdminUserInviteListResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/invite/list`,
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
@ -19,9 +19,7 @@ export async function appleLoginCallback(
if (item !== undefined && item !== null) {
if (typeof item === "object" && !(item instanceof File)) {
if (Array.isArray(item)) {
item.forEach((f) => {
formData.append(ele, f || "");
});
for (const f of item) formData.append(ele, f || "");
} else {
formData.append(
ele,

View File

@ -356,6 +356,7 @@ declare namespace API {
forced_invite: boolean;
referral_percentage: number;
only_first_purchase: boolean;
gift_days?: number;
};
type LoginResponse = {

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -373,6 +373,7 @@ declare namespace API {
forced_invite: boolean;
referral_percentage: number;
only_first_purchase: boolean;
gift_days?: number;
};
type MessageLog = {

View File

@ -1,4 +1,4 @@
// @ts-nocheck
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";
@ -269,24 +269,6 @@ export async function updateUserRules(
);
}
/** Redeem Code POST /v1/public/redemption/ */
export async function redeemCode(
body: { code: string },
options?: { [key: string]: any }
) {
return request<API.Response & { data: { message: string } }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/redemption/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Query User Subscribe GET /v1/public/user/subscribe */
export async function queryUserSubscribe(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.QueryUserSubscribeListResponse }>(

View File

@ -0,0 +1,33 @@
const DEVICE_HASH_SALT = 0x5a_3c_7e_9b;
/**
* Encode device id (user_device.id) to 8-char hex hash (bidirectional)
* e.g. 1 "5A3C7E9A", 42 "5A3C7EA1"
*/
export function deviceIdToHash(id: number): string {
// biome-ignore lint/suspicious/noBitwiseOperators: intentional XOR hash
return ((id ^ DEVICE_HASH_SALT) >>> 0)
.toString(16)
.toUpperCase()
.padStart(8, "0");
}
/**
* Decode 8-char hex hash back to device id
* e.g. "5A3C7E9A" 1
*/
export function hashToDeviceId(hash: string): number {
// biome-ignore lint/suspicious/noBitwiseOperators: intentional XOR hash
return (Number.parseInt(hash, 16) ^ DEVICE_HASH_SALT) >>> 0;
}
/**
* Shorten a long device identifier (e.g. "device68c71ab9f82d...") to 8-char display
* Used when only the identifier string is available (no numeric device id)
*/
export function shortenDeviceIdentifier(identifier: string): string {
return identifier
.replace(/^device/i, "")
.slice(0, 8)
.toUpperCase();
}