feat: 自定义版本功能更新
Some checks failed
Build and Release / Build (push) Has been cancelled

- 新增家庭共享订阅管理
- 新增用户邀请统计
- 新增签名和订阅模式设置表单
- 更新 API 服务层和国际化文件
- UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
shanshanzhong 2026-03-19 01:56:13 -07:00
parent 31803a1d24
commit 6b92979c7c
82 changed files with 3266 additions and 739 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

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

View File

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

View File

@ -3,6 +3,7 @@
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save Settings", "save": "Save Settings",
"saving": "Saving...",
"saveFailed": "Save Failed", "saveFailed": "Save Failed",
"saveSuccess": "Save Successful" "saveSuccess": "Save Successful"
}, },
@ -23,6 +24,10 @@
"description": "Configure user invitation and referral reward settings", "description": "Configure user invitation and referral reward settings",
"forcedInvite": "Require Invitation to Register", "forcedInvite": "Require Invitation to Register",
"forcedInviteDescription": "When enabled, users must register through an invitation link", "forcedInviteDescription": "When enabled, users must register through an invitation link",
"giftDays": "Invite Gift Days",
"giftDaysDescription": "When referral percentage is 0, both the inviter and invitee receive this many extra subscription days after the invitee makes a purchase",
"giftDaysPlaceholder": "Enter days",
"giftDaysSuffix": "day(s)",
"inputPlaceholder": "Please enter", "inputPlaceholder": "Please enter",
"onlyFirstPurchase": "First Purchase Reward Only", "onlyFirstPurchase": "First Purchase Reward Only",
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users", "onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
@ -42,6 +47,22 @@
"title": "Log Cleanup Settings" "title": "Log Cleanup Settings"
}, },
"logSettings": "Log Settings", "logSettings": "Log Settings",
"signature": {
"title": "Request Signature",
"description": "Enable or disable request signature verification for public APIs",
"enable": "Enable Signature Verification",
"enableDescription": "When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1",
"saveSuccess": "Save Successful",
"saveFailed": "Save Failed"
},
"subscribeMode": {
"title": "Subscription Mode",
"description": "Configure single or multiple subscription purchase behavior",
"singleSubscriptionMode": "Single Subscription Mode",
"singleSubscriptionModeDescription": "After enabling, users can only purchase/renew one subscription in the same account",
"saveSuccess": "Subscription mode updated successfully",
"saveFailed": "Failed to update settings"
},
"privacyPolicy": { "privacyPolicy": {
"description": "Edit and manage privacy policy content", "description": "Edit and manage privacy policy content",
"title": "Privacy Policy" "title": "Privacy Policy"

View File

@ -24,6 +24,7 @@
"createSubscription": "Create Subscription", "createSubscription": "Create Subscription",
"createSuccess": "Created successfully", "createSuccess": "Created successfully",
"createUser": "Create User", "createUser": "Create User",
"currentCommission": "Current Commission",
"delete": "Delete", "delete": "Delete",
"deleted": "Deleted", "deleted": "Deleted",
"deleteDescription": "This action cannot be undone.", "deleteDescription": "This action cannot be undone.",
@ -31,19 +32,57 @@
"deleteSuccess": "Deleted successfully", "deleteSuccess": "Deleted successfully",
"isDeleted": "Status", "isDeleted": "Status",
"deviceLimit": "Device Limit", "deviceLimit": "Device Limit",
"deviceGroup": "Device Group",
"deviceNo": "Device No.",
"deviceSearch": "Device",
"download": "Download", "download": "Download",
"downloadTraffic": "Download Traffic", "downloadTraffic": "Download Traffic",
"edit": "Edit", "edit": "Edit",
"editSubscription": "Edit Subscription", "editSubscription": "Edit Subscription",
"enable": "Enable", "enable": "Enable",
"enabled": "Enabled",
"disabled": "Disabled",
"expiredAt": "Expired At", "expiredAt": "Expired At",
"expireTime": "expireTime", "expireTime": "expireTime",
"familyActions": "Actions",
"familyConfirmDissolve": "Confirm Dissolve",
"familyConfirmRemoveMember": "Confirm Remove Member",
"familyDetail": "Device Group Detail",
"familyDisabled": "Disabled",
"familyDissolve": "Dissolve",
"familyDissolved": "Device group dissolved",
"familyDissolveDescription": "This will dissolve the device group and remove all active members.",
"familyId": "Device Group ID",
"familyInvalidMaxMembers": "Invalid max members",
"familyJoinSource": "Join Source",
"familyJoinSourceOwnerInit": "Owner Init",
"familyJoinedAt": "Joined At",
"familyLeftAt": "Left At",
"familyManagement": "Device Group Management",
"familyMaxMembers": "Max Members",
"familyMaxMembersTooSmall": "Max members cannot be lower than active member count",
"familyMember": "Member",
"familyMemberLeft": "Left",
"familyMemberRemoved": "Removed",
"familyMembers": "Members",
"familyNoData": "No device group data",
"familyNoMembers": "No members",
"familyOwnerUserId": "Owner User ID",
"familyRemoveMemberDescription": "This will remove the member from the active device group.",
"familyStatus": "Status",
"familySummary": "Summary",
"familyUpdateMaxMembers": "Update Max Members",
"firstPurchaseOnly": "First purchase only",
"giftAmount": "Gift Amount", "giftAmount": "Gift Amount",
"giftAmountPlaceholder": "Enter gift amount", "giftAmountPlaceholder": "Enter gift amount",
"giftLogs": "Gift Logs", "giftLogs": "Gift Logs",
"globalDefault": "Global Default",
"invalidEmailFormat": "Invalid email format", "invalidEmailFormat": "Invalid email format",
"inviteCode": "Invite Code", "inviteCode": "Invite Code",
"inviteCodePlaceholder": "Enter invite code", "inviteCodePlaceholder": "Enter invite code",
"inviteCount": "Invited Users",
"inviteStats": "Invite Statistics",
"invitedUsers": "Invited Users",
"kickOfflineConfirm": "kickOfflineConfirm", "kickOfflineConfirm": "kickOfflineConfirm",
"kickOfflineSuccess": "Device kicked offline", "kickOfflineSuccess": "Device kicked offline",
"lastSeen": "Last Seen", "lastSeen": "Last Seen",
@ -52,17 +91,22 @@
"loginNotifications": "Login Notifications", "loginNotifications": "Login Notifications",
"loginStatus": "Login Status", "loginStatus": "Login Status",
"manager": "Administrator", "manager": "Administrator",
"memberCount": "Member Count",
"more": "More", "more": "More",
"normal": "Normal", "normal": "Normal",
"next": "Next",
"noInvitedUsers": "No invited users yet",
"notifySettingsTitle": "Notify Settings", "notifySettingsTitle": "Notify Settings",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",
"onlineDevices": "Online Devices", "onlineDevices": "Online Devices",
"onlyFirstPurchase": "First Purchase Only", "onlyFirstPurchase": "First Purchase Only",
"orderList": "Order List", "orderList": "Order List",
"owner": "Owner",
"password": "Password", "password": "Password",
"passwordPlaceholder": "Enter password", "passwordPlaceholder": "Enter password",
"permanent": "Permanent", "permanent": "Permanent",
"prev": "Prev",
"pleaseEnterEmail": "Enter email", "pleaseEnterEmail": "Enter email",
"referer": "Referer", "referer": "Referer",
"refererId": "Referer ID", "refererId": "Referer ID",
@ -71,7 +115,9 @@
"referralPercentage": "Referral Percentage", "referralPercentage": "Referral Percentage",
"referralPercentagePlaceholder": "Enter percentage", "referralPercentagePlaceholder": "Enter percentage",
"referrerUserId": "Referrer User ID", "referrerUserId": "Referrer User ID",
"registeredAt": "Registered At",
"remove": "Remove", "remove": "Remove",
"removeSuccess": "Removed successfully",
"resetLogs": "Reset Logs", "resetLogs": "Reset Logs",
"resetTraffic": "Reset Traffic", "resetTraffic": "Reset Traffic",
"toggleStatus": "Toggle Status", "toggleStatus": "Toggle Status",
@ -81,6 +127,7 @@
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.", "resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
"toggleSubscriptionStatus": "Toggle Status", "toggleSubscriptionStatus": "Toggle Status",
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.", "toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
"resetSearch": "Reset",
"resetTime": "Reset Time", "resetTime": "Reset Time",
"resetToken": "Reset Subscription Address", "resetToken": "Reset Subscription Address",
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.", "resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
@ -102,6 +149,12 @@
"statusDeducted": "Deducted", "statusDeducted": "Deducted",
"statusStopped": "Stopped", "statusStopped": "Stopped",
"save": "Save", "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", "speedLimit": "Speed Limit",
"startTime": "startTime", "startTime": "startTime",
"subscription": "Subscription", "subscription": "Subscription",
@ -114,6 +167,7 @@
"telephone": "Phone", "telephone": "Phone",
"telephonePlaceholder": "Enter phone number", "telephonePlaceholder": "Enter phone number",
"token": "token", "token": "token",
"totalCommission": "Total Commission",
"totalTraffic": "Total Traffic", "totalTraffic": "Total Traffic",
"tradeNotifications": "Trade Notifications", "tradeNotifications": "Trade Notifications",
"trafficDetails": "Traffic Details", "trafficDetails": "Traffic Details",
@ -134,5 +188,7 @@
"userList": "User List", "userList": "User List",
"userName": "Username", "userName": "Username",
"userProfile": "User Profile", "userProfile": "User Profile",
"verified": "Verified" "verified": "Verified",
"viewDeviceGroup": "View Device Group",
"viewOwner": "View Owner"
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -88,6 +88,11 @@ export function useNavs() {
url: "/dashboard/user", url: "/dashboard/user",
icon: "flat-color-icons:conference-call", 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"), title: t("Ticket Management", "Ticket Management"),
url: "/dashboard/ticket", url: "/dashboard/ticket",

View File

@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport =
const DashboardMarketingIndexLazyRouteImport = createFileRoute( const DashboardMarketingIndexLazyRouteImport = createFileRoute(
'/dashboard/marketing/', '/dashboard/marketing/',
)() )()
const DashboardFamilyIndexLazyRouteImport =
createFileRoute('/dashboard/family/')()
const DashboardDocumentIndexLazyRouteImport = createFileRoute( const DashboardDocumentIndexLazyRouteImport = createFileRoute(
'/dashboard/document/', '/dashboard/document/',
)() )()
@ -189,6 +191,14 @@ const DashboardMarketingIndexLazyRoute =
} as any).lazy(() => } as any).lazy(() =>
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route), import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
) )
const DashboardFamilyIndexLazyRoute =
DashboardFamilyIndexLazyRouteImport.update({
id: '/family/',
path: '/family/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/family/index.lazy').then((d) => d.Route),
)
const DashboardDocumentIndexLazyRoute = const DashboardDocumentIndexLazyRoute =
DashboardDocumentIndexLazyRouteImport.update({ DashboardDocumentIndexLazyRouteImport.update({
id: '/document/', id: '/document/',
@ -345,6 +355,7 @@ export interface FileRoutesByFullPath {
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute '/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute '/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute '/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@ -377,6 +388,7 @@ export interface FileRoutesByTo {
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute '/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute '/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute '/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
@ -411,6 +423,7 @@ export interface FileRoutesById {
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute '/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute '/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute '/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute '/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute '/dashboard/order/': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute '/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
@ -446,6 +459,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control' | '/dashboard/auth-control'
| '/dashboard/coupon' | '/dashboard/coupon'
| '/dashboard/document' | '/dashboard/document'
| '/dashboard/family'
| '/dashboard/marketing' | '/dashboard/marketing'
| '/dashboard/order' | '/dashboard/order'
| '/dashboard/payment' | '/dashboard/payment'
@ -478,6 +492,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control' | '/dashboard/auth-control'
| '/dashboard/coupon' | '/dashboard/coupon'
| '/dashboard/document' | '/dashboard/document'
| '/dashboard/family'
| '/dashboard/marketing' | '/dashboard/marketing'
| '/dashboard/order' | '/dashboard/order'
| '/dashboard/payment' | '/dashboard/payment'
@ -511,6 +526,7 @@ export interface FileRouteTypes {
| '/dashboard/auth-control/' | '/dashboard/auth-control/'
| '/dashboard/coupon/' | '/dashboard/coupon/'
| '/dashboard/document/' | '/dashboard/document/'
| '/dashboard/family/'
| '/dashboard/marketing/' | '/dashboard/marketing/'
| '/dashboard/order/' | '/dashboard/order/'
| '/dashboard/payment/' | '/dashboard/payment/'
@ -627,6 +643,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute parentRoute: typeof DashboardRouteLazyRoute
} }
'/dashboard/family/': {
id: '/dashboard/family/'
path: '/family'
fullPath: '/dashboard/family'
preLoaderRoute: typeof DashboardFamilyIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/document/': { '/dashboard/document/': {
id: '/dashboard/document/' id: '/dashboard/document/'
path: '/document' path: '/document'
@ -770,6 +793,7 @@ interface DashboardRouteLazyRouteChildren {
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
@ -802,6 +826,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute, DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute, DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute, DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute, DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute, DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute, 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(), expiration_email_template: z.string().optional(),
maintenance_email_template: z.string().optional(), maintenance_email_template: z.string().optional(),
traffic_exceed_email_template: z.string().optional(), traffic_exceed_email_template: z.string().optional(),
delete_account_email_template: z.string().optional(),
platform: z.string(), platform: z.string(),
platform_config: z platform_config: z
.object({ .object({
@ -102,6 +103,7 @@ export default function EmailSettingsForm() {
expiration_email_template: "", expiration_email_template: "",
maintenance_email_template: "", maintenance_email_template: "",
traffic_exceed_email_template: "", traffic_exceed_email_template: "",
delete_account_email_template: "",
platform: "smtp", platform: "smtp",
platform_config: { platform_config: {
host: "", host: "",
@ -195,6 +197,12 @@ export default function EmailSettingsForm() {
<TabsTrigger value="traffic"> <TabsTrigger value="traffic">
{t("email.trafficTemplate", "Traffic Template")} {t("email.trafficTemplate", "Traffic Template")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="delete_account">
{t(
"email.deleteAccountTemplate",
"Delete Account Template"
)}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="space-y-2" value="basic"> <TabsContent className="space-y-2" value="basic">
@ -840,6 +848,88 @@ export default function EmailSettingsForm() {
)} )}
/> />
</TabsContent> </TabsContent>
<TabsContent className="space-y-2" value="delete_account">
<FormField
control={form.control}
name="config.delete_account_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.deleteAccountEmailTemplate",
"Delete Account Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Code}}"}
</code>
<span>
{t(
"email.templateVariables.code.description",
"Verification code"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Expire}}"}
</code>
<span>
{t(
"email.templateVariables.expire.description",
"Code expiration time"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs> </Tabs>
</form> </form>
</Form> </Form>

View File

@ -29,18 +29,21 @@ export default function Redemption() {
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
return ( return (
<> <>
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }> <ProTable<
API.RedemptionCode,
{ subscribe_plan: number; unit_time: string; code: string }
>
action={ref} action={ref}
actions={{ actions={{
render: (row) => [ render: (row) => [
<Button <Button
key="records" key="records"
variant="outline"
size="sm"
onClick={() => { onClick={() => {
setSelectedCodeId(row.id); setSelectedCodeId(row.id);
setRecordsOpen(true); setRecordsOpen(true);
}} }}
size="sm"
variant="outline"
> >
{t("records", "Records")} {t("records", "Records")}
</Button>, </Button>,
@ -240,8 +243,8 @@ export default function Redemption() {
/> />
<RedemptionRecords <RedemptionRecords
codeId={selectedCodeId} codeId={selectedCodeId}
open={recordsOpen}
onOpenChange={setRecordsOpen} onOpenChange={setRecordsOpen}
open={recordsOpen}
/> />
</> </>
); );

View File

@ -26,15 +26,24 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { useSubscribe } from "@/stores/subscribe"; import { useSubscribe } from "@/stores/subscribe";
const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({ const getFormSchema = (t: (key: string, defaultValue: string) => string) =>
z.object({
id: z.number().optional(), id: z.number().optional(),
code: z.string().optional(), code: z.string().optional(),
batch_count: z.number().optional(), batch_count: z.number().optional(),
total_count: z.number().min(1, t("form.totalCountRequired", "Total count is required")), total_count: z
subscribe_plan: z.number().min(1, t("form.subscribePlanRequired", "Subscribe plan is required")), .number()
unit_time: z.string().min(1, t("form.unitTimeRequired", "Unit time is required")), .min(1, t("form.totalCountRequired", "Total count is required")),
quantity: z.number().min(1, t("form.quantityRequired", "Quantity is required")), subscribe_plan: z
}); .number()
.min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
unit_time: z
.string()
.min(1, t("form.unitTimeRequired", "Unit time is required")),
quantity: z
.number()
.min(1, t("form.quantityRequired", "Quantity is required")),
});
interface RedemptionFormProps<T> { interface RedemptionFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean; onSubmit: (data: T) => Promise<boolean> | boolean;
@ -184,9 +193,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="unit_time" name="unit_time"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t("form.unitTime", "Unit Time")}</FormLabel>
{t("form.unitTime", "Unit Time")}
</FormLabel>
<FormControl> <FormControl>
<Combobox<string, false> <Combobox<string, false>
onChange={(value) => { onChange={(value) => {
@ -195,8 +202,14 @@ export default function RedemptionForm<T extends Record<string, any>>({
options={[ options={[
{ value: "day", label: t("form.day", "Day") }, { value: "day", label: t("form.day", "Day") },
{ value: "month", label: t("form.month", "Month") }, { value: "month", label: t("form.month", "Month") },
{ value: "quarter", label: t("form.quarter", "Quarter") }, {
{ value: "half_year", label: t("form.halfYear", "Half Year") }, value: "quarter",
label: t("form.quarter", "Quarter"),
},
{
value: "half_year",
label: t("form.halfYear", "Half Year"),
},
{ value: "year", label: t("form.year", "Year") }, { value: "year", label: t("form.year", "Year") },
]} ]}
placeholder={t( placeholder={t(
@ -215,16 +228,11 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="quantity" name="quantity"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t("form.duration", "Duration")}</FormLabel>
{t("form.duration", "Duration")}
</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
min={1} min={1}
placeholder={t( placeholder={t("form.durationPlaceholder", "Duration")}
"form.durationPlaceholder",
"Duration"
)}
step={1} step={1}
type="number" type="number"
{...field} {...field}
@ -242,9 +250,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
name="total_count" name="total_count"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>{t("form.totalCount", "Total Count")}</FormLabel>
{t("form.totalCount", "Total Count")}
</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
min={1} min={1}

View File

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

View File

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

View File

@ -36,6 +36,7 @@ const inviteSchema = z.object({
forced_invite: z.boolean().optional(), forced_invite: z.boolean().optional(),
referral_percentage: z.number().optional(), referral_percentage: z.number().optional(),
only_first_purchase: z.boolean().optional(), only_first_purchase: z.boolean().optional(),
gift_days: z.number().optional(),
}); });
type InviteFormData = z.infer<typeof inviteSchema>; type InviteFormData = z.infer<typeof inviteSchema>;
@ -60,6 +61,7 @@ export default function InviteConfig() {
forced_invite: false, forced_invite: false,
referral_percentage: 0, referral_percentage: 0,
only_first_purchase: false, 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 <FormField
control={form.control} control={form.control}
name="only_first_purchase" 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

@ -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, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"; } from "@workspace/ui/components/dropdown-menu";
import { Input } from "@workspace/ui/components/input";
import { ScrollArea } from "@workspace/ui/components/scroll-area"; import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -23,6 +31,7 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@workspace/ui/components/tabs"; } from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import { import {
ProTable, ProTable,
@ -35,14 +44,16 @@ import {
getUserList, getUserList,
updateUserBasicInfo, updateUserBasicInfo,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe"; import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common"; import { formatDate } from "@/utils/common";
import FamilyManagement from "./family";
import { UserDetail } from "./user-detail"; import { UserDetail } from "./user-detail";
import UserForm from "./user-form"; import UserForm from "./user-form";
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
import { AuthMethodsForm } from "./user-profile/auth-methods-form"; import { AuthMethodsForm } from "./user-profile/auth-methods-form";
import { BasicInfoForm } from "./user-profile/basic-info-form"; import { BasicInfoForm } from "./user-profile/basic-info-form";
import { NotifySettingsForm } from "./user-profile/notify-settings-form"; import { NotifySettingsForm } from "./user-profile/notify-settings-form";
@ -56,13 +67,15 @@ export default function User() {
const { subscribes } = useSubscribe(); const { subscribes } = useSubscribe();
const initialFilters = { const searchRef = useRef({
search: sp.search || undefined, type: sp.user_id ? "user_id" : "email",
user_id: sp.user_id || undefined, value: sp.search || sp.user_id || "",
subscribe_id: sp.subscribe_id || undefined, });
user_subscribe_id: sp.user_subscribe_id || undefined,
short_code: sp.short_code || undefined, const handleSearch = useCallback((type: string, value: string) => {
}; searchRef.current = { type, value };
ref.current?.refresh();
}, []);
return ( return (
<ProTable<API.User, API.GetUserListParams> <ProTable<API.User, API.GetUserListParams>
@ -75,6 +88,11 @@ export default function User() {
userId={row.id} userId={row.id}
/>, />,
<SubscriptionSheet key="subscription" userId={row.id} />, <SubscriptionSheet key="subscription" userId={row.id} />,
<DeviceGroupSheet
key="device-group"
onChanged={() => ref.current?.refresh()}
userId={row.id}
/>,
<ConfirmButton <ConfirmButton
cancelText={t("cancel", "Cancel")} cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")} confirmText={t("confirm", "Confirm")}
@ -98,6 +116,7 @@ export default function User() {
<Button variant="outline">{t("more", "More")}</Button> <Button variant="outline">{t("more", "More")}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<InviteStatsMenuItem userId={row.id} />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
search={{ user_id: String(row.id) }} search={{ user_id: String(row.id) }}
@ -194,6 +213,10 @@ export default function User() {
header: t("userName", "Username"), header: t("userName", "Username"),
cell: ({ row }) => { cell: ({ row }) => {
const method = row.original.auth_methods?.[0]; 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 ( return (
<div> <div>
<Badge <Badge
@ -202,7 +225,7 @@ export default function User() {
> >
{method?.auth_type} {method?.auth_type}
</Badge> </Badge>
{method?.auth_identifier} <span title={isDevice ? display : undefined}>{display}</span>
</div> </div>
); );
}, },
@ -245,7 +268,14 @@ export default function User() {
}, },
]} ]}
header={{ header={{
title: t("userList", "User List"), title: (
<UserSearchBar
initialType={searchRef.current.type}
initialValue={searchRef.current.value}
onSearch={handleSearch}
subscribes={subscribes}
/>
),
toolbar: ( toolbar: (
<UserForm<API.CreateUserRequest> <UserForm<API.CreateUserRequest>
key="create" key="create"
@ -270,39 +300,19 @@ export default function User() {
/> />
), ),
}} }}
initialFilters={initialFilters} request={async (pagination) => {
key={initialFilters.user_id} const { type, value } = searchRef.current;
params={[ const params: Record<string, unknown> = { ...pagination };
{ if (value) {
key: "subscribe_id", if (type === "user_id") {
placeholder: t("subscription", "Subscription"), params.user_id = value;
options: subscribes?.map((item) => ({ } else if (type === "subscribe_id") {
label: item.name!, params.subscribe_id = value;
value: String(item.id!), } else {
})), params.search = value;
}, }
{ }
key: "search", const { data } = await getUserList(params as API.GetUserListParams);
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,
});
return { return {
list: data.data?.list || [], list: data.data?.list || [],
total: data.data?.total || 0, total: data.data?.total || 0,
@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) {
</Sheet> </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, getUserDetail,
getUserSubscribeById, getUserSubscribeById,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
import { formatBytes } from "@workspace/ui/utils/formatting"; import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
@ -163,9 +164,14 @@ export function UserDetail({ id }: { id: number }) {
if (!id) return "--"; if (!id) return "--";
const identifier = const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email");
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier || const firstMethod = data?.auth_methods[0];
data?.auth_methods[0]?.auth_identifier; const rawIdentifier =
emailMethod?.auth_identifier || firstMethod?.auth_identifier || "";
const isDevice = !emailMethod && firstMethod?.auth_type === "device";
const identifier = isDevice
? shortenDeviceIdentifier(rawIdentifier)
: rawIdentifier;
return ( return (
<HoverCard> <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 { Link } from "@tanstack/react-router";
import { Alert, AlertDescription } from "@workspace/ui/components/alert";
import { Badge } from "@workspace/ui/components/badge"; import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
@ -15,12 +16,14 @@ import {
import { import {
createUserSubscribe, createUserSubscribe,
deleteUserSubscribe, deleteUserSubscribe,
getFamilyList,
getUserSubscribe, getUserSubscribe,
resetUserSubscribeToken, resetUserSubscribeToken,
toggleUserSubscribeStatus, toggleUserSubscribeStatus,
updateUserSubscribe, updateUserSubscribe,
} from "@workspace/ui/services/admin/user"; } 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 { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
@ -29,16 +32,126 @@ import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail"; import { SubscriptionDetail } from "./subscription-detail";
import { SubscriptionForm } from "./subscription-form"; import { SubscriptionForm } from "./subscription-form";
interface SharedInfo {
ownerUserId: number;
familyId: number;
}
export default function UserSubscription({ userId }: { userId: number }) { export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null); 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 ( return (
<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>> <ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref} action={ref}
actions={{ actions={{
render: (row) => [ 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 <SubscriptionForm
initialData={row} initialData={row}
key="edit" key="edit"
@ -75,7 +188,16 @@ export default function UserSubscription({ userId }: { userId: number }) {
{ {
accessorKey: "name", accessorKey: "name",
header: t("subscriptionName", "Subscription Name"), header: t("subscriptionName", "Subscription Name"),
cell: ({ row }) => row.original.subscribe.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", accessorKey: "status",
@ -85,7 +207,8 @@ export default function UserSubscription({ userId }: { userId: number }) {
const expireTime = row.original.expire_time; const expireTime = row.original.expire_time;
// 如果过期时间为0说明是永久订阅应该显示为激活状态 // 如果过期时间为0说明是永久订阅应该显示为激活状态
const displayStatus = status === 3 && expireTime === 0 ? 1 : status; const displayStatus =
status === 3 && expireTime === 0 ? 1 : status;
const statusMap: Record< const statusMap: Record<
number, number,
@ -94,7 +217,10 @@ export default function UserSubscription({ userId }: { userId: number }) {
variant: "default" | "secondary" | "destructive" | "outline"; variant: "default" | "secondary" | "destructive" | "outline";
} }
> = { > = {
0: { label: t("statusPending", "Pending"), variant: "outline" }, 0: {
label: t("statusPending", "Pending"),
variant: "outline",
},
1: { label: t("statusActive", "Active"), variant: "default" }, 1: { label: t("statusActive", "Active"), variant: "default" },
2: { 2: {
label: t("statusFinished", "Finished"), label: t("statusFinished", "Finished"),
@ -140,7 +266,11 @@ export default function UserSubscription({ userId }: { userId: number }) {
accessorKey: "traffic", accessorKey: "traffic",
header: t("totalTraffic", "Total Traffic"), header: t("totalTraffic", "Total Traffic"),
cell: ({ row }) => ( cell: ({ row }) => (
<Display type="traffic" unlimited value={row.getValue("traffic")} /> <Display
type="traffic"
unlimited
value={row.getValue("traffic")}
/>
), ),
}, },
{ {
@ -187,8 +317,10 @@ export default function UserSubscription({ userId }: { userId: number }) {
}, },
]} ]}
header={{ header={{
title: t("subscriptionList", "Subscription List"), title: isSharedView
toolbar: ( ? t("sharedSubscriptionList", "Shared Subscription List")
: t("subscriptionList", "Subscription List"),
toolbar: isSharedView ? undefined : (
<SubscriptionForm <SubscriptionForm
key="create" key="create"
loading={loading} loading={loading}
@ -208,17 +340,112 @@ export default function UserSubscription({ userId }: { userId: number }) {
/> />
), ),
}} }}
request={async (pagination) => { request={request}
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/> />
</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"));
}}
>
{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, getUserSubscribeDevices,
kickOfflineByUserDevice, kickOfflineByUserDevice,
} from "@workspace/ui/services/admin/user"; } from "@workspace/ui/services/admin/user";
import { deviceIdToHash } from "@workspace/ui/utils/device";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@ -86,7 +87,18 @@ export function SubscriptionDetail({
), ),
}, },
{ accessorKey: "id", header: "ID" }, { 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", accessorKey: "user_agent",
header: t("userAgent", "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) { export function formatDate(date?: Date | number, showTime = true) {
if (!date) return; 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"; const timeZone = localStorage.getItem("timezone") || "UTC";
return intlFormat(date, { return intlFormat(dateValue, {
year: "numeric", year: "numeric",
month: "numeric", month: "numeric",
day: "numeric", day: "numeric",

View File

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

View File

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

View File

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

View File

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

View File

@ -65,7 +65,11 @@ export default function Page() {
orderNo: order_no!, orderNo: order_no!,
returnUrl: window.location.href, 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"); window.open(data.data.checkout_url, "_blank");
setPaymentOpened(true); setPaymentOpened(true);
} }

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import type { Table } from "@tanstack/react-table"; import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox"; import { Combobox } from "@workspace/ui/composed/combobox";
import { useEffect, useState } from "react";
export interface IParams { export interface IParams {
key: string; key: string;
@ -81,15 +82,52 @@ export function ColumnFilter<TData>({
); );
} }
return ( return (
<Input <DebouncedInput
className="min-w-32" externalValue={filters[param.key] || ""}
key={param.key} key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)} onSearch={(value) => updateFilter(param.key, value)}
placeholder={param.placeholder || "Search..."} placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/> />
); );
})} })}
</div> </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 [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 200,
}); });
const loading = useRef(false); const loading = useRef(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// @ts-nocheck // @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
@ -56,9 +56,15 @@ export async function updateOrderStatus(
); );
} }
/** Manually activate order POST /v1/admin/order/activate */ /**
*
* POST /v1/admin/order/activate
* @param body - order_no
* @param options -
* @returns
*/
export async function activateOrder( export async function activateOrder(
body: { order_no: string }, body: API.ActivateOrderRequest,
options?: { [key: string]: any } options?: { [key: string]: any }
) { ) {
return request<API.Response & { data?: any }>( return request<API.Response & { data?: any }>(

View File

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

View File

@ -1,43 +1,14 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
/** Toggle redemption code status PUT /v1/admin/redemption/code/status */ /**
export async function toggleRedemptionCodeStatus( *
body: API.ToggleRedemptionCodeStatusRequest, * POST /v1/admin/redemption/code
options?: { [key: string]: any } * @param body -
) { * @param options -
return request<API.Response & { data?: any }>( * @returns
`${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 */
export async function createRedemptionCode( export async function createRedemptionCode(
body: API.CreateRedemptionCodeRequest, body: API.CreateRedemptionCodeRequest,
options?: { [key: string]: any } 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( export async function deleteRedemptionCode(
body: API.DeleteRedemptionCodeRequest, body: API.DeleteRedemptionCodeRequest,
options?: { [key: string]: any } 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( export async function batchDeleteRedemptionCode(
body: API.BatchDeleteRedemptionCodeRequest, body: API.BatchDeleteRedemptionCodeRequest,
options?: { [key: string]: any } 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( export async function getRedemptionCodeList(
params: API.GetRedemptionCodeListRequest, params: API.GetRedemptionCodeListParams,
options?: { [key: string]: any } options?: { [key: string]: any }
) { ) {
return request<API.Response & { data?: API.GetRedemptionCodeListResponse }>( 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( export async function getRedemptionRecordList(
params: API.GetRedemptionRecordListRequest, params: API.GetRedemptionRecordListParams,
options?: { [key: string]: any } options?: { [key: string]: any }
) { ) {
return request<API.Response & { data?: API.GetRedemptionRecordListResponse }>( return request<API.Response & { data?: API.GetRedemptionRecordListResponse }>(

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// @ts-nocheck // @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; 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 */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";

View File

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

View File

@ -250,85 +250,6 @@ declare namespace API {
enable?: boolean; 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 = { type CreateDocumentRequest = {
title: string; title: string;
content: string; content: string;
@ -1374,6 +1295,7 @@ declare namespace API {
forced_invite: boolean; forced_invite: boolean;
referral_percentage: number; referral_percentage: number;
only_first_purchase: boolean; only_first_purchase: boolean;
gift_days?: number;
}; };
type KickOfflineRequest = { type KickOfflineRequest = {
@ -1864,7 +1786,6 @@ declare namespace API {
enable_ip_register_limit: boolean; enable_ip_register_limit: boolean;
ip_register_limit: number; ip_register_limit: number;
ip_register_limit_duration: number; ip_register_limit_duration: number;
device_limit: number;
}; };
type RegisterLog = { type RegisterLog = {
@ -2659,4 +2580,186 @@ declare namespace API {
security: string; security: string;
security_config: SecurityConfig; 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 */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
@ -291,32 +291,6 @@ export async function getUserSubscribe(
params: { params: {
...params, ...params,
}, },
transformResponse: [
(data) => {
try {
if (typeof data === "string") {
// Convert large integers (int64) to strings BEFORE JSON.parse to prevent precision loss
// JavaScript MAX_SAFE_INTEGER is 2^53 - 1 = 9007199254740991
// This regex finds all numbers >= 10^16 (larger than MAX_SAFE_INTEGER)
const processedData = data.replace(
/"([^"]+)":\s*(\d{16,})/g,
(match, key, value) => {
// Check if number exceeds MAX_SAFE_INTEGER
const num = parseInt(value, 10);
if (!Number.isSafeInteger(num)) {
return `"${key}": "${value}"`;
}
return match;
}
);
return JSON.parse(processedData);
}
return data;
} catch {
return data;
}
},
],
...(options || {}), ...(options || {}),
} }
); );
@ -531,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 */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// @ts-nocheck // @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; 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 */ /** Query User Subscribe GET /v1/public/user/subscribe */
export async function queryUserSubscribe(options?: { [key: string]: any }) { export async function queryUserSubscribe(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.QueryUserSubscribeListResponse }>( 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();
}