- 新增家庭共享订阅管理 - 新增用户邀请统计 - 新增签名和订阅模式设置表单 - 更新 API 服务层和国际化文件 - UI 组件优化(enhanced-input、pro-table)
This commit is contained in:
parent
31803a1d24
commit
6b92979c7c
136
.claude/plan/shared-subscription-display.md
Normal file
136
.claude/plan/shared-subscription-display.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
# 实施计划:后台管理 - 共享订阅显示
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
设备组成员加入后,其原始订阅被删除,使用所有者的共享订阅。
|
||||||
|
在后台管理的用户订阅面板中,查看设备组成员的订阅时显示为空,因为数据已在合并时被删除。
|
||||||
|
需要在后台自动检测并显示共享订阅信息。
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
**纯前端方案**,不需要后端 API 变更。利用现有 API 组合实现:
|
||||||
|
|
||||||
|
1. `getUserSubscribe({ user_id })` → 获取用户自身订阅(可能为空)
|
||||||
|
2. `getFamilyList({ user_id, page: 1, size: 1 })` → 检测用户是否属于设备组
|
||||||
|
3. `getUserSubscribe({ user_id: owner_user_id })` → 获取所有者的共享订阅
|
||||||
|
|
||||||
|
**核心逻辑**:当用户自身订阅为空时,自动检查是否为设备组成员。若是非所有者成员,则展示所有者的订阅信息,并添加"共享订阅"视觉标识。
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
### Step 1: 修改 UserSubscription 组件
|
||||||
|
|
||||||
|
**文件**: `apps/admin/src/sections/user/user-subscription/index.tsx`
|
||||||
|
|
||||||
|
将组件从纯 ProTable 改为带有共享订阅检测逻辑的组件:
|
||||||
|
|
||||||
|
```
|
||||||
|
伪代码:
|
||||||
|
function UserSubscription({ userId }) {
|
||||||
|
// 1. 正常获取用户订阅
|
||||||
|
const { data: ownSubscriptions } = useQuery(getUserSubscribe({ user_id: userId }))
|
||||||
|
|
||||||
|
// 2. 当自身订阅为空时,检查设备组成员身份
|
||||||
|
const hasOwnSubscriptions = ownSubscriptions.list.length > 0
|
||||||
|
const { data: familyData } = useQuery(
|
||||||
|
getFamilyList({ user_id: userId, page: 1, size: 1 }),
|
||||||
|
{ enabled: !hasOwnSubscriptions } // 仅当订阅为空时触发
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 判断是否为非所有者成员
|
||||||
|
const family = familyData?.list?.[0]
|
||||||
|
const isNonOwnerMember = family && family.owner_user_id !== userId && family.status === 'active'
|
||||||
|
const ownerUserId = family?.owner_user_id
|
||||||
|
|
||||||
|
// 4. 若为成员,获取所有者的订阅
|
||||||
|
const { data: sharedSubscriptions } = useQuery(
|
||||||
|
getUserSubscribe({ user_id: ownerUserId }),
|
||||||
|
{ enabled: isNonOwnerMember && !!ownerUserId }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5. 决定展示内容
|
||||||
|
const isSharedView = isNonOwnerMember && sharedSubscriptions?.list?.length > 0
|
||||||
|
const displayData = isSharedView ? sharedSubscriptions : ownSubscriptions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isSharedView && <SharedSubscriptionBanner ownerUserId={ownerUserId} familyId={family.family_id} />}
|
||||||
|
<ProTable
|
||||||
|
data={displayData}
|
||||||
|
actions={isSharedView ? { render: () => [只读操作] } : { render: () => [完整操作] }}
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键变更点**:
|
||||||
|
- 将 ProTable 的 `request` 回调改为 React Query 管理数据获取
|
||||||
|
- 或者保持 ProTable request 模式,在外层用 state 管理共享视图切换
|
||||||
|
- 推荐方案:保持 ProTable 的 request 模式,但在 request 回调内部做链式检查
|
||||||
|
|
||||||
|
### Step 2: 添加共享订阅信息横幅
|
||||||
|
|
||||||
|
在 ProTable 上方显示提示信息:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ℹ️ 该用户为设备组成员,当前显示所有者 (ID: 258) │
|
||||||
|
│ 的共享订阅。[查看设备组] [查看所有者] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 使用 Alert 组件展示
|
||||||
|
- 提供跳转到设备组详情和所有者用户页面的链接
|
||||||
|
- 标题列后追加 `<Badge variant="secondary">共享</Badge>` 标识
|
||||||
|
|
||||||
|
### Step 3: 共享视图下禁用写操作
|
||||||
|
|
||||||
|
当处于共享订阅视图时:
|
||||||
|
- **隐藏** "添加订阅" 按钮(toolbar)
|
||||||
|
- **隐藏** "编辑" 按钮
|
||||||
|
- **隐藏** 删除、停止/恢复、重置令牌等破坏性操作
|
||||||
|
- **保留** 只读操作:查看日志、流量统计、在线设备等
|
||||||
|
|
||||||
|
### Step 4: 添加国际化翻译
|
||||||
|
|
||||||
|
**文件**:
|
||||||
|
- `apps/admin/public/assets/locales/zh-CN/user.json`
|
||||||
|
- `apps/admin/public/assets/locales/en-US/user.json`
|
||||||
|
|
||||||
|
新增翻译 key:
|
||||||
|
| Key | 中文 | 英文 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `sharedSubscription` | 共享订阅 | Shared Subscription |
|
||||||
|
| `sharedSubscriptionInfo` | 该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅 | This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}}) |
|
||||||
|
| `viewDeviceGroup` | 查看设备组 | View Device Group |
|
||||||
|
| `viewOwner` | 查看所有者 | View Owner |
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `apps/admin/src/sections/user/user-subscription/index.tsx` | 修改 | 添加共享订阅检测与展示逻辑 |
|
||||||
|
| `apps/admin/public/assets/locales/zh-CN/user.json` | 修改 | 新增共享订阅相关中文翻译 |
|
||||||
|
| `apps/admin/public/assets/locales/en-US/user.json` | 修改 | 新增共享订阅相关英文翻译 |
|
||||||
|
|
||||||
|
## 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解措施 |
|
||||||
|
|------|----------|
|
||||||
|
| 设备组 API 调用失败 | 捕获异常,静默降级为显示空列表(现有行为) |
|
||||||
|
| 所有者订阅也为空 | 正常显示空列表,不展示共享订阅横幅 |
|
||||||
|
| 用户同时有自身订阅和设备组成员身份 | 优先显示自身订阅(按描述,加入时会删除,不应同时存在) |
|
||||||
|
| 多个设备组 | 取第一个活跃的设备组即可(一个用户通常只属于一个组) |
|
||||||
|
|
||||||
|
## 边界情况
|
||||||
|
|
||||||
|
1. 用户无订阅 + 不在设备组 → 正常空列表
|
||||||
|
2. 用户无订阅 + 在设备组但为所有者 → 正常空列表(所有者自己订阅为空说明确实没有)
|
||||||
|
3. 用户无订阅 + 在设备组但组已禁用 → 正常空列表
|
||||||
|
4. 用户无订阅 + 在设备组且为活跃成员 → 显示所有者共享订阅 + 横幅提示
|
||||||
|
|
||||||
|
## SESSION_ID
|
||||||
|
- CODEX_SESSION: N/A(纯前端方案,未调用外部模型)
|
||||||
|
- GEMINI_SESSION: N/A
|
||||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
135
.serena/project.yml
Normal file
135
.serena/project.yml
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# java julia kotlin lua markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- vue
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as 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:
|
||||||
@ -99,6 +99,8 @@
|
|||||||
"title": "Email Settings",
|
"title": "Email Settings",
|
||||||
"trafficExceedEmailTemplate": "Traffic Exceed Email Template",
|
"trafficExceedEmailTemplate": "Traffic Exceed Email Template",
|
||||||
"trafficTemplate": "Traffic Template",
|
"trafficTemplate": "Traffic Template",
|
||||||
|
"deleteAccountEmailTemplate": "Delete Account Email Template",
|
||||||
|
"deleteAccountTemplate": "Delete Account Template",
|
||||||
"verifyEmailTemplate": "Verify Email Template",
|
"verifyEmailTemplate": "Verify Email Template",
|
||||||
"verifyTemplate": "Verify Template",
|
"verifyTemplate": "Verify Template",
|
||||||
"whitelistSuffixes": "Whitelist Suffixes",
|
"whitelistSuffixes": "Whitelist Suffixes",
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
"System Config": "System Config",
|
"System Config": "System Config",
|
||||||
"Ticket Management": "Ticket Management",
|
"Ticket Management": "Ticket Management",
|
||||||
"Traffic Details": "Traffic Details",
|
"Traffic Details": "Traffic Details",
|
||||||
|
"Device Group": "Device Group",
|
||||||
"User Management": "User Management",
|
"User Management": "User Management",
|
||||||
"Users & Support": "Users & Support"
|
"Users & Support": "Users & Support"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save Settings",
|
"save": "Save Settings",
|
||||||
|
"saving": "Saving...",
|
||||||
"saveFailed": "Save Failed",
|
"saveFailed": "Save Failed",
|
||||||
"saveSuccess": "Save Successful"
|
"saveSuccess": "Save Successful"
|
||||||
},
|
},
|
||||||
@ -23,6 +24,10 @@
|
|||||||
"description": "Configure user invitation and referral reward settings",
|
"description": "Configure user invitation and referral reward settings",
|
||||||
"forcedInvite": "Require Invitation to Register",
|
"forcedInvite": "Require Invitation to Register",
|
||||||
"forcedInviteDescription": "When enabled, users must register through an invitation link",
|
"forcedInviteDescription": "When enabled, users must register through an invitation link",
|
||||||
|
"giftDays": "Invite Gift Days",
|
||||||
|
"giftDaysDescription": "When referral percentage is 0, both the inviter and invitee receive this many extra subscription days after the invitee makes a purchase",
|
||||||
|
"giftDaysPlaceholder": "Enter days",
|
||||||
|
"giftDaysSuffix": "day(s)",
|
||||||
"inputPlaceholder": "Please enter",
|
"inputPlaceholder": "Please enter",
|
||||||
"onlyFirstPurchase": "First Purchase Reward Only",
|
"onlyFirstPurchase": "First Purchase Reward Only",
|
||||||
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
|
"onlyFirstPurchaseDescription": "When enabled, referrers only receive rewards for the first purchase by referred users",
|
||||||
@ -42,6 +47,22 @@
|
|||||||
"title": "Log Cleanup Settings"
|
"title": "Log Cleanup Settings"
|
||||||
},
|
},
|
||||||
"logSettings": "Log Settings",
|
"logSettings": "Log Settings",
|
||||||
|
"signature": {
|
||||||
|
"title": "Request Signature",
|
||||||
|
"description": "Enable or disable request signature verification for public APIs",
|
||||||
|
"enable": "Enable Signature Verification",
|
||||||
|
"enableDescription": "When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1",
|
||||||
|
"saveSuccess": "Save Successful",
|
||||||
|
"saveFailed": "Save Failed"
|
||||||
|
},
|
||||||
|
"subscribeMode": {
|
||||||
|
"title": "Subscription Mode",
|
||||||
|
"description": "Configure single or multiple subscription purchase behavior",
|
||||||
|
"singleSubscriptionMode": "Single Subscription Mode",
|
||||||
|
"singleSubscriptionModeDescription": "After enabling, users can only purchase/renew one subscription in the same account",
|
||||||
|
"saveSuccess": "Subscription mode updated successfully",
|
||||||
|
"saveFailed": "Failed to update settings"
|
||||||
|
},
|
||||||
"privacyPolicy": {
|
"privacyPolicy": {
|
||||||
"description": "Edit and manage privacy policy content",
|
"description": "Edit and manage privacy policy content",
|
||||||
"title": "Privacy Policy"
|
"title": "Privacy Policy"
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"createSubscription": "Create Subscription",
|
"createSubscription": "Create Subscription",
|
||||||
"createSuccess": "Created successfully",
|
"createSuccess": "Created successfully",
|
||||||
"createUser": "Create User",
|
"createUser": "Create User",
|
||||||
|
"currentCommission": "Current Commission",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"deleteDescription": "This action cannot be undone.",
|
"deleteDescription": "This action cannot be undone.",
|
||||||
@ -31,19 +32,57 @@
|
|||||||
"deleteSuccess": "Deleted successfully",
|
"deleteSuccess": "Deleted successfully",
|
||||||
"isDeleted": "Status",
|
"isDeleted": "Status",
|
||||||
"deviceLimit": "Device Limit",
|
"deviceLimit": "Device Limit",
|
||||||
|
"deviceGroup": "Device Group",
|
||||||
|
"deviceNo": "Device No.",
|
||||||
|
"deviceSearch": "Device",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTraffic": "Download Traffic",
|
"downloadTraffic": "Download Traffic",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"editSubscription": "Edit Subscription",
|
"editSubscription": "Edit Subscription",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
"expiredAt": "Expired At",
|
"expiredAt": "Expired At",
|
||||||
"expireTime": "expireTime",
|
"expireTime": "expireTime",
|
||||||
|
"familyActions": "Actions",
|
||||||
|
"familyConfirmDissolve": "Confirm Dissolve",
|
||||||
|
"familyConfirmRemoveMember": "Confirm Remove Member",
|
||||||
|
"familyDetail": "Device Group Detail",
|
||||||
|
"familyDisabled": "Disabled",
|
||||||
|
"familyDissolve": "Dissolve",
|
||||||
|
"familyDissolved": "Device group dissolved",
|
||||||
|
"familyDissolveDescription": "This will dissolve the device group and remove all active members.",
|
||||||
|
"familyId": "Device Group ID",
|
||||||
|
"familyInvalidMaxMembers": "Invalid max members",
|
||||||
|
"familyJoinSource": "Join Source",
|
||||||
|
"familyJoinSourceOwnerInit": "Owner Init",
|
||||||
|
"familyJoinedAt": "Joined At",
|
||||||
|
"familyLeftAt": "Left At",
|
||||||
|
"familyManagement": "Device Group Management",
|
||||||
|
"familyMaxMembers": "Max Members",
|
||||||
|
"familyMaxMembersTooSmall": "Max members cannot be lower than active member count",
|
||||||
|
"familyMember": "Member",
|
||||||
|
"familyMemberLeft": "Left",
|
||||||
|
"familyMemberRemoved": "Removed",
|
||||||
|
"familyMembers": "Members",
|
||||||
|
"familyNoData": "No device group data",
|
||||||
|
"familyNoMembers": "No members",
|
||||||
|
"familyOwnerUserId": "Owner User ID",
|
||||||
|
"familyRemoveMemberDescription": "This will remove the member from the active device group.",
|
||||||
|
"familyStatus": "Status",
|
||||||
|
"familySummary": "Summary",
|
||||||
|
"familyUpdateMaxMembers": "Update Max Members",
|
||||||
|
"firstPurchaseOnly": "First purchase only",
|
||||||
"giftAmount": "Gift Amount",
|
"giftAmount": "Gift Amount",
|
||||||
"giftAmountPlaceholder": "Enter gift amount",
|
"giftAmountPlaceholder": "Enter gift amount",
|
||||||
"giftLogs": "Gift Logs",
|
"giftLogs": "Gift Logs",
|
||||||
|
"globalDefault": "Global Default",
|
||||||
"invalidEmailFormat": "Invalid email format",
|
"invalidEmailFormat": "Invalid email format",
|
||||||
"inviteCode": "Invite Code",
|
"inviteCode": "Invite Code",
|
||||||
"inviteCodePlaceholder": "Enter invite code",
|
"inviteCodePlaceholder": "Enter invite code",
|
||||||
|
"inviteCount": "Invited Users",
|
||||||
|
"inviteStats": "Invite Statistics",
|
||||||
|
"invitedUsers": "Invited Users",
|
||||||
"kickOfflineConfirm": "kickOfflineConfirm",
|
"kickOfflineConfirm": "kickOfflineConfirm",
|
||||||
"kickOfflineSuccess": "Device kicked offline",
|
"kickOfflineSuccess": "Device kicked offline",
|
||||||
"lastSeen": "Last Seen",
|
"lastSeen": "Last Seen",
|
||||||
@ -52,17 +91,22 @@
|
|||||||
"loginNotifications": "Login Notifications",
|
"loginNotifications": "Login Notifications",
|
||||||
"loginStatus": "Login Status",
|
"loginStatus": "Login Status",
|
||||||
"manager": "Administrator",
|
"manager": "Administrator",
|
||||||
|
"memberCount": "Member Count",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
|
"next": "Next",
|
||||||
|
"noInvitedUsers": "No invited users yet",
|
||||||
"notifySettingsTitle": "Notify Settings",
|
"notifySettingsTitle": "Notify Settings",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"onlineDevices": "Online Devices",
|
"onlineDevices": "Online Devices",
|
||||||
"onlyFirstPurchase": "First Purchase Only",
|
"onlyFirstPurchase": "First Purchase Only",
|
||||||
"orderList": "Order List",
|
"orderList": "Order List",
|
||||||
|
"owner": "Owner",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordPlaceholder": "Enter password",
|
"passwordPlaceholder": "Enter password",
|
||||||
"permanent": "Permanent",
|
"permanent": "Permanent",
|
||||||
|
"prev": "Prev",
|
||||||
"pleaseEnterEmail": "Enter email",
|
"pleaseEnterEmail": "Enter email",
|
||||||
"referer": "Referer",
|
"referer": "Referer",
|
||||||
"refererId": "Referer ID",
|
"refererId": "Referer ID",
|
||||||
@ -71,7 +115,9 @@
|
|||||||
"referralPercentage": "Referral Percentage",
|
"referralPercentage": "Referral Percentage",
|
||||||
"referralPercentagePlaceholder": "Enter percentage",
|
"referralPercentagePlaceholder": "Enter percentage",
|
||||||
"referrerUserId": "Referrer User ID",
|
"referrerUserId": "Referrer User ID",
|
||||||
|
"registeredAt": "Registered At",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"removeSuccess": "Removed successfully",
|
||||||
"resetLogs": "Reset Logs",
|
"resetLogs": "Reset Logs",
|
||||||
"resetTraffic": "Reset Traffic",
|
"resetTraffic": "Reset Traffic",
|
||||||
"toggleStatus": "Toggle Status",
|
"toggleStatus": "Toggle Status",
|
||||||
@ -81,6 +127,7 @@
|
|||||||
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
|
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
|
||||||
"toggleSubscriptionStatus": "Toggle Status",
|
"toggleSubscriptionStatus": "Toggle Status",
|
||||||
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
|
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
|
||||||
|
"resetSearch": "Reset",
|
||||||
"resetTime": "Reset Time",
|
"resetTime": "Reset Time",
|
||||||
"resetToken": "Reset Subscription Address",
|
"resetToken": "Reset Subscription Address",
|
||||||
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
||||||
@ -102,6 +149,12 @@
|
|||||||
"statusDeducted": "Deducted",
|
"statusDeducted": "Deducted",
|
||||||
"statusStopped": "Stopped",
|
"statusStopped": "Stopped",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Email / Invite Code / Device ID",
|
||||||
|
"searchInputPlaceholder": "Enter search term",
|
||||||
|
"sharedSubscription": "Shared",
|
||||||
|
"sharedSubscriptionInfo": "This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
|
||||||
|
"sharedSubscriptionList": "Shared Subscription List",
|
||||||
"speedLimit": "Speed Limit",
|
"speedLimit": "Speed Limit",
|
||||||
"startTime": "startTime",
|
"startTime": "startTime",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
@ -114,6 +167,7 @@
|
|||||||
"telephone": "Phone",
|
"telephone": "Phone",
|
||||||
"telephonePlaceholder": "Enter phone number",
|
"telephonePlaceholder": "Enter phone number",
|
||||||
"token": "token",
|
"token": "token",
|
||||||
|
"totalCommission": "Total Commission",
|
||||||
"totalTraffic": "Total Traffic",
|
"totalTraffic": "Total Traffic",
|
||||||
"tradeNotifications": "Trade Notifications",
|
"tradeNotifications": "Trade Notifications",
|
||||||
"trafficDetails": "Traffic Details",
|
"trafficDetails": "Traffic Details",
|
||||||
@ -134,5 +188,7 @@
|
|||||||
"userList": "User List",
|
"userList": "User List",
|
||||||
"userName": "Username",
|
"userName": "Username",
|
||||||
"userProfile": "User Profile",
|
"userProfile": "User Profile",
|
||||||
"verified": "Verified"
|
"verified": "Verified",
|
||||||
|
"viewDeviceGroup": "View Device Group",
|
||||||
|
"viewOwner": "View Owner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,6 +99,8 @@
|
|||||||
"title": "邮箱设置",
|
"title": "邮箱设置",
|
||||||
"trafficExceedEmailTemplate": "流量超额邮件模板",
|
"trafficExceedEmailTemplate": "流量超额邮件模板",
|
||||||
"trafficTemplate": "流量模板",
|
"trafficTemplate": "流量模板",
|
||||||
|
"deleteAccountEmailTemplate": "注销账户邮件模板",
|
||||||
|
"deleteAccountTemplate": "注销模版",
|
||||||
"verifyEmailTemplate": "验证邮件模板",
|
"verifyEmailTemplate": "验证邮件模板",
|
||||||
"verifyTemplate": "验证模板",
|
"verifyTemplate": "验证模板",
|
||||||
"whitelistSuffixes": "白名单后缀",
|
"whitelistSuffixes": "白名单后缀",
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
"System Config": "系统配置",
|
"System Config": "系统配置",
|
||||||
"Ticket Management": "工单管理",
|
"Ticket Management": "工单管理",
|
||||||
"Traffic Details": "流量详情",
|
"Traffic Details": "流量详情",
|
||||||
|
"Device Group": "设备组",
|
||||||
"User Management": "用户管理",
|
"User Management": "用户管理",
|
||||||
"Users & Support": "用户与支持"
|
"Users & Support": "用户与支持"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存设置",
|
"save": "保存设置",
|
||||||
|
"saving": "保存中...",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"saveSuccess": "保存成功"
|
"saveSuccess": "保存成功"
|
||||||
},
|
},
|
||||||
@ -23,6 +24,10 @@
|
|||||||
"description": "配置用户邀请和推荐奖励设置",
|
"description": "配置用户邀请和推荐奖励设置",
|
||||||
"forcedInvite": "强制邀请注册",
|
"forcedInvite": "强制邀请注册",
|
||||||
"forcedInviteDescription": "启用后,用户必须通过邀请链接注册",
|
"forcedInviteDescription": "启用后,用户必须通过邀请链接注册",
|
||||||
|
"giftDays": "邀请赠送天数",
|
||||||
|
"giftDaysDescription": "当推荐佣金比例为 0 时,被邀请人完成购买后,邀请人和被邀请人各自获得的订阅延长天数",
|
||||||
|
"giftDaysPlaceholder": "请输入天数",
|
||||||
|
"giftDaysSuffix": "天",
|
||||||
"inputPlaceholder": "请输入",
|
"inputPlaceholder": "请输入",
|
||||||
"onlyFirstPurchase": "仅首次购买奖励",
|
"onlyFirstPurchase": "仅首次购买奖励",
|
||||||
"onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励",
|
"onlyFirstPurchaseDescription": "启用后,推荐人仅在被推荐用户首次购买时获得奖励",
|
||||||
@ -42,6 +47,22 @@
|
|||||||
"title": "日志清理设置"
|
"title": "日志清理设置"
|
||||||
},
|
},
|
||||||
"logSettings": "日志设置",
|
"logSettings": "日志设置",
|
||||||
|
"signature": {
|
||||||
|
"title": "请求签名",
|
||||||
|
"description": "启用或禁用公共 API 的请求签名验证",
|
||||||
|
"enable": "启用签名验证",
|
||||||
|
"enableDescription": "启用后,客户端可以通过发送 X-Signature-Enabled: 1 来触发严格的签名验证",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"saveFailed": "保存失败"
|
||||||
|
},
|
||||||
|
"subscribeMode": {
|
||||||
|
"title": "订阅模式",
|
||||||
|
"description": "配置单订阅或多订阅购买行为",
|
||||||
|
"singleSubscriptionMode": "单订阅模式",
|
||||||
|
"singleSubscriptionModeDescription": "启用后,用户在同一账户中只能购买/续费一个订阅",
|
||||||
|
"saveSuccess": "订阅模式更新成功",
|
||||||
|
"saveFailed": "更新设置失败"
|
||||||
|
},
|
||||||
"privacyPolicy": {
|
"privacyPolicy": {
|
||||||
"description": "编辑和管理隐私政策内容",
|
"description": "编辑和管理隐私政策内容",
|
||||||
"title": "隐私政策"
|
"title": "隐私政策"
|
||||||
|
|||||||
@ -24,26 +24,66 @@
|
|||||||
"createSubscription": "创建订阅",
|
"createSubscription": "创建订阅",
|
||||||
"createSuccess": "创建成功",
|
"createSuccess": "创建成功",
|
||||||
"createUser": "创建用户",
|
"createUser": "创建用户",
|
||||||
|
"currentCommission": "当前佣金",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
"deleteDescription": "此操作无法撤销。",
|
"deleteDescription": "此操作无法撤销。",
|
||||||
"deleteSubscriptionDescription": "此操作无法撤销。",
|
"deleteSubscriptionDescription": "此操作无法撤销。",
|
||||||
"deleteSuccess": "删除成功",
|
"deleteSuccess": "删除成功",
|
||||||
"isDeleted": "状态",
|
"isDeleted": "状态",
|
||||||
|
"deviceGroup": "设备组",
|
||||||
"deviceLimit": "IP限制",
|
"deviceLimit": "IP限制",
|
||||||
|
"deviceNo": "设备编号",
|
||||||
|
"deviceSearch": "设备",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTraffic": "下载流量",
|
"downloadTraffic": "下载流量",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"email": "邮箱",
|
||||||
"editSubscription": "编辑订阅",
|
"editSubscription": "编辑订阅",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
|
"enabled": "启用",
|
||||||
|
"disabled": "禁用",
|
||||||
"expiredAt": "过期时间",
|
"expiredAt": "过期时间",
|
||||||
"expireTime": "过期时间",
|
"expireTime": "过期时间",
|
||||||
|
"familyActions": "操作",
|
||||||
|
"familyConfirmDissolve": "确认注销账号",
|
||||||
|
"familyConfirmRemoveMember": "确认移除成员",
|
||||||
|
"familyDetail": "设备组详情",
|
||||||
|
"familyDisabled": "已禁用",
|
||||||
|
"familyDissolve": "注销账号",
|
||||||
|
"familyDissolved": "设备组已解散",
|
||||||
|
"familyDissolveDescription": "此操作将注销账号并移除所有活跃成员。",
|
||||||
|
"familyId": "设备组 ID",
|
||||||
|
"familyInvalidMaxMembers": "最大成员数无效",
|
||||||
|
"familyJoinSource": "加入方式",
|
||||||
|
"familyJoinSourceOwnerInit": "创建者初始化",
|
||||||
|
"familyJoinedAt": "加入时间",
|
||||||
|
"familyLeftAt": "离开时间",
|
||||||
|
"familyManagement": "设备组管理",
|
||||||
|
"familyMaxMembers": "最大成员数",
|
||||||
|
"familyMaxMembersTooSmall": "最大成员数不能小于当前活跃成员数",
|
||||||
|
"familyMember": "成员",
|
||||||
|
"familyMemberLeft": "已离开",
|
||||||
|
"familyMemberRemoved": "已移除",
|
||||||
|
"familyMembers": "设备组成员",
|
||||||
|
"familyNoData": "暂无设备组数据",
|
||||||
|
"familyNoMembers": "暂无成员",
|
||||||
|
"familyOwnerUserId": "所有者用户 ID",
|
||||||
|
"familyRemoveMemberDescription": "此操作将从活跃设备组中移除该成员。",
|
||||||
|
"familyStatus": "设备组状态",
|
||||||
|
"familySummary": "设备组概览",
|
||||||
|
"familyUpdateMaxMembers": "更新最大成员数",
|
||||||
|
"firstPurchaseOnly": "仅首次购买",
|
||||||
"giftAmount": "赠送金额",
|
"giftAmount": "赠送金额",
|
||||||
"giftAmountPlaceholder": "输入赠送金额",
|
"giftAmountPlaceholder": "输入赠送金额",
|
||||||
"giftLogs": "赠送日志",
|
"giftLogs": "赠送日志",
|
||||||
|
"globalDefault": "全局默认",
|
||||||
"invalidEmailFormat": "邮箱格式无效",
|
"invalidEmailFormat": "邮箱格式无效",
|
||||||
"inviteCode": "邀请码",
|
"inviteCode": "邀请码",
|
||||||
"inviteCodePlaceholder": "输入邀请码",
|
"inviteCodePlaceholder": "输入邀请码",
|
||||||
|
"inviteCount": "邀请用户数",
|
||||||
|
"inviteStats": "邀请统计",
|
||||||
|
"invitedUsers": "已邀请用户",
|
||||||
"kickOfflineConfirm": "确认踢下线",
|
"kickOfflineConfirm": "确认踢下线",
|
||||||
"kickOfflineSuccess": "设备已踢下线",
|
"kickOfflineSuccess": "设备已踢下线",
|
||||||
"lastSeen": "最后上线",
|
"lastSeen": "最后上线",
|
||||||
@ -52,17 +92,22 @@
|
|||||||
"loginNotifications": "登录通知",
|
"loginNotifications": "登录通知",
|
||||||
"loginStatus": "登录状态",
|
"loginStatus": "登录状态",
|
||||||
"manager": "管理员",
|
"manager": "管理员",
|
||||||
|
"memberCount": "成员数量",
|
||||||
"more": "更多",
|
"more": "更多",
|
||||||
"normal": "正常",
|
"normal": "正常",
|
||||||
|
"next": "下一页",
|
||||||
|
"noInvitedUsers": "暂无邀请用户",
|
||||||
"notifySettingsTitle": "通知设置",
|
"notifySettingsTitle": "通知设置",
|
||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"onlineDevices": "在线设备",
|
"onlineDevices": "在线设备",
|
||||||
"onlyFirstPurchase": "仅首次购买",
|
"onlyFirstPurchase": "仅首次购买",
|
||||||
"orderList": "订单列表",
|
"orderList": "订单列表",
|
||||||
|
"owner": "所有者",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"passwordPlaceholder": "输入密码",
|
"passwordPlaceholder": "输入密码",
|
||||||
"permanent": "永久",
|
"permanent": "永久",
|
||||||
|
"prev": "上一页",
|
||||||
"pleaseEnterEmail": "输入邮箱",
|
"pleaseEnterEmail": "输入邮箱",
|
||||||
"referer": "推荐人",
|
"referer": "推荐人",
|
||||||
"refererId": "推荐人 ID",
|
"refererId": "推荐人 ID",
|
||||||
@ -71,7 +116,9 @@
|
|||||||
"referralPercentage": "推荐百分比",
|
"referralPercentage": "推荐百分比",
|
||||||
"referralPercentagePlaceholder": "输入百分比",
|
"referralPercentagePlaceholder": "输入百分比",
|
||||||
"referrerUserId": "推荐人用户 ID",
|
"referrerUserId": "推荐人用户 ID",
|
||||||
|
"registeredAt": "注册时间",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
|
"removeSuccess": "移除成功",
|
||||||
"resetLogs": "重置日志",
|
"resetLogs": "重置日志",
|
||||||
"resetTraffic": "重置流量",
|
"resetTraffic": "重置流量",
|
||||||
"toggleStatus": "切换状态",
|
"toggleStatus": "切换状态",
|
||||||
@ -81,6 +128,7 @@
|
|||||||
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
|
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
|
||||||
"toggleSubscriptionStatus": "切换状态",
|
"toggleSubscriptionStatus": "切换状态",
|
||||||
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
|
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
|
||||||
|
"resetSearch": "重置",
|
||||||
"resetTime": "重置时间",
|
"resetTime": "重置时间",
|
||||||
"resetToken": "重置订阅地址",
|
"resetToken": "重置订阅地址",
|
||||||
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
||||||
@ -102,6 +150,12 @@
|
|||||||
"statusDeducted": "已扣除",
|
"statusDeducted": "已扣除",
|
||||||
"statusStopped": "已停止",
|
"statusStopped": "已停止",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
"search": "搜索",
|
||||||
|
"searchPlaceholder": "邮箱 / 邀请码 / 设备ID",
|
||||||
|
"searchInputPlaceholder": "请输入搜索内容",
|
||||||
|
"sharedSubscription": "共享",
|
||||||
|
"sharedSubscriptionInfo": "该用户为设备组成员,当前显示所有者 (ID: {{ownerId}}) 的共享订阅",
|
||||||
|
"sharedSubscriptionList": "共享订阅列表",
|
||||||
"speedLimit": "速度限制",
|
"speedLimit": "速度限制",
|
||||||
"startTime": "开始时间",
|
"startTime": "开始时间",
|
||||||
"subscription": "订阅",
|
"subscription": "订阅",
|
||||||
@ -114,6 +168,7 @@
|
|||||||
"telephone": "电话",
|
"telephone": "电话",
|
||||||
"telephonePlaceholder": "输入电话号码",
|
"telephonePlaceholder": "输入电话号码",
|
||||||
"token": "令牌",
|
"token": "令牌",
|
||||||
|
"totalCommission": "总佣金",
|
||||||
"totalTraffic": "总流量",
|
"totalTraffic": "总流量",
|
||||||
"tradeNotifications": "交易通知",
|
"tradeNotifications": "交易通知",
|
||||||
"trafficDetails": "流量详情",
|
"trafficDetails": "流量详情",
|
||||||
@ -134,5 +189,7 @@
|
|||||||
"userList": "用户列表",
|
"userList": "用户列表",
|
||||||
"userName": "用户名",
|
"userName": "用户名",
|
||||||
"userProfile": "用户资料",
|
"userProfile": "用户资料",
|
||||||
"verified": "已验证"
|
"verified": "已验证",
|
||||||
|
"viewDeviceGroup": "查看设备组",
|
||||||
|
"viewOwner": "查看所有者"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,11 @@ export function useNavs() {
|
|||||||
url: "/dashboard/user",
|
url: "/dashboard/user",
|
||||||
icon: "flat-color-icons:conference-call",
|
icon: "flat-color-icons:conference-call",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("Device Group", "Device Group"),
|
||||||
|
url: "/dashboard/family",
|
||||||
|
icon: "flat-color-icons:home",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("Ticket Management", "Ticket Management"),
|
title: t("Ticket Management", "Ticket Management"),
|
||||||
url: "/dashboard/ticket",
|
url: "/dashboard/ticket",
|
||||||
|
|||||||
@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport =
|
|||||||
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/marketing/',
|
'/dashboard/marketing/',
|
||||||
)()
|
)()
|
||||||
|
const DashboardFamilyIndexLazyRouteImport =
|
||||||
|
createFileRoute('/dashboard/family/')()
|
||||||
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/document/',
|
'/dashboard/document/',
|
||||||
)()
|
)()
|
||||||
@ -189,6 +191,14 @@ const DashboardMarketingIndexLazyRoute =
|
|||||||
} as any).lazy(() =>
|
} as any).lazy(() =>
|
||||||
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
||||||
)
|
)
|
||||||
|
const DashboardFamilyIndexLazyRoute =
|
||||||
|
DashboardFamilyIndexLazyRouteImport.update({
|
||||||
|
id: '/family/',
|
||||||
|
path: '/family/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/family/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
const DashboardDocumentIndexLazyRoute =
|
const DashboardDocumentIndexLazyRoute =
|
||||||
DashboardDocumentIndexLazyRouteImport.update({
|
DashboardDocumentIndexLazyRouteImport.update({
|
||||||
id: '/document/',
|
id: '/document/',
|
||||||
@ -345,6 +355,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -377,6 +388,7 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family': typeof DashboardFamilyIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -411,6 +423,7 @@ export interface FileRoutesById {
|
|||||||
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/family/': typeof DashboardFamilyIndexLazyRoute
|
||||||
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -446,6 +459,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/family'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
@ -478,6 +492,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/family'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
@ -511,6 +526,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control/'
|
| '/dashboard/auth-control/'
|
||||||
| '/dashboard/coupon/'
|
| '/dashboard/coupon/'
|
||||||
| '/dashboard/document/'
|
| '/dashboard/document/'
|
||||||
|
| '/dashboard/family/'
|
||||||
| '/dashboard/marketing/'
|
| '/dashboard/marketing/'
|
||||||
| '/dashboard/order/'
|
| '/dashboard/order/'
|
||||||
| '/dashboard/payment/'
|
| '/dashboard/payment/'
|
||||||
@ -627,6 +643,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
||||||
parentRoute: typeof DashboardRouteLazyRoute
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/family/': {
|
||||||
|
id: '/dashboard/family/'
|
||||||
|
path: '/family'
|
||||||
|
fullPath: '/dashboard/family'
|
||||||
|
preLoaderRoute: typeof DashboardFamilyIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
'/dashboard/document/': {
|
'/dashboard/document/': {
|
||||||
id: '/dashboard/document/'
|
id: '/dashboard/document/'
|
||||||
path: '/document'
|
path: '/document'
|
||||||
@ -770,6 +793,7 @@ interface DashboardRouteLazyRouteChildren {
|
|||||||
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
||||||
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
||||||
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
||||||
|
DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute
|
||||||
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
||||||
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
||||||
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -802,6 +826,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
|
|||||||
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
||||||
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
||||||
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
||||||
|
DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute,
|
||||||
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
||||||
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
||||||
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
||||||
|
|||||||
6
apps/admin/src/routes/dashboard/family/index.lazy.tsx
Normal file
6
apps/admin/src/routes/dashboard/family/index.lazy.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import FamilyManagement from "@/sections/user/family";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/family/")({
|
||||||
|
component: FamilyManagement,
|
||||||
|
});
|
||||||
@ -54,6 +54,7 @@ const emailSettingsSchema = z.object({
|
|||||||
expiration_email_template: z.string().optional(),
|
expiration_email_template: z.string().optional(),
|
||||||
maintenance_email_template: z.string().optional(),
|
maintenance_email_template: z.string().optional(),
|
||||||
traffic_exceed_email_template: z.string().optional(),
|
traffic_exceed_email_template: z.string().optional(),
|
||||||
|
delete_account_email_template: z.string().optional(),
|
||||||
platform: z.string(),
|
platform: z.string(),
|
||||||
platform_config: z
|
platform_config: z
|
||||||
.object({
|
.object({
|
||||||
@ -102,6 +103,7 @@ export default function EmailSettingsForm() {
|
|||||||
expiration_email_template: "",
|
expiration_email_template: "",
|
||||||
maintenance_email_template: "",
|
maintenance_email_template: "",
|
||||||
traffic_exceed_email_template: "",
|
traffic_exceed_email_template: "",
|
||||||
|
delete_account_email_template: "",
|
||||||
platform: "smtp",
|
platform: "smtp",
|
||||||
platform_config: {
|
platform_config: {
|
||||||
host: "",
|
host: "",
|
||||||
@ -195,6 +197,12 @@ export default function EmailSettingsForm() {
|
|||||||
<TabsTrigger value="traffic">
|
<TabsTrigger value="traffic">
|
||||||
{t("email.trafficTemplate", "Traffic Template")}
|
{t("email.trafficTemplate", "Traffic Template")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="delete_account">
|
||||||
|
{t(
|
||||||
|
"email.deleteAccountTemplate",
|
||||||
|
"Delete Account Template"
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent className="space-y-2" value="basic">
|
<TabsContent className="space-y-2" value="basic">
|
||||||
@ -840,6 +848,88 @@ export default function EmailSettingsForm() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent className="space-y-2" value="delete_account">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.delete_account_email_template"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"email.deleteAccountEmailTemplate",
|
||||||
|
"Delete Account Email Template"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HTMLEditor
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={t(
|
||||||
|
"email.inputPlaceholder",
|
||||||
|
"Please enter"
|
||||||
|
)}
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="mt-4 space-y-2 border-t pt-4">
|
||||||
|
<p className="font-medium text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.title",
|
||||||
|
"Template Variables"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-muted-foreground text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.SiteLogo}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.siteLogo.description",
|
||||||
|
"Site logo URL"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.SiteName}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.siteName.description",
|
||||||
|
"Site name"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.Code}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.code.description",
|
||||||
|
"Verification code"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
|
||||||
|
{"{{.Expire}}"}
|
||||||
|
</code>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"email.templateVariables.expire.description",
|
||||||
|
"Code expiration time"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -29,18 +29,21 @@ export default function Redemption() {
|
|||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProTable<API.RedemptionCode, { subscribe_plan: number; unit_time: string; code: string }>
|
<ProTable<
|
||||||
|
API.RedemptionCode,
|
||||||
|
{ subscribe_plan: number; unit_time: string; code: string }
|
||||||
|
>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
<Button
|
<Button
|
||||||
key="records"
|
key="records"
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCodeId(row.id);
|
setSelectedCodeId(row.id);
|
||||||
setRecordsOpen(true);
|
setRecordsOpen(true);
|
||||||
}}
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
{t("records", "Records")}
|
{t("records", "Records")}
|
||||||
</Button>,
|
</Button>,
|
||||||
@ -240,8 +243,8 @@ export default function Redemption() {
|
|||||||
/>
|
/>
|
||||||
<RedemptionRecords
|
<RedemptionRecords
|
||||||
codeId={selectedCodeId}
|
codeId={selectedCodeId}
|
||||||
open={recordsOpen}
|
|
||||||
onOpenChange={setRecordsOpen}
|
onOpenChange={setRecordsOpen}
|
||||||
|
open={recordsOpen}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,14 +26,23 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
|
|
||||||
const getFormSchema = (t: (key: string, defaultValue: string) => string) => z.object({
|
const getFormSchema = (t: (key: string, defaultValue: string) => string) =>
|
||||||
|
z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
batch_count: z.number().optional(),
|
batch_count: z.number().optional(),
|
||||||
total_count: z.number().min(1, t("form.totalCountRequired", "Total count is required")),
|
total_count: z
|
||||||
subscribe_plan: z.number().min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
.number()
|
||||||
unit_time: z.string().min(1, t("form.unitTimeRequired", "Unit time is required")),
|
.min(1, t("form.totalCountRequired", "Total count is required")),
|
||||||
quantity: z.number().min(1, t("form.quantityRequired", "Quantity is required")),
|
subscribe_plan: z
|
||||||
|
.number()
|
||||||
|
.min(1, t("form.subscribePlanRequired", "Subscribe plan is required")),
|
||||||
|
unit_time: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("form.unitTimeRequired", "Unit time is required")),
|
||||||
|
quantity: z
|
||||||
|
.number()
|
||||||
|
.min(1, t("form.quantityRequired", "Quantity is required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface RedemptionFormProps<T> {
|
interface RedemptionFormProps<T> {
|
||||||
@ -184,9 +193,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="unit_time"
|
name="unit_time"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.unitTime", "Unit Time")}</FormLabel>
|
||||||
{t("form.unitTime", "Unit Time")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Combobox<string, false>
|
<Combobox<string, false>
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -195,8 +202,14 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
options={[
|
options={[
|
||||||
{ value: "day", label: t("form.day", "Day") },
|
{ value: "day", label: t("form.day", "Day") },
|
||||||
{ value: "month", label: t("form.month", "Month") },
|
{ value: "month", label: t("form.month", "Month") },
|
||||||
{ value: "quarter", label: t("form.quarter", "Quarter") },
|
{
|
||||||
{ value: "half_year", label: t("form.halfYear", "Half Year") },
|
value: "quarter",
|
||||||
|
label: t("form.quarter", "Quarter"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "half_year",
|
||||||
|
label: t("form.halfYear", "Half Year"),
|
||||||
|
},
|
||||||
{ value: "year", label: t("form.year", "Year") },
|
{ value: "year", label: t("form.year", "Year") },
|
||||||
]}
|
]}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
@ -215,16 +228,11 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="quantity"
|
name="quantity"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.duration", "Duration")}</FormLabel>
|
||||||
{t("form.duration", "Duration")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<EnhancedInput
|
<EnhancedInput
|
||||||
min={1}
|
min={1}
|
||||||
placeholder={t(
|
placeholder={t("form.durationPlaceholder", "Duration")}
|
||||||
"form.durationPlaceholder",
|
|
||||||
"Duration"
|
|
||||||
)}
|
|
||||||
step={1}
|
step={1}
|
||||||
type="number"
|
type="number"
|
||||||
{...field}
|
{...field}
|
||||||
@ -242,9 +250,7 @@ export default function RedemptionForm<T extends Record<string, any>>({
|
|||||||
name="total_count"
|
name="total_count"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>{t("form.totalCount", "Total Count")}</FormLabel>
|
||||||
{t("form.totalCount", "Total Count")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<EnhancedInput
|
<EnhancedInput
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default function RedemptionRecords({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
const [records, setRecords] = useState<API.RedemptionRecord[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [pagination, setPagination] = useState({ page: 1, size: 10 });
|
const [pagination, setPagination] = useState({ page: 1, size: 200 });
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
if (!codeId) return;
|
if (!codeId) return;
|
||||||
@ -59,12 +59,10 @@ export default function RedemptionRecords({
|
|||||||
}, [open, codeId, pagination]);
|
}, [open, codeId, pagination]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{t("records", "Redemption Records")}</DialogTitle>
|
||||||
{t("records", "Redemption Records")}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -72,7 +70,7 @@ export default function RedemptionRecords({
|
|||||||
<span>{t("loading", "Loading...")}</span>
|
<span>{t("loading", "Loading...")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
{t("noRecords", "No records found")}
|
{t("noRecords", "No records found")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -102,7 +100,9 @@ export default function RedemptionRecords({
|
|||||||
<TableCell>{record.id}</TableCell>
|
<TableCell>{record.id}</TableCell>
|
||||||
<TableCell>{record.user_id}</TableCell>
|
<TableCell>{record.user_id}</TableCell>
|
||||||
<TableCell>{record.subscribe_id}</TableCell>
|
<TableCell>{record.subscribe_id}</TableCell>
|
||||||
<TableCell>{unitTimeMap[record.unit_time] || record.unit_time}</TableCell>
|
<TableCell>
|
||||||
|
{unitTimeMap[record.unit_time] || record.unit_time}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{record.quantity}</TableCell>
|
<TableCell>{record.quantity}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{record.redeemed_at
|
{record.redeemed_at
|
||||||
@ -115,26 +115,28 @@ export default function RedemptionRecords({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{total > pagination.size && (
|
{total > pagination.size && (
|
||||||
<div className="flex justify-between items-center mt-4">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{t("total", "Total")}: {total}
|
{t("total", "Total")}: {total}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||||
disabled={pagination.page === 1}
|
disabled={pagination.page === 1}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
setPagination((p) => ({ ...p, page: p.page - 1 }))
|
||||||
}
|
}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{t("previous", "Previous")}
|
{t("previous", "Previous")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 text-sm border rounded hover:bg-accent disabled:opacity-50"
|
className="rounded border px-3 py-1 text-sm hover:bg-accent disabled:opacity-50"
|
||||||
disabled={pagination.page * pagination.size >= total}
|
disabled={pagination.page * pagination.size >= total}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
setPagination((p) => ({ ...p, page: p.page + 1 }))
|
||||||
}
|
}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{t("next", "Next")}
|
{t("next", "Next")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import TosForm from "./basic-settings/tos-form";
|
|||||||
import LogCleanupForm from "./log-cleanup/log-cleanup-form";
|
import LogCleanupForm from "./log-cleanup/log-cleanup-form";
|
||||||
import InviteForm from "./user-security/invite-form";
|
import InviteForm from "./user-security/invite-form";
|
||||||
import RegisterForm from "./user-security/register-form";
|
import RegisterForm from "./user-security/register-form";
|
||||||
|
import SignatureForm from "./user-security/signature-form";
|
||||||
|
import SubscribeModeForm from "./user-security/subscribe-mode-form";
|
||||||
import VerifyCodeForm from "./user-security/verify-code-form";
|
import VerifyCodeForm from "./user-security/verify-code-form";
|
||||||
import VerifyForm from "./user-security/verify-form";
|
import VerifyForm from "./user-security/verify-form";
|
||||||
|
|
||||||
@ -35,6 +37,8 @@ export default function System() {
|
|||||||
{ component: InviteForm },
|
{ component: InviteForm },
|
||||||
{ component: VerifyForm },
|
{ component: VerifyForm },
|
||||||
{ component: VerifyCodeForm },
|
{ component: VerifyCodeForm },
|
||||||
|
{ component: SignatureForm },
|
||||||
|
{ component: SubscribeModeForm },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -36,6 +36,7 @@ const inviteSchema = z.object({
|
|||||||
forced_invite: z.boolean().optional(),
|
forced_invite: z.boolean().optional(),
|
||||||
referral_percentage: z.number().optional(),
|
referral_percentage: z.number().optional(),
|
||||||
only_first_purchase: z.boolean().optional(),
|
only_first_purchase: z.boolean().optional(),
|
||||||
|
gift_days: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type InviteFormData = z.infer<typeof inviteSchema>;
|
type InviteFormData = z.infer<typeof inviteSchema>;
|
||||||
@ -60,6 +61,7 @@ export default function InviteConfig() {
|
|||||||
forced_invite: false,
|
forced_invite: false,
|
||||||
referral_percentage: 0,
|
referral_percentage: 0,
|
||||||
only_first_purchase: false,
|
only_first_purchase: false,
|
||||||
|
gift_days: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -185,6 +187,38 @@ export default function InviteConfig() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gift_days"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("invite.giftDays", "Invite Gift Days")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<EnhancedInput
|
||||||
|
min={0}
|
||||||
|
onValueBlur={(value) => field.onChange(Number(value))}
|
||||||
|
placeholder={t(
|
||||||
|
"invite.giftDaysPlaceholder",
|
||||||
|
"Enter days"
|
||||||
|
)}
|
||||||
|
suffix={t("invite.giftDaysSuffix", "day(s)")}
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"invite.giftDaysDescription",
|
||||||
|
"When referral percentage is 0, both the inviter and invitee receive extra subscription days after a purchase"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="only_first_purchase"
|
name="only_first_purchase"
|
||||||
|
|||||||
165
apps/admin/src/sections/system/user-security/signature-form.tsx
Normal file
165
apps/admin/src/sections/system/user-security/signature-form.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@workspace/ui/components/form";
|
||||||
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import {
|
||||||
|
getSignatureConfig,
|
||||||
|
updateSignatureConfig,
|
||||||
|
} from "@workspace/ui/services/admin/system";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const signatureSchema = z.object({
|
||||||
|
enable_signature: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignatureFormData = z.infer<typeof signatureSchema>;
|
||||||
|
|
||||||
|
export default function SignatureForm() {
|
||||||
|
const { t } = useTranslation("system");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ["getSignatureConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSignatureConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<SignatureFormData>({
|
||||||
|
resolver: zodResolver(signatureSchema),
|
||||||
|
defaultValues: {
|
||||||
|
enable_signature: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({ enable_signature: data.enable_signature });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
async function onSubmit(values: SignatureFormData) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateSignatureConfig({
|
||||||
|
enable_signature: values.enable_signature ?? false,
|
||||||
|
});
|
||||||
|
toast.success(t("signature.saveSuccess", "Save Successful"));
|
||||||
|
refetch();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("signature.saveFailed", "Save Failed"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Icon
|
||||||
|
className="h-5 w-5 text-primary"
|
||||||
|
icon="mdi:shield-lock-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t("signature.title", "Request Signature")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"signature.description",
|
||||||
|
"Enable or disable request signature verification for public APIs"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||||
|
</div>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{t("signature.title", "Request Signature")}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-2 pt-4"
|
||||||
|
id="signature-form"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enable_signature"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("signature.enable", "Enable Signature Verification")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value ?? false}
|
||||||
|
className="!mt-0 float-end"
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"signature.enableDescription",
|
||||||
|
"When enabled, clients can trigger strict signature verification by sending X-Signature-Enabled: 1"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</ScrollArea>
|
||||||
|
<SheetFooter className="px-6">
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={loading} form="signature-form" type="submit">
|
||||||
|
{loading
|
||||||
|
? t("common.saving", "Saving...")
|
||||||
|
: t("common.save", "Save Settings")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@workspace/ui/components/form";
|
||||||
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import {
|
||||||
|
getSubscribeConfig,
|
||||||
|
updateSubscribeConfig,
|
||||||
|
} from "@workspace/ui/services/admin/system";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const subscribeModeSchema = z.object({
|
||||||
|
single_model: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SubscribeModeFormData = z.infer<typeof subscribeModeSchema>;
|
||||||
|
|
||||||
|
export default function SubscribeModeForm() {
|
||||||
|
const { t } = useTranslation("system");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ["getSubscribeConfig"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSubscribeConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<SubscribeModeFormData>({
|
||||||
|
resolver: zodResolver(subscribeModeSchema),
|
||||||
|
defaultValues: {
|
||||||
|
single_model: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({ single_model: data.single_model });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
async function onSubmit(values: SubscribeModeFormData) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateSubscribeConfig({
|
||||||
|
...data,
|
||||||
|
single_model: values.single_model ?? false,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("subscribeMode.saveSuccess", "Subscription mode updated successfully")
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("subscribeMode.saveFailed", "Failed to update settings"));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<div className="flex cursor-pointer items-center justify-between transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Icon className="h-5 w-5 text-primary" icon="mdi:toggle-switch" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t("subscribeMode.title", "Subscription Mode")}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"subscribeMode.description",
|
||||||
|
"Configure single or multiple subscription purchase behavior"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon className="size-6" icon="mdi:chevron-right" />
|
||||||
|
</div>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>
|
||||||
|
{t("subscribeMode.title", "Subscription Mode")}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="space-y-2 pt-4"
|
||||||
|
id="subscribe-mode-form"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="single_model"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"subscribeMode.singleSubscriptionMode",
|
||||||
|
"Single Subscription Mode"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value ?? false}
|
||||||
|
className="!mt-0 float-end"
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"subscribeMode.singleSubscriptionModeDescription",
|
||||||
|
"After enabling, users can only purchase/renew one subscription in the same account"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</ScrollArea>
|
||||||
|
<SheetFooter className="px-6">
|
||||||
|
<Button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("common.cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading || !data}
|
||||||
|
form="subscribe-mode-form"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? t("common.saving", "Saving...")
|
||||||
|
: t("common.save", "Save Settings")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/admin/src/sections/user/family/.gitkeep
Normal file
1
apps/admin/src/sections/user/family/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
78
apps/admin/src/sections/user/family/enums.ts
Normal file
78
apps/admin/src/sections/user/family/enums.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
type TranslateFn = (...args: any[]) => any;
|
||||||
|
|
||||||
|
function normalizeEnumValue(value?: string) {
|
||||||
|
return (value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFamilyStatusActive(status?: string) {
|
||||||
|
return normalizeEnumValue(status) === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFamilyRoleOwner(role?: string) {
|
||||||
|
return normalizeEnumValue(role) === "owner";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFamilyMemberStatusActive(status?: string) {
|
||||||
|
return normalizeEnumValue(status) === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFamilyStatusLabel(t: TranslateFn, status?: string) {
|
||||||
|
const normalized = normalizeEnumValue(status);
|
||||||
|
if (!normalized) return "--";
|
||||||
|
|
||||||
|
switch (normalized) {
|
||||||
|
case "active":
|
||||||
|
return t("statusActive", "Active");
|
||||||
|
case "disabled":
|
||||||
|
return t("familyDisabled", "Disabled");
|
||||||
|
default:
|
||||||
|
return status || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFamilyRoleLabel(t: TranslateFn, role?: string) {
|
||||||
|
const normalized = normalizeEnumValue(role);
|
||||||
|
if (!normalized) return "--";
|
||||||
|
|
||||||
|
switch (normalized) {
|
||||||
|
case "owner":
|
||||||
|
return t("owner", "Owner");
|
||||||
|
case "member":
|
||||||
|
return t("familyMember", "Member");
|
||||||
|
default:
|
||||||
|
return role || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFamilyMemberStatusLabel(t: TranslateFn, status?: string) {
|
||||||
|
const normalized = normalizeEnumValue(status);
|
||||||
|
if (!normalized) return "--";
|
||||||
|
|
||||||
|
switch (normalized) {
|
||||||
|
case "active":
|
||||||
|
return t("statusActive", "Active");
|
||||||
|
case "left":
|
||||||
|
return t("familyMemberLeft", "Left");
|
||||||
|
case "removed":
|
||||||
|
return t("familyMemberRemoved", "Removed");
|
||||||
|
default:
|
||||||
|
return status || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFamilyJoinSourceLabel(t: TranslateFn, joinSource?: string) {
|
||||||
|
const normalized = normalizeEnumValue(joinSource);
|
||||||
|
if (!normalized) return "--";
|
||||||
|
|
||||||
|
switch (normalized) {
|
||||||
|
case "owner_init":
|
||||||
|
return t("familyJoinSourceOwnerInit", "Owner Initialization");
|
||||||
|
case "bind_email_with_verification":
|
||||||
|
return t(
|
||||||
|
"familyJoinSourceBindEmailWithVerification",
|
||||||
|
"Bind Email With Verification"
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return joinSource || "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
349
apps/admin/src/sections/user/family/family-detail-sheet.tsx
Normal file
349
apps/admin/src/sections/user/family/family-detail-sheet.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
|
import {
|
||||||
|
dissolveFamily,
|
||||||
|
getFamilyDetail,
|
||||||
|
removeFamilyMember,
|
||||||
|
updateFamilyMaxMembers,
|
||||||
|
} from "@workspace/ui/services/admin/user";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
import {
|
||||||
|
getFamilyJoinSourceLabel,
|
||||||
|
getFamilyMemberStatusLabel,
|
||||||
|
getFamilyRoleLabel,
|
||||||
|
getFamilyStatusLabel,
|
||||||
|
isFamilyMemberStatusActive,
|
||||||
|
isFamilyRoleOwner,
|
||||||
|
isFamilyStatusActive,
|
||||||
|
} from "./enums";
|
||||||
|
|
||||||
|
interface FamilyDetailSheetProps {
|
||||||
|
familyId?: number;
|
||||||
|
trigger: ReactNode;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FamilyDetailSheet({
|
||||||
|
familyId,
|
||||||
|
trigger,
|
||||||
|
onChanged,
|
||||||
|
}: Readonly<FamilyDetailSheetProps>) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [maxMembersInput, setMaxMembersInput] = useState("");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const validFamilyId = Number(familyId || 0);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
enabled: open && validFamilyId > 0,
|
||||||
|
queryKey: ["familyDetail", validFamilyId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getFamilyDetail({ id: validFamilyId });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(open && data?.summary)) return;
|
||||||
|
setMaxMembersInput(String(data.summary.max_members));
|
||||||
|
}, [data?.summary, open]);
|
||||||
|
|
||||||
|
const invalidateAll = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["familyList"] }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["familyDetail", validFamilyId],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["getUserList"] }),
|
||||||
|
]);
|
||||||
|
onChanged?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMaxMutation = useMutation({
|
||||||
|
mutationFn: async (maxMembers: number) =>
|
||||||
|
updateFamilyMaxMembers({
|
||||||
|
family_id: validFamilyId,
|
||||||
|
max_members: maxMembers,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t("updateSuccess", "Updated successfully"));
|
||||||
|
await invalidateAll();
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: async (userId: number) =>
|
||||||
|
removeFamilyMember({ family_id: validFamilyId, user_id: userId }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t("removeSuccess", "Removed successfully"));
|
||||||
|
await invalidateAll();
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dissolveMutation = useMutation({
|
||||||
|
mutationFn: async () => dissolveFamily({ family_id: validFamilyId }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t("familyDissolved", "Family dissolved"));
|
||||||
|
await invalidateAll();
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const canDissolve = isFamilyStatusActive(data?.summary.status);
|
||||||
|
|
||||||
|
const summaryItems = useMemo(() => {
|
||||||
|
if (!data?.summary) return [];
|
||||||
|
return [
|
||||||
|
{ label: t("familyId", "Family ID"), value: data.summary.family_id },
|
||||||
|
{ label: t("owner", "Owner"), value: data.summary.owner_identifier },
|
||||||
|
{ label: t("userId", "User ID"), value: data.summary.owner_user_id },
|
||||||
|
{
|
||||||
|
label: t("familyStatus", "Family Status"),
|
||||||
|
value: getFamilyStatusLabel(t, data.summary.status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("memberCount", "Member Count"),
|
||||||
|
value: `${data.summary.active_member_count}/${data.summary.max_members}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("createdAt", "Created At"),
|
||||||
|
value: formatDate(data.summary.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("updatedAt", "Updated At"),
|
||||||
|
value: formatDate(data.summary.updated_at),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [data?.summary, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||||
|
<SheetContent className="w-[920px] max-w-full md:max-w-6xl" side="right">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>
|
||||||
|
{t("familyDetail", "Family Detail")}
|
||||||
|
{validFamilyId ? ` · ID: ${validFamilyId}` : ""}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="h-[calc(100dvh-120px)] pr-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4">{t("loading", "Loading...")}</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-4 p-2">
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<h3 className="mb-3 font-medium text-sm">
|
||||||
|
{t("familySummary", "Family Summary")}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded bg-muted/40 px-3 py-2"
|
||||||
|
key={item.label}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<h3 className="mb-3 font-medium text-sm">
|
||||||
|
{t("familyActions", "Family Actions")}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="w-40">
|
||||||
|
<EnhancedInput
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setMaxMembersInput(value);
|
||||||
|
}}
|
||||||
|
type="number"
|
||||||
|
value={maxMembersInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={updateMaxMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const maxMembers = Number(maxMembersInput);
|
||||||
|
if (
|
||||||
|
!maxMembers ||
|
||||||
|
Number.isNaN(maxMembers) ||
|
||||||
|
maxMembers <= 0
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
t("familyInvalidMaxMembers", "Invalid max members")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.summary.active_member_count &&
|
||||||
|
maxMembers < data.summary.active_member_count
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
"familyMaxMembersTooSmall",
|
||||||
|
"Max members cannot be lower than active member count"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateMaxMutation.mutate(maxMembers);
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{t("familyUpdateMaxMembers", "Update Max Members")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"familyDissolveDescription",
|
||||||
|
"This will dissolve the family and remove all active members."
|
||||||
|
)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await dissolveMutation.mutateAsync();
|
||||||
|
}}
|
||||||
|
title={t(
|
||||||
|
"familyConfirmDissolve",
|
||||||
|
"Confirm Dissolve Family"
|
||||||
|
)}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
disabled={!canDissolve || dissolveMutation.isPending}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{t("familyDissolve", "Dissolve Family")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<h3 className="mb-3 font-medium text-sm">
|
||||||
|
{t("familyMembers", "Family Members")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.members?.length ? (
|
||||||
|
data.members.map((member) => {
|
||||||
|
const canRemove =
|
||||||
|
isFamilyStatusActive(data.summary.status) &&
|
||||||
|
!isFamilyRoleOwner(member.role_name) &&
|
||||||
|
isFamilyMemberStatusActive(member.status_name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded border px-3 py-2"
|
||||||
|
key={`${member.user_id}-${member.joined_at}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
ID: {member.user_id}
|
||||||
|
</Badge>
|
||||||
|
{member.device_no ? (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<span className="font-mono">
|
||||||
|
{member.device_no}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span>{member.identifier}</span>
|
||||||
|
<Badge>
|
||||||
|
{getFamilyRoleLabel(t, member.role_name)}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isFamilyMemberStatusActive(member.status_name)
|
||||||
|
? "default"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getFamilyMemberStatusLabel(
|
||||||
|
t,
|
||||||
|
member.status_name
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t("familyJoinSource", "Join Source")}:{" "}
|
||||||
|
{getFamilyJoinSourceLabel(t, member.join_source)}{" "}
|
||||||
|
· {t("familyJoinedAt", "Joined At")}:{" "}
|
||||||
|
{member.joined_at
|
||||||
|
? formatDate(member.joined_at)
|
||||||
|
: "--"}{" "}
|
||||||
|
· {t("familyLeftAt", "Left At")}:{" "}
|
||||||
|
{member.left_at
|
||||||
|
? formatDate(member.left_at)
|
||||||
|
: "--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canRemove ? (
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"familyRemoveMemberDescription",
|
||||||
|
"This will remove the member from the active family."
|
||||||
|
)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await removeMemberMutation.mutateAsync(
|
||||||
|
member.user_id
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title={t(
|
||||||
|
"familyConfirmRemoveMember",
|
||||||
|
"Confirm Remove Member"
|
||||||
|
)}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
disabled={removeMemberMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{t("remove", "Remove")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t("familyNoMembers", "No members")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-muted-foreground">
|
||||||
|
{t("familyNoData", "No family data")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
apps/admin/src/sections/user/family/index.tsx
Normal file
138
apps/admin/src/sections/user/family/index.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import { getFamilyList } from "@workspace/ui/services/admin/user";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
import { getFamilyStatusLabel, isFamilyStatusActive } from "./enums";
|
||||||
|
import { FamilyDetailSheet } from "./family-detail-sheet";
|
||||||
|
|
||||||
|
interface FamilyManagementProps {
|
||||||
|
initialFamilyId?: number;
|
||||||
|
initialUserId?: number;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FamilyManagement({
|
||||||
|
initialFamilyId,
|
||||||
|
initialUserId,
|
||||||
|
onChanged,
|
||||||
|
}: Readonly<FamilyManagementProps>) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
const initialFilters = {
|
||||||
|
family_id: initialFamilyId || undefined,
|
||||||
|
user_id: initialUserId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProTable<API.FamilySummary, API.GetFamilyListParams>
|
||||||
|
action={ref}
|
||||||
|
actions={{
|
||||||
|
render: (row) => [
|
||||||
|
<FamilyDetailSheet
|
||||||
|
familyId={row.family_id}
|
||||||
|
key={`detail-${row.family_id}`}
|
||||||
|
onChanged={() => {
|
||||||
|
ref.current?.refresh();
|
||||||
|
onChanged?.();
|
||||||
|
}}
|
||||||
|
trigger={<Button>{t("familyDetail", "Family Detail")}</Button>}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "family_id",
|
||||||
|
header: t("familyId", "Family ID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "owner_identifier",
|
||||||
|
header: t("owner", "Owner"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
`${row.original.owner_identifier} (ID: ${row.original.owner_user_id})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: t("status", "Status"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.getValue("status") as string;
|
||||||
|
return isFamilyStatusActive(status) ? (
|
||||||
|
<Badge>{t("statusActive", "Active")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getFamilyStatusLabel(t, status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "active_member_count",
|
||||||
|
header: t("memberCount", "Member Count"),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
`${row.original.active_member_count}/${row.original.max_members}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "max_members",
|
||||||
|
header: t("familyMaxMembers", "Max Members"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: t("createdAt", "Created At"),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "updated_at",
|
||||||
|
header: t("updatedAt", "Updated At"),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
header={{
|
||||||
|
title: t("familyManagement", "Family Group Management"),
|
||||||
|
}}
|
||||||
|
initialFilters={initialFilters}
|
||||||
|
key={String(initialFamilyId || initialUserId || "all")}
|
||||||
|
params={[
|
||||||
|
{
|
||||||
|
key: "keyword",
|
||||||
|
placeholder: t("search", "Search"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
placeholder: t("status", "Status"),
|
||||||
|
options: [
|
||||||
|
{ label: getFamilyStatusLabel(t, "active"), value: "active" },
|
||||||
|
{ label: getFamilyStatusLabel(t, "disabled"), value: "disabled" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "owner_user_id",
|
||||||
|
placeholder: t("familyOwnerUserId", "Owner User ID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "family_id",
|
||||||
|
placeholder: t("familyId", "Family ID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user_id",
|
||||||
|
placeholder: t("userId", "User ID"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
request={async (pagination, filter) => {
|
||||||
|
const { data } = await getFamilyList({
|
||||||
|
...pagination,
|
||||||
|
...filter,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,7 +8,15 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@workspace/ui/components/dropdown-menu";
|
} from "@workspace/ui/components/dropdown-menu";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@workspace/ui/components/select";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@ -23,6 +31,7 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@workspace/ui/components/tabs";
|
} from "@workspace/ui/components/tabs";
|
||||||
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
import {
|
import {
|
||||||
ProTable,
|
ProTable,
|
||||||
@ -35,14 +44,16 @@ import {
|
|||||||
getUserList,
|
getUserList,
|
||||||
updateUserBasicInfo,
|
updateUserBasicInfo,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
import { useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
|
import FamilyManagement from "./family";
|
||||||
import { UserDetail } from "./user-detail";
|
import { UserDetail } from "./user-detail";
|
||||||
import UserForm from "./user-form";
|
import UserForm from "./user-form";
|
||||||
|
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
||||||
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
|
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
|
||||||
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||||
@ -56,13 +67,15 @@ export default function User() {
|
|||||||
|
|
||||||
const { subscribes } = useSubscribe();
|
const { subscribes } = useSubscribe();
|
||||||
|
|
||||||
const initialFilters = {
|
const searchRef = useRef({
|
||||||
search: sp.search || undefined,
|
type: sp.user_id ? "user_id" : "email",
|
||||||
user_id: sp.user_id || undefined,
|
value: sp.search || sp.user_id || "",
|
||||||
subscribe_id: sp.subscribe_id || undefined,
|
});
|
||||||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
|
||||||
short_code: sp.short_code || undefined,
|
const handleSearch = useCallback((type: string, value: string) => {
|
||||||
};
|
searchRef.current = { type, value };
|
||||||
|
ref.current?.refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.User, API.GetUserListParams>
|
<ProTable<API.User, API.GetUserListParams>
|
||||||
@ -75,6 +88,11 @@ export default function User() {
|
|||||||
userId={row.id}
|
userId={row.id}
|
||||||
/>,
|
/>,
|
||||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||||
|
<DeviceGroupSheet
|
||||||
|
key="device-group"
|
||||||
|
onChanged={() => ref.current?.refresh()}
|
||||||
|
userId={row.id}
|
||||||
|
/>,
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
cancelText={t("cancel", "Cancel")}
|
cancelText={t("cancel", "Cancel")}
|
||||||
confirmText={t("confirm", "Confirm")}
|
confirmText={t("confirm", "Confirm")}
|
||||||
@ -98,6 +116,7 @@ export default function User() {
|
|||||||
<Button variant="outline">{t("more", "More")}</Button>
|
<Button variant="outline">{t("more", "More")}</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<InviteStatsMenuItem userId={row.id} />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link
|
||||||
search={{ user_id: String(row.id) }}
|
search={{ user_id: String(row.id) }}
|
||||||
@ -194,6 +213,10 @@ export default function User() {
|
|||||||
header: t("userName", "Username"),
|
header: t("userName", "Username"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const method = row.original.auth_methods?.[0];
|
const method = row.original.auth_methods?.[0];
|
||||||
|
const identifier = method?.auth_identifier || "";
|
||||||
|
const isDevice = method?.auth_type === "device";
|
||||||
|
const deviceNo = (row.original.user_devices?.[0] as any)?.device_no;
|
||||||
|
const display = isDevice ? deviceNo || identifier : identifier;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Badge
|
<Badge
|
||||||
@ -202,7 +225,7 @@ export default function User() {
|
|||||||
>
|
>
|
||||||
{method?.auth_type}
|
{method?.auth_type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{method?.auth_identifier}
|
<span title={isDevice ? display : undefined}>{display}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -245,7 +268,14 @@ export default function User() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
header={{
|
header={{
|
||||||
title: t("userList", "User List"),
|
title: (
|
||||||
|
<UserSearchBar
|
||||||
|
initialType={searchRef.current.type}
|
||||||
|
initialValue={searchRef.current.value}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
subscribes={subscribes}
|
||||||
|
/>
|
||||||
|
),
|
||||||
toolbar: (
|
toolbar: (
|
||||||
<UserForm<API.CreateUserRequest>
|
<UserForm<API.CreateUserRequest>
|
||||||
key="create"
|
key="create"
|
||||||
@ -270,39 +300,19 @@ export default function User() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
initialFilters={initialFilters}
|
request={async (pagination) => {
|
||||||
key={initialFilters.user_id}
|
const { type, value } = searchRef.current;
|
||||||
params={[
|
const params: Record<string, unknown> = { ...pagination };
|
||||||
{
|
if (value) {
|
||||||
key: "subscribe_id",
|
if (type === "user_id") {
|
||||||
placeholder: t("subscription", "Subscription"),
|
params.user_id = value;
|
||||||
options: subscribes?.map((item) => ({
|
} else if (type === "subscribe_id") {
|
||||||
label: item.name!,
|
params.subscribe_id = value;
|
||||||
value: String(item.id!),
|
} else {
|
||||||
})),
|
params.search = value;
|
||||||
},
|
}
|
||||||
{
|
}
|
||||||
key: "search",
|
const { data } = await getUserList(params as API.GetUserListParams);
|
||||||
placeholder: "Search",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "user_id",
|
|
||||||
placeholder: t("userId", "User ID"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "user_subscribe_id",
|
|
||||||
placeholder: t("subscriptionId", "Subscription ID"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "short_code",
|
|
||||||
placeholder: t("shortCode", "Short Code"),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
request={async (pagination, filter) => {
|
|
||||||
const { data } = await getUserList({
|
|
||||||
...pagination,
|
|
||||||
...filter,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
list: data.data?.list || [],
|
list: data.data?.list || [],
|
||||||
total: data.data?.total || 0,
|
total: data.data?.total || 0,
|
||||||
@ -401,3 +411,137 @@ function SubscriptionSheet({ userId }: { userId: number }) {
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InviteStatsMenuItem({ userId }: { userId: number }) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("inviteStats", "Invite Statistics")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<UserInviteStatsSheet
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
open={open}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceGroupSheet({
|
||||||
|
userId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline">{t("deviceGroup", "Device Group")}</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>
|
||||||
|
{t("deviceGroup", "Device Group")} · ID: {userId}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-2 px-4">
|
||||||
|
<FamilyManagement initialUserId={userId} onChanged={onChanged} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserSearchBar({
|
||||||
|
initialType,
|
||||||
|
initialValue,
|
||||||
|
onSearch,
|
||||||
|
subscribes,
|
||||||
|
}: {
|
||||||
|
initialType: string;
|
||||||
|
initialValue: string;
|
||||||
|
onSearch: (type: string, value: string) => void;
|
||||||
|
subscribes?: API.SubscribeItem[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const [searchType, setSearchType] = useState(initialType);
|
||||||
|
const [searchValue, setSearchValue] = useState(initialValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setSearchType(v);
|
||||||
|
setSearchValue("");
|
||||||
|
}}
|
||||||
|
value={searchType}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="email">{t("email", "Email")}</SelectItem>
|
||||||
|
<SelectItem value="device">{t("deviceSearch", "Device")}</SelectItem>
|
||||||
|
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
|
||||||
|
<SelectItem value="subscribe_id">
|
||||||
|
{t("subscription", "Subscription")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{searchType === "subscribe_id" ? (
|
||||||
|
<Combobox
|
||||||
|
className="w-48"
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
onSearch("subscribe_id", value);
|
||||||
|
}}
|
||||||
|
options={subscribes?.map((item) => ({
|
||||||
|
label: item.name!,
|
||||||
|
value: String(item.id!),
|
||||||
|
}))}
|
||||||
|
placeholder={t("subscription", "Subscription")}
|
||||||
|
value={searchValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
className="w-48"
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" && onSearch(searchType, searchValue)
|
||||||
|
}
|
||||||
|
placeholder={t("searchInputPlaceholder", "Enter search term")}
|
||||||
|
value={searchValue}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSearch(searchType, searchValue)}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{t("search", "Search")}
|
||||||
|
</Button>
|
||||||
|
{searchValue && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchValue("");
|
||||||
|
onSearch(searchType, "");
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{t("resetSearch", "Reset")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
getUserDetail,
|
getUserDetail,
|
||||||
getUserSubscribeById,
|
getUserSubscribeById,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
|
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
|
||||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
@ -163,9 +164,14 @@ export function UserDetail({ id }: { id: number }) {
|
|||||||
|
|
||||||
if (!id) return "--";
|
if (!id) return "--";
|
||||||
|
|
||||||
const identifier =
|
const emailMethod = data?.auth_methods.find((m) => m.auth_type === "email");
|
||||||
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
|
const firstMethod = data?.auth_methods[0];
|
||||||
data?.auth_methods[0]?.auth_identifier;
|
const rawIdentifier =
|
||||||
|
emailMethod?.auth_identifier || firstMethod?.auth_identifier || "";
|
||||||
|
const isDevice = !emailMethod && firstMethod?.auth_type === "device";
|
||||||
|
const identifier = isDevice
|
||||||
|
? shortenDeviceIdentifier(rawIdentifier)
|
||||||
|
: rawIdentifier;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
|
|||||||
228
apps/admin/src/sections/user/user-invite-stats-sheet.tsx
Normal file
228
apps/admin/src/sections/user/user-invite-stats-sheet.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@workspace/ui/components/avatar";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import {
|
||||||
|
getAdminUserInviteList,
|
||||||
|
getAdminUserInviteStats,
|
||||||
|
} from "@workspace/ui/services/admin/user";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Display } from "@/components/display";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
|
||||||
|
interface UserInviteStatsSheetProps {
|
||||||
|
userId: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserInviteStatsSheet({
|
||||||
|
userId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: UserInviteStatsSheetProps) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 200;
|
||||||
|
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ["adminUserInviteStats", userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getAdminUserInviteStats({ user_id: userId });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
enabled: open && !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: listResult, isLoading: listLoading } = useQuery({
|
||||||
|
queryKey: ["adminUserInviteList", userId, page],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getAdminUserInviteList({
|
||||||
|
user_id: userId,
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
enabled: open && !!userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteList = listResult?.list ?? [];
|
||||||
|
const total = listResult?.total ?? 0;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||||
|
<SheetContent className="w-[560px] overflow-y-auto sm:max-w-[560px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{t("inviteStats", "Invite Statistics")}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* 概览卡片 */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<StatCard
|
||||||
|
label={t("inviteCount", "Invited Users")}
|
||||||
|
loading={statsLoading}
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-2xl">
|
||||||
|
{stats?.invite_count ?? 0}
|
||||||
|
</span>
|
||||||
|
</StatCard>
|
||||||
|
<StatCard
|
||||||
|
label={t("totalCommission", "Total Commission")}
|
||||||
|
loading={statsLoading}
|
||||||
|
>
|
||||||
|
<Display type="currency" value={stats?.total_commission} />
|
||||||
|
</StatCard>
|
||||||
|
<StatCard
|
||||||
|
label={t("currentCommission", "Current Commission")}
|
||||||
|
loading={statsLoading}
|
||||||
|
>
|
||||||
|
<Display type="currency" value={stats?.current_commission} />
|
||||||
|
</StatCard>
|
||||||
|
<StatCard
|
||||||
|
label={t("referralPercentage", "Referral %")}
|
||||||
|
loading={statsLoading}
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-2xl">
|
||||||
|
{stats?.referral_percentage
|
||||||
|
? `${stats.referral_percentage}%`
|
||||||
|
: t("globalDefault", "Global Default")}
|
||||||
|
</span>
|
||||||
|
{stats?.only_first_purchase && (
|
||||||
|
<span className="ml-1 text-muted-foreground text-xs">
|
||||||
|
({t("firstPurchaseOnly", "First purchase only")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</StatCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邀请用户列表 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="mb-3 font-medium text-sm">
|
||||||
|
{t("invitedUsers", "Invited Users")} ({total})
|
||||||
|
</h3>
|
||||||
|
{listLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton className="h-10 w-full" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : inviteList.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
{t("noInvitedUsers", "No invited users yet")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("user", "User")}</TableHead>
|
||||||
|
<TableHead>{t("status", "Status")}</TableHead>
|
||||||
|
<TableHead>{t("registeredAt", "Registered At")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{inviteList.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
<AvatarImage src={user.avatar} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user.identifier?.charAt(0)?.toUpperCase() ?? "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="max-w-[160px] truncate text-sm">
|
||||||
|
{user.identifier || `#${user.id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.enable ? "default" : "secondary"}>
|
||||||
|
{user.enable
|
||||||
|
? t("enabled", "Enabled")
|
||||||
|
: t("disabled", "Disabled")}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{formatDate(user.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("prev", "Prev")}
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1 text-sm">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("next", "Next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
loading: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 rounded-lg border p-4">
|
||||||
|
<p className="text-muted-foreground text-xs">{label}</p>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-baseline gap-1 font-semibold text-2xl">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Alert, AlertDescription } from "@workspace/ui/components/alert";
|
||||||
import { Badge } from "@workspace/ui/components/badge";
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@ -15,12 +16,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
createUserSubscribe,
|
createUserSubscribe,
|
||||||
deleteUserSubscribe,
|
deleteUserSubscribe,
|
||||||
|
getFamilyList,
|
||||||
getUserSubscribe,
|
getUserSubscribe,
|
||||||
resetUserSubscribeToken,
|
resetUserSubscribeToken,
|
||||||
toggleUserSubscribeStatus,
|
toggleUserSubscribeStatus,
|
||||||
updateUserSubscribe,
|
updateUserSubscribe,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
import { useRef, useState } from "react";
|
import { Info } from "lucide-react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
@ -29,16 +32,126 @@ import { formatDate } from "@/utils/common";
|
|||||||
import { SubscriptionDetail } from "./subscription-detail";
|
import { SubscriptionDetail } from "./subscription-detail";
|
||||||
import { SubscriptionForm } from "./subscription-form";
|
import { SubscriptionForm } from "./subscription-form";
|
||||||
|
|
||||||
|
interface SharedInfo {
|
||||||
|
ownerUserId: number;
|
||||||
|
familyId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserSubscription({ userId }: { userId: number }) {
|
export default function UserSubscription({ userId }: { userId: number }) {
|
||||||
const { t } = useTranslation("user");
|
const { t } = useTranslation("user");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
const [sharedInfo, setSharedInfo] = useState<SharedInfo | null>(null);
|
||||||
|
|
||||||
|
const request = useCallback(
|
||||||
|
async (pagination: { page: number; size: number }) => {
|
||||||
|
// 1. Fetch user's own subscriptions
|
||||||
|
const { data } = await getUserSubscribe({
|
||||||
|
user_id: userId,
|
||||||
|
...pagination,
|
||||||
|
});
|
||||||
|
const list = data.data?.list || [];
|
||||||
|
const total = data.data?.total || 0;
|
||||||
|
|
||||||
|
// 2. If user has own subscriptions, show them directly
|
||||||
|
if (list.length > 0) {
|
||||||
|
setSharedInfo(null);
|
||||||
|
return { list, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check if user belongs to a device group
|
||||||
|
try {
|
||||||
|
const { data: familyData } = await getFamilyList({
|
||||||
|
user_id: userId,
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
});
|
||||||
|
const familyList = familyData.data?.list || [];
|
||||||
|
const family = familyList.find(
|
||||||
|
(f) => f.owner_user_id !== userId && f.status === "active"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (family) {
|
||||||
|
// 4. Fetch owner's subscriptions
|
||||||
|
const { data: ownerData } = await getUserSubscribe({
|
||||||
|
user_id: family.owner_user_id,
|
||||||
|
...pagination,
|
||||||
|
});
|
||||||
|
const ownerList = ownerData.data?.list || [];
|
||||||
|
const ownerTotal = ownerData.data?.total || 0;
|
||||||
|
|
||||||
|
if (ownerList.length > 0) {
|
||||||
|
setSharedInfo({
|
||||||
|
ownerUserId: family.owner_user_id,
|
||||||
|
familyId: family.family_id,
|
||||||
|
});
|
||||||
|
return { list: ownerList, total: ownerTotal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fall through to show empty list
|
||||||
|
}
|
||||||
|
|
||||||
|
setSharedInfo(null);
|
||||||
|
return { list: [], total: 0 };
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSharedView = !!sharedInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isSharedView && (
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{t("sharedSubscriptionInfo", {
|
||||||
|
defaultValue:
|
||||||
|
"This user is a device group member. Showing shared subscriptions from owner (ID: {{ownerId}})",
|
||||||
|
ownerId: sharedInfo.ownerUserId,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link
|
||||||
|
search={{ user_id: String(sharedInfo.ownerUserId) }}
|
||||||
|
to="/dashboard/family"
|
||||||
|
>
|
||||||
|
{t("viewDeviceGroup", "View Device Group")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link
|
||||||
|
search={{ user_id: String(sharedInfo.ownerUserId) }}
|
||||||
|
to="/dashboard/user"
|
||||||
|
>
|
||||||
|
{t("viewOwner", "View Owner")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<ProTable<API.UserSubscribe, Record<string, unknown>>
|
<ProTable<API.UserSubscribe, Record<string, unknown>>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) =>
|
||||||
|
isSharedView
|
||||||
|
? [
|
||||||
|
<Badge key="shared" variant="secondary">
|
||||||
|
{t("sharedSubscription", "Shared")}
|
||||||
|
</Badge>,
|
||||||
|
<RowReadOnlyActions
|
||||||
|
key="more"
|
||||||
|
refresh={() => ref.current?.refresh()}
|
||||||
|
row={row}
|
||||||
|
token={row.token}
|
||||||
|
userId={sharedInfo!.ownerUserId}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: [
|
||||||
<SubscriptionForm
|
<SubscriptionForm
|
||||||
initialData={row}
|
initialData={row}
|
||||||
key="edit"
|
key="edit"
|
||||||
@ -75,7 +188,16 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: t("subscriptionName", "Subscription Name"),
|
header: t("subscriptionName", "Subscription Name"),
|
||||||
cell: ({ row }) => row.original.subscribe.name,
|
cell: ({ row }) => (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{row.original.subscribe.name}
|
||||||
|
{isSharedView && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t("sharedSubscription", "Shared")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
@ -85,7 +207,8 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
const expireTime = row.original.expire_time;
|
const expireTime = row.original.expire_time;
|
||||||
|
|
||||||
// 如果过期时间为0,说明是永久订阅,应该显示为激活状态
|
// 如果过期时间为0,说明是永久订阅,应该显示为激活状态
|
||||||
const displayStatus = status === 3 && expireTime === 0 ? 1 : status;
|
const displayStatus =
|
||||||
|
status === 3 && expireTime === 0 ? 1 : status;
|
||||||
|
|
||||||
const statusMap: Record<
|
const statusMap: Record<
|
||||||
number,
|
number,
|
||||||
@ -94,7 +217,10 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
variant: "default" | "secondary" | "destructive" | "outline";
|
variant: "default" | "secondary" | "destructive" | "outline";
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
0: { label: t("statusPending", "Pending"), variant: "outline" },
|
0: {
|
||||||
|
label: t("statusPending", "Pending"),
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
1: { label: t("statusActive", "Active"), variant: "default" },
|
1: { label: t("statusActive", "Active"), variant: "default" },
|
||||||
2: {
|
2: {
|
||||||
label: t("statusFinished", "Finished"),
|
label: t("statusFinished", "Finished"),
|
||||||
@ -140,7 +266,11 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
accessorKey: "traffic",
|
accessorKey: "traffic",
|
||||||
header: t("totalTraffic", "Total Traffic"),
|
header: t("totalTraffic", "Total Traffic"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
<Display
|
||||||
|
type="traffic"
|
||||||
|
unlimited
|
||||||
|
value={row.getValue("traffic")}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -187,8 +317,10 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
header={{
|
header={{
|
||||||
title: t("subscriptionList", "Subscription List"),
|
title: isSharedView
|
||||||
toolbar: (
|
? t("sharedSubscriptionList", "Shared Subscription List")
|
||||||
|
: t("subscriptionList", "Subscription List"),
|
||||||
|
toolbar: isSharedView ? undefined : (
|
||||||
<SubscriptionForm
|
<SubscriptionForm
|
||||||
key="create"
|
key="create"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -208,17 +340,112 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
request={async (pagination) => {
|
request={request}
|
||||||
const { data } = await getUserSubscribe({
|
|
||||||
user_id: userId,
|
|
||||||
...pagination,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
list: data.data?.list || [],
|
|
||||||
total: data.data?.total || 0,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowReadOnlyActions({
|
||||||
|
userId,
|
||||||
|
row,
|
||||||
|
token,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
row: API.UserSubscribe;
|
||||||
|
token: string;
|
||||||
|
refresh?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("user");
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const deleteRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex">
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">{t("more", "More")}</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
getUserSubscribeUrls(row.short, token)[0] || ""
|
||||||
|
);
|
||||||
|
toast.success(t("copySuccess", "Copied successfully"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("copySubscription", "Copy Subscription")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
search={{ user_id: userId, user_subscribe_id: row.id }}
|
||||||
|
to="/dashboard/log/subscribe"
|
||||||
|
>
|
||||||
|
{t("subscriptionLogs", "Subscription Logs")}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
search={{ user_id: userId, user_subscribe_id: row.id }}
|
||||||
|
to="/dashboard/log/subscribe-traffic"
|
||||||
|
>
|
||||||
|
{t("trafficStats", "Traffic Stats")}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
search={{ user_id: userId, subscribe_id: row.id }}
|
||||||
|
to="/dashboard/log/traffic-details"
|
||||||
|
>
|
||||||
|
{t("trafficDetails", "Traffic Details")}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onlineDevices", "Online Devices")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<ConfirmButton
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteSubscriptionDescription",
|
||||||
|
"This action cannot be undone."
|
||||||
|
)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteUserSubscribe({ user_subscribe_id: row.id });
|
||||||
|
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||||
|
refresh?.();
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Confirm Delete")}
|
||||||
|
trigger={<Button className="hidden" ref={deleteRef} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubscriptionDetail
|
||||||
|
subscriptionId={row.id}
|
||||||
|
trigger={<Button className="hidden" ref={triggerRef} />}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
getUserSubscribeDevices,
|
getUserSubscribeDevices,
|
||||||
kickOfflineByUserDevice,
|
kickOfflineByUserDevice,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
|
import { deviceIdToHash } from "@workspace/ui/utils/device";
|
||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -86,7 +87,18 @@ export function SubscriptionDetail({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ accessorKey: "id", header: "ID" },
|
{ accessorKey: "id", header: "ID" },
|
||||||
{ accessorKey: "identifier", header: "IMEI" },
|
{
|
||||||
|
accessorKey: "identifier",
|
||||||
|
header: t("deviceNo", "Device No."),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const id = row.original.id;
|
||||||
|
return (
|
||||||
|
<span className="font-mono" title={row.original.identifier}>
|
||||||
|
{deviceIdToHash(id)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "user_agent",
|
accessorKey: "user_agent",
|
||||||
header: t("userAgent", "User Agent"),
|
header: t("userAgent", "User Agent"),
|
||||||
|
|||||||
@ -24,8 +24,11 @@ export function differenceInDays(date1: Date, date2: Date): number {
|
|||||||
|
|
||||||
export function formatDate(date?: Date | number, showTime = true) {
|
export function formatDate(date?: Date | number, showTime = true) {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
|
// Backend returns Unix timestamps in seconds; convert to milliseconds for JS Date
|
||||||
|
const dateValue =
|
||||||
|
typeof date === "number" && date < 1e12 ? date * 1000 : date;
|
||||||
const timeZone = localStorage.getItem("timezone") || "UTC";
|
const timeZone = localStorage.getItem("timezone") || "UTC";
|
||||||
return intlFormat(date, {
|
return intlFormat(dateValue, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@workspace/ui/components/dropdown-menu";
|
} from "@workspace/ui/components/dropdown-menu";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavs } from "@/layout/navs";
|
import { useNavs } from "@/layout/navs";
|
||||||
import { useGlobalStore } from "@/stores/global";
|
import { useGlobalStore } from "@/stores/global";
|
||||||
@ -32,6 +33,13 @@ export function UserNav() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
const method = user?.auth_methods?.[0];
|
||||||
|
const isDevice = method?.auth_type === "device";
|
||||||
|
const rawIdentifier = method?.auth_identifier ?? "";
|
||||||
|
const displayName = isDevice
|
||||||
|
? shortenDeviceIdentifier(rawIdentifier)
|
||||||
|
: rawIdentifier.split("@")[0] || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -40,16 +48,14 @@ export function UserNav() {
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
alt={user?.avatar ?? ""}
|
alt={user?.avatar ?? ""}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
src={user?.auth_methods?.[0]?.auth_identifier ?? ""}
|
src={isDevice ? "" : rawIdentifier}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="bg-linear-to-br from-primary/90 to-primary font-medium text-background">
|
<AvatarFallback className="bg-linear-to-br from-primary/90 to-primary font-medium text-background">
|
||||||
{user?.auth_methods?.[0]?.auth_identifier
|
{displayName.toUpperCase().charAt(0)}
|
||||||
.toUpperCase()
|
|
||||||
.charAt(0)}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="max-w-10 truncate text-sm sm:max-w-[100px]">
|
<span className="max-w-10 truncate text-sm sm:max-w-[100px]">
|
||||||
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
<Icon
|
<Icon
|
||||||
className="size-4 text-muted-foreground"
|
className="size-4 text-muted-foreground"
|
||||||
|
|||||||
@ -26,7 +26,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
|
|||||||
const result = await queryAnnouncement(
|
const result = await queryAnnouncement(
|
||||||
{
|
{
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10,
|
size: 200,
|
||||||
pinned: type === "pinned",
|
pinned: type === "pinned",
|
||||||
popup: type === "popup",
|
popup: type === "popup",
|
||||||
},
|
},
|
||||||
@ -52,7 +52,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{data.title}</DialogTitle>
|
<DialogTitle>{data.title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
<Markdown>{data.content}</Markdown>
|
<Markdown>{data.content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -69,7 +69,7 @@ export default function Announcement({ type }: { type: "popup" | "pinned" }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
{data.content ? (
|
{data.content ? (
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
<Markdown>{data.content}</Markdown>
|
<Markdown>{data.content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -13,12 +13,12 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@workspace/ui/components/tabs";
|
} from "@workspace/ui/components/tabs";
|
||||||
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import { Markdown } from "@workspace/ui/composed/markdown";
|
||||||
import {
|
import {
|
||||||
ProList,
|
ProList,
|
||||||
type ProListActions,
|
type ProListActions,
|
||||||
} from "@workspace/ui/composed/pro-list/pro-list";
|
} from "@workspace/ui/composed/pro-list/pro-list";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
|
||||||
import { Markdown } from "@workspace/ui/composed/markdown";
|
|
||||||
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
|
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
|
||||||
import { formatDate } from "@workspace/ui/utils/formatting";
|
import { formatDate } from "@workspace/ui/utils/formatting";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@ -60,13 +60,13 @@ export default function Announcement() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-lg">{item.title}</CardTitle>
|
<CardTitle className="text-lg">{item.title}</CardTitle>
|
||||||
{item.pinned && (
|
{item.pinned && (
|
||||||
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary text-xs">
|
||||||
<Icon className="size-3" icon="uil:pin" />
|
<Icon className="size-3" icon="uil:pin" />
|
||||||
{t("pinned", "Pinned")}
|
{t("pinned", "Pinned")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.popup && (
|
{item.popup && (
|
||||||
<span className="flex items-center gap-1 rounded-full bg-orange-500/10 px-2 py-0.5 text-xs font-medium text-orange-500">
|
<span className="flex items-center gap-1 rounded-full bg-orange-500/10 px-2 py-0.5 font-medium text-orange-500 text-xs">
|
||||||
<Icon className="size-3" icon="uil:popcorn" />
|
<Icon className="size-3" icon="uil:popcorn" />
|
||||||
{t("popup", "Popup")}
|
{t("popup", "Popup")}
|
||||||
</span>
|
</span>
|
||||||
@ -80,7 +80,7 @@ export default function Announcement() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
<Markdown>{item.content}</Markdown>
|
<Markdown>{item.content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -95,9 +95,7 @@ export default function Announcement() {
|
|||||||
</h2>
|
</h2>
|
||||||
<Tabs defaultValue="all" onValueChange={setActiveTab}>
|
<Tabs defaultValue="all" onValueChange={setActiveTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">
|
<TabsTrigger value="all">{t("all", "All")}</TabsTrigger>
|
||||||
{t("all", "All")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="pinned">
|
<TabsTrigger value="pinned">
|
||||||
{t("pinnedOnly", "Pinned Only")}
|
{t("pinnedOnly", "Pinned Only")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -110,9 +108,7 @@ export default function Announcement() {
|
|||||||
<ProList
|
<ProList
|
||||||
action={normalRef}
|
action={normalRef}
|
||||||
renderItem={renderAnnouncement}
|
renderItem={renderAnnouncement}
|
||||||
request={async (pagination) => {
|
request={async (pagination) => requestAnnouncements(pagination, {})}
|
||||||
return requestAnnouncements(pagination, {});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -120,9 +116,9 @@ export default function Announcement() {
|
|||||||
<ProList
|
<ProList
|
||||||
action={pinnedRef}
|
action={pinnedRef}
|
||||||
renderItem={renderAnnouncement}
|
renderItem={renderAnnouncement}
|
||||||
request={async (pagination) => {
|
request={async (pagination) =>
|
||||||
return requestAnnouncements(pagination, { pinned: true });
|
requestAnnouncements(pagination, { pinned: true })
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -130,9 +126,9 @@ export default function Announcement() {
|
|||||||
<ProList
|
<ProList
|
||||||
action={normalRef}
|
action={normalRef}
|
||||||
renderItem={renderAnnouncement}
|
renderItem={renderAnnouncement}
|
||||||
request={async (pagination) => {
|
request={async (pagination) =>
|
||||||
return requestAnnouncements(pagination, { popup: true });
|
requestAnnouncements(pagination, { popup: true })
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -25,7 +25,9 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) {
|
|||||||
const redeemMutation = useMutation({
|
const redeemMutation = useMutation({
|
||||||
mutationFn: (code: string) => redeemCode({ code }),
|
mutationFn: (code: string) => redeemCode({ code }),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
const message = (response.data as { message?: string })?.message || t("redeemSuccess", "兑换成功");
|
const message =
|
||||||
|
(response.data as { message?: string })?.message ||
|
||||||
|
t("redeemSuccess", "兑换成功");
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
setCode("");
|
setCode("");
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@ -64,10 +66,7 @@ export default function RedeemCode({ onSuccess }: RedeemCodeProps) {
|
|||||||
<Input
|
<Input
|
||||||
id="redemption-code"
|
id="redemption-code"
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
placeholder={t(
|
placeholder={t("enterRedemptionCode", "请输入兑换码")}
|
||||||
"enterRedemptionCode",
|
|
||||||
"请输入兑换码"
|
|
||||||
)}
|
|
||||||
value={code}
|
value={code}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -65,7 +65,11 @@ export default function Page() {
|
|||||||
orderNo: order_no!,
|
orderNo: order_no!,
|
||||||
returnUrl: window.location.href,
|
returnUrl: window.location.href,
|
||||||
});
|
});
|
||||||
if (data.data?.type === "url" && data.data.checkout_url && !paymentOpened) {
|
if (
|
||||||
|
data.data?.type === "url" &&
|
||||||
|
data.data.checkout_url &&
|
||||||
|
!paymentOpened
|
||||||
|
) {
|
||||||
window.open(data.data.checkout_url, "_blank");
|
window.open(data.data.checkout_url, "_blank");
|
||||||
setPaymentOpened(true);
|
setPaymentOpened(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
updateBindEmail,
|
updateBindEmail,
|
||||||
updateBindMobile,
|
updateBindMobile,
|
||||||
} from "@workspace/ui/services/user/user";
|
} from "@workspace/ui/services/user/user";
|
||||||
|
import { shortenDeviceIdentifier } from "@workspace/ui/utils/device";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -287,6 +288,14 @@ export default function ThirdPartyAccounts() {
|
|||||||
? currentValue
|
? currentValue
|
||||||
: method?.auth_identifier || "";
|
: method?.auth_identifier || "";
|
||||||
break;
|
break;
|
||||||
|
case "device":
|
||||||
|
displayValue = method?.auth_identifier
|
||||||
|
? shortenDeviceIdentifier(method.auth_identifier)
|
||||||
|
: t(
|
||||||
|
`thirdParty.${account.id}.description`,
|
||||||
|
account.descriptionDefault
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
displayValue =
|
displayValue =
|
||||||
method?.auth_identifier ||
|
method?.auth_identifier ||
|
||||||
|
|||||||
@ -30,7 +30,8 @@ export function EnhancedInput<T = string>({
|
|||||||
...props
|
...props
|
||||||
}: EnhancedInputProps<T>) {
|
}: EnhancedInputProps<T>) {
|
||||||
const getProcessedValue = (inputValue: unknown) => {
|
const getProcessedValue = (inputValue: unknown) => {
|
||||||
if (inputValue === "" || inputValue === 0 || inputValue === "0") return "";
|
if (inputValue === "" || inputValue === undefined || inputValue === null)
|
||||||
|
return "";
|
||||||
const newValue = String(inputValue ?? "");
|
const newValue = String(inputValue ?? "");
|
||||||
return formatInput ? formatInput(inputValue as T) : newValue;
|
return formatInput ? formatInput(inputValue as T) : newValue;
|
||||||
};
|
};
|
||||||
@ -69,13 +70,6 @@ export function EnhancedInput<T = string>({
|
|||||||
let inputValue = e.target.value;
|
let inputValue = e.target.value;
|
||||||
|
|
||||||
if (props.type === "number") {
|
if (props.type === "number") {
|
||||||
if (inputValue === "0") {
|
|
||||||
setValue("");
|
|
||||||
setInternalValue(0);
|
|
||||||
onValueChange?.(processValue(0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
/^-?\d*\.?\d*$/.test(inputValue) ||
|
/^-?\d*\.?\d*$/.test(inputValue) ||
|
||||||
inputValue === "-" ||
|
inputValue === "-" ||
|
||||||
@ -99,7 +93,7 @@ export function EnhancedInput<T = string>({
|
|||||||
} else {
|
} else {
|
||||||
setInternalValue(inputValue);
|
setInternalValue(inputValue);
|
||||||
}
|
}
|
||||||
setValue(inputValue === "0" ? "" : inputValue);
|
setValue(inputValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setValue(inputValue);
|
setValue(inputValue);
|
||||||
@ -111,21 +105,17 @@ export function EnhancedInput<T = string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
if (props.type === "number" && value) {
|
if (
|
||||||
if (value === "-" || value === ".") {
|
props.type === "number" &&
|
||||||
|
value !== "" &&
|
||||||
|
(value === "-" || value === ".")
|
||||||
|
) {
|
||||||
setValue("");
|
setValue("");
|
||||||
setInternalValue("");
|
setInternalValue("");
|
||||||
onValueBlur?.("" as T);
|
onValueBlur?.("" as T);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "0") {
|
|
||||||
setValue("");
|
|
||||||
onValueBlur?.(processValue(0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputValue = processValue(value);
|
const outputValue = processValue(value);
|
||||||
if ((initialValue || "") !== outputValue) {
|
if ((initialValue || "") !== outputValue) {
|
||||||
onValueBlur?.(outputValue);
|
onValueBlur?.(outputValue);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import type { Table } from "@tanstack/react-table";
|
import type { Table } from "@tanstack/react-table";
|
||||||
import { Input } from "@workspace/ui/components/input";
|
import { Input } from "@workspace/ui/components/input";
|
||||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface IParams {
|
export interface IParams {
|
||||||
key: string;
|
key: string;
|
||||||
@ -81,15 +82,52 @@ export function ColumnFilter<TData>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<DebouncedInput
|
||||||
className="min-w-32"
|
externalValue={filters[param.key] || ""}
|
||||||
key={param.key}
|
key={param.key}
|
||||||
onChange={(event) => updateFilter(param.key, event.target.value)}
|
onSearch={(value) => updateFilter(param.key, value)}
|
||||||
placeholder={param.placeholder || "Search..."}
|
placeholder={param.placeholder || "Search..."}
|
||||||
value={filters[param.key] || ""}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DebouncedInput({
|
||||||
|
externalValue,
|
||||||
|
onSearch,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
externalValue: string;
|
||||||
|
onSearch: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const [localValue, setLocalValue] = useState(externalValue);
|
||||||
|
|
||||||
|
// Sync from external (e.g. reset)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(externalValue);
|
||||||
|
}, [externalValue]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLocalValue(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(localValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
className="min-w-32"
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={localValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function ProTable<
|
|||||||
const [rowCount, setRowCount] = useState<number>(0);
|
const [rowCount, setRowCount] = useState<number>(0);
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 200,
|
||||||
});
|
});
|
||||||
const loading = useRef(false);
|
const loading = useRef(false);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// API 更新时间:
|
// API 更新时间:
|
||||||
// API 唯一标识:
|
// API 唯一标识:
|
||||||
@ -13,6 +13,7 @@ import * as log from "./log";
|
|||||||
import * as marketing from "./marketing";
|
import * as marketing from "./marketing";
|
||||||
import * as order from "./order";
|
import * as order from "./order";
|
||||||
import * as payment from "./payment";
|
import * as payment from "./payment";
|
||||||
|
import * as redemption from "./redemption";
|
||||||
import * as server from "./server";
|
import * as server from "./server";
|
||||||
import * as subscribe from "./subscribe";
|
import * as subscribe from "./subscribe";
|
||||||
import * as system from "./system";
|
import * as system from "./system";
|
||||||
@ -31,6 +32,7 @@ export default {
|
|||||||
marketing,
|
marketing,
|
||||||
order,
|
order,
|
||||||
payment,
|
payment,
|
||||||
|
redemption,
|
||||||
server,
|
server,
|
||||||
subscribe,
|
subscribe,
|
||||||
system,
|
system,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@ -56,9 +56,15 @@ export async function updateOrderStatus(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Manually activate order POST /v1/admin/order/activate */
|
/**
|
||||||
|
* 手动激活订单
|
||||||
|
* POST /v1/admin/order/activate
|
||||||
|
* @param body - 激活订单请求参数,包含 order_no
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 激活结果
|
||||||
|
*/
|
||||||
export async function activateOrder(
|
export async function activateOrder(
|
||||||
body: { order_no: string },
|
body: API.ActivateOrderRequest,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
return request<API.Response & { data?: any }>(
|
return request<API.Response & { data?: any }>(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,14 @@
|
|||||||
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
/** Toggle redemption code status PUT /v1/admin/redemption/code/status */
|
/**
|
||||||
export async function toggleRedemptionCodeStatus(
|
* 创建兑换码
|
||||||
body: API.ToggleRedemptionCodeStatusRequest,
|
* POST /v1/admin/redemption/code
|
||||||
options?: { [key: string]: any }
|
* @param body - 创建兑换码请求参数
|
||||||
) {
|
* @param options - 可选的请求配置
|
||||||
return request<API.Response & { data?: any }>(
|
* @returns 创建结果
|
||||||
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code/status`,
|
*/
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: body,
|
|
||||||
...(options || {}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update redemption code PUT /v1/admin/redemption/code */
|
|
||||||
export async function updateRedemptionCode(
|
|
||||||
body: API.UpdateRedemptionCodeRequest,
|
|
||||||
options?: { [key: string]: any }
|
|
||||||
) {
|
|
||||||
return request<API.Response & { data?: any }>(
|
|
||||||
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: body,
|
|
||||||
...(options || {}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create redemption code POST /v1/admin/redemption/code */
|
|
||||||
export async function createRedemptionCode(
|
export async function createRedemptionCode(
|
||||||
body: API.CreateRedemptionCodeRequest,
|
body: API.CreateRedemptionCodeRequest,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
@ -55,7 +26,37 @@ export async function createRedemptionCode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete redemption code DELETE /v1/admin/redemption/code */
|
/**
|
||||||
|
* 更新兑换码
|
||||||
|
* PUT /v1/admin/redemption/code
|
||||||
|
* @param body - 更新兑换码请求参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 更新结果
|
||||||
|
*/
|
||||||
|
export async function updateRedemptionCode(
|
||||||
|
body: API.UpdateRedemptionCodeRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除兑换码
|
||||||
|
* DELETE /v1/admin/redemption/code
|
||||||
|
* @param body - 删除兑换码请求参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 删除结果
|
||||||
|
*/
|
||||||
export async function deleteRedemptionCode(
|
export async function deleteRedemptionCode(
|
||||||
body: API.DeleteRedemptionCodeRequest,
|
body: API.DeleteRedemptionCodeRequest,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
@ -73,7 +74,13 @@ export async function deleteRedemptionCode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Batch delete redemption code DELETE /v1/admin/redemption/code/batch */
|
/**
|
||||||
|
* 批量删除兑换码
|
||||||
|
* DELETE /v1/admin/redemption/code/batch
|
||||||
|
* @param body - 批量删除请求参数,包含 ids 数组
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 批量删除结果
|
||||||
|
*/
|
||||||
export async function batchDeleteRedemptionCode(
|
export async function batchDeleteRedemptionCode(
|
||||||
body: API.BatchDeleteRedemptionCodeRequest,
|
body: API.BatchDeleteRedemptionCodeRequest,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
@ -91,9 +98,15 @@ export async function batchDeleteRedemptionCode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get redemption code list GET /v1/admin/redemption/code/list */
|
/**
|
||||||
|
* 获取兑换码列表
|
||||||
|
* GET /v1/admin/redemption/code/list
|
||||||
|
* @param params - 分页及筛选参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 兑换码列表及总数
|
||||||
|
*/
|
||||||
export async function getRedemptionCodeList(
|
export async function getRedemptionCodeList(
|
||||||
params: API.GetRedemptionCodeListRequest,
|
params: API.GetRedemptionCodeListParams,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
return request<API.Response & { data?: API.GetRedemptionCodeListResponse }>(
|
return request<API.Response & { data?: API.GetRedemptionCodeListResponse }>(
|
||||||
@ -108,9 +121,39 @@ export async function getRedemptionCodeList(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get redemption record list GET /v1/admin/redemption/record/list */
|
/**
|
||||||
|
* 切换兑换码状态(启用/禁用)
|
||||||
|
* PUT /v1/admin/redemption/code/status
|
||||||
|
* @param body - 切换状态请求参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 切换结果
|
||||||
|
*/
|
||||||
|
export async function toggleRedemptionCodeStatus(
|
||||||
|
body: API.ToggleRedemptionCodeStatusRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/redemption/code/status`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取兑换记录列表
|
||||||
|
* GET /v1/admin/redemption/record/list
|
||||||
|
* @param params - 分页及筛选参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 兑换记录列表及总数
|
||||||
|
*/
|
||||||
export async function getRedemptionRecordList(
|
export async function getRedemptionRecordList(
|
||||||
params: API.GetRedemptionRecordListRequest,
|
params: API.GetRedemptionRecordListParams,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
return request<API.Response & { data?: API.GetRedemptionRecordListResponse }>(
|
return request<API.Response & { data?: API.GetRedemptionRecordListResponse }>(
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@ -365,3 +365,43 @@ export async function updateVerifyConfig(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名配置
|
||||||
|
* GET /v1/admin/system/signature_config
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 签名配置
|
||||||
|
*/
|
||||||
|
export async function getSignatureConfig(options?: { [key: string]: any }) {
|
||||||
|
return request<API.Response & { data?: API.SignatureConfig }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/system/signature_config`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新签名配置
|
||||||
|
* PUT /v1/admin/system/signature_config
|
||||||
|
* @param body - 签名配置参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 更新结果
|
||||||
|
*/
|
||||||
|
export async function updateSignatureConfig(
|
||||||
|
body: API.SignatureConfig,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/system/signature_config`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
263
packages/ui/src/services/admin/typings.d.ts
vendored
263
packages/ui/src/services/admin/typings.d.ts
vendored
@ -250,85 +250,6 @@ declare namespace API {
|
|||||||
enable?: boolean;
|
enable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateRedemptionCodeRequest = {
|
|
||||||
total_count: number;
|
|
||||||
subscribe_plan: number;
|
|
||||||
unit_time: string;
|
|
||||||
quantity: number;
|
|
||||||
batch_count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UpdateRedemptionCodeRequest = {
|
|
||||||
id: number;
|
|
||||||
total_count?: number;
|
|
||||||
subscribe_plan?: number;
|
|
||||||
unit_time?: string;
|
|
||||||
quantity?: number;
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToggleRedemptionCodeStatusRequest = {
|
|
||||||
id: number;
|
|
||||||
status: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DeleteRedemptionCodeRequest = {
|
|
||||||
id: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BatchDeleteRedemptionCodeRequest = {
|
|
||||||
ids: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetRedemptionCodeListRequest = {
|
|
||||||
page: number;
|
|
||||||
size: number;
|
|
||||||
subscribe_plan?: number;
|
|
||||||
unit_time?: string;
|
|
||||||
code?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetRedemptionCodeListResponse = {
|
|
||||||
total: number;
|
|
||||||
list: RedemptionCode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetRedemptionRecordListRequest = {
|
|
||||||
page: number;
|
|
||||||
size: number;
|
|
||||||
user_id?: number;
|
|
||||||
code_id?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetRedemptionRecordListResponse = {
|
|
||||||
total: number;
|
|
||||||
list: RedemptionRecord[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type RedemptionCode = {
|
|
||||||
id: number;
|
|
||||||
code: string;
|
|
||||||
total_count: number;
|
|
||||||
used_count: number;
|
|
||||||
subscribe_plan: number;
|
|
||||||
unit_time: string;
|
|
||||||
quantity: number;
|
|
||||||
status: number;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RedemptionRecord = {
|
|
||||||
id: number;
|
|
||||||
redemption_code_id: number;
|
|
||||||
user_id: number;
|
|
||||||
subscribe_id: number;
|
|
||||||
unit_time: string;
|
|
||||||
quantity: number;
|
|
||||||
redeemed_at: number;
|
|
||||||
created_at: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CreateDocumentRequest = {
|
type CreateDocumentRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
@ -1374,6 +1295,7 @@ declare namespace API {
|
|||||||
forced_invite: boolean;
|
forced_invite: boolean;
|
||||||
referral_percentage: number;
|
referral_percentage: number;
|
||||||
only_first_purchase: boolean;
|
only_first_purchase: boolean;
|
||||||
|
gift_days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type KickOfflineRequest = {
|
type KickOfflineRequest = {
|
||||||
@ -1864,7 +1786,6 @@ declare namespace API {
|
|||||||
enable_ip_register_limit: boolean;
|
enable_ip_register_limit: boolean;
|
||||||
ip_register_limit: number;
|
ip_register_limit: number;
|
||||||
ip_register_limit_duration: number;
|
ip_register_limit_duration: number;
|
||||||
device_limit: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type RegisterLog = {
|
type RegisterLog = {
|
||||||
@ -2659,4 +2580,186 @@ declare namespace API {
|
|||||||
security: string;
|
security: string;
|
||||||
security_config: SecurityConfig;
|
security_config: SecurityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SignatureConfig = {
|
||||||
|
enable_signature: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FamilyDetail = {
|
||||||
|
summary: FamilySummary;
|
||||||
|
members: FamilyMemberItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FamilyMemberItem = {
|
||||||
|
user_id: number;
|
||||||
|
identifier: string;
|
||||||
|
device_no: string;
|
||||||
|
role: number;
|
||||||
|
role_name: string;
|
||||||
|
status: number;
|
||||||
|
status_name: string;
|
||||||
|
join_source: string;
|
||||||
|
joined_at: number;
|
||||||
|
left_at?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FamilySummary = {
|
||||||
|
family_id: number;
|
||||||
|
owner_user_id: number;
|
||||||
|
owner_identifier: string;
|
||||||
|
status: string;
|
||||||
|
active_member_count: number;
|
||||||
|
max_members: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetFamilyDetailParams = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetFamilyListParams = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
owner_user_id?: number;
|
||||||
|
family_id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetFamilyListResponse = {
|
||||||
|
list: FamilySummary[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DissolveFamilyRequest = {
|
||||||
|
family_id: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateFamilyMaxMembersRequest = {
|
||||||
|
family_id: number;
|
||||||
|
max_members: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RemoveFamilyMemberRequest = {
|
||||||
|
family_id: number;
|
||||||
|
user_id: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAdminUserInviteStatsParams = {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAdminUserInviteStatsResponse = {
|
||||||
|
invite_count: number;
|
||||||
|
total_commission: number;
|
||||||
|
current_commission: number;
|
||||||
|
referral_percentage: number;
|
||||||
|
only_first_purchase: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAdminUserInviteListParams = {
|
||||||
|
user_id: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminInvitedUser = {
|
||||||
|
id: number;
|
||||||
|
avatar: string;
|
||||||
|
identifier: string;
|
||||||
|
enable: boolean;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAdminUserInviteListResponse = {
|
||||||
|
total: number;
|
||||||
|
list: AdminInvitedUser[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivateOrderRequest = {
|
||||||
|
order_no: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedemptionCode = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
total_count: number;
|
||||||
|
used_count: number;
|
||||||
|
subscribe_plan: number;
|
||||||
|
unit_time: string;
|
||||||
|
quantity: number;
|
||||||
|
status: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RedemptionRecord = {
|
||||||
|
id: number;
|
||||||
|
redemption_code_id: number;
|
||||||
|
user_id: number;
|
||||||
|
subscribe_id: number;
|
||||||
|
unit_time: string;
|
||||||
|
quantity: number;
|
||||||
|
redeemed_at: number;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateRedemptionCodeRequest = {
|
||||||
|
total_count: number;
|
||||||
|
subscribe_plan: number;
|
||||||
|
unit_time: string;
|
||||||
|
quantity: number;
|
||||||
|
batch_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateRedemptionCodeRequest = {
|
||||||
|
id: number;
|
||||||
|
total_count?: number;
|
||||||
|
subscribe_plan?: number;
|
||||||
|
unit_time?: string;
|
||||||
|
quantity?: number;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteRedemptionCodeRequest = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchDeleteRedemptionCodeRequest = {
|
||||||
|
ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetRedemptionCodeListParams = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
subscribe_plan?: number;
|
||||||
|
unit_time?: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetRedemptionCodeListResponse = {
|
||||||
|
total: number;
|
||||||
|
list: RedemptionCode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToggleRedemptionCodeStatusRequest = {
|
||||||
|
id: number;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetRedemptionRecordListParams = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
user_id?: number;
|
||||||
|
code_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetRedemptionRecordListResponse = {
|
||||||
|
total: number;
|
||||||
|
list: RedemptionRecord[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@ -291,32 +291,6 @@ export async function getUserSubscribe(
|
|||||||
params: {
|
params: {
|
||||||
...params,
|
...params,
|
||||||
},
|
},
|
||||||
transformResponse: [
|
|
||||||
(data) => {
|
|
||||||
try {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
// Convert large integers (int64) to strings BEFORE JSON.parse to prevent precision loss
|
|
||||||
// JavaScript MAX_SAFE_INTEGER is 2^53 - 1 = 9007199254740991
|
|
||||||
// This regex finds all numbers >= 10^16 (larger than MAX_SAFE_INTEGER)
|
|
||||||
const processedData = data.replace(
|
|
||||||
/"([^"]+)":\s*(\d{16,})/g,
|
|
||||||
(match, key, value) => {
|
|
||||||
// Check if number exceeds MAX_SAFE_INTEGER
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
if (!Number.isSafeInteger(num)) {
|
|
||||||
return `"${key}": "${value}"`;
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return JSON.parse(processedData);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...(options || {}),
|
...(options || {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -531,3 +505,169 @@ export async function getUserSubscribeTrafficLogs(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家庭组详情
|
||||||
|
* GET /v1/admin/user/family/detail
|
||||||
|
* @param params - 家庭组 ID
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 家庭组详情
|
||||||
|
*/
|
||||||
|
export async function getFamilyDetail(
|
||||||
|
params: API.GetFamilyDetailParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.FamilyDetail }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/detail`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家庭组列表
|
||||||
|
* GET /v1/admin/user/family/list
|
||||||
|
* @param params - 分页及筛选参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 家庭组列表
|
||||||
|
*/
|
||||||
|
export async function getFamilyList(
|
||||||
|
params: API.GetFamilyListParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetFamilyListResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/list`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解散家庭组
|
||||||
|
* PUT /v1/admin/user/family/dissolve
|
||||||
|
* @param body - 解散请求参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 解散结果
|
||||||
|
*/
|
||||||
|
export async function dissolveFamily(
|
||||||
|
body: API.DissolveFamilyRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/dissolve`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新家庭组最大成员数
|
||||||
|
* PUT /v1/admin/user/family/max_members
|
||||||
|
* @param body - 更新参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 更新结果
|
||||||
|
*/
|
||||||
|
export async function updateFamilyMaxMembers(
|
||||||
|
body: API.UpdateFamilyMaxMembersRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/family/max_members`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除家庭组成员
|
||||||
|
* PUT /v1/admin/user/family/member/remove
|
||||||
|
* @param body - 移除成员请求参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 移除结果
|
||||||
|
*/
|
||||||
|
export async function removeFamilyMember(
|
||||||
|
body: API.RemoveFamilyMemberRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_API_PREFIX || ""
|
||||||
|
}/v1/admin/user/family/member/remove`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员用户邀请统计
|
||||||
|
* GET /v1/admin/user/invite/stats
|
||||||
|
* @param params - 用户 ID
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 邀请统计数据
|
||||||
|
*/
|
||||||
|
export async function getAdminUserInviteStats(
|
||||||
|
params: API.GetAdminUserInviteStatsParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetAdminUserInviteStatsResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/invite/stats`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员用户邀请列表
|
||||||
|
* GET /v1/admin/user/invite/list
|
||||||
|
* @param params - 用户 ID 及分页参数
|
||||||
|
* @param options - 可选的请求配置
|
||||||
|
* @returns 邀请用户列表
|
||||||
|
*/
|
||||||
|
export async function getAdminUserInviteList(
|
||||||
|
params: API.GetAdminUserInviteListParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetAdminUserInviteListResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/invite/list`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// API 更新时间:
|
// API 更新时间:
|
||||||
// API 唯一标识:
|
// API 唯一标识:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@ -19,9 +19,7 @@ export async function appleLoginCallback(
|
|||||||
if (item !== undefined && item !== null) {
|
if (item !== undefined && item !== null) {
|
||||||
if (typeof item === "object" && !(item instanceof File)) {
|
if (typeof item === "object" && !(item instanceof File)) {
|
||||||
if (Array.isArray(item)) {
|
if (Array.isArray(item)) {
|
||||||
item.forEach((f) => {
|
for (const f of item) formData.append(ele, f || "");
|
||||||
formData.append(ele, f || "");
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
formData.append(
|
formData.append(
|
||||||
ele,
|
ele,
|
||||||
|
|||||||
1
packages/ui/src/services/common/typings.d.ts
vendored
1
packages/ui/src/services/common/typings.d.ts
vendored
@ -356,6 +356,7 @@ declare namespace API {
|
|||||||
forced_invite: boolean;
|
forced_invite: boolean;
|
||||||
referral_percentage: number;
|
referral_percentage: number;
|
||||||
only_first_purchase: boolean;
|
only_first_purchase: boolean;
|
||||||
|
gift_days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginResponse = {
|
type LoginResponse = {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// API 更新时间:
|
// API 更新时间:
|
||||||
// API 唯一标识:
|
// API 唯一标识:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// API 更新时间:
|
// API 更新时间:
|
||||||
// API 唯一标识:
|
// API 唯一标识:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
1
packages/ui/src/services/user/typings.d.ts
vendored
1
packages/ui/src/services/user/typings.d.ts
vendored
@ -373,6 +373,7 @@ declare namespace API {
|
|||||||
forced_invite: boolean;
|
forced_invite: boolean;
|
||||||
referral_percentage: number;
|
referral_percentage: number;
|
||||||
only_first_purchase: boolean;
|
only_first_purchase: boolean;
|
||||||
|
gift_days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageLog = {
|
type MessageLog = {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-expect-error
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@ -269,24 +269,6 @@ export async function updateUserRules(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redeem Code POST /v1/public/redemption/ */
|
|
||||||
export async function redeemCode(
|
|
||||||
body: { code: string },
|
|
||||||
options?: { [key: string]: any }
|
|
||||||
) {
|
|
||||||
return request<API.Response & { data: { message: string } }>(
|
|
||||||
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/redemption/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
data: body,
|
|
||||||
...(options || {}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Query User Subscribe GET /v1/public/user/subscribe */
|
/** Query User Subscribe GET /v1/public/user/subscribe */
|
||||||
export async function queryUserSubscribe(options?: { [key: string]: any }) {
|
export async function queryUserSubscribe(options?: { [key: string]: any }) {
|
||||||
return request<API.Response & { data?: API.QueryUserSubscribeListResponse }>(
|
return request<API.Response & { data?: API.QueryUserSubscribeListResponse }>(
|
||||||
|
|||||||
33
packages/ui/src/utils/device.ts
Normal file
33
packages/ui/src/utils/device.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const DEVICE_HASH_SALT = 0x5a_3c_7e_9b;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode device id (user_device.id) to 8-char hex hash (bidirectional)
|
||||||
|
* e.g. 1 → "5A3C7E9A", 42 → "5A3C7EA1"
|
||||||
|
*/
|
||||||
|
export function deviceIdToHash(id: number): string {
|
||||||
|
// biome-ignore lint/suspicious/noBitwiseOperators: intentional XOR hash
|
||||||
|
return ((id ^ DEVICE_HASH_SALT) >>> 0)
|
||||||
|
.toString(16)
|
||||||
|
.toUpperCase()
|
||||||
|
.padStart(8, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode 8-char hex hash back to device id
|
||||||
|
* e.g. "5A3C7E9A" → 1
|
||||||
|
*/
|
||||||
|
export function hashToDeviceId(hash: string): number {
|
||||||
|
// biome-ignore lint/suspicious/noBitwiseOperators: intentional XOR hash
|
||||||
|
return (Number.parseInt(hash, 16) ^ DEVICE_HASH_SALT) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten a long device identifier (e.g. "device68c71ab9f82d...") to 8-char display
|
||||||
|
* Used when only the identifier string is available (no numeric device id)
|
||||||
|
*/
|
||||||
|
export function shortenDeviceIdentifier(identifier: string): string {
|
||||||
|
return identifier
|
||||||
|
.replace(/^device/i, "")
|
||||||
|
.slice(0, 8)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user