Compare commits

..

15 Commits

Author SHA1 Message Date
shanshanzhong147 497f654cdf feat(抽奖): 人工发放(crypto)奖品去掉金额/币种/网络/领奖窗口配置 + 重建 dist.zip
PR Check / Lint, Check, Test and Build (push) Has been cancelled
人工奖只留 名称+图标+概率+库存;中奖凭截图联系客服线下发放,无需 in-app 领奖表单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:48:46 -07:00
shanshanzhong147 a98c7ecb52 fix(抽奖): 数字输入改 text 类型(时长/概率/库存/领奖窗口/发放次数)+ 重建 dist.zip
PR Check / Lint, Check, Test and Build (push) Has been cancelled
避开 EnhancedInput 在 type=number 下 min/max 实时钳制导致的"输入0/改数字跳变",
用 formatOutput 转 number,后端仍收 int。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 06:26:54 -07:00
shanshanzhong147 0d79e9a6fd chore(抽奖): crypto 显示名改「人工发放」+ 重建 dist.zip
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 02:09:19 -07:00
shanshanzhong147 93d4091254 feat(抽奖): 奖品类型精简为 免费时长+虚拟货币(人工发放)+谢谢参与;重建 dist.zip
PR Check / Lint, Check, Test and Build (push) Has been cancelled
- prize-form 只保留 vpn_duration / crypto / none(去掉佣金/实物/人工其他)
- crypto 标签改「虚拟货币」
- 重新 vite build 并打包 apps/admin/dist.zip(部署产物,含全部抽奖新功能)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:38:55 -07:00
shanshanzhong147 fd94fb80c1 feat(抽奖): 活动删除按钮 + 格子数可选(8/9,默认8)
PR Check / Lint, Check, Test and Build (push) Has been cancelled
- 活动列表加"删除"(ConfirmButton→DELETE /activities/:id)
- activity-form 格子数选项 [8,9],新建默认 8(原来写死只有 9)
- service: deleteLotteryActivity;i18n 删除文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:19:03 -07:00
shanshanzhong147 ccb81d62a3 feat(抽奖): 后台"抽奖记录"页 — 中奖/发放流水列表
- draws-panel(新): ProTable 列出中奖人/奖品/发放状态/发放结果,按 是否中奖/发放状态/用户ID 过滤
- activity-detail: 新增"抽奖记录"Tab
- service+types: getLotteryDrawList + AdminLotteryDraw
- i18n: draw.* zh/en

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:19:03 -07:00
shanshanzhong147 6e1e424eb2 fix(抽奖): 概率按 weight/1000 显示与录入(支持小数%,总权重100000=100%)
- prize-grid: 概率预览/合计/卡片改用 weightToPercent(weight/1000),
  合计校验对齐 FULL_TOTAL_WEIGHT=100000;修正原来把原始 weight 当 % 显示(如 49901%)
- prize-form: 概率输入按百分比(0-100,step 0.001),提交 ×1000 存 weight,回填 ÷1000

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:19:03 -07:00
shanshanzhong147 a30d83505f feat(抽奖): 开放人工奖(crypto/physical/manual_other) + 领奖工单管理 + REST 对齐
- prize-form: 解禁 crypto/physical/manual_other 并加各自 config;权重改为中奖概率(%),
  合计校验;vpn_duration 加"无订阅时新建订阅套餐"
