feat: 用户订阅流量限速规则编辑器与列表信息增强
- 订阅表单新增卡片式流量限速规则编辑器(TrafficLimitRuleEditor),替换 ArrayInput - 用户列表与订阅详情展示会员状态(member_status)、最近登录时间(last_login_time) - ProTable 支持列级 meta.className 自定义样式 - 仪表盘统计与促销规则表单配套调整,用户文案双语同步
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -316,19 +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 = resolvePlanSpeed(row.original);
|
||||
const speed = effectiveSpeed || userSpeed || planSpeed;
|
||||
const source = row.original.is_throttled
|
||||
? t("effectiveSpeed", "Effective Speed")
|
||||
? t("effectiveSpeed")
|
||||
: userSpeed > 0
|
||||
? t("userOverride", "User Override")
|
||||
: t("subscriptionDefault", "Subscription Default");
|
||||
? 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 && (
|
||||
@@ -337,19 +340,18 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{source}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("userOverride", "User Override")}:{" "}
|
||||
<span className="inline-block">
|
||||
<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>{" "}
|
||||
/ {t("subscriptionDefault", "Subscription Default")}:{" "}
|
||||
<span className="inline-block">
|
||||
</span>
|
||||
<span>{t("subscriptionDefault")}</span>
|
||||
<span>
|
||||
<Display type="trafficSpeed" value={planSpeed} />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -121,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>
|
||||
)}
|
||||
@@ -138,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>
|
||||
@@ -173,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) => (
|
||||
@@ -188,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>
|
||||
@@ -209,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";
|
||||
@@ -87,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,
|
||||
@@ -317,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>
|
||||
|
||||
Reference in New Issue
Block a user