Compare commits

..

11 Commits

Author SHA1 Message Date
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
31 changed files with 426 additions and 1267 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.
@@ -92,7 +92,7 @@
"iconUrlInvalid": "Must be a valid URL",
"isFallback": "Fallback Prize",
"isFallbackHint": "Awarded when limited prizes are sold out",
"manualPrizeHint": "Manual prize: no extra config. When won, the user contacts customer service with a screenshot to redeem — no in-app claim form.",
"manualPrizeHint": "Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab.",
"name": "Prize Name",
"namePlaceholder": "e.g. 1-Day Pass",
"nameRequired": "Name is required",
@@ -119,7 +119,7 @@
"type": "Prize Type",
"typeCommission": "Commission",
"typeCoupon": "Coupon",
"typeCrypto": "Manual Grant",
"typeCrypto": "Crypto",
"typeManualOther": "Manual (Other)",
"typeNone": "No Prize (Thanks)",
"typePhysical": "Physical",
@@ -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",
@@ -92,7 +92,7 @@
"iconUrlInvalid": "请输入有效的 URL",
"isFallback": "保底奖品",
"isFallbackHint": "限量奖品抽完后发放该奖品",
"manualPrizeHint": "人工发放奖品:无需额外配置。中奖后用户凭截图联系人工客服兑换,无需在 app 内填领奖表单。",
"manualPrizeHint": "人工奖:用户先提交领奖信息,运营在「领奖工单」页审核并标记发放/发货。",
"name": "奖品名称",
"namePlaceholder": "例如:1 天时长卡",
"nameRequired": "请输入奖品名称",
@@ -119,7 +119,7 @@
"type": "奖品类型",
"typeCommission": "佣金",
"typeCoupon": "优惠券",
"typeCrypto": "人工发放",
"typeCrypto": "加密货币",
"typeManualOther": "人工发放(其他)",
"typeNone": "谢谢参与",
"typePhysical": "实物奖品",
@@ -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",
@@ -31,16 +31,6 @@ const grantSchema = z.object({
source_ref: z.string().trim().min(1),
});
// 数字输入统一用 type="text" + 该 formatOutput,避开 number 类型 input 的实时钳制问题。
const numberOutput = (v: string | number): number | undefined => {
const s = String(v ?? "").trim();
if (s === "") {
return;
}
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
};
type GrantFormValues = z.infer<typeof grantSchema>;
const SOURCE_REF_RANDOM_LENGTH = 6;
@@ -133,11 +123,10 @@ export default function GrantDialog({
<FormLabel>{t("grant.userId", "User ID")}</FormLabel>
<FormControl>
<EnhancedInput
formatOutput={numberOutput}
inputMode="numeric"
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder={t("grant.userIdPlaceholder", "e.g. 87433")}
type="text"
type="number"
value={field.value}
/>
</FormControl>
@@ -159,11 +148,10 @@ export default function GrantDialog({
<FormLabel>{t("grant.amount", "Amount")}</FormLabel>
<FormControl>
<EnhancedInput
formatOutput={numberOutput}
inputMode="numeric"
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder="1"
type="text"
type="number"
value={field.value}
/>
</FormControl>
+234 -32
View File
@@ -42,21 +42,10 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const CENTS_PER_YUAN = 100;
// 概率单位约定:weight 1000 = 1%(支持 3 位小数概率,总权重 100000 = 100%)。
const WEIGHT_PER_PERCENT = 1000;
// numberOutput 把 text 输入转成数字(空/非法→undefined)。数字输入统一用 type="text"
// + 该 formatOutput,避开 EnhancedInput 在 type="number" 下 min/max 实时钳制导致的
// “输入 0 / 改中间位跳变”问题;提交仍是数字,后端收 int。
const numberOutput = (v: string | number): number | undefined => {
const s = String(v ?? "").trim();
if (s === "") {
return;
}
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
};
type TranslateFn = (key: string, defaultValue: string) => string;
const PRIZE_TYPE_OPTIONS: {
@@ -70,21 +59,43 @@ const PRIZE_TYPE_OPTIONS: {
labelDefault: "VPN Duration",
},
{
value: "crypto",
labelKey: "prize.typeCrypto",
labelDefault: "Crypto",
value: "commission",
labelKey: "prize.typeCommission",
labelDefault: "Commission",
},
{
value: "none",
labelKey: "prize.typeNone",
labelDefault: "No Prize (Thanks)",
},
{
value: "crypto",
labelKey: "prize.typeCrypto",
labelDefault: "Crypto",
},
{
value: "physical",
labelKey: "prize.typePhysical",
labelDefault: "Physical",
},
{
value: "manual_other",
labelKey: "prize.typeManualOther",
labelDefault: "Manual (Other)",
},
];
const getPrizeSchema = (t: TranslateFn) =>
z
.object({
type: z.enum(["vpn_duration", "crypto", "none"]),
type: z.enum([
"vpn_duration",
"commission",
"none",
"crypto",
"physical",
"manual_other",
]),
name: z.string().min(1, t("prize.nameRequired", "Name is required")),
icon_url: z
.string()
@@ -100,6 +111,13 @@ const getPrizeSchema = (t: TranslateFn) =>
is_fallback: z.boolean(),
duration_days: z.number().optional(),
subscribe_id: z.number().optional(),
amount_yuan: z.number().optional(),
// 人工奖(crypto / physical / manual_other)通用/专属字段
crypto_amount: z.string().optional(),
crypto_currency: z.string().optional(),
crypto_networks: z.string().optional(), // 逗号分隔,提交时 split 成 string[]
sku_name: z.string().optional(),
claim_ttl_hours: z.number().optional(),
})
.superRefine((data, ctx) => {
const issue = (path: string, message: string) => {
@@ -116,6 +134,63 @@ const getPrizeSchema = (t: TranslateFn) =>
)
);
}
if (data.type === "commission") {
const yuan = data.amount_yuan ?? 0;
const cents = yuan * CENTS_PER_YUAN;
if (yuan <= 0) {
issue(
"amount_yuan",
t("prize.amountRequired", "Amount must be greater than 0")
);
} else if (Math.abs(Math.round(cents) - cents) > 1e-6) {
issue(
"amount_yuan",
t(
"prize.amountPrecision",
"Amount supports at most 2 decimal places"
)
);
}
}
if (data.type === "crypto") {
if (!data.crypto_amount?.trim()) {
issue(
"crypto_amount",
t("prize.cryptoAmountRequired", "Amount is required")
);
}
if (!data.crypto_currency?.trim()) {
issue(
"crypto_currency",
t("prize.cryptoCurrencyRequired", "Currency is required")
);
}
const networks = (data.crypto_networks ?? "")
.split(",")
.map((n) => n.trim())
.filter(Boolean);
if (networks.length === 0) {
issue(
"crypto_networks",
t(
"prize.cryptoNetworksRequired",
"At least one network is required (comma-separated)"
)
);
}
}
if (
data.claim_ttl_hours !== undefined &&
(!Number.isInteger(data.claim_ttl_hours) || data.claim_ttl_hours <= 0)
) {
issue(
"claim_ttl_hours",
t(
"prize.claimTtlInvalid",
"Claim window (hours) must be a positive integer"
)
);
}
if (!(data.unlimited_stock || isPositiveInt(data.total_stock))) {
issue(
"total_stock",
@@ -140,6 +215,9 @@ interface PrizeFormProps {
function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
const config = prize?.config ?? {};
const networks = Array.isArray(config.networks)
? (config.networks as string[]).join(", ")
: undefined;
return {
type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration",
name: prize?.name ?? "",
@@ -150,10 +228,26 @@ function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
is_fallback: prize?.is_fallback ?? false,
duration_days: config.duration_days as number | undefined,
subscribe_id: config.subscribe_id as number | undefined,
amount_yuan:
typeof config.amount_cents === "number"
? config.amount_cents / CENTS_PER_YUAN
: undefined,
crypto_amount: config.amount as string | undefined,
crypto_currency: config.currency as string | undefined,
crypto_networks: networks,
sku_name: config.sku_name as string | undefined,
claim_ttl_hours: config.claim_ttl_hours as number | undefined,
};
}
function toConfig(values: PrizeFormValues): Record<string, any> {
// claim_ttl_hours 对所有人工奖通用:控制领奖窗口(缺省后端默认 7 天)。
const withTtl = (base: Record<string, any>): Record<string, any> => {
if (values.claim_ttl_hours && values.claim_ttl_hours > 0) {
base.claim_ttl_hours = values.claim_ttl_hours;
}
return base;
};
if (values.type === "vpn_duration") {
const config: Record<string, any> = { duration_days: values.duration_days };
// 可选:无活跃订阅时按该套餐新建订阅发放时长。0/未选则沿用旧行为(跳过发放)。
@@ -162,7 +256,28 @@ function toConfig(values: PrizeFormValues): Record<string, any> {
}
return config;
}
// crypto(人工发放) / none 无需额外配置:中奖后凭截图联系客服线下发放。
if (values.type === "commission") {
return {
amount_cents: Math.round((values.amount_yuan ?? 0) * CENTS_PER_YUAN),
};
}
if (values.type === "crypto") {
return withTtl({
amount: values.crypto_amount?.trim() ?? "",
currency: values.crypto_currency?.trim() ?? "",
networks: (values.crypto_networks ?? "")
.split(",")
.map((n) => n.trim())
.filter(Boolean),
});
}
if (values.type === "physical" || values.type === "manual_other") {
const config: Record<string, any> = {};
if (values.sku_name?.trim()) {
config.sku_name = values.sku_name.trim();
}
return withTtl(config);
}
return {};
}
@@ -251,7 +366,18 @@ export default function PrizeForm({
}
const renderInput = (
name: "name" | "icon_url" | "weight" | "total_stock" | "duration_days",
name:
| "name"
| "icon_url"
| "weight"
| "total_stock"
| "duration_days"
| "amount_yuan"
| "crypto_amount"
| "crypto_currency"
| "crypto_networks"
| "sku_name"
| "claim_ttl_hours",
label: string,
inputProps: Partial<EnhancedInputProps<any>>,
description?: string
@@ -368,9 +494,9 @@ export default function PrizeForm({
"duration_days",
t("prize.durationDays", "Duration (days)"),
{
type: "text",
inputMode: "numeric",
formatOutput: numberOutput,
type: "number",
min: 1,
step: 1,
placeholder: t("prize.durationDaysPlaceholder", "e.g. 1"),
suffix: t("prize.daysSuffix", "days"),
}
@@ -422,6 +548,23 @@ export default function PrizeForm({
)}
/>
)}
{type === "commission" &&
renderInput(
"amount_yuan",
t("prize.amountYuan", "Commission Amount (CNY)"),
{
type: "number",
min: 0,
step: 0.01,
placeholder: "0.00",
prefix: "¥",
suffix: t("prize.yuanSuffix", "CNY"),
},
t(
"prize.amountUnitHint",
"Entered in CNY (yuan), stored as cents; up to 2 decimal places"
)
)}
{type === "none" && (
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
@@ -431,20 +574,79 @@ export default function PrizeForm({
</p>
)}
{type === "crypto" && (
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
"prize.manualPrizeHint",
"Manual prize: no extra config. When won, the user contacts customer service with a screenshot to redeem — no in-app claim form."
<>
{renderInput(
"crypto_amount",
t("prize.cryptoAmount", "Amount"),
{
placeholder: t(
"prize.cryptoAmountPlaceholder",
"e.g. 0.01"
),
}
)}
</p>
{renderInput(
"crypto_currency",
t("prize.cryptoCurrency", "Currency"),
{ placeholder: "USDT / BTC / ETH" }
)}
{renderInput(
"crypto_networks",
t("prize.cryptoNetworks", "Networks"),
{ placeholder: "TRC20, ERC20, BTC" },
t(
"prize.cryptoNetworksHint",
"Comma-separated. The user picks one of these when claiming."
)
)}
</>
)}
{(type === "physical" || type === "manual_other") &&
renderInput(
"sku_name",
t("prize.skuName", "Item name (optional)"),
{
placeholder: t(
"prize.skuNamePlaceholder",
"e.g. Limited T-shirt"
),
}
)}
{(type === "crypto" ||
type === "physical" ||
type === "manual_other") && (
<>
{renderInput(
"claim_ttl_hours",
t("prize.claimTtlHours", "Claim window (hours)"),
{
type: "number",
min: 1,
step: 1,
placeholder: "168",
suffix: t("prize.hoursSuffix", "hours"),
},
t(
"prize.claimTtlHint",
"How long the user has to submit claim info. Default 168h (7 days)."
)
)}
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
"prize.manualPrizeHint",
"Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab."
)}
</p>
</>
)}
{renderInput(
"weight",
t("prize.weight", "Win probability (%)"),
{
type: "text",
inputMode: "decimal",
formatOutput: numberOutput,
type: "number",
min: 0,
max: 100,
step: 0.001,
suffix: "%",
disabled: isFallback,
},
@@ -467,9 +669,9 @@ export default function PrizeForm({
"total_stock",
t("prize.totalStock", "Total Stock"),
{
type: "text",
inputMode: "numeric",
formatOutput: numberOutput,
type: "number",
min: 1,
step: 1,
placeholder: t("prize.totalStockPlaceholder", "e.g. 100"),
}
)}
+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";