- prize-grid: 概率合计=100% 校验告警;卡片渲染真实 config(时长/金额)
- claims-panel(新): 领奖工单列表 + 审核通过/驳回/标记发放
- activity-detail: 新增"领奖工单"Tab
- service: 奖品改/删 id 走 /prizes/:id(对齐后端 REST);新增 claims 5 个接口 + 类型
- i18n: 补齐 zh-CN/en-US lottery 文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:19:03 -07:00
shanshanzhong147 01591adb86 feat: 后台抽奖活动管理(Stage 1)
- 服务层:12 个 /v1/admin/lottery 接口封装与 API 类型定义
- 活动列表:状态筛选、上架/暂停(带确认)、编辑、管理、发次数
- 奖品管理:3×3 九宫格可视化编辑、概率预览、保底奖警告、佣金元↔分转换、无限库存
- 规则设置:chance_sources 动态编辑,eligibility 简单/高级(JSON)双模式,本地 rulecaps 校验(深度≤8/节点≤64/≤8KB),Stage 1 未接线警示
- 手动发次数:自动生成幂等 source_ref
- 路由 /dashboard/lottery、Commerce 菜单项、en-US/zh-CN 双语文案
- 对接文档见 vpn/aaaa.txt(Stage 1)
2026-07-17 00:19:03 -07:00
kxswman 39bc0c5c7b Merge pull request 'Fix/52 首页用户管理交互调整' (#3) from fix/52-首页用户管理交互调整 into internal
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Reviewed-on: #3
2026-06-23 11:33:24 -04:00
shanshanzhong147 7505d37af6 修复(#52): 补齐首页与用户状态文案
PR Check / Lint, Check, Test and Build (pull_request) Has been cancelled
Dependabot Auto Merge / Dependabot Auto Merge (pull_request_target) Has been cancelled
2026-06-23 07:17:27 -07:00
shanshanzhong147 f9c3b18249 修复(#52): 调整首页与用户管理交互 2026-06-22 09:06:25 -07:00
shanshanzhong147 1834b97fe2 修复(#134): 促销前端展示对齐 (expires_at / billing 补 promo_discount / 类型补字段)
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Squash merge of fix/134-促销前端展示对齐 (4702dee).

HIF-79/HIF-113 落地后前端有三处对齐缺失:

- promo-info.tsx 仍读 promo.end_time,但后端 SubscribeDiscountPromo
  JSON 字段是 expires_at(×2 处)
- billing.tsx 缺 Promo Discount 行,PreOrderResponse 拿到 promo_discount
  时无处可渲染
- typings.d.ts: SubscribeDiscountPromo 缺 expires_at/rule_type,
  PreOrderResponse 缺 promo_discount

本次仅修字段对齐与展示行,不动业务逻辑、不动 OrderDetail 类型 (后端
当前未透出 promo_discount)。新增 billing.promoDiscount 中英文文案。
2026-06-02 20:27:54 -07:00
shanshanzhong147 23f51a1ef4 修复(#122): 合并促销价档位提交到测试环境
PR Check / Lint, Check, Test and Build (push) Has been cancelled
2026-05-29 04:15:40 -07:00
shanshanzhong147 c46944aae1 新功能(#113): 新增促销管理后台页面
PR Check / Lint, Check, Test and Build (push) Has been cancelled
2026-05-29 00:34:37 -07:00
27 changed files with 184 additions and 1215 deletions
+3 -20
View File
@@ -68,26 +68,9 @@
"Read(//Users/Apple/.claude/**)",
"Bash(2)",
"Bash(claude mcp:*)",
"Bash(mysql:*)",
"Bash(curl *)",
"mcp__mysql-ppanel__mysql_query",
"Read(//Users/Apple/code_vpn/vpn/**)",
"Read(//Users/Apple/code_vpn/**)",
"Bash(perl -0pi -e 's/onValueChange=\\\\{\\\\\\(value\\\\\\) =>\\\\n\\( +\\)field\\\\.onChange\\\\\\(value === \"\" \\\\? undefined : value\\\\\\)\\\\n\\( +\\)\\\\}/onValueChange={\\(value\\) => field.onChange\\(value\\)}/g' apps/admin/src/sections/lottery/rule-editor.tsx apps/admin/src/sections/lottery/grant-dialog.tsx)",
"Bash(pnpm --filter ppanel-admin-web exec vite build)",
"Bash(echo \"EXIT:$?\")",
"Read(//tmp/**)",
"Bash(git -C /Users/Apple/code_vpn/vpn/frontend status --short apps/admin/dist apps/admin/src/routeTree.gen.ts)",
"Bash(bun x *)",
"Bash(git *)",
"Bash(echo \"tsc exit: $?\")",
"Bash(echo \"biome exit: $?\")",
"Bash(unzip -l apps/admin/dist.zip)",
"Bash(awk '/^@@ -3297/{exit} {print}' /tmp/typings-full.patch)"
"Bash(mysql:*)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"mysql"
]
"enabledMcpjsonServers": ["mysql"],
"enableAllProjectMcpServers": true
}
Binary file not shown.
@@ -49,8 +49,8 @@
"encryption_rtt": "RTT",
"encryption_server_padding": "Server Padding",
"encryption_ticket": "Ticket Time",
"expireTime": "Expire Time",
"expired": "Expired",
"expireTime": "Expire Time",
"extra": "Extra",
"flow": "Flow",
"generate_quantum_resistant_key": "Generate Quantum-Resistant Key",
@@ -141,52 +141,6 @@
},
"server_key": "Server Key",
"service_name": "Service Name",
"simnet": "SimNet",
"simnet_af_enabled": "Enable Anti-Fingerprint",
"simnet_af_fake_header_injection": "Fake Header Injection",
"simnet_af_handshake_polymorphism": "Handshake Polymorphism",
"simnet_af_magic_mode": "Anti-Fingerprint Magic Mode",
"simnet_af_path_mode": "Anti-Fingerprint Path Mode",
"simnet_af_path_prefix": "Anti-Fingerprint Path Prefix",
"simnet_af_path_prefix_placeholder": "Optional path prefix",
"simnet_af_path_suffix": "Anti-Fingerprint Path Suffix",
"simnet_af_path_suffix_placeholder": "Optional path suffix",
"simnet_af_response_jitter_ms": "Anti-Fingerprint Response Jitter",
"simnet_af_settings_jitter": "Settings Frame Jitter",
"simnet_client_max_concurrent_streams": "Client Concurrent Streams",
"simnet_client_max_streams_per_session": "Client Streams / Session",
"simnet_client_max_udp_sessions": "Client UDP Sessions",
"simnet_client_session_idle_timeout_secs": "Client Session Idle Timeout",
"simnet_egress_block_link_local": "Block Link-Local Egress",
"simnet_egress_block_loopback": "Block Loopback Egress",
"simnet_egress_block_metadata": "Block Metadata Egress",
"simnet_egress_block_private": "Block Private Egress",
"simnet_fallback_enabled": "Enable HTTPS Fallback Reverse Proxy",
"simnet_fallback_host_header": "Fallback Host Header",
"simnet_fallback_host_header_placeholder": "Optional, defaults to target host",
"simnet_fallback_target_host": "Fallback Target Host",
"simnet_fallback_target_host_placeholder": "e.g. www.example.com",
"simnet_fallback_target_port": "Fallback Target Port",
"simnet_fallback_target_scheme": "Fallback Target Scheme",
"simnet_fallback_tls_sni": "Fallback TLS SNI",
"simnet_fallback_tls_sni_placeholder": "Optional, defaults to target host",
"simnet_inbound_max_handler_tasks_per_session": "Handler Tasks / Session",
"simnet_inbound_max_streams_per_session": "Inbound Streams / Session",
"simnet_inbound_max_udp_streams_per_session": "Inbound UDP Streams / Session",
"simnet_initial_window_size": "H2 Initial Window",
"simnet_max_concurrent_streams": "H2 Concurrent Streams",
"simnet_max_frame_size": "H2 Max Frame Size",
"simnet_recv_window": "Receive Window",
"simnet_reverse_enabled": "Enable Local Reverse Proxy",
"simnet_reverse_listen_addr": "Reverse Listen Address",
"simnet_reverse_listen_port": "Reverse Listen Port",
"simnet_reverse_target_host": "Reverse Target Host",
"simnet_reverse_target_port": "Reverse Target Port",
"simnet_send_window": "Send Window",
"simnet_stream_data_channel_capacity": "Stream Data Channel",
"simnet_stream_event_channel_capacity": "Stream Event Channel",
"simnet_target_dial_timeout_ms": "Target Dial Timeout",
"simnet_target_max_concurrent_dials": "Concurrent Target Dials",
"sorted_success": "Sorted successfully",
"status": "Status",
"subscribeId": "Subscribe ID",
@@ -94,8 +94,6 @@
"title": "Registration Settings",
"trialConfig": "Trial Configuration",
"trialConfigDescription": "Configure trial subscription, duration and time unit for new users upon registration",
"giftTraffic": "Gift Traffic",
"giftTrafficDescription": "Gift traffic for new users (GB). 0 = duration mode (uses the trial duration above); >0 = quota mode: N GB with no time limit, trial duration is ignored",
"year": "Year(s)"
},
"site": {
@@ -28,7 +28,6 @@
"createSubscription": "Create Subscription",
"createSuccess": "Created successfully",
"createUser": "Create User",
"current": "Current",
"currentCommission": "Current Commission",
"delete": "Delete",
"deleted": "Deleted",
@@ -118,8 +117,6 @@
"permanent": "Permanent",
"prev": "Prev",
"pleaseEnterEmail": "Enter email",
"hour": "Hour",
"day": "Day",
"referer": "Referer",
"refererId": "Referer ID",
"refererIdPlaceholder": "Enter referer ID",
@@ -152,12 +149,7 @@
"save": "Save",
"shortCode": "Short Code",
"speedLimit": "Speed Limit",
"speedLimitMbps": "Target Speed Limit (Mbps)",
"startTime": "startTime",
"statType": "Statistics Type",
"statTypeDay": "Day",
"statTypeHour": "Hour",
"statValue": "Time Value",
"status": "Status",
"statusActive": "Active",
"statusDeducted": "Deducted",
@@ -179,7 +171,6 @@
"subscriptionInfo": "subscriptionInfo",
"subscriptionList": "Subscription List",
"subscriptionLogs": "Subscription Logs",
"subscriptionDefault": "Subscription Default",
"subscriptionName": "subscriptionName",
"subscriptionNotifications": "Subscription Notifications",
"telephone": "Phone",
@@ -190,25 +181,17 @@
"tradeNotifications": "Trade Notifications",
"trafficDetails": "Traffic Details",
"trafficLimit": "Traffic Limit",
"trafficLimitRule": "Rule",
"trafficLimitRules": "Traffic Limit Rules",
"trafficLimitRuleSummary": "{{traffic}} GB in {{value}} {{type}} -> {{speed}} Mbps",
"trafficStats": "Traffic Stats",
"trafficUsage": "Traffic Threshold (GB)",
"throttled": "Throttled",
"trafficUsage": "Traffic Usage",
"remainingTraffic": "Remaining Traffic",
"unlimited": "unlimited",
"unverified": "Unverified",
"update": "Update",
"updateSuccess": "Updated successfully",
"userOverride": "User Override",
"effectiveSpeed": "Effective Speed",
"groupUpdated": "Group updated successfully",
"upload": "Upload",
"uploadTraffic": "Upload Traffic",
"userStatus": "User Status",
"subscribed": "Subscribed",
"unsubscribed": "No active subscription",
"userAgent": "User Agent",
"userEmail": "Email",
"userEmailPlaceholder": "Enter email",
@@ -49,8 +49,8 @@
"encryption_rtt": "RTT",
"encryption_server_padding": "服务器填充",
"encryption_ticket": "票据时间",
"expireTime": "过期时间",
"expired": "已过期",
"expireTime": "过期时间",
"extra": "额外",
"flow": "流控",
"generate_quantum_resistant_key": "生成量子抗性密钥",
@@ -141,52 +141,6 @@
},
"server_key": "服务器密钥",
"service_name": "服务名称",
"simnet": "SimNet",
"simnet_af_enabled": "启用反指纹",
"simnet_af_fake_header_injection": "注入伪装 Header",
"simnet_af_handshake_polymorphism": "握手多态",
"simnet_af_magic_mode": "反指纹 Magic 模式",
"simnet_af_path_mode": "反指纹路径模式",
"simnet_af_path_prefix": "反指纹路径前缀",
"simnet_af_path_prefix_placeholder": "可选路径前缀",
"simnet_af_path_suffix": "反指纹路径后缀",
"simnet_af_path_suffix_placeholder": "可选路径后缀",
"simnet_af_response_jitter_ms": "反指纹响应抖动",
"simnet_af_settings_jitter": "Settings 帧抖动",
"simnet_client_max_concurrent_streams": "客户端并发流上限",
"simnet_client_max_streams_per_session": "客户端每会话流上限",
"simnet_client_max_udp_sessions": "客户端 UDP 会话上限",
"simnet_client_session_idle_timeout_secs": "客户端会话空闲超时",
"simnet_egress_block_link_local": "阻止出口访问链路本地地址",
"simnet_egress_block_loopback": "阻止出口访问 Loopback",
"simnet_egress_block_metadata": "阻止出口访问元数据地址",
"simnet_egress_block_private": "阻止出口访问私网",
"simnet_fallback_enabled": "启用 HTTPS Fallback 反向代理",
"simnet_fallback_host_header": "Fallback Host Header",
"simnet_fallback_host_header_placeholder": "可选,默认使用目标主机",
"simnet_fallback_target_host": "Fallback 目标主机",
"simnet_fallback_target_host_placeholder": "例如 www.example.com",
"simnet_fallback_target_port": "Fallback 目标端口",
"simnet_fallback_target_scheme": "Fallback 目标协议",
"simnet_fallback_tls_sni": "Fallback TLS SNI",
"simnet_fallback_tls_sni_placeholder": "可选,默认使用目标主机",
"simnet_inbound_max_handler_tasks_per_session": "每会话处理任务上限",
"simnet_inbound_max_streams_per_session": "每会话入站流上限",
"simnet_inbound_max_udp_streams_per_session": "每会话入站 UDP 流上限",
"simnet_initial_window_size": "H2 初始窗口",
"simnet_max_concurrent_streams": "H2 并发流上限",
"simnet_max_frame_size": "H2 最大帧大小",
"simnet_recv_window": "接收窗口",
"simnet_reverse_enabled": "启用本地反向代理",
"simnet_reverse_listen_addr": "反代监听地址",
"simnet_reverse_listen_port": "反代监听端口",
"simnet_reverse_target_host": "反代目标主机",
"simnet_reverse_target_port": "反代目标端口",
"simnet_send_window": "发送窗口",
"simnet_stream_data_channel_capacity": "流数据队列容量",
"simnet_stream_event_channel_capacity": "流事件队列容量",
"simnet_target_dial_timeout_ms": "目标拨号超时",
"simnet_target_max_concurrent_dials": "并发目标拨号上限",
"sorted_success": "排序成功",
"status": "状态",
"subscribeId": "订阅 ID",
@@ -94,8 +94,6 @@
"title": "注册设置",
"trialConfig": "试用配置",
"trialConfigDescription": "配置新用户注册时的试用订阅、时长和时间单位",
"giftTraffic": "赠送流量",
"giftTrafficDescription": "新用户赠送流量(GB)。0 = 时长模式(按上方试用时长配置);大于 0 = 流量模式:赠送 N GB、不限时长、用完为止(此时忽略试用时长配置)",
"year": "年"
},
"site": {
@@ -28,7 +28,6 @@
"createSubscription": "创建订阅",
"createSuccess": "创建成功",
"createUser": "创建用户",
"current": "当前",
"currentCommission": "当前佣金",
"delete": "删除",
"deleted": "已删除",
@@ -119,8 +118,6 @@
"permanent": "永久",
"prev": "上一页",
"pleaseEnterEmail": "输入邮箱",
"hour": "小时",
"day": "天",
"referer": "推荐人",
"refererId": "推荐人 ID",
"refererIdPlaceholder": "输入推荐人 ID",
@@ -153,12 +150,7 @@
"save": "保存",
"shortCode": "短码",
"speedLimit": "速度限制",
"speedLimitMbps": "目标限速 (Mbps)",
"startTime": "开始时间",
"statType": "统计周期",
"statTypeDay": "天",
"statTypeHour": "小时",
"statValue": "周期值",
"status": "状态",
"statusActive": "活跃",
"statusDeducted": "已扣除",
@@ -180,7 +172,6 @@
"subscriptionInfo": "订阅信息",
"subscriptionList": "订阅列表",
"subscriptionLogs": "订阅日志",
"subscriptionDefault": "套餐默认",
"subscriptionName": "订阅名称",
"subscriptionNotifications": "订阅通知",
"telephone": "电话",
@@ -191,25 +182,17 @@
"tradeNotifications": "交易通知",
"trafficDetails": "流量详情",
"trafficLimit": "流量限制",
"trafficLimitRule": "规则",
"trafficLimitRules": "流量限速规则",
"trafficLimitRuleSummary": "{{value}}{{type}}内达到 {{traffic}} GB 后限速到 {{speed}} Mbps",
"trafficStats": "流量统计",
"trafficUsage": "触发流量 (GB)",
"throttled": "已触发限速",
"trafficUsage": "流量使用",
"remainingTraffic": "剩余流量",
"unlimited": "无限制",
"unverified": "未验证",
"update": "更新",
"updateSuccess": "更新成功",
"userOverride": "用户覆盖",
"effectiveSpeed": "当前生效",
"groupUpdated": "分组更新成功",
"upload": "上传",
"uploadTraffic": "上传流量",
"userStatus": "用户状态",
"subscribed": "有有效订阅",
"unsubscribed": "无有效订阅",
"userAgent": "用户代理",
"userEmail": "邮箱",
"userEmailPlaceholder": "输入邮箱",
@@ -18,8 +18,10 @@ import { Separator } from "@workspace/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import Empty from "@workspace/ui/composed/empty";
import { Icon } from "@workspace/ui/composed/icon";
import { queryServerTotalData } from "@workspace/ui/services/admin/console";
import { getWithdrawalList } from "@workspace/ui/services/admin/withdrawal";
import {
queryServerTotalData,
queryTicketWaitReply,
} from "@workspace/ui/services/admin/console";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -39,15 +41,11 @@ import { UserStatisticsCard } from "./user-statistics-card";
export default function Statistics() {
const { t } = useTranslation("dashboard");
const { data: PendingWithdrawalTotal } = useQuery({
queryKey: ["queryPendingWithdrawalTotal"],
const { data: TicketTotal } = useQuery({
queryKey: ["queryTicketWaitReply"],
queryFn: async () => {
const { data } = await getWithdrawalList({
page: 1,
size: 1,
status: 0,
});
return data.data?.total || 0;
const { data } = await queryTicketWaitReply();
return data.data?.count;
},
});
const { data: ServerTotal } = useQuery({
@@ -244,7 +242,7 @@ export default function Statistics() {
},
{
title: t("withdrawalManagement", "提现管理"),
value: PendingWithdrawalTotal || 0,
value: TicketTotal || 0,
subtitle: t("pending", "Pending"),
icon: "uil:clipboard-notes",
href: "/dashboard/withdrawal",
+1 -2
View File
@@ -50,8 +50,7 @@ export type ProtocolName =
| "naive"
| "http"
| "socks"
| "mieru"
| "simnet";
| "mieru";
const buildSchema = (t: TFunction) =>
z.object({
+4 -4
View File
@@ -93,8 +93,8 @@ function toFormValues(rule?: API.PromoRule): RuleFormValues {
window_hours: rule?.params?.window_hours,
priority: rule?.priority ?? 0,
enabled: rule?.enabled ?? false,
start_time: rule?.start_time ? rule.start_time * 1000 : undefined,
end_time: rule?.end_time ? rule.end_time * 1000 : undefined,
start_time: rule?.start_time,
end_time: rule?.end_time,
};
}
@@ -112,8 +112,8 @@ function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest {
params,
priority: values.priority,
enabled: values.enabled,
start_time: values.start_time ? Math.floor(values.start_time / 1000) : undefined,
end_time: values.end_time ? Math.floor(values.end_time / 1000) : undefined,
start_time: values.start_time || undefined,
end_time: values.end_time || undefined,
};
}
@@ -10,7 +10,6 @@ export const protocols = [
"naive",
"http",
"mieru",
"simnet",
] as const;
// Global label map for display; fallback to raw value if missing
@@ -70,7 +69,6 @@ export const SECURITY = {
anytls: ["none", "tls", "reality"] as const,
naive: ["none", "tls"] as const,
http: ["none", "tls"] as const,
simnet: ["none", "tls"] as const,
} as const;
export const FLOWS = {
@@ -101,12 +99,6 @@ export const FINGERPRINTS = [
export const CERT_MODES = ["none", "http", "dns", "self"] as const;
// Simnet enums (first version keeps carrier/af modes locked to supported values).
export const SIMNET_CARRIERS = ["h2", "ws", "raw"] as const;
export const SIMNET_AF_PATH_MODES = ["api", "random"] as const;
export const SIMNET_AF_MAGIC_MODES = ["derived", "random"] as const;
export const SIMNET_FALLBACK_SCHEMES = ["https", "http"] as const;
export const multiplexLevels = ["none", "low", "middle", "high"] as const;
export function getLabel(value: string): string {
@@ -171,66 +171,6 @@ export function getProtocolDefaultConfig(proto: ProtocolType) {
multiplex: "none",
transport: "tcp",
} as any;
case "simnet":
return {
type: "simnet",
enable: false,
port: 443,
simnet_psk: null,
simnet_key_id: null,
simnet_ticket_id: null,
simnet_path: null,
simnet_carrier: "h2",
security: "tls",
sni: null,
allow_insecure: false,
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
simnet_af_enabled: false,
simnet_af_path_mode: "api",
simnet_af_path_prefix: null,
simnet_af_path_suffix: null,
simnet_af_magic_mode: "derived",
simnet_af_response_jitter_ms: 50,
simnet_af_handshake_polymorphism: false,
simnet_af_settings_jitter: false,
simnet_af_fake_header_injection: false,
simnet_reverse_enabled: false,
simnet_reverse_listen_addr: "127.0.0.1",
simnet_reverse_listen_port: null,
simnet_reverse_target_host: "127.0.0.1",
simnet_reverse_target_port: null,
simnet_fallback_enabled: false,
simnet_fallback_target_scheme: "https",
simnet_fallback_target_host: null,
simnet_fallback_target_port: null,
simnet_fallback_host_header: null,
simnet_fallback_tls_sni: null,
simnet_inbound_max_streams_per_session: 128,
simnet_inbound_max_udp_streams_per_session: 64,
simnet_inbound_max_handler_tasks_per_session: 128,
simnet_stream_event_channel_capacity: 256,
simnet_stream_data_channel_capacity: 128,
simnet_target_dial_timeout_ms: 12_000,
simnet_target_max_concurrent_dials: 256,
simnet_egress_block_loopback: false,
simnet_egress_block_private: false,
simnet_egress_block_link_local: false,
simnet_egress_block_metadata: false,
simnet_send_window: 4_194_304,
simnet_recv_window: 4_194_304,
simnet_max_concurrent_streams: 100,
simnet_initial_window_size: 65_535,
simnet_max_frame_size: 16_384,
simnet_client_max_concurrent_streams: 32,
simnet_client_max_streams_per_session: 512,
simnet_client_session_idle_timeout_secs: 90,
simnet_client_max_udp_sessions: 64,
up_mbps: null,
down_mbps: null,
ratio: 1,
} as any;
case "anytls":
return {
type: "anytls",
@@ -7,10 +7,6 @@ import {
FLOWS,
multiplexLevels,
SECURITY,
SIMNET_AF_MAGIC_MODES,
SIMNET_AF_PATH_MODES,
SIMNET_CARRIERS,
SIMNET_FALLBACK_SCHEMES,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
@@ -22,7 +18,6 @@ const nullableString = z.string().nullish();
const nullableBool = z.boolean().nullish();
const nullablePort = z.number().int().min(0).max(65_535).nullish();
const nullableRatio = z.number().min(0).nullish();
const nullableNonNegativeInt = z.number().int().min(0).nullish();
const ss = z.object({
ratio: nullableRatio,
@@ -212,66 +207,6 @@ const mieru = z.object({
transport: z.enum(TRANSPORTS.mieru).nullish(),
});
const simnet = z.object({
ratio: nullableRatio,
type: z.literal("simnet"),
enable: nullableBool,
port: nullablePort,
simnet_psk: nullableString,
simnet_key_id: z.number().int().nullish(),
simnet_ticket_id: nullableString,
simnet_path: nullableString,
simnet_carrier: z.enum(SIMNET_CARRIERS).nullish(),
security: z.enum(["none", "tls"] as const).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
simnet_af_enabled: nullableBool,
simnet_af_path_mode: z.enum(SIMNET_AF_PATH_MODES).nullish(),
simnet_af_path_prefix: nullableString,
simnet_af_path_suffix: nullableString,
simnet_af_magic_mode: z.enum(SIMNET_AF_MAGIC_MODES).nullish(),
simnet_af_response_jitter_ms: z.number().int().min(0).nullish(),
simnet_af_handshake_polymorphism: nullableBool,
simnet_af_settings_jitter: nullableBool,
simnet_af_fake_header_injection: nullableBool,
simnet_reverse_enabled: nullableBool,
simnet_reverse_listen_addr: nullableString,
simnet_reverse_listen_port: nullablePort,
simnet_reverse_target_host: nullableString,
simnet_reverse_target_port: nullablePort,
simnet_fallback_enabled: nullableBool,
simnet_fallback_target_scheme: z.enum(SIMNET_FALLBACK_SCHEMES).nullish(),
simnet_fallback_target_host: nullableString,
simnet_fallback_target_port: nullablePort,
simnet_fallback_host_header: nullableString,
simnet_fallback_tls_sni: nullableString,
simnet_inbound_max_streams_per_session: nullableNonNegativeInt,
simnet_inbound_max_udp_streams_per_session: nullableNonNegativeInt,
simnet_inbound_max_handler_tasks_per_session: nullableNonNegativeInt,
simnet_stream_event_channel_capacity: nullableNonNegativeInt,
simnet_stream_data_channel_capacity: nullableNonNegativeInt,
simnet_target_dial_timeout_ms: nullableNonNegativeInt,
simnet_target_max_concurrent_dials: nullableNonNegativeInt,
simnet_egress_block_loopback: nullableBool,
simnet_egress_block_private: nullableBool,
simnet_egress_block_link_local: nullableBool,
simnet_egress_block_metadata: nullableBool,
simnet_send_window: nullableNonNegativeInt,
simnet_recv_window: nullableNonNegativeInt,
simnet_max_concurrent_streams: nullableNonNegativeInt,
simnet_initial_window_size: nullableNonNegativeInt,
simnet_max_frame_size: nullableNonNegativeInt,
simnet_client_max_concurrent_streams: nullableNonNegativeInt,
simnet_client_max_streams_per_session: nullableNonNegativeInt,
simnet_client_session_idle_timeout_secs: nullableNonNegativeInt,
simnet_client_max_udp_sessions: nullableNonNegativeInt,
up_mbps: z.number().nullish(),
down_mbps: z.number().nullish(),
});
export const protocolApiScheme = z.discriminatedUnion("type", [
ss,
vmess,
@@ -284,7 +219,6 @@ export const protocolApiScheme = z.discriminatedUnion("type", [
naive,
http,
mieru,
simnet,
]);
export const formSchema = z.object({
@@ -32,8 +32,7 @@ export type FieldConfig = {
| "security"
| "reality"
| "obfs"
| "encryption"
| "simnet";
| "encryption";
gridSpan?: 1 | 2;
};
@@ -15,10 +15,6 @@ import {
FLOWS,
multiplexLevels,
SECURITY,
SIMNET_AF_MAGIC_MODES,
SIMNET_AF_PATH_MODES,
SIMNET_CARRIERS,
SIMNET_FALLBACK_SCHEMES,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
@@ -1225,499 +1221,6 @@ export function useProtocolFields() {
condition: (p) => p.security === "reality",
},
],
simnet: [
{
name: "ratio",
type: "number",
label: t("traffic_ratio", "Ratio"),
min: 0,
step: 0.01,
defaultValue: 1,
group: "basic",
},
{
name: "port",
type: "number",
label: t("port", "Port"),
min: 1,
max: 65_535,
placeholder: "443",
group: "basic",
},
{
name: "simnet_psk",
type: "input",
label: t("simnet_psk", "SimNet PSK"),
placeholder: t(
"simnet_psk_placeholder",
"Server pre-shared key (shared across all users)"
),
generate: {
function: () => generatePassword(32),
},
group: "basic",
},
{
name: "simnet_ticket_id",
type: "input",
label: t("simnet_ticket_id", "SimNet Ticket ID"),
placeholder: t("simnet_ticket_id_placeholder", "Optional ticket id"),
group: "basic",
},
{
name: "simnet_path",
type: "input",
label: t("simnet_path", "SimNet Path"),
placeholder: t("simnet_path_placeholder", "e.g. /simnet"),
group: "basic",
},
{
name: "simnet_carrier",
type: "select",
label: t("simnet_carrier", "Carrier"),
options: SIMNET_CARRIERS,
defaultValue: "h2",
group: "basic",
},
{
name: "up_mbps",
type: "number",
label: t("up_mbps", "Upload Bandwidth"),
min: 1,
placeholder: t(
"bandwidth_placeholder",
"Enter bandwidth, leave empty for BBR"
),
suffix: "Mbps",
group: "basic",
},
{
name: "down_mbps",
type: "number",
label: t("down_mbps", "Download Bandwidth"),
min: 1,
placeholder: t(
"bandwidth_placeholder",
"Enter bandwidth, leave empty for BBR"
),
suffix: "Mbps",
group: "basic",
},
{
name: "security",
type: "select",
label: t("security", "Security"),
options: ["none", "tls"],
defaultValue: "tls",
group: "security",
},
{
name: "sni",
type: "input",
label: t("security_sni", "SNI"),
placeholder: t("sni_placeholder", "e.g. edge.example.com"),
group: "security",
},
{
name: "allow_insecure",
type: "switch",
label: t("security_allow_insecure", "Allow Insecure"),
group: "security",
},
{
name: "cert_mode",
type: "select",
label: t("cert_mode", "Certificate Mode"),
options: CERT_MODES,
defaultValue: "none",
group: "security",
},
{
name: "cert_dns_provider",
type: "input",
label: t("cert_dns_provider", "DNS Provider"),
placeholder: "e.g. cloudflare, aliyun",
group: "security",
condition: (p) => p.cert_mode === "dns",
},
{
name: "cert_dns_env",
type: "textarea",
label: t("cert_dns_env", "DNS Environment Variables"),
placeholder:
"CF_DNS_API_TOKEN=1234567890abcdefghijklmnopqrstuvwxyz\nALI_ACCESS_KEY_ID=your_access_key_id\nALI_ACCESS_KEY_SECRET=your_access_key_secret",
group: "security",
condition: (p) => p.cert_mode === "dns",
},
{
name: "simnet_af_enabled",
type: "switch",
label: t("simnet_af_enabled", "Enable Anti-Fingerprint"),
group: "simnet",
},
{
name: "simnet_af_path_mode",
type: "select",
label: t("simnet_af_path_mode", "AF Path Mode"),
options: SIMNET_AF_PATH_MODES,
defaultValue: "api",
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_path_prefix",
type: "input",
label: t("simnet_af_path_prefix", "AF Path Prefix"),
placeholder: t(
"simnet_af_path_prefix_placeholder",
"Optional path prefix"
),
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_path_suffix",
type: "input",
label: t("simnet_af_path_suffix", "AF Path Suffix"),
placeholder: t(
"simnet_af_path_suffix_placeholder",
"Optional path suffix"
),
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_magic_mode",
type: "select",
label: t("simnet_af_magic_mode", "AF Magic Mode"),
options: SIMNET_AF_MAGIC_MODES,
defaultValue: "derived",
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_response_jitter_ms",
type: "number",
label: t("simnet_af_response_jitter_ms", "AF Response Jitter"),
min: 0,
suffix: "ms",
defaultValue: 50,
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_handshake_polymorphism",
type: "switch",
label: t(
"simnet_af_handshake_polymorphism",
"Handshake Polymorphism"
),
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_settings_jitter",
type: "switch",
label: t("simnet_af_settings_jitter", "Settings Jitter"),
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_af_fake_header_injection",
type: "switch",
label: t("simnet_af_fake_header_injection", "Fake Header Injection"),
group: "simnet",
condition: (p) => !!p.simnet_af_enabled,
},
{
name: "simnet_reverse_enabled",
type: "switch",
label: t("simnet_reverse_enabled", "Enable Local Reverse Proxy"),
group: "simnet",
},
{
name: "simnet_reverse_listen_addr",
type: "input",
label: t("simnet_reverse_listen_addr", "Reverse Listen Address"),
placeholder: "127.0.0.1",
group: "simnet",
condition: (p) => !!p.simnet_reverse_enabled,
},
{
name: "simnet_reverse_listen_port",
type: "number",
label: t("simnet_reverse_listen_port", "Reverse Listen Port"),
min: 1,
max: 65_535,
placeholder: "18080",
group: "simnet",
condition: (p) => !!p.simnet_reverse_enabled,
},
{
name: "simnet_reverse_target_host",
type: "input",
label: t("simnet_reverse_target_host", "Reverse Target Host"),
placeholder: "127.0.0.1",
group: "simnet",
condition: (p) => !!p.simnet_reverse_enabled,
},
{
name: "simnet_reverse_target_port",
type: "number",
label: t("simnet_reverse_target_port", "Reverse Target Port"),
min: 1,
max: 65_535,
placeholder: "8080",
group: "simnet",
condition: (p) => !!p.simnet_reverse_enabled,
},
{
name: "simnet_fallback_enabled",
type: "switch",
label: t(
"simnet_fallback_enabled",
"Enable HTTPS Fallback Reverse Proxy"
),
group: "simnet",
},
{
name: "simnet_fallback_target_scheme",
type: "select",
label: t("simnet_fallback_target_scheme", "Fallback Target Scheme"),
options: SIMNET_FALLBACK_SCHEMES,
defaultValue: "https",
group: "simnet",
condition: (p) => !!p.simnet_fallback_enabled,
},
{
name: "simnet_fallback_target_host",
type: "input",
label: t("simnet_fallback_target_host", "Fallback Target Host"),
placeholder: t(
"simnet_fallback_target_host_placeholder",
"e.g. www.example.com"
),
group: "simnet",
condition: (p) => !!p.simnet_fallback_enabled,
},
{
name: "simnet_fallback_target_port",
type: "number",
label: t("simnet_fallback_target_port", "Fallback Target Port"),
min: 1,
max: 65_535,
placeholder: "443 / 80",
group: "simnet",
condition: (p) => !!p.simnet_fallback_enabled,
},
{
name: "simnet_fallback_host_header",
type: "input",
label: t("simnet_fallback_host_header", "Fallback Host Header"),
placeholder: t(
"simnet_fallback_host_header_placeholder",
"Optional, defaults to target host"
),
group: "simnet",
condition: (p) => !!p.simnet_fallback_enabled,
},
{
name: "simnet_fallback_tls_sni",
type: "input",
label: t("simnet_fallback_tls_sni", "Fallback TLS SNI"),
placeholder: t(
"simnet_fallback_tls_sni_placeholder",
"Optional, defaults to target host"
),
group: "simnet",
condition: (p) => !!p.simnet_fallback_enabled,
},
{
name: "simnet_inbound_max_streams_per_session",
type: "number",
label: t(
"simnet_inbound_max_streams_per_session",
"Inbound Streams / Session"
),
min: 1,
defaultValue: 128,
group: "simnet",
},
{
name: "simnet_inbound_max_udp_streams_per_session",
type: "number",
label: t(
"simnet_inbound_max_udp_streams_per_session",
"Inbound UDP Streams / Session"
),
min: 1,
defaultValue: 64,
group: "simnet",
},
{
name: "simnet_inbound_max_handler_tasks_per_session",
type: "number",
label: t(
"simnet_inbound_max_handler_tasks_per_session",
"Handler Tasks / Session"
),
min: 1,
defaultValue: 128,
group: "simnet",
},
{
name: "simnet_stream_event_channel_capacity",
type: "number",
label: t(
"simnet_stream_event_channel_capacity",
"Stream Event Channel"
),
min: 1,
defaultValue: 256,
group: "simnet",
},
{
name: "simnet_stream_data_channel_capacity",
type: "number",
label: t(
"simnet_stream_data_channel_capacity",
"Stream Data Channel"
),
min: 1,
defaultValue: 128,
group: "simnet",
},
{
name: "simnet_target_dial_timeout_ms",
type: "number",
label: t("simnet_target_dial_timeout_ms", "Target Dial Timeout"),
min: 1,
suffix: "ms",
defaultValue: 12_000,
group: "simnet",
},
{
name: "simnet_target_max_concurrent_dials",
type: "number",
label: t(
"simnet_target_max_concurrent_dials",
"Concurrent Target Dials"
),
min: 1,
defaultValue: 256,
group: "simnet",
},
{
name: "simnet_send_window",
type: "number",
label: t("simnet_send_window", "Send Window"),
min: 1,
suffix: "bytes",
defaultValue: 4_194_304,
group: "simnet",
},
{
name: "simnet_recv_window",
type: "number",
label: t("simnet_recv_window", "Receive Window"),
min: 1,
suffix: "bytes",
defaultValue: 4_194_304,
group: "simnet",
},
{
name: "simnet_max_concurrent_streams",
type: "number",
label: t("simnet_max_concurrent_streams", "H2 Concurrent Streams"),
min: 1,
defaultValue: 100,
group: "simnet",
},
{
name: "simnet_initial_window_size",
type: "number",
label: t("simnet_initial_window_size", "H2 Initial Window"),
min: 1,
suffix: "bytes",
defaultValue: 65_535,
group: "simnet",
},
{
name: "simnet_max_frame_size",
type: "number",
label: t("simnet_max_frame_size", "H2 Max Frame Size"),
min: 1,
suffix: "bytes",
defaultValue: 16_384,
group: "simnet",
},
{
name: "simnet_client_max_concurrent_streams",
type: "number",
label: t(
"simnet_client_max_concurrent_streams",
"Client Concurrent Streams"
),
min: 1,
defaultValue: 32,
group: "simnet",
},
{
name: "simnet_client_max_streams_per_session",
type: "number",
label: t(
"simnet_client_max_streams_per_session",
"Client Streams / Session"
),
min: 1,
defaultValue: 512,
group: "simnet",
},
{
name: "simnet_client_session_idle_timeout_secs",
type: "number",
label: t(
"simnet_client_session_idle_timeout_secs",
"Client Session Idle Timeout"
),
min: 1,
suffix: "s",
defaultValue: 90,
group: "simnet",
},
{
name: "simnet_client_max_udp_sessions",
type: "number",
label: t("simnet_client_max_udp_sessions", "Client UDP Sessions"),
min: 1,
defaultValue: 64,
group: "simnet",
},
{
name: "simnet_egress_block_loopback",
type: "switch",
label: t("simnet_egress_block_loopback", "Block Loopback Egress"),
group: "simnet",
},
{
name: "simnet_egress_block_private",
type: "switch",
label: t("simnet_egress_block_private", "Block Private Egress"),
group: "simnet",
},
{
name: "simnet_egress_block_link_local",
type: "switch",
label: t("simnet_egress_block_link_local", "Block Link-Local Egress"),
group: "simnet",
},
{
name: "simnet_egress_block_metadata",
type: "switch",
label: t("simnet_egress_block_metadata", "Block Metadata Egress"),
group: "simnet",
},
],
}),
[t]
);
@@ -696,15 +696,6 @@ export default function ServerForm(props: {
i,
current
)}
{renderGroupCard(
t("simnet", "SimNet"),
fields,
"simnet",
control,
form,
i,
current
)}
</div>
</AccordionContent>
</AccordionItem>
@@ -41,7 +41,6 @@ const registerSchema = z.object({
trial_subscribe: z.number().optional(),
trial_time: z.number().optional(),
trial_time_unit: z.string().optional(),
gift_traffic: z.number().optional(),
enable_trial_email_whitelist: z.boolean().optional(),
trial_email_domain_whitelist: z.string().optional(),
enable_ip_register_limit: z.boolean().optional(),
@@ -76,7 +75,6 @@ export default function RegisterConfig() {
trial_subscribe: undefined,
trial_time: 0,
trial_time_unit: "day",
gift_traffic: 0,
enable_trial_email_whitelist: false,
trial_email_domain_whitelist: "",
enable_ip_register_limit: false,
@@ -451,40 +449,6 @@ export default function RegisterConfig() {
)}
/>
<FormField
control={form.control}
name="gift_traffic"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("register.giftTraffic", "Gift Traffic")}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueBlur={(value) =>
field.onChange(Number(value))
}
placeholder={t(
"register.inputPlaceholder",
"Please enter"
)}
suffix="GB"
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"register.giftTrafficDescription",
"Gift traffic for new users (GB). 0 = duration mode (uses the trial duration above); >0 = quota mode: N GB with no time limit, trial duration is ignored"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_trial_email_whitelist"
+23 -60
View File
@@ -66,6 +66,7 @@ import { formatDate } from "@/utils/common";
import FamilyManagement from "./family";
import { RemarkForm } from "./remark-form";
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
import { UserDetail } from "./user-detail";
import UserForm from "./user-form";
import { UserIdentifier } from "./user-identifier";
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
@@ -335,31 +336,23 @@ export default function User() {
id: "referer_id",
accessorKey: "referer_id",
header: t("referer", "Referer"),
cell: ({ row }) => (
<RefererProfileTrigger
onUpdated={() => ref.current?.refresh()}
userId={row.original.referer_id}
/>
),
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
},
{
id: "member_status",
accessorKey: "member_status",
id: "last_login_time",
accessorKey: "last_login_time",
header: t("userStatus", "用户状态"),
cell: ({ row }) => {
// member_status 由后端基于当前有效订阅计算:有有效订阅时为套餐名,否则为空
const memberStatus = (
const lastLoginTime = (
row.original as API.User & {
member_status?: string;
last_login_time?: number;
}
).member_status;
const hasActiveSubscription = Boolean(memberStatus);
const dotClassName = hasActiveSubscription
? "bg-green-500"
: "bg-gray-400";
const tooltipLabel = hasActiveSubscription
? memberStatus
: t("unsubscribed", "No active subscription");
).last_login_time;
const isOnline = Boolean(lastLoginTime);
const dotClassName = isOnline ? "bg-green-500" : "bg-gray-400";
const statusLabel = isOnline
? t("active", "Active")
: t("inactive", "Inactive");
return (
<TooltipProvider>
@@ -370,10 +363,18 @@ export default function User() {
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full ${dotClassName}`}
/>
<span className="sr-only">{tooltipLabel}</span>
<span className="sr-only">
{isOnline
? `${statusLabel}: ${formatDate(lastLoginTime)}`
: statusLabel}
</span>
</div>
</TooltipTrigger>
<TooltipContent>{tooltipLabel}</TooltipContent>
<TooltipContent>
{isOnline
? formatDate(lastLoginTime)
: t("neverLoggedIn", "Never logged in")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -438,11 +439,9 @@ export default function User() {
function ProfileSheet({
userId,
onUpdated,
trigger,
}: {
userId: number;
onUpdated?: () => void;
trigger?: React.ReactNode;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
@@ -463,7 +462,7 @@ function ProfileSheet({
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
{trigger || <Button variant="default">{t("edit", "Edit")}</Button>}
<Button variant="default">{t("edit", "Edit")}</Button>
</SheetTrigger>
<SheetContent
className="w-[700px] max-w-full md:max-w-screen-lg"
@@ -527,42 +526,6 @@ function SubscriptionSheet({ userId }: { userId: number }) {
);
}
function RefererProfileTrigger({
userId,
onUpdated,
}: {
userId: number;
onUpdated?: () => void;
}) {
const { t } = useTranslation("user");
const { data: user } = useQuery({
enabled: userId > 0,
queryKey: ["refererUserDetail", userId],
queryFn: async () => {
const { data } = await getUserDetail({ id: userId });
return data.data as API.User;
},
});
if (!userId) return "--";
return (
<ProfileSheet
onUpdated={onUpdated}
trigger={
<Button className="h-auto p-0 font-normal" variant="link">
{user ? (
<UserIdentifier className="flex items-center" user={user} />
) : (
t("loading", "Loading...")
)}
</Button>
}
userId={userId}
/>
);
}
function InviteStatsMenuItem({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
@@ -54,10 +54,6 @@ function toUserSubscribePayload(values: SubscriptionFormValues) {
};
}
function resolvePlanSpeed(row: API.UserSubscribe) {
return row.plan_speed_limit ?? row.subscribe?.speed_limit ?? 0;
}
export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
@@ -316,22 +312,18 @@ export default function UserSubscription({ userId }: { userId: number }) {
{
accessorKey: "speed_limit",
header: t("speedLimit", "Speed Limit"),
meta: {
className: "min-w-[220px] whitespace-normal",
},
cell: ({ row }) => {
const userSpeed = row.original.speed_limit ?? 0;
const effectiveSpeed = row.original.effective_speed ?? 0;
const planSpeed = resolvePlanSpeed(row.original);
const planSpeed = row.original.subscribe?.speed_limit ?? 0;
const speed = effectiveSpeed || userSpeed || planSpeed;
const source = row.original.is_throttled
? t("effectiveSpeed")
: userSpeed > 0
? t("userOverride")
: t("subscriptionDefault");
const source =
userSpeed > 0
? t("userOverride", "User Override")
: t("subscriptionDefault", "Subscription Default");
return (
<div className="flex min-w-[200px] flex-col gap-1 whitespace-normal">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Display type="trafficSpeed" value={speed} />
{row.original.is_throttled && (
@@ -340,18 +332,9 @@ export default function UserSubscription({ userId }: { userId: number }) {
</Badge>
)}
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-muted-foreground text-xs leading-5">
<span>{t("current")}</span>
<span>{source}</span>
<span>{t("userOverride")}</span>
<span>
<Display type="trafficSpeed" value={userSpeed} />
</span>
<span>{t("subscriptionDefault")}</span>
<span>
<Display type="trafficSpeed" value={planSpeed} />
</span>
</div>
<span className="text-muted-foreground text-xs">
{source}
</span>
</div>
);
},
@@ -87,12 +87,11 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
if (detail.status !== 1) return null;
const baseSpeed =
detail.plan_speed_limit ?? detail.subscribe?.speed_limit ?? 0;
const baseSpeed = detail.subscribe?.speed_limit ?? 0;
const userSpeed = detail.speed_limit ?? 0;
const effectiveSpeed = detail.effective_speed ?? 0;
const isThrottled = detail.is_throttled;
const rules = detail.user_traffic_limit || detail.traffic_limit || [];
const rules = detail.traffic_limit || [];
const hasLimitConfig =
baseSpeed > 0 || userSpeed > 0 || effectiveSpeed > 0 || rules.length > 0;
@@ -121,13 +120,13 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
<div className="flex flex-wrap items-center gap-2 font-medium">
{isThrottled ? (
<>
<span>{t("throttled")}</span>
<span>{t("throttled", "Speed Throttled")}</span>
<Badge className="text-xs" variant="destructive">
{effectiveSpeed} Mbps
</Badge>
{baseSpeed > 0 && (
<span className="text-muted-foreground text-xs line-through">
{t("subscriptionDefault")}:{" "}
{t("subscriptionDefault", "Subscription Default")}:{" "}
{baseSpeed} Mbps
</span>
)}
@@ -138,22 +137,24 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
{t("speedLimit", "Speed Limit")}
</span>
<Badge className="text-xs" variant="secondary">
{effectiveSpeed > 0 ? `${effectiveSpeed} Mbps` : t("unlimited")}
{effectiveSpeed > 0
? `${effectiveSpeed} Mbps`
: t("unlimited", "Unlimited")}
</Badge>
</>
)}
</div>
<div className="grid gap-2 sm:grid-cols-3">
<SpeedMetric
label={t("subscriptionDefault")}
label={t("subscriptionDefault", "Subscription Default")}
value={baseSpeed}
/>
<SpeedMetric
label={t("userOverride")}
label={t("userOverride", "User Override")}
value={userSpeed}
/>
<SpeedMetric
label={t("effectiveSpeed")}
label={t("effectiveSpeed", "Effective Speed")}
value={effectiveSpeed}
/>
</div>
@@ -171,7 +172,7 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
{rules.length > 0 && (
<div className="space-y-2">
<p className="font-medium text-foreground text-xs">
{t("trafficLimitRules")}
{t("trafficLimitRules", "Traffic Limit Rules")}
</p>
<div className="grid gap-2">
{rules.map((rule, index) => (
@@ -186,8 +187,8 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
value: rule.stat_value,
type:
rule.stat_type === "hour"
? t("hour")
: t("day"),
? t("hour", "Hour")
: t("day", "Day"),
speed: rule.speed_limit,
})}
</div>
@@ -207,7 +208,7 @@ function SpeedMetric({ label, value }: { label: string; value: number }) {
<div className="rounded-md border bg-background/60 p-2">
<div className="text-muted-foreground text-xs">{label}</div>
<div className="font-medium text-foreground">
{value > 0 ? `${value} Mbps` : t("unlimited")}
{value > 0 ? `${value} Mbps` : t("unlimited", "Unlimited")}
</div>
</div>
);
@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Form,
FormControl,
@@ -26,11 +20,10 @@ import {
} from "@workspace/ui/components/sheet";
import { Combobox } from "@workspace/ui/composed/combobox";
import { DatePicker } from "@workspace/ui/composed/date-picker";
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react";
import { type KeyboardEvent, type ReactNode, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -47,12 +40,6 @@ interface Props {
type SubscriptionTrafficLimitRule = API.UserSubscribeTrafficLimitRule;
function getUserTrafficLimitRules(
data?: Pick<API.UserSubscribe, "traffic_limit" | "user_traffic_limit">
) {
return data?.user_traffic_limit || data?.traffic_limit || [];
}
const defaultLimitRule: SubscriptionTrafficLimitRule = {
stat_type: "day",
stat_value: 1,
@@ -94,199 +81,6 @@ const normalizeLimitRule = (
speed_limit: Math.max(0, Number(item.speed_limit) || 0),
});
function TrafficLimitRuleEditor({
value,
onChange,
}: {
value: SubscriptionTrafficLimitRule[];
onChange: (value: SubscriptionTrafficLimitRule[]) => void;
}) {
const { t } = useTranslation("user");
const rules = value.length > 0 ? value : [defaultLimitRule];
const updateRule = (
index: number,
patch: Partial<SubscriptionTrafficLimitRule>
) => {
const next = rules.map((rule, i) =>
i === index ? normalizeLimitRule({ ...rule, ...patch }) : rule
);
onChange(next);
};
const addRule = () => {
onChange([...rules, defaultLimitRule]);
};
const removeRule = (index: number) => {
const next = rules.filter((_, i) => i !== index);
onChange(next.length > 0 ? next : []);
};
return (
<div className="space-y-3">
{rules.map((rule, index) => (
<Card className="gap-0 rounded-lg py-0 shadow-none" key={index}>
<CardHeader className="border-b px-4 py-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm">
{t("trafficLimitRule")} {index + 1}
</CardTitle>
<div className="flex items-center gap-1">
{rules.length > 1 && (
<Button
className="h-8 w-8 p-0 text-destructive"
onClick={() => removeRule(index)}
size="icon"
type="button"
variant="ghost"
>
<CircleMinusIcon className="h-4 w-4" />
</Button>
)}
{index === rules.length - 1 && (
<Button
className="h-8 w-8 p-0 text-primary"
onClick={addRule}
size="icon"
type="button"
variant="ghost"
>
<CirclePlusIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 py-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<FormLabel className="text-xs">
{t("statType")}
</FormLabel>
<Combobox<string, false>
onChange={(nextValue) => {
updateRule(index, {
stat_type: nextValue || "day",
});
}}
options={[
{
label: t("statTypeHour"),
value: "hour",
},
{
label: t("statTypeDay"),
value: "day",
},
]}
placeholder={t("statType")}
value={rule.stat_type}
/>
</div>
<div className="space-y-2">
<FormLabel className="text-xs">
{t("statValue")}
</FormLabel>
<EnhancedInput
min={1}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}}
onValueChange={(nextValue) => {
updateRule(index, {
stat_value: Math.max(
1,
Math.floor(Number(nextValue) || 1)
),
});
}}
placeholder={t("statValue")}
type="number"
value={rule.stat_value}
/>
</div>
<div className="space-y-2">
<FormLabel className="text-xs">
{t("trafficUsage")}
</FormLabel>
<EnhancedInput
min={0}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}}
onValueChange={(nextValue) => {
updateRule(index, {
traffic_usage: Math.max(
0,
Math.floor(Number(nextValue) || 0)
),
});
}}
placeholder={t("trafficUsage")}
suffix="GB"
type="number"
value={rule.traffic_usage}
/>
</div>
<div className="space-y-2">
<FormLabel className="text-xs">
{t("speedLimitMbps")}
</FormLabel>
<EnhancedInput
min={0}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}}
onValueChange={(nextValue) => {
updateRule(index, {
speed_limit: Math.max(
0,
Math.floor(Number(nextValue) || 0)
),
});
}}
placeholder={t("speedLimitMbps")}
suffix="Mbps"
type="number"
value={rule.speed_limit}
/>
</div>
</div>
<div
className={cn(
"mt-3 rounded-md bg-muted/40 px-3 py-2 text-muted-foreground text-xs"
)}
>
{t("trafficLimitRuleSummary", {
defaultValue:
"{{traffic}} GB in {{value}} {{type}} -> {{speed}} Mbps",
traffic: rule.traffic_usage,
value: rule.stat_value,
type:
rule.stat_type === "hour"
? t("hour")
: t("day"),
speed: rule.speed_limit,
})}
</div>
</CardContent>
</Card>
))}
</div>
);
}
export function SubscriptionForm({
trigger,
title,
@@ -304,7 +98,7 @@ export function SubscriptionForm({
traffic: initialData?.traffic || 0,
speed_limit: initialData?.speed_limit || 0,
device_limit: initialData?.device_limit || 0,
traffic_limit: getUserTrafficLimitRules(initialData),
traffic_limit: initialData?.traffic_limit || [],
upload: initialData?.upload || 0,
download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0,
@@ -517,11 +311,98 @@ export function SubscriptionForm({
{t("trafficLimitRules", "Traffic Limit Rules")}
</FormLabel>
<FormControl>
<TrafficLimitRuleEditor
<ArrayInput<SubscriptionTrafficLimitRule>
className="grid grid-cols-2 gap-3 lg:grid-cols-4"
fields={[
{
name: "stat_type",
type: "select",
placeholder: t("statType", "Statistics Type"),
value: "day",
options: [
{
label: t("statTypeHour", "Hour"),
value: "hour",
},
{
label: t("statTypeDay", "Day"),
value: "day",
},
],
},
{
name: "stat_value",
type: "number",
placeholder: t("statValue", "Time Value"),
min: 1,
step: 1,
onKeyDown: (
e: KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 1 : Math.floor(num)
);
},
},
{
name: "traffic_usage",
type: "number",
placeholder: t(
"trafficUsage",
"Traffic Usage (GB)"
),
min: 0,
onKeyDown: (
e: KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
{
name: "speed_limit",
type: "number",
placeholder: t(
"speedLimitMbps",
"Speed Limit (Mbps)"
),
min: 0,
onKeyDown: (
e: KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
},
formatOutput: (value: string | number) => {
const num = Number(value);
return String(
Number.isNaN(num) ? 0 : Math.floor(num)
);
},
},
]}
onChange={(items) => {
field.onChange(items.map(normalizeLimitRule));
}}
value={field.value || []}
value={
field.value && field.value.length > 0
? field.value
: [defaultLimitRule]
}
/>
</FormControl>
<FormDescription>
+2 -2
View File
@@ -44,7 +44,7 @@ const WITHDRAWAL_METHOD_MAP: Record<number, string> = {
0: "其他",
1: "支付宝",
2: "微信",
3: "USDT(TRC20)",
3: "银行卡",
};
const rejectSchema = z.object({
@@ -143,7 +143,7 @@ export default function WithdrawalPage() {
const methodOptions = [
{ value: "1", label: "支付宝" },
{ value: "2", label: "微信" },
{ value: "3", label: "USDT(TRC20)" },
{ value: "3", label: "银行卡" },
{ value: "0", label: "其他" },
];
+19 -23
View File
@@ -63,7 +63,7 @@ import { useGlobalStore } from "@/stores/global";
const WITHDRAWAL_METHODS = [
{ value: 1, label: "支付宝" },
{ value: 2, label: "微信" },
{ value: 3, label: "USDT(TRC20)" },
{ value: 3, label: "银行卡" },
{ value: 0, label: "其他" },
] as const;
@@ -111,21 +111,13 @@ function QrCodeView({ url }: { url: string }) {
<img
alt="收款码"
className="h-16 w-16 cursor-zoom-in object-cover"
height={64}
src={url}
width={64}
/>
</button>
{zoomOpen && (
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
<DialogContent className="max-w-sm">
<img
alt="收款码"
className="w-full"
height={320}
src={url}
width={320}
/>
<img alt="收款码" className="w-full" src={url} />
</DialogContent>
</Dialog>
)}
@@ -161,16 +153,18 @@ const withdrawalFormSchema = z
if (!data.account) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "USDT(TRC20) 地址为必填",
message: "银行卡收款账号为必填",
path: ["account"],
});
}
} else if (data.method === 0) {
if (!data.account && !data.content) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
path: ["account"],
});
}
} else if (data.method === 0 && !(data.account || data.content)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
path: ["account"],
});
}
});
@@ -427,7 +421,9 @@ export default function Affiliate() {
<FormItem>
<FormLabel>
<span className="ml-1 text-destructive">*</span>
<span className="ml-1 text-destructive">
*
</span>
</FormLabel>
{watchedQrCodeUrl ? (
<div className="relative inline-block">
@@ -450,9 +446,7 @@ export default function Affiliate() {
returnType="file"
>
<div className="flex h-24 w-24 flex-col items-center justify-center rounded border-2 border-dashed text-muted-foreground text-xs">
{qrUploading
? "上传中..."
: "点击上传\n收款码"}
{qrUploading ? "上传中..." : "点击上传\n收款码"}
</div>
</UploadImage>
)}
@@ -480,7 +474,7 @@ export default function Affiliate() {
<Input
placeholder={
watchedMethod === 3
? "请输入 USDT(TRC20) 收款地址"
? "请输入银行卡号或账户信息"
: "请输入收款账号(与备注至少填一项)"
}
{...field}
@@ -525,7 +519,9 @@ export default function Affiliate() {
</Button>
<Button
disabled={
submitting || qrUploading || availableCommission <= 0
submitting ||
qrUploading ||
availableCommission <= 0
}
form="withdrawal-form"
type="submit"
@@ -313,8 +313,7 @@ export function ProTable<
<TableHead
className={cn(
"!z-auto",
getTableHeaderClass(header.column.id),
getColumnClassName(header)
getTableHeaderClass(header.column.id)
)}
key={header.id}
>
@@ -363,10 +362,7 @@ export function ProTable<
.filter((cell) => cell.column.id !== "sortable")
.map((cell) => (
<TableCell
className={cn(
getTableCellClass(cell.column.id),
getColumnClassName(cell)
)}
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
@@ -385,10 +381,7 @@ export function ProTable<
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={cn(
getTableCellClass(cell.column.id),
getColumnClassName(cell)
)}
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
@@ -483,17 +476,3 @@ function getTableCellClass(columnId: string) {
}
return "truncate";
}
function getColumnClassName(
columnLike: {
column: {
columnDef: {
meta?: {
className?: string;
};
};
};
}
) {
return columnLike.column.columnDef.meta?.className || "";
}
-8
View File
@@ -1938,7 +1938,6 @@ declare namespace API {
trial_subscribe: number;
trial_time: number;
trial_time_unit: string;
gift_traffic: number;
enable_trial_email_whitelist: boolean;
trial_email_domain_whitelist: string;
enable_ip_register_limit: boolean;
@@ -2599,9 +2598,6 @@ declare namespace API {
user_devices: UserDevice[];
remark: string;
rules: string[];
/** 当前有效订阅的套餐名(由后端计算),无有效订阅时为空 */
member_status?: string;
last_login_time?: number;
created_at: number;
updated_at: number;
deleted_at?: number;
@@ -2668,13 +2664,11 @@ declare namespace API {
download: number;
upload: number;
speed_limit?: number;
plan_speed_limit?: number;
device_limit?: number;
effective_speed?: number;
is_throttled?: boolean;
throttle_rule?: string;
traffic_limit?: UserSubscribeTrafficLimitRule[];
user_traffic_limit?: UserSubscribeTrafficLimitRule[];
token: string;
status: number;
short: string;
@@ -2705,10 +2699,8 @@ declare namespace API {
download: number;
upload: number;
speed_limit?: number;
plan_speed_limit?: number;
device_limit?: number;
traffic_limit?: UserSubscribeTrafficLimitRule[];
user_traffic_limit?: UserSubscribeTrafficLimitRule[];
token: string;
status: number;
effective_speed: number;
+1
View File
@@ -1,3 +1,4 @@
// @ts-expect-error
/* eslint-disable */
import request from "@workspace/ui/lib/request";