feat: 用户订阅流量限速规则编辑器与列表信息增强
- 订阅表单新增卡片式流量限速规则编辑器(TrafficLimitRuleEditor),替换 ArrayInput - 用户列表与订阅详情展示会员状态(member_status)、最近登录时间(last_login_time) - ProTable 支持列级 meta.className 自定义样式 - 仪表盘统计与促销规则表单配套调整,用户文案双语同步
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || "";
|
||||
}
|
||||
|
||||
+3
@@ -2598,6 +2598,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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-expect-error
|
||||
/* eslint-disable */
|
||||
import request from "@workspace/ui/lib/request";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user