diff --git a/.claude/plan/shared-subscription-display.md b/.claude/plan/shared-subscription-display.md new file mode 100644 index 0000000..878dd3e --- /dev/null +++ b/.claude/plan/shared-subscription-display.md @@ -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 ( +
+ {isSharedView && } + [只读操作] } : { render: () => [完整操作] }} + ... + /> +
+ ) +} +``` + +**关键变更点**: +- 将 ProTable 的 `request` 回调改为 React Query 管理数据获取 +- 或者保持 ProTable request 模式,在外层用 state 管理共享视图切换 +- 推荐方案:保持 ProTable 的 request 模式,但在 request 回调内部做链式检查 + +### Step 2: 添加共享订阅信息横幅 + +在 ProTable 上方显示提示信息: + +``` +┌─────────────────────────────────────────────────────┐ +│ ℹ️ 该用户为设备组成员,当前显示所有者 (ID: 258) │ +│ 的共享订阅。[查看设备组] [查看所有者] │ +└─────────────────────────────────────────────────────┘ +``` + +- 使用 Alert 组件展示 +- 提供跳转到设备组详情和所有者用户页面的链接 +- 标题列后追加 `共享` 标识 + +### 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 diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..0561e0f --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,135 @@ +# the name by which the project can be referenced within Serena +project_name: "frontend" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- vue + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: diff --git a/apps/admin/public/assets/locales/en-US/auth-control.json b/apps/admin/public/assets/locales/en-US/auth-control.json index d5e9a77..a9c38d1 100644 --- a/apps/admin/public/assets/locales/en-US/auth-control.json +++ b/apps/admin/public/assets/locales/en-US/auth-control.json @@ -99,6 +99,8 @@ "title": "Email Settings", "trafficExceedEmailTemplate": "Traffic Exceed Email Template", "trafficTemplate": "Traffic Template", + "deleteAccountEmailTemplate": "Delete Account Email Template", + "deleteAccountTemplate": "Delete Account Template", "verifyEmailTemplate": "Verify Email Template", "verifyTemplate": "Verify Template", "whitelistSuffixes": "Whitelist Suffixes", diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json index 4c17948..44462b9 100644 --- a/apps/admin/public/assets/locales/en-US/menu.json +++ b/apps/admin/public/assets/locales/en-US/menu.json @@ -31,6 +31,7 @@ "System Config": "System Config", "Ticket Management": "Ticket Management", "Traffic Details": "Traffic Details", + "Device Group": "Device Group", "User Management": "User Management", "Users & Support": "Users & Support" } diff --git a/apps/admin/public/assets/locales/en-US/system.json b/apps/admin/public/assets/locales/en-US/system.json index ead55ff..74e2395 100644 --- a/apps/admin/public/assets/locales/en-US/system.json +++ b/apps/admin/public/assets/locales/en-US/system.json @@ -3,6 +3,7 @@ "common": { "cancel": "Cancel", "save": "Save Settings", + "saving": "Saving...", "saveFailed": "Save Failed", "saveSuccess": "Save Successful" }, @@ -23,6 +24,10 @@ "description": "Configure user invitation and referral reward settings", "forcedInvite": "Require Invitation to Register", "forcedInviteDescription": "When enabled, users must register through an invitation link", + "giftDays": "Invite Gift Days", + "giftDaysDescription": "When referral percentage is 0, both the inviter and invitee receive this many extra subscription days after the invitee makes a purchase", + "giftDaysPlaceholder": "Enter days", + "giftDaysSuffix": "day(s)", "inputPlaceholder": "Please enter", "onlyFirstPurchase": "First Purchase Reward Only", "onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users", @@ -42,6 +47,22 @@ "title": "Log Cleanup Settings" }, "logSettings": "Log Settings", + "signature": { + "title": "Request Signature", + "description": "Enable or disable request signature verification for public APIs", + "enable": "Enable Signature Verification", + "enableDescription": "When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1", + "saveSuccess": "Save Successful", + "saveFailed": "Save Failed" + }, + "subscribeMode": { + "title": "Subscription Mode", + "description": "Configure single or multiple subscription purchase behavior", + "singleSubscriptionMode": "Single Subscription Mode", + "singleSubscriptionModeDescription": "After enabling, users can only purchase/renew one subscription in the same account", + "saveSuccess": "Subscription mode updated successfully", + "saveFailed": "Failed to update settings" + }, "privacyPolicy": { "description": "Edit and manage privacy policy content", "title": "Privacy Policy" diff --git a/apps/admin/public/assets/locales/en-US/user.json b/apps/admin/public/assets/locales/en-US/user.json index 71a2bbe..e0422bc 100644 --- a/apps/admin/public/assets/locales/en-US/user.json +++ b/apps/admin/public/assets/locales/en-US/user.json @@ -24,6 +24,7 @@ "createSubscription": "Create Subscription", "createSuccess": "Created successfully", "createUser": "Create User", + "currentCommission": "Current Commission", "delete": "Delete", "deleted": "Deleted", "deleteDescription": "This action cannot be undone.", @@ -31,19 +32,57 @@ "deleteSuccess": "Deleted successfully", "isDeleted": "Status", "deviceLimit": "Device Limit", + "deviceGroup": "Device Group", + "deviceNo": "Device No.", + "deviceSearch": "Device", "download": "Download", "downloadTraffic": "Download Traffic", "edit": "Edit", "editSubscription": "Edit Subscription", "enable": "Enable", + "enabled": "Enabled", + "disabled": "Disabled", "expiredAt": "Expired At", "expireTime": "expireTime", + "familyActions": "Actions", + "familyConfirmDissolve": "Confirm Dissolve", + "familyConfirmRemoveMember": "Confirm Remove Member", + "familyDetail": "Device Group Detail", + "familyDisabled": "Disabled", + "familyDissolve": "Dissolve", + "familyDissolved": "Device group dissolved", + "familyDissolveDescription": "This will dissolve the device group and remove all active members.", + "familyId": "Device Group ID", + "familyInvalidMaxMembers": "Invalid max members", + "familyJoinSource": "Join Source", + "familyJoinSourceOwnerInit": "Owner Init", + "familyJoinedAt": "Joined At", + "familyLeftAt": "Left At", + "familyManagement": "Device Group Management", + "familyMaxMembers": "Max Members", + "familyMaxMembersTooSmall": "Max members cannot be lower than active member count", + "familyMember": "Member", + "familyMemberLeft": "Left", + "familyMemberRemoved": "Removed", + "familyMembers": "Members", + "familyNoData": "No device group data", + "familyNoMembers": "No members", + "familyOwnerUserId": "Owner User ID", + "familyRemoveMemberDescription": "This will remove the member from the active device group.", + "familyStatus": "Status", + "familySummary": "Summary", + "familyUpdateMaxMembers": "Update Max Members", + "firstPurchaseOnly": "First purchase only", "giftAmount": "Gift Amount", "giftAmountPlaceholder": "Enter gift amount", "giftLogs": "Gift Logs", + "globalDefault": "Global Default", "invalidEmailFormat": "Invalid email format", "inviteCode": "Invite Code", "inviteCodePlaceholder": "Enter invite code", + "inviteCount": "Invited Users", + "inviteStats": "Invite Statistics", + "invitedUsers": "Invited Users", "kickOfflineConfirm": "kickOfflineConfirm", "kickOfflineSuccess": "Device kicked offline", "lastSeen": "Last Seen", @@ -52,17 +91,22 @@ "loginNotifications": "Login Notifications", "loginStatus": "Login Status", "manager": "Administrator", + "memberCount": "Member Count", "more": "More", "normal": "Normal", + "next": "Next", + "noInvitedUsers": "No invited users yet", "notifySettingsTitle": "Notify Settings", "offline": "Offline", "online": "Online", "onlineDevices": "Online Devices", "onlyFirstPurchase": "First Purchase Only", "orderList": "Order List", + "owner": "Owner", "password": "Password", "passwordPlaceholder": "Enter password", "permanent": "Permanent", + "prev": "Prev", "pleaseEnterEmail": "Enter email", "referer": "Referer", "refererId": "Referer ID", @@ -71,7 +115,9 @@ "referralPercentage": "Referral Percentage", "referralPercentagePlaceholder": "Enter percentage", "referrerUserId": "Referrer User ID", + "registeredAt": "Registered At", "remove": "Remove", + "removeSuccess": "Removed successfully", "resetLogs": "Reset Logs", "resetTraffic": "Reset Traffic", "toggleStatus": "Toggle Status", @@ -81,6 +127,7 @@ "resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.", "toggleSubscriptionStatus": "Toggle Status", "toggleSubscriptionStatusDescription": "This will toggle the subscription status.", + "resetSearch": "Reset", "resetTime": "Reset Time", "resetToken": "Reset Subscription Address", "resetTokenDescription": "This will reset the subscription address and regenerate a new token.", @@ -102,6 +149,12 @@ "statusDeducted": "Deducted", "statusStopped": "Stopped", "save": "Save", + "search": "Search", + "searchPlaceholder": "Email / Invite Code / Device ID", + "searchInputPlaceholder": "Enter search term", + "sharedSubscription": "Shared", + "sharedSubscriptionInfo": "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})", + "sharedSubscriptionList": "Shared Subscription List", "speedLimit": "Speed Limit", "startTime": "startTime", "subscription": "Subscription", @@ -114,6 +167,7 @@ "telephone": "Phone", "telephonePlaceholder": "Enter phone number", "token": "token", + "totalCommission": "Total Commission", "totalTraffic": "Total Traffic", "tradeNotifications": "Trade Notifications", "trafficDetails": "Traffic Details", @@ -134,5 +188,7 @@ "userList": "User List", "userName": "Username", "userProfile": "User Profile", - "verified": "Verified" + "verified": "Verified", + "viewDeviceGroup": "View Device Group", + "viewOwner": "View Owner" } diff --git a/apps/admin/public/assets/locales/zh-CN/auth-control.json b/apps/admin/public/assets/locales/zh-CN/auth-control.json index a25dbb9..282b8b3 100644 --- a/apps/admin/public/assets/locales/zh-CN/auth-control.json +++ b/apps/admin/public/assets/locales/zh-CN/auth-control.json @@ -99,6 +99,8 @@ "title": "邮箱设置", "trafficExceedEmailTemplate": "流量超额邮件模板", "trafficTemplate": "流量模板", + "deleteAccountEmailTemplate": "注销账户邮件模板", + "deleteAccountTemplate": "注销模版", "verifyEmailTemplate": "验证邮件模板", "verifyTemplate": "验证模板", "whitelistSuffixes": "白名单后缀", diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json index 54fe7ab..a024a96 100644 --- a/apps/admin/public/assets/locales/zh-CN/menu.json +++ b/apps/admin/public/assets/locales/zh-CN/menu.json @@ -31,6 +31,7 @@ "System Config": "系统配置", "Ticket Management": "工单管理", "Traffic Details": "流量详情", + "Device Group": "设备组", "User Management": "用户管理", "Users & Support": "用户与支持" } diff --git a/apps/admin/public/assets/locales/zh-CN/system.json b/apps/admin/public/assets/locales/zh-CN/system.json index adfda2d..cc6cdc3 100644 --- a/apps/admin/public/assets/locales/zh-CN/system.json +++ b/apps/admin/public/assets/locales/zh-CN/system.json @@ -3,6 +3,7 @@ "common": { "cancel": "取消", "save": "保存设置", + "saving": "保存中...", "saveFailed": "保存失败", "saveSuccess": "保存成功" }, @@ -23,6 +24,10 @@ "description": "配置用户邀请和推荐奖励设置", "forcedInvite": "强制邀请注册", "forcedInviteDescription": "启用后,用户必须通过邀请链接注册", + "giftDays": "邀请赠送天数", + "giftDaysDescription": "当推荐佣金比例为 0 时,被邀请人完成购买后,邀请人和被邀请人各自获得的订阅延长天数", + "giftDaysPlaceholder": "请输入天数", + "giftDaysSuffix": "天", "inputPlaceholder": "请输入", "onlyFirstPurchase": "仅首次购买奖励", "onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励", @@ -42,6 +47,22 @@ "title": "日志清理设置" }, "logSettings": "日志设置", + "signature": { + "title": "请求签名", + "description": "启用或禁用公共 API 的请求签名验证", + "enable": "启用签名验证", + "enableDescription": "启用后,客户端可以通过发送 X-Signature-Enabled: 1 来触发严格的签名验证", + "saveSuccess": "保存成功", + "saveFailed": "保存失败" + }, + "subscribeMode": { + "title": "订阅模式", + "description": "配置单订阅或多订阅购买行为", + "singleSubscriptionMode": "单订阅模式", + "singleSubscriptionModeDescription": "启用后,用户在同一账户中只能购买/续费一个订阅", + "saveSuccess": "订阅模式更新成功", + "saveFailed": "更新设置失败" + }, "privacyPolicy": { "description": "编辑和管理隐私政策内容", "title": "隐私政策" diff --git a/apps/admin/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json index 7d4fb06..10734a2 100644 --- a/apps/admin/public/assets/locales/zh-CN/user.json +++ b/apps/admin/public/assets/locales/zh-CN/user.json @@ -24,26 +24,66 @@ "createSubscription": "创建订阅", "createSuccess": "创建成功", "createUser": "创建用户", + "currentCommission": "当前佣金", "delete": "删除", "deleted": "已删除", "deleteDescription": "此操作无法撤销。", "deleteSubscriptionDescription": "此操作无法撤销。", "deleteSuccess": "删除成功", "isDeleted": "状态", + "deviceGroup": "设备组", "deviceLimit": "IP限制", + "deviceNo": "设备编号", + "deviceSearch": "设备", "download": "下载", "downloadTraffic": "下载流量", "edit": "编辑", + "email": "邮箱", "editSubscription": "编辑订阅", "enable": "启用", + "enabled": "启用", + "disabled": "禁用", "expiredAt": "过期时间", "expireTime": "过期时间", + "familyActions": "操作", + "familyConfirmDissolve": "确认注销账号", + "familyConfirmRemoveMember": "确认移除成员", + "familyDetail": "设备组详情", + "familyDisabled": "已禁用", + "familyDissolve": "注销账号", + "familyDissolved": "设备组已解散", + "familyDissolveDescription": "此操作将注销账号并移除所有活跃成员。", + "familyId": "设备组 ID", + "familyInvalidMaxMembers": "最大成员数无效", + "familyJoinSource": "加入方式", + "familyJoinSourceOwnerInit": "创建者初始化", + "familyJoinedAt": "加入时间", + "familyLeftAt": "离开时间", + "familyManagement": "设备组管理", + "familyMaxMembers": "最大成员数", + "familyMaxMembersTooSmall": "最大成员数不能小于当前活跃成员数", + "familyMember": "成员", + "familyMemberLeft": "已离开", + "familyMemberRemoved": "已移除", + "familyMembers": "设备组成员", + "familyNoData": "暂无设备组数据", + "familyNoMembers": "暂无成员", + "familyOwnerUserId": "所有者用户 ID", + "familyRemoveMemberDescription": "此操作将从活跃设备组中移除该成员。", + "familyStatus": "设备组状态", + "familySummary": "设备组概览", + "familyUpdateMaxMembers": "更新最大成员数", + "firstPurchaseOnly": "仅首次购买", "giftAmount": "赠送金额", "giftAmountPlaceholder": "输入赠送金额", "giftLogs": "赠送日志", + "globalDefault": "全局默认", "invalidEmailFormat": "邮箱格式无效", "inviteCode": "邀请码", "inviteCodePlaceholder": "输入邀请码", + "inviteCount": "邀请用户数", + "inviteStats": "邀请统计", + "invitedUsers": "已邀请用户", "kickOfflineConfirm": "确认踢下线", "kickOfflineSuccess": "设备已踢下线", "lastSeen": "最后上线", @@ -52,17 +92,22 @@ "loginNotifications": "登录通知", "loginStatus": "登录状态", "manager": "管理员", + "memberCount": "成员数量", "more": "更多", "normal": "正常", + "next": "下一页", + "noInvitedUsers": "暂无邀请用户", "notifySettingsTitle": "通知设置", "offline": "离线", "online": "在线", "onlineDevices": "在线设备", "onlyFirstPurchase": "仅首次购买", "orderList": "订单列表", + "owner": "所有者", "password": "密码", "passwordPlaceholder": "输入密码", "permanent": "永久", + "prev": "上一页", "pleaseEnterEmail": "输入邮箱", "referer": "推荐人", "refererId": "推荐人 ID", @@ -71,7 +116,9 @@ "referralPercentage": "推荐百分比", "referralPercentagePlaceholder": "输入百分比", "referrerUserId": "推荐人用户 ID", + "registeredAt": "注册时间", "remove": "移除", + "removeSuccess": "移除成功", "resetLogs": "重置日志", "resetTraffic": "重置流量", "toggleStatus": "切换状态", @@ -81,6 +128,7 @@ "resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。", "toggleSubscriptionStatus": "切换状态", "toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。", + "resetSearch": "重置", "resetTime": "重置时间", "resetToken": "重置订阅地址", "resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。", @@ -102,6 +150,12 @@ "statusDeducted": "已扣除", "statusStopped": "已停止", "save": "保存", + "search": "搜索", + "searchPlaceholder": "邮箱 / 邀请码 / 设备ID", + "searchInputPlaceholder": "请输入搜索内容", + "sharedSubscription": "共享", + "sharedSubscriptionInfo": "该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅", + "sharedSubscriptionList": "共享订阅列表", "speedLimit": "速度限制", "startTime": "开始时间", "subscription": "订阅", @@ -114,6 +168,7 @@ "telephone": "电话", "telephonePlaceholder": "输入电话号码", "token": "令牌", + "totalCommission": "总佣金", "totalTraffic": "总流量", "tradeNotifications": "交易通知", "trafficDetails": "流量详情", @@ -134,5 +189,7 @@ "userList": "用户列表", "userName": "用户名", "userProfile": "用户资料", - "verified": "已验证" + "verified": "已验证", + "viewDeviceGroup": "查看设备组", + "viewOwner": "查看所有者" } diff --git a/apps/admin/src/layout/navs.ts b/apps/admin/src/layout/navs.ts index 07ea8ba..666957e 100644 --- a/apps/admin/src/layout/navs.ts +++ b/apps/admin/src/layout/navs.ts @@ -88,6 +88,11 @@ export function useNavs() { url: "/dashboard/user", icon: "flat-color-icons:conference-call", }, + { + title: t("Device Group", "Device Group"), + url: "/dashboard/family", + icon: "flat-color-icons:home", + }, { title: t("Ticket Management", "Ticket Management"), url: "/dashboard/ticket", diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index 65c03c2..ae694f6 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport = const DashboardMarketingIndexLazyRouteImport = createFileRoute( '/dashboard/marketing/', )() +const DashboardFamilyIndexLazyRouteImport = + createFileRoute('/dashboard/family/')() const DashboardDocumentIndexLazyRouteImport = createFileRoute( '/dashboard/document/', )() @@ -189,6 +191,14 @@ const DashboardMarketingIndexLazyRoute = } as any).lazy(() => import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route), ) +const DashboardFamilyIndexLazyRoute = + DashboardFamilyIndexLazyRouteImport.update({ + id: '/family/', + path: '/family/', + getParentRoute: () => DashboardRouteLazyRoute, + } as any).lazy(() => + import('./routes/dashboard/family/index.lazy').then((d) => d.Route), + ) const DashboardDocumentIndexLazyRoute = DashboardDocumentIndexLazyRouteImport.update({ id: '/document/', @@ -345,6 +355,7 @@ export interface FileRoutesByFullPath { '/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute '/dashboard/coupon': typeof DashboardCouponIndexLazyRoute '/dashboard/document': typeof DashboardDocumentIndexLazyRoute + '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -377,6 +388,7 @@ export interface FileRoutesByTo { '/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute '/dashboard/coupon': typeof DashboardCouponIndexLazyRoute '/dashboard/document': typeof DashboardDocumentIndexLazyRoute + '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -411,6 +423,7 @@ export interface FileRoutesById { '/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute '/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute '/dashboard/document/': typeof DashboardDocumentIndexLazyRoute + '/dashboard/family/': typeof DashboardFamilyIndexLazyRoute '/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute '/dashboard/order/': typeof DashboardOrderIndexLazyRoute '/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute @@ -446,6 +459,7 @@ export interface FileRouteTypes { | '/dashboard/auth-control' | '/dashboard/coupon' | '/dashboard/document' + | '/dashboard/family' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -478,6 +492,7 @@ export interface FileRouteTypes { | '/dashboard/auth-control' | '/dashboard/coupon' | '/dashboard/document' + | '/dashboard/family' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -511,6 +526,7 @@ export interface FileRouteTypes { | '/dashboard/auth-control/' | '/dashboard/coupon/' | '/dashboard/document/' + | '/dashboard/family/' | '/dashboard/marketing/' | '/dashboard/order/' | '/dashboard/payment/' @@ -627,6 +643,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport parentRoute: typeof DashboardRouteLazyRoute } + '/dashboard/family/': { + id: '/dashboard/family/' + path: '/family' + fullPath: '/dashboard/family' + preLoaderRoute: typeof DashboardFamilyIndexLazyRouteImport + parentRoute: typeof DashboardRouteLazyRoute + } '/dashboard/document/': { id: '/dashboard/document/' path: '/document' @@ -770,6 +793,7 @@ interface DashboardRouteLazyRouteChildren { DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute + DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute @@ -802,6 +826,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute, DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute, DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute, + DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute, DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute, DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute, DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute, diff --git a/apps/admin/src/routes/dashboard/family/index.lazy.tsx b/apps/admin/src/routes/dashboard/family/index.lazy.tsx new file mode 100644 index 0000000..0f9998d --- /dev/null +++ b/apps/admin/src/routes/dashboard/family/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import FamilyManagement from "@/sections/user/family"; + +export const Route = createLazyFileRoute("/dashboard/family/")({ + component: FamilyManagement, +}); diff --git a/apps/admin/src/sections/auth-control/forms/email-settings-form.tsx b/apps/admin/src/sections/auth-control/forms/email-settings-form.tsx index 09a6cce..d021b34 100644 --- a/apps/admin/src/sections/auth-control/forms/email-settings-form.tsx +++ b/apps/admin/src/sections/auth-control/forms/email-settings-form.tsx @@ -54,6 +54,7 @@ const emailSettingsSchema = z.object({ expiration_email_template: z.string().optional(), maintenance_email_template: z.string().optional(), traffic_exceed_email_template: z.string().optional(), + delete_account_email_template: z.string().optional(), platform: z.string(), platform_config: z .object({ @@ -102,6 +103,7 @@ export default function EmailSettingsForm() { expiration_email_template: "", maintenance_email_template: "", traffic_exceed_email_template: "", + delete_account_email_template: "", platform: "smtp", platform_config: { host: "", @@ -195,6 +197,12 @@ export default function EmailSettingsForm() { {t("email.trafficTemplate", "Traffic Template")} + + {t( + "email.deleteAccountTemplate", + "Delete Account Template" + )} + @@ -840,6 +848,88 @@ export default function EmailSettingsForm() { )} /> + + + ( + + + {t( + "email.deleteAccountEmailTemplate", + "Delete Account Email Template" + )} + + + + +
+

+ {t( + "email.templateVariables.title", + "Template Variables" + )} +

+
+
+ + {"{{.SiteLogo}}"} + + + {t( + "email.templateVariables.siteLogo.description", + "Site logo URL" + )} + +
+
+ + {"{{.SiteName}}"} + + + {t( + "email.templateVariables.siteName.description", + "Site name" + )} + +
+
+ + {"{{.Code}}"} + + + {t( + "email.templateVariables.code.description", + "Verification code" + )} + +
+
+ + {"{{.Expire}}"} + + + {t( + "email.templateVariables.expire.description", + "Code expiration time" + )} + +
+
+
+ +
+ )} + /> +
diff --git a/apps/admin/src/sections/redemption/index.tsx b/apps/admin/src/sections/redemption/index.tsx index c96f038..abbaa28 100644 --- a/apps/admin/src/sections/redemption/index.tsx +++ b/apps/admin/src/sections/redemption/index.tsx @@ -29,220 +29,223 @@ export default function Redemption() { const ref = useRef(null); return ( <> - - action={ref} - actions={{ - render: (row) => [ - , - - initialValues={row} - key="edit" - loading={loading} - onSubmit={async (values) => { - setLoading(true); - try { - await updateRedemptionCode({ ...values }); - toast.success(t("updateSuccess", "Update Success")); - ref.current?.refresh(); - setLoading(false); - return true; - } catch (_error) { - setLoading(false); - return false; - } - }} - title={t("editRedemptionCode", "Edit Redemption Code")} - trigger={t("edit", "Edit")} - />, - { - await deleteRedemptionCode({ id: row.id }); - toast.success(t("deleteSuccess", "Delete Success")); - ref.current?.refresh(); - }} - title={t("confirmDelete", "Are you sure you want to delete?")} - trigger={ - - } - />, - ], - batchRender: (rows) => [ - { - await batchDeleteRedemptionCode({ - ids: rows.map((item) => item.id), - }); - toast.success(t("deleteSuccess", "Delete Success")); - ref.current?.reset(); - }} - title={t("confirmDelete", "Are you sure you want to delete?")} - trigger={ - - } - />, - ], - }} - columns={[ - { - accessorKey: "code", - header: t("code", "Code"), - }, - { - accessorKey: "subscribe_plan", - header: t("subscribePlan", "Subscribe Plan"), - cell: ({ row }) => { - const plan = subscribes?.find( - (s) => s.id === row.getValue("subscribe_plan") - ); - return plan?.name || "--"; - }, - }, - { - accessorKey: "unit_time", - header: t("unitTime", "Unit Time"), - cell: ({ row }) => { - const unitTime = row.getValue("unit_time") as string; - const unitTimeMap: Record = { - day: t("form.day", "Day"), - month: t("form.month", "Month"), - quarter: t("form.quarter", "Quarter"), - half_year: t("form.halfYear", "Half Year"), - year: t("form.year", "Year"), - }; - return unitTimeMap[unitTime] || unitTime; - }, - }, - { - accessorKey: "quantity", - header: t("duration", "Duration"), - cell: ({ row }) => `${row.original.quantity}`, - }, - { - accessorKey: "total_count", - header: t("totalCount", "Total Count"), - cell: ({ row }) => ( -
- - {t("totalCount", "Total")}: {row.original.total_count} - - - {t("remainingCount", "Remaining")}:{" "} - {row.original.total_count - (row.original.used_count || 0)} - - - {t("usedCount", "Used")}: {row.original.used_count || 0} - -
- ), - }, - { - accessorKey: "status", - header: t("status", "Status"), - cell: ({ row }) => ( - { - await toggleRedemptionCodeStatus({ - id: row.original.id, - status: checked ? 1 : 0, - }); - toast.success( - checked - ? t("updateSuccess", "Update Success") - : t("updateSuccess", "Update Success") - ); + + action={ref} + actions={{ + render: (row) => [ + , + + initialValues={row} + key="edit" + loading={loading} + onSubmit={async (values) => { + setLoading(true); + try { + await updateRedemptionCode({ ...values }); + toast.success(t("updateSuccess", "Update Success")); + ref.current?.refresh(); + setLoading(false); + return true; + } catch (_error) { + setLoading(false); + return false; + } + }} + title={t("editRedemptionCode", "Edit Redemption Code")} + trigger={t("edit", "Edit")} + />, + { + await deleteRedemptionCode({ id: row.id }); + toast.success(t("deleteSuccess", "Delete Success")); ref.current?.refresh(); }} + title={t("confirmDelete", "Are you sure you want to delete?")} + trigger={ + + } + />, + ], + batchRender: (rows) => [ + { + await batchDeleteRedemptionCode({ + ids: rows.map((item) => item.id), + }); + toast.success(t("deleteSuccess", "Delete Success")); + ref.current?.reset(); + }} + title={t("confirmDelete", "Are you sure you want to delete?")} + trigger={ + + } + />, + ], + }} + columns={[ + { + accessorKey: "code", + header: t("code", "Code"), + }, + { + accessorKey: "subscribe_plan", + header: t("subscribePlan", "Subscribe Plan"), + cell: ({ row }) => { + const plan = subscribes?.find( + (s) => s.id === row.getValue("subscribe_plan") + ); + return plan?.name || "--"; + }, + }, + { + accessorKey: "unit_time", + header: t("unitTime", "Unit Time"), + cell: ({ row }) => { + const unitTime = row.getValue("unit_time") as string; + const unitTimeMap: Record = { + day: t("form.day", "Day"), + month: t("form.month", "Month"), + quarter: t("form.quarter", "Quarter"), + half_year: t("form.halfYear", "Half Year"), + year: t("form.year", "Year"), + }; + return unitTimeMap[unitTime] || unitTime; + }, + }, + { + accessorKey: "quantity", + header: t("duration", "Duration"), + cell: ({ row }) => `${row.original.quantity}`, + }, + { + accessorKey: "total_count", + header: t("totalCount", "Total Count"), + cell: ({ row }) => ( +
+ + {t("totalCount", "Total")}: {row.original.total_count} + + + {t("remainingCount", "Remaining")}:{" "} + {row.original.total_count - (row.original.used_count || 0)} + + + {t("usedCount", "Used")}: {row.original.used_count || 0} + +
+ ), + }, + { + accessorKey: "status", + header: t("status", "Status"), + cell: ({ row }) => ( + { + await toggleRedemptionCodeStatus({ + id: row.original.id, + status: checked ? 1 : 0, + }); + toast.success( + checked + ? t("updateSuccess", "Update Success") + : t("updateSuccess", "Update Success") + ); + ref.current?.refresh(); + }} + /> + ), + }, + ]} + header={{ + toolbar: ( + + loading={loading} + onSubmit={async (values) => { + setLoading(true); + try { + await createRedemptionCode(values); + toast.success(t("createSuccess", "Create Success")); + ref.current?.refresh(); + setLoading(false); + return true; + } catch (_error) { + setLoading(false); + return false; + } + }} + title={t("createRedemptionCode", "Create Redemption Code")} + trigger={t("create", "Create")} /> ), - }, - ]} - header={{ - toolbar: ( - - loading={loading} - onSubmit={async (values) => { - setLoading(true); - try { - await createRedemptionCode(values); - toast.success(t("createSuccess", "Create Success")); - ref.current?.refresh(); - setLoading(false); - return true; - } catch (_error) { - setLoading(false); - return false; - } - }} - title={t("createRedemptionCode", "Create Redemption Code")} - trigger={t("create", "Create")} - /> - ), - }} - params={[ - { - key: "subscribe_plan", - placeholder: t("subscribePlan", "Subscribe Plan"), - options: subscribes?.map((item) => ({ - label: item.name!, - value: String(item.id), - })), - }, - { - key: "unit_time", - placeholder: t("unitTime", "Unit Time"), - options: [ - { label: t("form.day", "Day"), value: "day" }, - { label: t("form.month", "Month"), value: "month" }, - { label: t("form.quarter", "Quarter"), value: "quarter" }, - { label: t("form.halfYear", "Half Year"), value: "half_year" }, - { label: t("form.year", "Year"), value: "year" }, - ], - }, - { - key: "code", - }, - ]} - request={async (pagination, filters) => { - const { data } = await getRedemptionCodeList({ - ...pagination, - ...filters, - }); - return { - list: data.data?.list || [], - total: data.data?.total || 0, - }; - }} - /> - + }} + params={[ + { + key: "subscribe_plan", + placeholder: t("subscribePlan", "Subscribe Plan"), + options: subscribes?.map((item) => ({ + label: item.name!, + value: String(item.id), + })), + }, + { + key: "unit_time", + placeholder: t("unitTime", "Unit Time"), + options: [ + { label: t("form.day", "Day"), value: "day" }, + { label: t("form.month", "Month"), value: "month" }, + { label: t("form.quarter", "Quarter"), value: "quarter" }, + { label: t("form.halfYear", "Half Year"), value: "half_year" }, + { label: t("form.year", "Year"), value: "year" }, + ], + }, + { + key: "code", + }, + ]} + request={async (pagination, filters) => { + const { data } = await getRedemptionCodeList({ + ...pagination, + ...filters, + }); + return { + list: data.data?.list || [], + total: data.data?.total || 0, + }; + }} + /> + ); } diff --git a/apps/admin/src/sections/redemption/redemption-form.tsx b/apps/admin/src/sections/redemption/redemption-form.tsx index dcb5e91..8e988d3 100644 --- a/apps/admin/src/sections/redemption/redemption-form.tsx +++ b/apps/admin/src/sections/redemption/redemption-form.tsx @@ -26,15 +26,24 @@ import { useTranslation } from "react-i18next"; import { z } from "zod"; import { useSubscribe } from "@/stores/subscribe"; -const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({ - id: z.number().optional(), - code: z.string().optional(), - batch_count: z.number().optional(), - total_count: z.number().min(1, t("form.totalCountRequired", "Total count is required")), - subscribe_plan: z.number().min(1, t("form.subscribePlanRequired", "Subscribe plan is required")), - unit_time: z.string().min(1, t("form.unitTimeRequired", "Unit time is required")), - quantity: z.number().min(1, t("form.quantityRequired", "Quantity is required")), -}); +const getFormSchema = (t: (key: string, defaultValue: string) => string) => + z.object({ + id: z.number().optional(), + code: z.string().optional(), + batch_count: z.number().optional(), + total_count: z + .number() + .min(1, t("form.totalCountRequired", "Total count is required")), + subscribe_plan: z + .number() + .min(1, t("form.subscribePlanRequired", "Subscribe plan is required")), + unit_time: z + .string() + .min(1, t("form.unitTimeRequired", "Unit time is required")), + quantity: z + .number() + .min(1, t("form.quantityRequired", "Quantity is required")), + }); interface RedemptionFormProps { onSubmit: (data: T) => Promise | boolean; @@ -184,9 +193,7 @@ export default function RedemptionForm>({ name="unit_time" render={({ field }) => ( - - {t("form.unitTime", "Unit Time")} - + {t("form.unitTime", "Unit Time")} onChange={(value) => { @@ -195,8 +202,14 @@ export default function RedemptionForm>({ options={[ { value: "day", label: t("form.day", "Day") }, { value: "month", label: t("form.month", "Month") }, - { value: "quarter", label: t("form.quarter", "Quarter") }, - { value: "half_year", label: t("form.halfYear", "Half Year") }, + { + value: "quarter", + label: t("form.quarter", "Quarter"), + }, + { + value: "half_year", + label: t("form.halfYear", "Half Year"), + }, { value: "year", label: t("form.year", "Year") }, ]} placeholder={t( @@ -215,16 +228,11 @@ export default function RedemptionForm>({ name="quantity" render={({ field }) => ( - - {t("form.duration", "Duration")} - + {t("form.duration", "Duration")} >({ name="total_count" render={({ field }) => ( - - {t("form.totalCount", "Total Count")} - + {t("form.totalCount", "Total Count")} ([]); const [total, setTotal] = useState(0); - const [pagination, setPagination] = useState({ page: 1, size: 10 }); + const [pagination, setPagination] = useState({ page: 1, size: 200 }); const fetchRecords = async () => { if (!codeId) return; @@ -59,12 +59,10 @@ export default function RedemptionRecords({ }, [open, codeId, pagination]); return ( - - + + - - {t("records", "Redemption Records")} - + {t("records", "Redemption Records")}
{loading ? ( @@ -72,7 +70,7 @@ export default function RedemptionRecords({ {t("loading", "Loading...")}
) : records.length === 0 ? ( -
+
{t("noRecords", "No records found")}
) : ( @@ -102,7 +100,9 @@ export default function RedemptionRecords({ {record.id} {record.user_id} {record.subscribe_id} - {unitTimeMap[record.unit_time] || record.unit_time} + + {unitTimeMap[record.unit_time] || record.unit_time} + {record.quantity} {record.redeemed_at @@ -115,26 +115,28 @@ export default function RedemptionRecords({ {total > pagination.size && ( -
- +
+ {t("total", "Total")}: {total}
diff --git a/apps/admin/src/sections/system/index.tsx b/apps/admin/src/sections/system/index.tsx index d155d1c..1f56efe 100644 --- a/apps/admin/src/sections/system/index.tsx +++ b/apps/admin/src/sections/system/index.tsx @@ -12,6 +12,8 @@ import TosForm from "./basic-settings/tos-form"; import LogCleanupForm from "./log-cleanup/log-cleanup-form"; import InviteForm from "./user-security/invite-form"; import RegisterForm from "./user-security/register-form"; +import SignatureForm from "./user-security/signature-form"; +import SubscribeModeForm from "./user-security/subscribe-mode-form"; import VerifyCodeForm from "./user-security/verify-code-form"; import VerifyForm from "./user-security/verify-form"; @@ -35,6 +37,8 @@ export default function System() { { component: InviteForm }, { component: VerifyForm }, { component: VerifyCodeForm }, + { component: SignatureForm }, + { component: SubscribeModeForm }, ], }, { diff --git a/apps/admin/src/sections/system/user-security/invite-form.tsx b/apps/admin/src/sections/system/user-security/invite-form.tsx index fc834a7..a12419b 100644 --- a/apps/admin/src/sections/system/user-security/invite-form.tsx +++ b/apps/admin/src/sections/system/user-security/invite-form.tsx @@ -36,6 +36,7 @@ const inviteSchema = z.object({ forced_invite: z.boolean().optional(), referral_percentage: z.number().optional(), only_first_purchase: z.boolean().optional(), + gift_days: z.number().optional(), }); type InviteFormData = z.infer; @@ -60,6 +61,7 @@ export default function InviteConfig() { forced_invite: false, referral_percentage: 0, only_first_purchase: false, + gift_days: 0, }, }); @@ -185,6 +187,38 @@ export default function InviteConfig() { )} /> + ( + + + {t("invite.giftDays", "Invite Gift Days")} + + + field.onChange(Number(value))} + placeholder={t( + "invite.giftDaysPlaceholder", + "Enter days" + )} + suffix={t("invite.giftDaysSuffix", "day(s)")} + type="number" + value={field.value} + /> + + + {t( + "invite.giftDaysDescription", + "When referral percentage is 0, both the inviter and invitee receive extra subscription days after a purchase" + )} + + + + )} + /> + ; + +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({ + 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 ( + + +
+
+
+ +
+
+

+ {t("signature.title", "Request Signature")} +

+

+ {t( + "signature.description", + "Enable or disable request signature verification for public APIs" + )} +

+
+
+ +
+
+ + + {t("signature.title", "Request Signature")} + + +
+ + ( + + + {t("signature.enable", "Enable Signature Verification")} + + + + + + {t( + "signature.enableDescription", + "When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1" + )} + + + + )} + /> + + +
+ + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/system/user-security/subscribe-mode-form.tsx b/apps/admin/src/sections/system/user-security/subscribe-mode-form.tsx new file mode 100644 index 0000000..5074eb2 --- /dev/null +++ b/apps/admin/src/sections/system/user-security/subscribe-mode-form.tsx @@ -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; + +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({ + 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 ( + + +
+
+
+ +
+
+

+ {t("subscribeMode.title", "Subscription Mode")} +

+

+ {t( + "subscribeMode.description", + "Configure single or multiple subscription purchase behavior" + )} +

+
+
+ +
+
+ + + + {t("subscribeMode.title", "Subscription Mode")} + + + +
+ + ( + + + {t( + "subscribeMode.singleSubscriptionMode", + "Single Subscription Mode" + )} + + + + + + {t( + "subscribeMode.singleSubscriptionModeDescription", + "After enabling, users can only purchase/renew one subscription in the same account" + )} + + + + )} + /> + + +
+ + + + +
+
+ ); +} diff --git a/apps/admin/src/sections/user/family/.gitkeep b/apps/admin/src/sections/user/family/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/admin/src/sections/user/family/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/admin/src/sections/user/family/enums.ts b/apps/admin/src/sections/user/family/enums.ts new file mode 100644 index 0000000..46eda79 --- /dev/null +++ b/apps/admin/src/sections/user/family/enums.ts @@ -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 || "--"; + } +} diff --git a/apps/admin/src/sections/user/family/family-detail-sheet.tsx b/apps/admin/src/sections/user/family/family-detail-sheet.tsx new file mode 100644 index 0000000..24ade79 --- /dev/null +++ b/apps/admin/src/sections/user/family/family-detail-sheet.tsx @@ -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) { + 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 ( + + {trigger} + + + + {t("familyDetail", "Family Detail")} + {validFamilyId ? ` · ID: ${validFamilyId}` : ""} + + + + {isLoading ? ( +
{t("loading", "Loading...")}
+ ) : data ? ( +
+
+

+ {t("familySummary", "Family Summary")} +

+
+ {summaryItems.map((item) => ( +
+ + {item.label} + + {item.value} +
+ ))} +
+
+ +
+

+ {t("familyActions", "Family Actions")} +

+
+
+ { + setMaxMembersInput(value); + }} + type="number" + value={maxMembersInput} + /> +
+ + + { + await dissolveMutation.mutateAsync(); + }} + title={t( + "familyConfirmDissolve", + "Confirm Dissolve Family" + )} + trigger={ + + } + /> +
+
+ +
+

+ {t("familyMembers", "Family Members")} +

+
+ {data.members?.length ? ( + data.members.map((member) => { + const canRemove = + isFamilyStatusActive(data.summary.status) && + !isFamilyRoleOwner(member.role_name) && + isFamilyMemberStatusActive(member.status_name); + return ( +
+
+
+ + ID: {member.user_id} + + {member.device_no ? ( + + + {member.device_no} + + + ) : null} + {member.identifier} + + {getFamilyRoleLabel(t, member.role_name)} + + + {getFamilyMemberStatusLabel( + t, + member.status_name + )} + +
+
+ {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) + : "--"} +
+
+ {canRemove ? ( + { + await removeMemberMutation.mutateAsync( + member.user_id + ); + }} + title={t( + "familyConfirmRemoveMember", + "Confirm Remove Member" + )} + trigger={ + + } + /> + ) : null} +
+ ); + }) + ) : ( +
+ {t("familyNoMembers", "No members")} +
+ )} +
+
+
+ ) : ( +
+ {t("familyNoData", "No family data")} +
+ )} +
+
+
+ ); +} diff --git a/apps/admin/src/sections/user/family/index.tsx b/apps/admin/src/sections/user/family/index.tsx new file mode 100644 index 0000000..14198b0 --- /dev/null +++ b/apps/admin/src/sections/user/family/index.tsx @@ -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) { + const { t } = useTranslation("user"); + const ref = useRef(null); + + const initialFilters = { + family_id: initialFamilyId || undefined, + user_id: initialUserId || undefined, + }; + + return ( + + action={ref} + actions={{ + render: (row) => [ + { + ref.current?.refresh(); + onChanged?.(); + }} + trigger={} + />, + ], + }} + 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) ? ( + {t("statusActive", "Active")} + ) : ( + + {getFamilyStatusLabel(t, status)} + + ); + }, + }, + { + 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, + }; + }} + /> + ); +} diff --git a/apps/admin/src/sections/user/index.tsx b/apps/admin/src/sections/user/index.tsx index 0b50a80..cf9b361 100644 --- a/apps/admin/src/sections/user/index.tsx +++ b/apps/admin/src/sections/user/index.tsx @@ -8,7 +8,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; +import { Input } from "@workspace/ui/components/input"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select"; import { Sheet, SheetContent, @@ -23,6 +31,7 @@ import { TabsList, TabsTrigger, } from "@workspace/ui/components/tabs"; +import { Combobox } from "@workspace/ui/composed/combobox"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ProTable, @@ -35,14 +44,16 @@ import { getUserList, updateUserBasicInfo, } from "@workspace/ui/services/admin/user"; -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Display } from "@/components/display"; import { useSubscribe } from "@/stores/subscribe"; import { formatDate } from "@/utils/common"; +import FamilyManagement from "./family"; import { UserDetail } from "./user-detail"; import UserForm from "./user-form"; +import { UserInviteStatsSheet } from "./user-invite-stats-sheet"; import { AuthMethodsForm } from "./user-profile/auth-methods-form"; import { BasicInfoForm } from "./user-profile/basic-info-form"; import { NotifySettingsForm } from "./user-profile/notify-settings-form"; @@ -56,13 +67,15 @@ export default function User() { const { subscribes } = useSubscribe(); - const initialFilters = { - search: sp.search || undefined, - user_id: sp.user_id || undefined, - subscribe_id: sp.subscribe_id || undefined, - user_subscribe_id: sp.user_subscribe_id || undefined, - short_code: sp.short_code || undefined, - }; + const searchRef = useRef({ + type: sp.user_id ? "user_id" : "email", + value: sp.search || sp.user_id || "", + }); + + const handleSearch = useCallback((type: string, value: string) => { + searchRef.current = { type, value }; + ref.current?.refresh(); + }, []); return ( @@ -75,6 +88,11 @@ export default function User() { userId={row.id} />, , + ref.current?.refresh()} + userId={row.id} + />, {t("more", "More")} + { 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 (
{method?.auth_type} - {method?.auth_identifier} + {display}
); }, @@ -245,7 +268,14 @@ export default function User() { }, ]} header={{ - title: t("userList", "User List"), + title: ( + + ), toolbar: ( key="create" @@ -270,39 +300,19 @@ export default function User() { /> ), }} - initialFilters={initialFilters} - key={initialFilters.user_id} - params={[ - { - key: "subscribe_id", - placeholder: t("subscription", "Subscription"), - options: subscribes?.map((item) => ({ - label: item.name!, - value: String(item.id!), - })), - }, - { - key: "search", - placeholder: "Search", - }, - { - key: "user_id", - placeholder: t("userId", "User ID"), - }, - { - key: "user_subscribe_id", - placeholder: t("subscriptionId", "Subscription ID"), - }, - { - key: "short_code", - placeholder: t("shortCode", "Short Code"), - }, - ]} - request={async (pagination, filter) => { - const { data } = await getUserList({ - ...pagination, - ...filter, - }); + request={async (pagination) => { + const { type, value } = searchRef.current; + const params: Record = { ...pagination }; + if (value) { + if (type === "user_id") { + params.user_id = value; + } else if (type === "subscribe_id") { + params.subscribe_id = value; + } else { + params.search = value; + } + } + const { data } = await getUserList(params as API.GetUserListParams); return { list: data.data?.list || [], total: data.data?.total || 0, @@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) { ); } + +function InviteStatsMenuItem({ userId }: { userId: number }) { + const { t } = useTranslation("user"); + const [open, setOpen] = useState(false); + return ( + <> + { + e.preventDefault(); + setOpen(true); + }} + > + {t("inviteStats", "Invite Statistics")} + + + + ); +} + +function DeviceGroupSheet({ + userId, + onChanged, +}: { + userId: number; + onChanged?: () => void; +}) { + const { t } = useTranslation("user"); + const [open, setOpen] = useState(false); + return ( + + + + + + + + {t("deviceGroup", "Device Group")} · ID: {userId} + + +
+ +
+
+
+ ); +} + +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 ( +
+ + {searchType === "subscribe_id" ? ( + { + setSearchValue(value); + onSearch("subscribe_id", value); + }} + options={subscribes?.map((item) => ({ + label: item.name!, + value: String(item.id!), + }))} + placeholder={t("subscription", "Subscription")} + value={searchValue} + /> + ) : ( + <> + setSearchValue(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && onSearch(searchType, searchValue) + } + placeholder={t("searchInputPlaceholder", "Enter search term")} + value={searchValue} + /> + + {searchValue && ( + + )} + + )} +
+ ); +} diff --git a/apps/admin/src/sections/user/user-detail.tsx b/apps/admin/src/sections/user/user-detail.tsx index f830fc2..3098a5b 100644 --- a/apps/admin/src/sections/user/user-detail.tsx +++ b/apps/admin/src/sections/user/user-detail.tsx @@ -12,6 +12,7 @@ import { getUserDetail, getUserSubscribeById, } from "@workspace/ui/services/admin/user"; +import { shortenDeviceIdentifier } from "@workspace/ui/utils/device"; import { formatBytes } from "@workspace/ui/utils/formatting"; import { useTranslation } from "react-i18next"; import { Display } from "@/components/display"; @@ -163,9 +164,14 @@ export function UserDetail({ id }: { id: number }) { if (!id) return "--"; - const identifier = - data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier || - data?.auth_methods[0]?.auth_identifier; + const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email"); + const firstMethod = data?.auth_methods[0]; + const rawIdentifier = + emailMethod?.auth_identifier || firstMethod?.auth_identifier || ""; + const isDevice = !emailMethod && firstMethod?.auth_type === "device"; + const identifier = isDevice + ? shortenDeviceIdentifier(rawIdentifier) + : rawIdentifier; return ( diff --git a/apps/admin/src/sections/user/user-invite-stats-sheet.tsx b/apps/admin/src/sections/user/user-invite-stats-sheet.tsx new file mode 100644 index 0000000..bd47cf3 --- /dev/null +++ b/apps/admin/src/sections/user/user-invite-stats-sheet.tsx @@ -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 ( + + + + {t("inviteStats", "Invite Statistics")} + + + {/* 概览卡片 */} +
+ + + {stats?.invite_count ?? 0} + + + + + + + + + + + {stats?.referral_percentage + ? `${stats.referral_percentage}%` + : t("globalDefault", "Global Default")} + + {stats?.only_first_purchase && ( + + ({t("firstPurchaseOnly", "First purchase only")}) + + )} + +
+ + {/* 邀请用户列表 */} +
+

+ {t("invitedUsers", "Invited Users")} ({total}) +

+ {listLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : inviteList.length === 0 ? ( +

+ {t("noInvitedUsers", "No invited users yet")} +

+ ) : ( + <> + + + + {t("user", "User")} + {t("status", "Status")} + {t("registeredAt", "Registered At")} + + + + {inviteList.map((user) => ( + + +
+ + + + {user.identifier?.charAt(0)?.toUpperCase() ?? "U"} + + + + {user.identifier || `#${user.id}`} + +
+
+ + + {user.enable + ? t("enabled", "Enabled") + : t("disabled", "Disabled")} + + + + {formatDate(user.created_at)} + +
+ ))} +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + )} +
+
+
+ ); +} + +function StatCard({ + label, + loading, + children, +}: { + label: string; + loading: boolean; + children: React.ReactNode; +}) { + return ( +
+

{label}

+ {loading ? ( + + ) : ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/admin/src/sections/user/user-subscription/index.tsx b/apps/admin/src/sections/user/user-subscription/index.tsx index a132924..18aed01 100644 --- a/apps/admin/src/sections/user/user-subscription/index.tsx +++ b/apps/admin/src/sections/user/user-subscription/index.tsx @@ -1,4 +1,5 @@ import { Link } from "@tanstack/react-router"; +import { Alert, AlertDescription } from "@workspace/ui/components/alert"; import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { @@ -15,12 +16,14 @@ import { import { createUserSubscribe, deleteUserSubscribe, + getFamilyList, getUserSubscribe, resetUserSubscribeToken, toggleUserSubscribeStatus, updateUserSubscribe, } from "@workspace/ui/services/admin/user"; -import { useRef, useState } from "react"; +import { Info } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Display } from "@/components/display"; @@ -29,196 +32,420 @@ import { formatDate } from "@/utils/common"; import { SubscriptionDetail } from "./subscription-detail"; import { SubscriptionForm } from "./subscription-form"; +interface SharedInfo { + ownerUserId: number; + familyId: number; +} + export default function UserSubscription({ userId }: { userId: number }) { const { t } = useTranslation("user"); const [loading, setLoading] = useState(false); const ref = useRef(null); + const [sharedInfo, setSharedInfo] = useState(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 ( - > - action={ref} - actions={{ - render: (row) => [ - { - setLoading(true); - await updateUserSubscribe({ - user_id: Number(userId), - user_subscribe_id: row.id, - ...values, - }); - toast.success(t("updateSuccess", "Updated successfully")); - ref.current?.refresh(); - setLoading(false); - return true; - }} - title={t("editSubscription", "Edit Subscription")} - trigger={t("edit", "Edit")} - />, - ref.current?.refresh()} - row={row} - token={row.token} - userId={userId} - />, - ], - }} - columns={[ - { - accessorKey: "id", - header: "ID", - }, - { - accessorKey: "name", - header: t("subscriptionName", "Subscription Name"), - cell: ({ row }) => row.original.subscribe.name, - }, - { - accessorKey: "status", - header: t("status", "Status"), - cell: ({ row }) => { - const status = row.getValue("status") as number; - const expireTime = row.original.expire_time; +
+ {isSharedView && ( + + + + + {t("sharedSubscriptionInfo", { + defaultValue: + "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})", + ownerId: sharedInfo.ownerUserId, + })} + + + + + + + + )} + > + action={ref} + actions={{ + render: (row) => + isSharedView + ? [ + + {t("sharedSubscription", "Shared")} + , + ref.current?.refresh()} + row={row} + token={row.token} + userId={sharedInfo!.ownerUserId} + />, + ] + : [ + { + setLoading(true); + await updateUserSubscribe({ + user_id: Number(userId), + user_subscribe_id: row.id, + ...values, + }); + toast.success(t("updateSuccess", "Updated successfully")); + ref.current?.refresh(); + setLoading(false); + return true; + }} + title={t("editSubscription", "Edit Subscription")} + trigger={t("edit", "Edit")} + />, + ref.current?.refresh()} + row={row} + token={row.token} + userId={userId} + />, + ], + }} + columns={[ + { + accessorKey: "id", + header: "ID", + }, + { + accessorKey: "name", + header: t("subscriptionName", "Subscription Name"), + cell: ({ row }) => ( + + {row.original.subscribe.name} + {isSharedView && ( + + {t("sharedSubscription", "Shared")} + + )} + + ), + }, + { + accessorKey: "status", + header: t("status", "Status"), + cell: ({ row }) => { + const status = row.getValue("status") as number; + const expireTime = row.original.expire_time; - // 如果过期时间为0,说明是永久订阅,应该显示为激活状态 - const displayStatus = status === 3 && expireTime === 0 ? 1 : status; + // 如果过期时间为0,说明是永久订阅,应该显示为激活状态 + const displayStatus = + status === 3 && expireTime === 0 ? 1 : status; - const statusMap: Record< - number, - { - label: string; - variant: "default" | "secondary" | "destructive" | "outline"; - } - > = { - 0: { label: t("statusPending", "Pending"), variant: "outline" }, - 1: { label: t("statusActive", "Active"), variant: "default" }, - 2: { - label: t("statusFinished", "Finished"), - variant: "secondary", - }, - 3: { - label: t("statusExpired", "Expired"), - variant: "destructive", - }, - 4: { - label: t("statusDeducted", "Deducted"), - variant: "secondary", - }, - 5: { - label: t("statusStopped", "Stopped"), - variant: "destructive", - }, - }; - const statusInfo = statusMap[displayStatus] || { - label: "Unknown", - variant: "outline", - }; - return ( - {statusInfo.label} - ); + const statusMap: Record< + number, + { + label: string; + variant: "default" | "secondary" | "destructive" | "outline"; + } + > = { + 0: { + label: t("statusPending", "Pending"), + variant: "outline", + }, + 1: { label: t("statusActive", "Active"), variant: "default" }, + 2: { + label: t("statusFinished", "Finished"), + variant: "secondary", + }, + 3: { + label: t("statusExpired", "Expired"), + variant: "destructive", + }, + 4: { + label: t("statusDeducted", "Deducted"), + variant: "secondary", + }, + 5: { + label: t("statusStopped", "Stopped"), + variant: "destructive", + }, + }; + const statusInfo = statusMap[displayStatus] || { + label: "Unknown", + variant: "outline", + }; + return ( + {statusInfo.label} + ); + }, }, - }, - { - accessorKey: "upload", - header: t("upload", "Upload"), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "download", - header: t("download", "Download"), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "traffic", - header: t("totalTraffic", "Total Traffic"), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: "speed_limit", - header: t("speedLimit", "Speed Limit"), - cell: ({ row }) => { - const speed = row.original?.subscribe?.speed_limit; - return ; + { + accessorKey: "upload", + header: t("upload", "Upload"), + cell: ({ row }) => ( + + ), }, - }, - { - accessorKey: "device_limit", - header: t("deviceLimit", "Device Limit"), - cell: ({ row }) => { - const limit = row.original?.subscribe?.device_limit; - return ; + { + accessorKey: "download", + header: t("download", "Download"), + cell: ({ row }) => ( + + ), }, - }, - { - accessorKey: "reset_time", - header: t("resetTime", "Reset Time"), - cell: ({ row }) => ( - ( + + ), + }, + { + accessorKey: "speed_limit", + header: t("speedLimit", "Speed Limit"), + cell: ({ row }) => { + const speed = row.original?.subscribe?.speed_limit; + return ; + }, + }, + { + accessorKey: "device_limit", + header: t("deviceLimit", "Device Limit"), + cell: ({ row }) => { + const limit = row.original?.subscribe?.device_limit; + return ; + }, + }, + { + accessorKey: "reset_time", + header: t("resetTime", "Reset Time"), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "expire_time", + header: t("expireTime", "Expire Time"), + cell: ({ row }) => { + const expireTime = row.getValue("expire_time") as number; + return expireTime && expireTime !== 0 + ? formatDate(expireTime) + : t("permanent", "Permanent"); + }, + }, + { + accessorKey: "created_at", + header: t("createdAt", "Created At"), + cell: ({ row }) => formatDate(row.getValue("created_at")), + }, + ]} + header={{ + title: isSharedView + ? t("sharedSubscriptionList", "Shared Subscription List") + : t("subscriptionList", "Subscription List"), + toolbar: isSharedView ? undefined : ( + { + setLoading(true); + await createUserSubscribe({ + user_id: Number(userId), + ...values, + }); + toast.success(t("createSuccess", "Created successfully")); + ref.current?.refresh(); + setLoading(false); + return true; + }} + title={t("createSubscription", "Create Subscription")} + trigger={t("add", "Add")} /> ), - }, - { - accessorKey: "expire_time", - header: t("expireTime", "Expire Time"), - cell: ({ row }) => { - const expireTime = row.getValue("expire_time") as number; - return expireTime && expireTime !== 0 - ? formatDate(expireTime) - : t("permanent", "Permanent"); - }, - }, - { - accessorKey: "created_at", - header: t("createdAt", "Created At"), - cell: ({ row }) => formatDate(row.getValue("created_at")), - }, - ]} - header={{ - title: t("subscriptionList", "Subscription List"), - toolbar: ( - { - setLoading(true); - await createUserSubscribe({ - user_id: Number(userId), - ...values, - }); - toast.success(t("createSuccess", "Created successfully")); - ref.current?.refresh(); - setLoading(false); - return true; + }} + request={request} + /> +
+ ); +} + +function RowReadOnlyActions({ + userId, + row, + token, + refresh, +}: { + userId: number; + row: API.UserSubscribe; + token: string; + refresh?: () => void; +}) { + const { t } = useTranslation("user"); + const triggerRef = useRef(null); + const deleteRef = useRef(null); + const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore(); + + return ( +
+ + + + + + { + e.preventDefault(); + await navigator.clipboard.writeText( + getUserSubscribeUrls(row.short, token)[0] || "" + ); + toast.success(t("copySuccess", "Copied successfully")); }} - title={t("createSubscription", "Create Subscription")} - trigger={t("add", "Add")} - /> - ), - }} - request={async (pagination) => { - const { data } = await getUserSubscribe({ - user_id: userId, - ...pagination, - }); - return { - list: data.data?.list || [], - total: data.data?.total || 0, - }; - }} - /> + > + {t("copySubscription", "Copy Subscription")} + + + + {t("subscriptionLogs", "Subscription Logs")} + + + + + {t("trafficStats", "Traffic Stats")} + + + + + {t("trafficDetails", "Traffic Details")} + + + { + e.preventDefault(); + triggerRef.current?.click(); + }} + > + {t("onlineDevices", "Online Devices")} + + { + e.preventDefault(); + deleteRef.current?.click(); + }} + > + {t("delete", "Delete")} + + + + + { + await deleteUserSubscribe({ user_subscribe_id: row.id }); + toast.success(t("deleteSuccess", "Deleted successfully")); + refresh?.(); + }} + title={t("confirmDelete", "Confirm Delete")} + trigger={
); } diff --git a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx index 79d2d5b..d02ae3d 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx @@ -14,6 +14,7 @@ import { getUserSubscribeDevices, kickOfflineByUserDevice, } from "@workspace/ui/services/admin/user"; +import { deviceIdToHash } from "@workspace/ui/utils/device"; import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -86,7 +87,18 @@ export function SubscriptionDetail({ ), }, { accessorKey: "id", header: "ID" }, - { accessorKey: "identifier", header: "IMEI" }, + { + accessorKey: "identifier", + header: t("deviceNo", "Device No."), + cell: ({ row }) => { + const id = row.original.id; + return ( + + {deviceIdToHash(id)} + + ); + }, + }, { accessorKey: "user_agent", header: t("userAgent", "User Agent"), diff --git a/apps/admin/src/utils/common.ts b/apps/admin/src/utils/common.ts index 7b4960c..f90f51d 100644 --- a/apps/admin/src/utils/common.ts +++ b/apps/admin/src/utils/common.ts @@ -24,8 +24,11 @@ export function differenceInDays(date1: Date, date2: Date): number { export function formatDate(date?: Date | number, showTime = true) { if (!date) return; + // Backend returns Unix timestamps in seconds; convert to milliseconds for JS Date + const dateValue = + typeof date === "number" && date < 1e12 ? date * 1000 : date; const timeZone = localStorage.getItem("timezone") || "UTC"; - return intlFormat(date, { + return intlFormat(dateValue, { year: "numeric", month: "numeric", day: "numeric", diff --git a/apps/user/src/layout/user-nav.tsx b/apps/user/src/layout/user-nav.tsx index ea6c6cf..662a1ba 100644 --- a/apps/user/src/layout/user-nav.tsx +++ b/apps/user/src/layout/user-nav.tsx @@ -15,6 +15,7 @@ import { DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; import { Icon } from "@workspace/ui/composed/icon"; +import { shortenDeviceIdentifier } from "@workspace/ui/utils/device"; import { useTranslation } from "react-i18next"; import { useNavs } from "@/layout/navs"; import { useGlobalStore } from "@/stores/global"; @@ -32,6 +33,13 @@ export function UserNav() { }; if (user) { + const method = user?.auth_methods?.[0]; + const isDevice = method?.auth_type === "device"; + const rawIdentifier = method?.auth_identifier ?? ""; + const displayName = isDevice + ? shortenDeviceIdentifier(rawIdentifier) + : rawIdentifier.split("@")[0] || ""; + return ( @@ -40,16 +48,14 @@ export function UserNav() { - {user?.auth_methods?.[0]?.auth_identifier - .toUpperCase() - .charAt(0)} + {displayName.toUpperCase().charAt(0)} - {user?.auth_methods?.[0]?.auth_identifier.split("@")[0]} + {displayName} {data.title} -
+
{data.content}
@@ -69,7 +69,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) { {data.content ? ( -
+
{data.content}
) : ( diff --git a/apps/user/src/sections/user/announcement/index.tsx b/apps/user/src/sections/user/announcement/index.tsx index 724cabe..8f5c555 100644 --- a/apps/user/src/sections/user/announcement/index.tsx +++ b/apps/user/src/sections/user/announcement/index.tsx @@ -13,12 +13,12 @@ import { TabsList, TabsTrigger, } from "@workspace/ui/components/tabs"; +import { Icon } from "@workspace/ui/composed/icon"; +import { Markdown } from "@workspace/ui/composed/markdown"; import { ProList, type ProListActions, } from "@workspace/ui/composed/pro-list/pro-list"; -import { Icon } from "@workspace/ui/composed/icon"; -import { Markdown } from "@workspace/ui/composed/markdown"; import { queryAnnouncement } from "@workspace/ui/services/user/announcement"; import { formatDate } from "@workspace/ui/utils/formatting"; import { useRef, useState } from "react"; @@ -60,13 +60,13 @@ export default function Announcement() {
{item.title} {item.pinned && ( - + {t("pinned", "Pinned")} )} {item.popup && ( - + {t("popup", "Popup")} @@ -80,7 +80,7 @@ export default function Announcement() {
-
+
{item.content}
@@ -95,9 +95,7 @@ export default function Announcement() { - - {t("all", "All")} - + {t("all", "All")} {t("pinnedOnly", "Pinned Only")} @@ -110,9 +108,7 @@ export default function Announcement() { { - return requestAnnouncements(pagination, {}); - }} + request={async (pagination) => requestAnnouncements(pagination, {})} /> @@ -120,9 +116,9 @@ export default function Announcement() { { - return requestAnnouncements(pagination, { pinned: true }); - }} + request={async (pagination) => + requestAnnouncements(pagination, { pinned: true }) + } /> @@ -130,9 +126,9 @@ export default function Announcement() { { - return requestAnnouncements(pagination, { popup: true }); - }} + request={async (pagination) => + requestAnnouncements(pagination, { popup: true }) + } /> diff --git a/apps/user/src/sections/user/dashboard/redeem-code.tsx b/apps/user/src/sections/user/dashboard/redeem-code.tsx index a4d4543..9984fc4 100644 --- a/apps/user/src/sections/user/dashboard/redeem-code.tsx +++ b/apps/user/src/sections/user/dashboard/redeem-code.tsx @@ -25,7 +25,9 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) { const redeemMutation = useMutation({ mutationFn: (code: string) => redeemCode({ code }), onSuccess: (response) => { - const message = (response.data as { message?: string })?.message || t("redeemSuccess", "兑换成功"); + const message = + (response.data as { message?: string })?.message || + t("redeemSuccess", "兑换成功"); toast.success(message); setCode(""); onSuccess?.(); @@ -64,10 +66,7 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) { setCode(e.target.value)} - placeholder={t( - "enterRedemptionCode", - "请输入兑换码" - )} + placeholder={t("enterRedemptionCode", "请输入兑换码")} value={code} />
); } + +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) => { + setLocalValue(e.target.value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + onSearch(localValue); + } + }; + + return ( + + ); +} diff --git a/packages/ui/src/composed/pro-table/pro-table.tsx b/packages/ui/src/composed/pro-table/pro-table.tsx index f201786..4a7124f 100644 --- a/packages/ui/src/composed/pro-table/pro-table.tsx +++ b/packages/ui/src/composed/pro-table/pro-table.tsx @@ -123,7 +123,7 @@ export function ProTable< const [rowCount, setRowCount] = useState(0); const [pagination, setPagination] = useState({ pageIndex: 0, - pageSize: 10, + pageSize: 200, }); const loading = useRef(false); diff --git a/packages/ui/src/services/admin/ads.ts b/packages/ui/src/services/admin/ads.ts index 020245c..9077eee 100644 --- a/packages/ui/src/services/admin/ads.ts +++ b/packages/ui/src/services/admin/ads.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/announcement.ts b/packages/ui/src/services/admin/announcement.ts index 465733a..ec6bec2 100644 --- a/packages/ui/src/services/admin/announcement.ts +++ b/packages/ui/src/services/admin/announcement.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/application.ts b/packages/ui/src/services/admin/application.ts index bcba8e2..617121e 100644 --- a/packages/ui/src/services/admin/application.ts +++ b/packages/ui/src/services/admin/application.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/authMethod.ts b/packages/ui/src/services/admin/authMethod.ts index d8ba053..1a4a32b 100644 --- a/packages/ui/src/services/admin/authMethod.ts +++ b/packages/ui/src/services/admin/authMethod.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/console.ts b/packages/ui/src/services/admin/console.ts index 31477d9..f2b0583 100644 --- a/packages/ui/src/services/admin/console.ts +++ b/packages/ui/src/services/admin/console.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/coupon.ts b/packages/ui/src/services/admin/coupon.ts index 18593a5..2fcb1cb 100644 --- a/packages/ui/src/services/admin/coupon.ts +++ b/packages/ui/src/services/admin/coupon.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/document.ts b/packages/ui/src/services/admin/document.ts index 1834e89..76a4122 100644 --- a/packages/ui/src/services/admin/document.ts +++ b/packages/ui/src/services/admin/document.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/index.ts b/packages/ui/src/services/admin/index.ts index bf43e11..277471c 100644 --- a/packages/ui/src/services/admin/index.ts +++ b/packages/ui/src/services/admin/index.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ // API 更新时间: // API 唯一标识: @@ -13,6 +13,7 @@ import * as log from "./log"; import * as marketing from "./marketing"; import * as order from "./order"; import * as payment from "./payment"; +import * as redemption from "./redemption"; import * as server from "./server"; import * as subscribe from "./subscribe"; import * as system from "./system"; @@ -31,6 +32,7 @@ export default { marketing, order, payment, + redemption, server, subscribe, system, diff --git a/packages/ui/src/services/admin/log.ts b/packages/ui/src/services/admin/log.ts index 7c2ac04..b6bf699 100644 --- a/packages/ui/src/services/admin/log.ts +++ b/packages/ui/src/services/admin/log.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/marketing.ts b/packages/ui/src/services/admin/marketing.ts index e26a375..2244ee5 100644 --- a/packages/ui/src/services/admin/marketing.ts +++ b/packages/ui/src/services/admin/marketing.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/order.ts b/packages/ui/src/services/admin/order.ts index 1c5c529..62f0509 100644 --- a/packages/ui/src/services/admin/order.ts +++ b/packages/ui/src/services/admin/order.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ 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( - body: { order_no: string }, + body: API.ActivateOrderRequest, options?: { [key: string]: any } ) { return request( diff --git a/packages/ui/src/services/admin/payment.ts b/packages/ui/src/services/admin/payment.ts index a770206..ca49993 100644 --- a/packages/ui/src/services/admin/payment.ts +++ b/packages/ui/src/services/admin/payment.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/redemption.ts b/packages/ui/src/services/admin/redemption.ts index 8873daa..ce7632e 100644 --- a/packages/ui/src/services/admin/redemption.ts +++ b/packages/ui/src/services/admin/redemption.ts @@ -1,43 +1,14 @@ +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; -/** Toggle redemption code status PUT /v1/admin/redemption/code/status */ -export async function toggleRedemptionCodeStatus( - body: API.ToggleRedemptionCodeStatusRequest, - options?: { [key: string]: any } -) { - return request( - `${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( - `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - data: body, - ...(options || {}), - } - ); -} - -/** Create redemption code POST /v1/admin/redemption/code */ +/** + * 创建兑换码 + * POST /v1/admin/redemption/code + * @param body - 创建兑换码请求参数 + * @param options - 可选的请求配置 + * @returns 创建结果 + */ export async function createRedemptionCode( body: API.CreateRedemptionCodeRequest, options?: { [key: string]: any } @@ -55,7 +26,37 @@ export async function createRedemptionCode( ); } -/** Delete redemption code DELETE /v1/admin/redemption/code */ +/** + * 更新兑换码 + * PUT /v1/admin/redemption/code + * @param body - 更新兑换码请求参数 + * @param options - 可选的请求配置 + * @returns 更新结果 + */ +export async function updateRedemptionCode( + body: API.UpdateRedemptionCodeRequest, + options?: { [key: string]: any } +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} + +/** + * 删除兑换码 + * DELETE /v1/admin/redemption/code + * @param body - 删除兑换码请求参数 + * @param options - 可选的请求配置 + * @returns 删除结果 + */ export async function deleteRedemptionCode( body: API.DeleteRedemptionCodeRequest, options?: { [key: string]: any } @@ -73,7 +74,13 @@ export async function deleteRedemptionCode( ); } -/** Batch delete redemption code DELETE /v1/admin/redemption/code/batch */ +/** + * 批量删除兑换码 + * DELETE /v1/admin/redemption/code/batch + * @param body - 批量删除请求参数,包含 ids 数组 + * @param options - 可选的请求配置 + * @returns 批量删除结果 + */ export async function batchDeleteRedemptionCode( body: API.BatchDeleteRedemptionCodeRequest, options?: { [key: string]: any } @@ -91,9 +98,15 @@ export async function batchDeleteRedemptionCode( ); } -/** Get redemption code list GET /v1/admin/redemption/code/list */ +/** + * 获取兑换码列表 + * GET /v1/admin/redemption/code/list + * @param params - 分页及筛选参数 + * @param options - 可选的请求配置 + * @returns 兑换码列表及总数 + */ export async function getRedemptionCodeList( - params: API.GetRedemptionCodeListRequest, + params: API.GetRedemptionCodeListParams, options?: { [key: string]: any } ) { return request( @@ -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( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code/status`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} + +/** + * 获取兑换记录列表 + * GET /v1/admin/redemption/record/list + * @param params - 分页及筛选参数 + * @param options - 可选的请求配置 + * @returns 兑换记录列表及总数 + */ export async function getRedemptionRecordList( - params: API.GetRedemptionRecordListRequest, + params: API.GetRedemptionRecordListParams, options?: { [key: string]: any } ) { return request( diff --git a/packages/ui/src/services/admin/server.ts b/packages/ui/src/services/admin/server.ts index aeddd39..a7a20e3 100644 --- a/packages/ui/src/services/admin/server.ts +++ b/packages/ui/src/services/admin/server.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/subscribe.ts b/packages/ui/src/services/admin/subscribe.ts index b4ea634..1908151 100644 --- a/packages/ui/src/services/admin/subscribe.ts +++ b/packages/ui/src/services/admin/subscribe.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/system.ts b/packages/ui/src/services/admin/system.ts index ba228b2..527bee7 100644 --- a/packages/ui/src/services/admin/system.ts +++ b/packages/ui/src/services/admin/system.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; @@ -365,3 +365,43 @@ export async function updateVerifyConfig( } ); } + +/** + * 获取签名配置 + * GET /v1/admin/system/signature_config + * @param options - 可选的请求配置 + * @returns 签名配置 + */ +export async function getSignatureConfig(options?: { [key: string]: any }) { + return request( + `${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( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/system/signature_config`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/admin/ticket.ts b/packages/ui/src/services/admin/ticket.ts index 6f97134..bfd6607 100644 --- a/packages/ui/src/services/admin/ticket.ts +++ b/packages/ui/src/services/admin/ticket.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/tool.ts b/packages/ui/src/services/admin/tool.ts index c407056..d370a4d 100644 --- a/packages/ui/src/services/admin/tool.ts +++ b/packages/ui/src/services/admin/tool.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index 4414245..228ad2d 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -250,85 +250,6 @@ declare namespace API { enable?: boolean; }; - type CreateRedemptionCodeRequest = { - total_count: number; - subscribe_plan: number; - unit_time: string; - quantity: number; - batch_count: number; - }; - - type UpdateRedemptionCodeRequest = { - id: number; - total_count?: number; - subscribe_plan?: number; - unit_time?: string; - quantity?: number; - status?: number; - }; - - type ToggleRedemptionCodeStatusRequest = { - id: number; - status: number; - }; - - type DeleteRedemptionCodeRequest = { - id: number; - }; - - type BatchDeleteRedemptionCodeRequest = { - ids: number[]; - }; - - type GetRedemptionCodeListRequest = { - page: number; - size: number; - subscribe_plan?: number; - unit_time?: string; - code?: string; - }; - - type GetRedemptionCodeListResponse = { - total: number; - list: RedemptionCode[]; - }; - - type GetRedemptionRecordListRequest = { - page: number; - size: number; - user_id?: number; - code_id?: number; - }; - - type GetRedemptionRecordListResponse = { - total: number; - list: RedemptionRecord[]; - }; - - type RedemptionCode = { - id: number; - code: string; - total_count: number; - used_count: number; - subscribe_plan: number; - unit_time: string; - quantity: number; - status: number; - created_at: number; - updated_at: number; - }; - - type RedemptionRecord = { - id: number; - redemption_code_id: number; - user_id: number; - subscribe_id: number; - unit_time: string; - quantity: number; - redeemed_at: number; - created_at: number; - }; - type CreateDocumentRequest = { title: string; content: string; @@ -1374,6 +1295,7 @@ declare namespace API { forced_invite: boolean; referral_percentage: number; only_first_purchase: boolean; + gift_days?: number; }; type KickOfflineRequest = { @@ -1864,7 +1786,6 @@ declare namespace API { enable_ip_register_limit: boolean; ip_register_limit: number; ip_register_limit_duration: number; - device_limit: number; }; type RegisterLog = { @@ -2659,4 +2580,186 @@ declare namespace API { security: string; security_config: SecurityConfig; }; + + type SignatureConfig = { + enable_signature: boolean; + }; + + type FamilyDetail = { + summary: FamilySummary; + members: FamilyMemberItem[]; + }; + + type FamilyMemberItem = { + user_id: number; + identifier: string; + device_no: string; + role: number; + role_name: string; + status: number; + status_name: string; + join_source: string; + joined_at: number; + left_at?: number; + }; + + type FamilySummary = { + family_id: number; + owner_user_id: number; + owner_identifier: string; + status: string; + active_member_count: number; + max_members: number; + created_at: number; + updated_at: number; + }; + + type GetFamilyDetailParams = { + id: number; + }; + + type GetFamilyListParams = { + page: number; + size: number; + keyword?: string; + status?: string; + owner_user_id?: number; + family_id?: number; + user_id?: number; + }; + + type GetFamilyListResponse = { + list: FamilySummary[]; + total: number; + }; + + type DissolveFamilyRequest = { + family_id: number; + reason?: string; + }; + + type UpdateFamilyMaxMembersRequest = { + family_id: number; + max_members: number; + }; + + type RemoveFamilyMemberRequest = { + family_id: number; + user_id: number; + reason?: string; + }; + + type GetAdminUserInviteStatsParams = { + user_id: number; + }; + + type GetAdminUserInviteStatsResponse = { + invite_count: number; + total_commission: number; + current_commission: number; + referral_percentage: number; + only_first_purchase: boolean; + }; + + type GetAdminUserInviteListParams = { + user_id: number; + page: number; + size: number; + }; + + type AdminInvitedUser = { + id: number; + avatar: string; + identifier: string; + enable: boolean; + created_at: number; + }; + + type GetAdminUserInviteListResponse = { + total: number; + list: AdminInvitedUser[]; + }; + + type ActivateOrderRequest = { + order_no: string; + }; + + type RedemptionCode = { + id: number; + code: string; + total_count: number; + used_count: number; + subscribe_plan: number; + unit_time: string; + quantity: number; + status: number; + created_at: number; + updated_at: number; + }; + + type RedemptionRecord = { + id: number; + redemption_code_id: number; + user_id: number; + subscribe_id: number; + unit_time: string; + quantity: number; + redeemed_at: number; + created_at: number; + }; + + type CreateRedemptionCodeRequest = { + total_count: number; + subscribe_plan: number; + unit_time: string; + quantity: number; + batch_count: number; + }; + + type UpdateRedemptionCodeRequest = { + id: number; + total_count?: number; + subscribe_plan?: number; + unit_time?: string; + quantity?: number; + status?: number; + }; + + type DeleteRedemptionCodeRequest = { + id: number; + }; + + type BatchDeleteRedemptionCodeRequest = { + ids: number[]; + }; + + type GetRedemptionCodeListParams = { + page: number; + size: number; + subscribe_plan?: number; + unit_time?: string; + code?: string; + }; + + type GetRedemptionCodeListResponse = { + total: number; + list: RedemptionCode[]; + }; + + type ToggleRedemptionCodeStatusRequest = { + id: number; + status: number; + }; + + type GetRedemptionRecordListParams = { + page: number; + size: number; + user_id?: number; + code_id?: number; + }; + + type GetRedemptionRecordListResponse = { + total: number; + list: RedemptionRecord[]; + }; } diff --git a/packages/ui/src/services/admin/user.ts b/packages/ui/src/services/admin/user.ts index f07a63e..45f6300 100644 --- a/packages/ui/src/services/admin/user.ts +++ b/packages/ui/src/services/admin/user.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; @@ -291,32 +291,6 @@ export async function getUserSubscribe( 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 || {}), } ); @@ -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( + `${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( + `${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( + `${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( + `${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( + `${ + 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( + `${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( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/invite/list`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/common/auth.ts b/packages/ui/src/services/common/auth.ts index f7b6828..671ea1b 100644 --- a/packages/ui/src/services/common/auth.ts +++ b/packages/ui/src/services/common/auth.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/common/common.ts b/packages/ui/src/services/common/common.ts index 897c3b2..81c745e 100644 --- a/packages/ui/src/services/common/common.ts +++ b/packages/ui/src/services/common/common.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/common/index.ts b/packages/ui/src/services/common/index.ts index 53a7bc4..2e08ad9 100644 --- a/packages/ui/src/services/common/index.ts +++ b/packages/ui/src/services/common/index.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ // API 更新时间: // API 唯一标识: diff --git a/packages/ui/src/services/common/oauth.ts b/packages/ui/src/services/common/oauth.ts index 9a695ad..400a2db 100644 --- a/packages/ui/src/services/common/oauth.ts +++ b/packages/ui/src/services/common/oauth.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; @@ -19,9 +19,7 @@ export async function appleLoginCallback( if (item !== undefined && item !== null) { if (typeof item === "object" && !(item instanceof File)) { if (Array.isArray(item)) { - item.forEach((f) => { - formData.append(ele, f || ""); - }); + for (const f of item) formData.append(ele, f || ""); } else { formData.append( ele, diff --git a/packages/ui/src/services/common/typings.d.ts b/packages/ui/src/services/common/typings.d.ts index ad7e9a1..4448dea 100644 --- a/packages/ui/src/services/common/typings.d.ts +++ b/packages/ui/src/services/common/typings.d.ts @@ -356,6 +356,7 @@ declare namespace API { forced_invite: boolean; referral_percentage: number; only_first_purchase: boolean; + gift_days?: number; }; type LoginResponse = { diff --git a/packages/ui/src/services/gateway/basicCheckServiceVersion.ts b/packages/ui/src/services/gateway/basicCheckServiceVersion.ts index 5ef8f50..a69088f 100644 --- a/packages/ui/src/services/gateway/basicCheckServiceVersion.ts +++ b/packages/ui/src/services/gateway/basicCheckServiceVersion.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/gateway/basicHeartbeat.ts b/packages/ui/src/services/gateway/basicHeartbeat.ts index ec3345c..27b6e86 100644 --- a/packages/ui/src/services/gateway/basicHeartbeat.ts +++ b/packages/ui/src/services/gateway/basicHeartbeat.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/gateway/basicRegisterService.ts b/packages/ui/src/services/gateway/basicRegisterService.ts index 0512e58..668fba3 100644 --- a/packages/ui/src/services/gateway/basicRegisterService.ts +++ b/packages/ui/src/services/gateway/basicRegisterService.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/gateway/basicUpdateService.ts b/packages/ui/src/services/gateway/basicUpdateService.ts index 8261438..9e6fedf 100644 --- a/packages/ui/src/services/gateway/basicUpdateService.ts +++ b/packages/ui/src/services/gateway/basicUpdateService.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/gateway/index.ts b/packages/ui/src/services/gateway/index.ts index 52e694e..b82c8b2 100644 --- a/packages/ui/src/services/gateway/index.ts +++ b/packages/ui/src/services/gateway/index.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ // API 更新时间: // API 唯一标识: diff --git a/packages/ui/src/services/user/announcement.ts b/packages/ui/src/services/user/announcement.ts index 6f614a3..925530e 100644 --- a/packages/ui/src/services/user/announcement.ts +++ b/packages/ui/src/services/user/announcement.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/document.ts b/packages/ui/src/services/user/document.ts index 49477b0..d52d760 100644 --- a/packages/ui/src/services/user/document.ts +++ b/packages/ui/src/services/user/document.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/index.ts b/packages/ui/src/services/user/index.ts index e67bdfa..5270609 100644 --- a/packages/ui/src/services/user/index.ts +++ b/packages/ui/src/services/user/index.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ // API 更新时间: // API 唯一标识: diff --git a/packages/ui/src/services/user/order.ts b/packages/ui/src/services/user/order.ts index aa9fd75..998ab11 100644 --- a/packages/ui/src/services/user/order.ts +++ b/packages/ui/src/services/user/order.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/payment.ts b/packages/ui/src/services/user/payment.ts index a25b456..83c53a7 100644 --- a/packages/ui/src/services/user/payment.ts +++ b/packages/ui/src/services/user/payment.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/portal.ts b/packages/ui/src/services/user/portal.ts index 0a01629..4bac9c1 100644 --- a/packages/ui/src/services/user/portal.ts +++ b/packages/ui/src/services/user/portal.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/subscribe.ts b/packages/ui/src/services/user/subscribe.ts index eab11e4..db12259 100644 --- a/packages/ui/src/services/user/subscribe.ts +++ b/packages/ui/src/services/user/subscribe.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/ticket.ts b/packages/ui/src/services/user/ticket.ts index 8dfb5b6..a07e12a 100644 --- a/packages/ui/src/services/user/ticket.ts +++ b/packages/ui/src/services/user/ticket.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; diff --git a/packages/ui/src/services/user/typings.d.ts b/packages/ui/src/services/user/typings.d.ts index b37e50d..3adcb22 100644 --- a/packages/ui/src/services/user/typings.d.ts +++ b/packages/ui/src/services/user/typings.d.ts @@ -373,6 +373,7 @@ declare namespace API { forced_invite: boolean; referral_percentage: number; only_first_purchase: boolean; + gift_days?: number; }; type MessageLog = { diff --git a/packages/ui/src/services/user/user.ts b/packages/ui/src/services/user/user.ts index d7bffdd..a8c65a8 100644 --- a/packages/ui/src/services/user/user.ts +++ b/packages/ui/src/services/user/user.ts @@ -1,4 +1,4 @@ -// @ts-nocheck +// @ts-expect-error /* eslint-disable */ import request from "@workspace/ui/lib/request"; @@ -269,24 +269,6 @@ export async function updateUserRules( ); } -/** Redeem Code POST /v1/public/redemption/ */ -export async function redeemCode( - body: { code: string }, - options?: { [key: string]: any } -) { - return request( - `${import.meta.env.VITE_API_PREFIX || ""}/v1/public/redemption/`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - data: body, - ...(options || {}), - } - ); -} - /** Query User Subscribe GET /v1/public/user/subscribe */ export async function queryUserSubscribe(options?: { [key: string]: any }) { return request( diff --git a/packages/ui/src/utils/device.ts b/packages/ui/src/utils/device.ts new file mode 100644 index 0000000..0682224 --- /dev/null +++ b/packages/ui/src/utils/device.ts @@ -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(); +}