Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 017650946c | |||
| 24a1f8c4f3 | |||
| 466507a196 | |||
| 6b9ae70491 | |||
| e64f808633 | |||
| 813d491f97 | |||
| 0feb907954 | |||
| 302275bd17 | |||
| 322d60b648 | |||
| 508cfa91ba | |||
| 143773c928 | |||
| dd837cac7c | |||
| ea4f6799e9 | |||
| ab327f5118 | |||
| dfe1bafd1c | |||
| 511ce4ff65 | |||
| 64fe23f877 | |||
| 09c5216f60 | |||
| 4d84095af2 | |||
| d66b664198 | |||
| a7d89acc7f |
@@ -68,9 +68,26 @@
|
||||
"Read(//Users/Apple/.claude/**)",
|
||||
"Bash(2)",
|
||||
"Bash(claude mcp:*)",
|
||||
"Bash(mysql:*)"
|
||||
"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)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": ["mysql"],
|
||||
"enableAllProjectMcpServers": true
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"mysql"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -49,8 +49,8 @@
|
||||
"encryption_rtt": "RTT",
|
||||
"encryption_server_padding": "Server Padding",
|
||||
"encryption_ticket": "Ticket Time",
|
||||
"expired": "Expired",
|
||||
"expireTime": "Expire Time",
|
||||
"expired": "Expired",
|
||||
"extra": "Extra",
|
||||
"flow": "Flow",
|
||||
"generate_quantum_resistant_key": "Generate Quantum-Resistant Key",
|
||||
@@ -141,6 +141,52 @@
|
||||
},
|
||||
"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,6 +94,8 @@
|
||||
"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,6 +28,7 @@
|
||||
"createSubscription": "Create Subscription",
|
||||
"createSuccess": "Created successfully",
|
||||
"createUser": "Create User",
|
||||
"current": "Current",
|
||||
"currentCommission": "Current Commission",
|
||||
"delete": "Delete",
|
||||
"deleted": "Deleted",
|
||||
@@ -117,6 +118,8 @@
|
||||
"permanent": "Permanent",
|
||||
"prev": "Prev",
|
||||
"pleaseEnterEmail": "Enter email",
|
||||
"hour": "Hour",
|
||||
"day": "Day",
|
||||
"referer": "Referer",
|
||||
"refererId": "Referer ID",
|
||||
"refererIdPlaceholder": "Enter referer ID",
|
||||
@@ -149,7 +152,12 @@
|
||||
"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",
|
||||
@@ -171,6 +179,7 @@
|
||||
"subscriptionInfo": "subscriptionInfo",
|
||||
"subscriptionList": "Subscription List",
|
||||
"subscriptionLogs": "Subscription Logs",
|
||||
"subscriptionDefault": "Subscription Default",
|
||||
"subscriptionName": "subscriptionName",
|
||||
"subscriptionNotifications": "Subscription Notifications",
|
||||
"telephone": "Phone",
|
||||
@@ -181,17 +190,25 @@
|
||||
"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 Usage",
|
||||
"trafficUsage": "Traffic Threshold (GB)",
|
||||
"throttled": "Throttled",
|
||||
"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": "票据时间",
|
||||
"expired": "已过期",
|
||||
"expireTime": "过期时间",
|
||||
"expired": "已过期",
|
||||
"extra": "额外",
|
||||
"flow": "流控",
|
||||
"generate_quantum_resistant_key": "生成量子抗性密钥",
|
||||
@@ -141,6 +141,52 @@
|
||||
},
|
||||
"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,6 +94,8 @@
|
||||
"title": "注册设置",
|
||||
"trialConfig": "试用配置",
|
||||
"trialConfigDescription": "配置新用户注册时的试用订阅、时长和时间单位",
|
||||
"giftTraffic": "赠送流量",
|
||||
"giftTrafficDescription": "新用户赠送流量(GB)。0 = 时长模式(按上方试用时长配置);大于 0 = 流量模式:赠送 N GB、不限时长、用完为止(此时忽略试用时长配置)",
|
||||
"year": "年"
|
||||
},
|
||||
"site": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"createSubscription": "创建订阅",
|
||||
"createSuccess": "创建成功",
|
||||
"createUser": "创建用户",
|
||||
"current": "当前",
|
||||
"currentCommission": "当前佣金",
|
||||
"delete": "删除",
|
||||
"deleted": "已删除",
|
||||
@@ -118,6 +119,8 @@
|
||||
"permanent": "永久",
|
||||
"prev": "上一页",
|
||||
"pleaseEnterEmail": "输入邮箱",
|
||||
"hour": "小时",
|
||||
"day": "天",
|
||||
"referer": "推荐人",
|
||||
"refererId": "推荐人 ID",
|
||||
"refererIdPlaceholder": "输入推荐人 ID",
|
||||
@@ -150,7 +153,12 @@
|
||||
"save": "保存",
|
||||
"shortCode": "短码",
|
||||
"speedLimit": "速度限制",
|
||||
"speedLimitMbps": "目标限速 (Mbps)",
|
||||
"startTime": "开始时间",
|
||||
"statType": "统计周期",
|
||||
"statTypeDay": "天",
|
||||
"statTypeHour": "小时",
|
||||
"statValue": "周期值",
|
||||
"status": "状态",
|
||||
"statusActive": "活跃",
|
||||
"statusDeducted": "已扣除",
|
||||
@@ -172,6 +180,7 @@
|
||||
"subscriptionInfo": "订阅信息",
|
||||
"subscriptionList": "订阅列表",
|
||||
"subscriptionLogs": "订阅日志",
|
||||
"subscriptionDefault": "套餐默认",
|
||||
"subscriptionName": "订阅名称",
|
||||
"subscriptionNotifications": "订阅通知",
|
||||
"telephone": "电话",
|
||||
@@ -182,17 +191,25 @@
|
||||
"tradeNotifications": "交易通知",
|
||||
"trafficDetails": "流量详情",
|
||||
"trafficLimit": "流量限制",
|
||||
"trafficLimitRule": "规则",
|
||||
"trafficLimitRules": "流量限速规则",
|
||||
"trafficLimitRuleSummary": "{{value}}{{type}}内达到 {{traffic}} GB 后限速到 {{speed}} Mbps",
|
||||
"trafficStats": "流量统计",
|
||||
"trafficUsage": "流量使用",
|
||||
"trafficUsage": "触发流量 (GB)",
|
||||
"throttled": "已触发限速",
|
||||
"remainingTraffic": "剩余流量",
|
||||
"unlimited": "无限制",
|
||||
"unverified": "未验证",
|
||||
"update": "更新",
|
||||
"updateSuccess": "更新成功",
|
||||
"userOverride": "用户覆盖",
|
||||
"effectiveSpeed": "当前生效",
|
||||
"groupUpdated": "分组更新成功",
|
||||
"upload": "上传",
|
||||
"uploadTraffic": "上传流量",
|
||||
"userStatus": "用户状态",
|
||||
"subscribed": "有有效订阅",
|
||||
"unsubscribed": "无有效订阅",
|
||||
"userAgent": "用户代理",
|
||||
"userEmail": "邮箱",
|
||||
"userEmailPlaceholder": "输入邮箱",
|
||||
|
||||
@@ -18,10 +18,8 @@ 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,
|
||||
queryTicketWaitReply,
|
||||
} from "@workspace/ui/services/admin/console";
|
||||
import { queryServerTotalData } from "@workspace/ui/services/admin/console";
|
||||
import { getWithdrawalList } from "@workspace/ui/services/admin/withdrawal";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -41,11 +39,15 @@ import { UserStatisticsCard } from "./user-statistics-card";
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const { data: TicketTotal } = useQuery({
|
||||
queryKey: ["queryTicketWaitReply"],
|
||||
const { data: PendingWithdrawalTotal } = useQuery({
|
||||
queryKey: ["queryPendingWithdrawalTotal"],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryTicketWaitReply();
|
||||
return data.data?.count;
|
||||
const { data } = await getWithdrawalList({
|
||||
page: 1,
|
||||
size: 1,
|
||||
status: 0,
|
||||
});
|
||||
return data.data?.total || 0;
|
||||
},
|
||||
});
|
||||
const { data: ServerTotal } = useQuery({
|
||||
@@ -242,7 +244,7 @@ export default function Statistics() {
|
||||
},
|
||||
{
|
||||
title: t("withdrawalManagement", "提现管理"),
|
||||
value: TicketTotal || 0,
|
||||
value: PendingWithdrawalTotal || 0,
|
||||
subtitle: t("pending", "Pending"),
|
||||
icon: "uil:clipboard-notes",
|
||||
href: "/dashboard/withdrawal",
|
||||
|
||||
@@ -50,7 +50,8 @@ export type ProtocolName =
|
||||
| "naive"
|
||||
| "http"
|
||||
| "socks"
|
||||
| "mieru";
|
||||
| "mieru"
|
||||
| "simnet";
|
||||
|
||||
const buildSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
|
||||
@@ -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,
|
||||
end_time: rule?.end_time,
|
||||
start_time: rule?.start_time ? rule.start_time * 1000 : undefined,
|
||||
end_time: rule?.end_time ? rule.end_time * 1000 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest {
|
||||
params,
|
||||
priority: values.priority,
|
||||
enabled: values.enabled,
|
||||
start_time: values.start_time || undefined,
|
||||
end_time: values.end_time || undefined,
|
||||
start_time: values.start_time ? Math.floor(values.start_time / 1000) : undefined,
|
||||
end_time: values.end_time ? Math.floor(values.end_time / 1000) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const protocols = [
|
||||
"naive",
|
||||
"http",
|
||||
"mieru",
|
||||
"simnet",
|
||||
] as const;
|
||||
|
||||
// Global label map for display; fallback to raw value if missing
|
||||
@@ -69,6 +70,7 @@ 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 = {
|
||||
@@ -99,6 +101,12 @@ 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,6 +171,66 @@ 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,6 +7,10 @@ import {
|
||||
FLOWS,
|
||||
multiplexLevels,
|
||||
SECURITY,
|
||||
SIMNET_AF_MAGIC_MODES,
|
||||
SIMNET_AF_PATH_MODES,
|
||||
SIMNET_CARRIERS,
|
||||
SIMNET_FALLBACK_SCHEMES,
|
||||
SS_CIPHERS,
|
||||
TRANSPORTS,
|
||||
TUIC_CONGESTION,
|
||||
@@ -18,6 +22,7 @@ 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,
|
||||
@@ -207,6 +212,66 @@ 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,
|
||||
@@ -219,6 +284,7 @@ export const protocolApiScheme = z.discriminatedUnion("type", [
|
||||
naive,
|
||||
http,
|
||||
mieru,
|
||||
simnet,
|
||||
]);
|
||||
|
||||
export const formSchema = z.object({
|
||||
|
||||
@@ -32,7 +32,8 @@ export type FieldConfig = {
|
||||
| "security"
|
||||
| "reality"
|
||||
| "obfs"
|
||||
| "encryption";
|
||||
| "encryption"
|
||||
| "simnet";
|
||||
gridSpan?: 1 | 2;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
FLOWS,
|
||||
multiplexLevels,
|
||||
SECURITY,
|
||||
SIMNET_AF_MAGIC_MODES,
|
||||
SIMNET_AF_PATH_MODES,
|
||||
SIMNET_CARRIERS,
|
||||
SIMNET_FALLBACK_SCHEMES,
|
||||
SS_CIPHERS,
|
||||
TRANSPORTS,
|
||||
TUIC_CONGESTION,
|
||||
@@ -1221,6 +1225,499 @@ 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,6 +696,15 @@ export default function ServerForm(props: {
|
||||
i,
|
||||
current
|
||||
)}
|
||||
{renderGroupCard(
|
||||
t("simnet", "SimNet"),
|
||||
fields,
|
||||
"simnet",
|
||||
control,
|
||||
form,
|
||||
i,
|
||||
current
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -41,6 +41,7 @@ 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(),
|
||||
@@ -75,6 +76,7 @@ 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,
|
||||
@@ -449,6 +451,40 @@ 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"
|
||||
|
||||
@@ -66,7 +66,6 @@ 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";
|
||||
@@ -336,23 +335,31 @@ export default function User() {
|
||||
id: "referer_id",
|
||||
accessorKey: "referer_id",
|
||||
header: t("referer", "Referer"),
|
||||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||||
cell: ({ row }) => (
|
||||
<RefererProfileTrigger
|
||||
onUpdated={() => ref.current?.refresh()}
|
||||
userId={row.original.referer_id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "last_login_time",
|
||||
accessorKey: "last_login_time",
|
||||
id: "member_status",
|
||||
accessorKey: "member_status",
|
||||
header: t("userStatus", "用户状态"),
|
||||
cell: ({ row }) => {
|
||||
const lastLoginTime = (
|
||||
// member_status 由后端基于当前有效订阅计算:有有效订阅时为套餐名,否则为空
|
||||
const memberStatus = (
|
||||
row.original as API.User & {
|
||||
last_login_time?: number;
|
||||
member_status?: string;
|
||||
}
|
||||
).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");
|
||||
).member_status;
|
||||
const hasActiveSubscription = Boolean(memberStatus);
|
||||
const dotClassName = hasActiveSubscription
|
||||
? "bg-green-500"
|
||||
: "bg-gray-400";
|
||||
const tooltipLabel = hasActiveSubscription
|
||||
? memberStatus
|
||||
: t("unsubscribed", "No active subscription");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -363,18 +370,10 @@ export default function User() {
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 rounded-full ${dotClassName}`}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{isOnline
|
||||
? `${statusLabel}: ${formatDate(lastLoginTime)}`
|
||||
: statusLabel}
|
||||
</span>
|
||||
<span className="sr-only">{tooltipLabel}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isOnline
|
||||
? formatDate(lastLoginTime)
|
||||
: t("neverLoggedIn", "Never logged in")}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -439,9 +438,11 @@ 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);
|
||||
@@ -462,7 +463,7 @@ function ProfileSheet({
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="default">{t("edit", "Edit")}</Button>
|
||||
{trigger || <Button variant="default">{t("edit", "Edit")}</Button>}
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[700px] max-w-full md:max-w-screen-lg"
|
||||
@@ -526,6 +527,42 @@ 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,6 +54,10 @@ 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);
|
||||
@@ -312,18 +316,22 @@ 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 = row.original.subscribe?.speed_limit ?? 0;
|
||||
const planSpeed = resolvePlanSpeed(row.original);
|
||||
const speed = effectiveSpeed || userSpeed || planSpeed;
|
||||
const source =
|
||||
userSpeed > 0
|
||||
? t("userOverride", "User Override")
|
||||
: t("subscriptionDefault", "Subscription Default");
|
||||
const source = row.original.is_throttled
|
||||
? t("effectiveSpeed")
|
||||
: userSpeed > 0
|
||||
? t("userOverride")
|
||||
: t("subscriptionDefault");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex min-w-[200px] flex-col gap-1 whitespace-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<Display type="trafficSpeed" value={speed} />
|
||||
{row.original.is_throttled && (
|
||||
@@ -332,9 +340,18 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{source}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -87,11 +87,12 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
||||
|
||||
if (detail.status !== 1) return null;
|
||||
|
||||
const baseSpeed = detail.subscribe?.speed_limit ?? 0;
|
||||
const baseSpeed =
|
||||
detail.plan_speed_limit ?? 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.traffic_limit || [];
|
||||
const rules = detail.user_traffic_limit || detail.traffic_limit || [];
|
||||
const hasLimitConfig =
|
||||
baseSpeed > 0 || userSpeed > 0 || effectiveSpeed > 0 || rules.length > 0;
|
||||
|
||||
@@ -120,13 +121,13 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
||||
<div className="flex flex-wrap items-center gap-2 font-medium">
|
||||
{isThrottled ? (
|
||||
<>
|
||||
<span>{t("throttled", "Speed Throttled")}</span>
|
||||
<span>{t("throttled")}</span>
|
||||
<Badge className="text-xs" variant="destructive">
|
||||
{effectiveSpeed} Mbps
|
||||
</Badge>
|
||||
{baseSpeed > 0 && (
|
||||
<span className="text-muted-foreground text-xs line-through">
|
||||
{t("subscriptionDefault", "Subscription Default")}:{" "}
|
||||
{t("subscriptionDefault")}:{" "}
|
||||
{baseSpeed} Mbps
|
||||
</span>
|
||||
)}
|
||||
@@ -137,24 +138,22 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
||||
{t("speedLimit", "Speed Limit")}
|
||||
</span>
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{effectiveSpeed > 0
|
||||
? `${effectiveSpeed} Mbps`
|
||||
: t("unlimited", "Unlimited")}
|
||||
{effectiveSpeed > 0 ? `${effectiveSpeed} Mbps` : t("unlimited")}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<SpeedMetric
|
||||
label={t("subscriptionDefault", "Subscription Default")}
|
||||
label={t("subscriptionDefault")}
|
||||
value={baseSpeed}
|
||||
/>
|
||||
<SpeedMetric
|
||||
label={t("userOverride", "User Override")}
|
||||
label={t("userOverride")}
|
||||
value={userSpeed}
|
||||
/>
|
||||
<SpeedMetric
|
||||
label={t("effectiveSpeed", "Effective Speed")}
|
||||
label={t("effectiveSpeed")}
|
||||
value={effectiveSpeed}
|
||||
/>
|
||||
</div>
|
||||
@@ -172,7 +171,7 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
||||
{rules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground text-xs">
|
||||
{t("trafficLimitRules", "Traffic Limit Rules")}
|
||||
{t("trafficLimitRules")}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{rules.map((rule, index) => (
|
||||
@@ -187,8 +186,8 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
||||
value: rule.stat_value,
|
||||
type:
|
||||
rule.stat_type === "hour"
|
||||
? t("hour", "Hour")
|
||||
: t("day", "Day"),
|
||||
? t("hour")
|
||||
: t("day"),
|
||||
speed: rule.speed_limit,
|
||||
})}
|
||||
</div>
|
||||
@@ -208,7 +207,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", "Unlimited")}
|
||||
{value > 0 ? `${value} Mbps` : t("unlimited")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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,
|
||||
@@ -20,10 +26,11 @@ 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";
|
||||
@@ -40,6 +47,12 @@ 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,
|
||||
@@ -81,6 +94,199 @@ 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,
|
||||
@@ -98,7 +304,7 @@ export function SubscriptionForm({
|
||||
traffic: initialData?.traffic || 0,
|
||||
speed_limit: initialData?.speed_limit || 0,
|
||||
device_limit: initialData?.device_limit || 0,
|
||||
traffic_limit: initialData?.traffic_limit || [],
|
||||
traffic_limit: getUserTrafficLimitRules(initialData),
|
||||
upload: initialData?.upload || 0,
|
||||
download: initialData?.download || 0,
|
||||
expired_at: initialData?.expire_time || 0,
|
||||
@@ -311,98 +517,11 @@ export function SubscriptionForm({
|
||||
{t("trafficLimitRules", "Traffic Limit Rules")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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)
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
<TrafficLimitRuleEditor
|
||||
onChange={(items) => {
|
||||
field.onChange(items.map(normalizeLimitRule));
|
||||
}}
|
||||
value={
|
||||
field.value && field.value.length > 0
|
||||
? field.value
|
||||
: [defaultLimitRule]
|
||||
}
|
||||
value={field.value || []}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -44,7 +44,7 @@ const WITHDRAWAL_METHOD_MAP: Record<number, string> = {
|
||||
0: "其他",
|
||||
1: "支付宝",
|
||||
2: "微信",
|
||||
3: "银行卡",
|
||||
3: "USDT(TRC20)",
|
||||
};
|
||||
|
||||
const rejectSchema = z.object({
|
||||
@@ -143,7 +143,7 @@ export default function WithdrawalPage() {
|
||||
const methodOptions = [
|
||||
{ value: "1", label: "支付宝" },
|
||||
{ value: "2", label: "微信" },
|
||||
{ value: "3", label: "银行卡" },
|
||||
{ value: "3", label: "USDT(TRC20)" },
|
||||
{ value: "0", label: "其他" },
|
||||
];
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ import { useGlobalStore } from "@/stores/global";
|
||||
const WITHDRAWAL_METHODS = [
|
||||
{ value: 1, label: "支付宝" },
|
||||
{ value: 2, label: "微信" },
|
||||
{ value: 3, label: "银行卡" },
|
||||
{ value: 3, label: "USDT(TRC20)" },
|
||||
{ value: 0, label: "其他" },
|
||||
] as const;
|
||||
|
||||
@@ -111,13 +111,21 @@ 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" src={url} />
|
||||
<img
|
||||
alt="收款码"
|
||||
className="w-full"
|
||||
height={320}
|
||||
src={url}
|
||||
width={320}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
@@ -153,18 +161,16 @@ const withdrawalFormSchema = z
|
||||
if (!data.account) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "银行卡收款账号为必填",
|
||||
path: ["account"],
|
||||
});
|
||||
}
|
||||
} else if (data.method === 0) {
|
||||
if (!data.account && !data.content) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "收款账号和备注至少填一项",
|
||||
message: "USDT(TRC20) 地址为必填",
|
||||
path: ["account"],
|
||||
});
|
||||
}
|
||||
} else if (data.method === 0 && !(data.account || data.content)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "收款账号和备注至少填一项",
|
||||
path: ["account"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -421,9 +427,7 @@ 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">
|
||||
@@ -446,7 +450,9 @@ 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>
|
||||
)}
|
||||
@@ -474,7 +480,7 @@ export default function Affiliate() {
|
||||
<Input
|
||||
placeholder={
|
||||
watchedMethod === 3
|
||||
? "请输入银行卡号或账户信息"
|
||||
? "请输入 USDT(TRC20) 收款地址"
|
||||
: "请输入收款账号(与备注至少填一项)"
|
||||
}
|
||||
{...field}
|
||||
@@ -519,9 +525,7 @@ export default function Affiliate() {
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
submitting ||
|
||||
qrUploading ||
|
||||
availableCommission <= 0
|
||||
submitting || qrUploading || availableCommission <= 0
|
||||
}
|
||||
form="withdrawal-form"
|
||||
type="submit"
|
||||
|
||||
@@ -313,7 +313,8 @@ export function ProTable<
|
||||
<TableHead
|
||||
className={cn(
|
||||
"!z-auto",
|
||||
getTableHeaderClass(header.column.id)
|
||||
getTableHeaderClass(header.column.id),
|
||||
getColumnClassName(header)
|
||||
)}
|
||||
key={header.id}
|
||||
>
|
||||
@@ -362,7 +363,10 @@ export function ProTable<
|
||||
.filter((cell) => cell.column.id !== "sortable")
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
className={getTableCellClass(cell.column.id)}
|
||||
className={cn(
|
||||
getTableCellClass(cell.column.id),
|
||||
getColumnClassName(cell)
|
||||
)}
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(
|
||||
@@ -381,7 +385,10 @@ export function ProTable<
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className={getTableCellClass(cell.column.id)}
|
||||
className={cn(
|
||||
getTableCellClass(cell.column.id),
|
||||
getColumnClassName(cell)
|
||||
)}
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(
|
||||
@@ -476,3 +483,17 @@ function getTableCellClass(columnId: string) {
|
||||
}
|
||||
return "truncate";
|
||||
}
|
||||
|
||||
function getColumnClassName(
|
||||
columnLike: {
|
||||
column: {
|
||||
columnDef: {
|
||||
meta?: {
|
||||
className?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
) {
|
||||
return columnLike.column.columnDef.meta?.className || "";
|
||||
}
|
||||
|
||||
+8
@@ -1938,6 +1938,7 @@ 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;
|
||||
@@ -2598,6 +2599,9 @@ 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;
|
||||
@@ -2664,11 +2668,13 @@ 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;
|
||||
@@ -2699,8 +2705,10 @@ 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,4 +1,3 @@
|
||||
// @ts-expect-error
|
||||
/* eslint-disable */
|
||||
import request from "@workspace/ui/lib/request";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user