feat: 用户订阅流量限速规则编辑器与列表信息增强
- 订阅表单新增卡片式流量限速规则编辑器(TrafficLimitRuleEditor),替换 ArrayInput - 用户列表与订阅详情展示会员状态(member_status)、最近登录时间(last_login_time) - ProTable 支持列级 meta.className 自定义样式 - 仪表盘统计与促销规则表单配套调整,用户文案双语同步
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"createSubscription": "Create Subscription",
|
"createSubscription": "Create Subscription",
|
||||||
"createSuccess": "Created successfully",
|
"createSuccess": "Created successfully",
|
||||||
"createUser": "Create User",
|
"createUser": "Create User",
|
||||||
|
"current": "Current",
|
||||||
"currentCommission": "Current Commission",
|
"currentCommission": "Current Commission",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
@@ -117,6 +118,8 @@
|
|||||||
"permanent": "Permanent",
|
"permanent": "Permanent",
|
||||||
"prev": "Prev",
|
"prev": "Prev",
|
||||||
"pleaseEnterEmail": "Enter email",
|
"pleaseEnterEmail": "Enter email",
|
||||||
|
"hour": "Hour",
|
||||||
|
"day": "Day",
|
||||||
"referer": "Referer",
|
"referer": "Referer",
|
||||||
"refererId": "Referer ID",
|
"refererId": "Referer ID",
|
||||||
"refererIdPlaceholder": "Enter referer ID",
|
"refererIdPlaceholder": "Enter referer ID",
|
||||||
@@ -149,7 +152,12 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"shortCode": "Short Code",
|
"shortCode": "Short Code",
|
||||||
"speedLimit": "Speed Limit",
|
"speedLimit": "Speed Limit",
|
||||||
|
"speedLimitMbps": "Target Speed Limit (Mbps)",
|
||||||
"startTime": "startTime",
|
"startTime": "startTime",
|
||||||
|
"statType": "Statistics Type",
|
||||||
|
"statTypeDay": "Day",
|
||||||
|
"statTypeHour": "Hour",
|
||||||
|
"statValue": "Time Value",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"statusActive": "Active",
|
"statusActive": "Active",
|
||||||
"statusDeducted": "Deducted",
|
"statusDeducted": "Deducted",
|
||||||
@@ -171,6 +179,7 @@
|
|||||||
"subscriptionInfo": "subscriptionInfo",
|
"subscriptionInfo": "subscriptionInfo",
|
||||||
"subscriptionList": "Subscription List",
|
"subscriptionList": "Subscription List",
|
||||||
"subscriptionLogs": "Subscription Logs",
|
"subscriptionLogs": "Subscription Logs",
|
||||||
|
"subscriptionDefault": "Subscription Default",
|
||||||
"subscriptionName": "subscriptionName",
|
"subscriptionName": "subscriptionName",
|
||||||
"subscriptionNotifications": "Subscription Notifications",
|
"subscriptionNotifications": "Subscription Notifications",
|
||||||
"telephone": "Phone",
|
"telephone": "Phone",
|
||||||
@@ -181,17 +190,25 @@
|
|||||||
"tradeNotifications": "Trade Notifications",
|
"tradeNotifications": "Trade Notifications",
|
||||||
"trafficDetails": "Traffic Details",
|
"trafficDetails": "Traffic Details",
|
||||||
"trafficLimit": "Traffic Limit",
|
"trafficLimit": "Traffic Limit",
|
||||||
|
"trafficLimitRule": "Rule",
|
||||||
|
"trafficLimitRules": "Traffic Limit Rules",
|
||||||
|
"trafficLimitRuleSummary": "{{traffic}} GB in {{value}} {{type}} -> {{speed}} Mbps",
|
||||||
"trafficStats": "Traffic Stats",
|
"trafficStats": "Traffic Stats",
|
||||||
"trafficUsage": "Traffic Usage",
|
"trafficUsage": "Traffic Threshold (GB)",
|
||||||
|
"throttled": "Throttled",
|
||||||
"remainingTraffic": "Remaining Traffic",
|
"remainingTraffic": "Remaining Traffic",
|
||||||
"unlimited": "unlimited",
|
"unlimited": "unlimited",
|
||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateSuccess": "Updated successfully",
|
"updateSuccess": "Updated successfully",
|
||||||
|
"userOverride": "User Override",
|
||||||
|
"effectiveSpeed": "Effective Speed",
|
||||||
"groupUpdated": "Group updated successfully",
|
"groupUpdated": "Group updated successfully",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadTraffic": "Upload Traffic",
|
"uploadTraffic": "Upload Traffic",
|
||||||
"userStatus": "User Status",
|
"userStatus": "User Status",
|
||||||
|
"subscribed": "Subscribed",
|
||||||
|
"unsubscribed": "No active subscription",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "User Agent",
|
||||||
"userEmail": "Email",
|
"userEmail": "Email",
|
||||||
"userEmailPlaceholder": "Enter email",
|
"userEmailPlaceholder": "Enter email",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"createSubscription": "创建订阅",
|
"createSubscription": "创建订阅",
|
||||||
"createSuccess": "创建成功",
|
"createSuccess": "创建成功",
|
||||||
"createUser": "创建用户",
|
"createUser": "创建用户",
|
||||||
|
"current": "当前",
|
||||||
"currentCommission": "当前佣金",
|
"currentCommission": "当前佣金",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleted": "已删除",
|
"deleted": "已删除",
|
||||||
@@ -118,6 +119,8 @@
|
|||||||
"permanent": "永久",
|
"permanent": "永久",
|
||||||
"prev": "上一页",
|
"prev": "上一页",
|
||||||
"pleaseEnterEmail": "输入邮箱",
|
"pleaseEnterEmail": "输入邮箱",
|
||||||
|
"hour": "小时",
|
||||||
|
"day": "天",
|
||||||
"referer": "推荐人",
|
"referer": "推荐人",
|
||||||
"refererId": "推荐人 ID",
|
"refererId": "推荐人 ID",
|
||||||
"refererIdPlaceholder": "输入推荐人 ID",
|
"refererIdPlaceholder": "输入推荐人 ID",
|
||||||
@@ -150,7 +153,12 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"shortCode": "短码",
|
"shortCode": "短码",
|
||||||
"speedLimit": "速度限制",
|
"speedLimit": "速度限制",
|
||||||
|
"speedLimitMbps": "目标限速 (Mbps)",
|
||||||
"startTime": "开始时间",
|
"startTime": "开始时间",
|
||||||
|
"statType": "统计周期",
|
||||||
|
"statTypeDay": "天",
|
||||||
|
"statTypeHour": "小时",
|
||||||
|
"statValue": "周期值",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"statusActive": "活跃",
|
"statusActive": "活跃",
|
||||||
"statusDeducted": "已扣除",
|
"statusDeducted": "已扣除",
|
||||||
@@ -172,6 +180,7 @@
|
|||||||
"subscriptionInfo": "订阅信息",
|
"subscriptionInfo": "订阅信息",
|
||||||
"subscriptionList": "订阅列表",
|
"subscriptionList": "订阅列表",
|
||||||
"subscriptionLogs": "订阅日志",
|
"subscriptionLogs": "订阅日志",
|
||||||
|
"subscriptionDefault": "套餐默认",
|
||||||
"subscriptionName": "订阅名称",
|
"subscriptionName": "订阅名称",
|
||||||
"subscriptionNotifications": "订阅通知",
|
"subscriptionNotifications": "订阅通知",
|
||||||
"telephone": "电话",
|
"telephone": "电话",
|
||||||
@@ -182,17 +191,25 @@
|
|||||||
"tradeNotifications": "交易通知",
|
"tradeNotifications": "交易通知",
|
||||||
"trafficDetails": "流量详情",
|
"trafficDetails": "流量详情",
|
||||||
"trafficLimit": "流量限制",
|
"trafficLimit": "流量限制",
|
||||||
|
"trafficLimitRule": "规则",
|
||||||
|
"trafficLimitRules": "流量限速规则",
|
||||||
|
"trafficLimitRuleSummary": "{{value}}{{type}}内达到 {{traffic}} GB 后限速到 {{speed}} Mbps",
|
||||||
"trafficStats": "流量统计",
|
"trafficStats": "流量统计",
|
||||||
"trafficUsage": "流量使用",
|
"trafficUsage": "触发流量 (GB)",
|
||||||
|
"throttled": "已触发限速",
|
||||||
"remainingTraffic": "剩余流量",
|
"remainingTraffic": "剩余流量",
|
||||||
"unlimited": "无限制",
|
"unlimited": "无限制",
|
||||||
"unverified": "未验证",
|
"unverified": "未验证",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
|
"userOverride": "用户覆盖",
|
||||||
|
"effectiveSpeed": "当前生效",
|
||||||
"groupUpdated": "分组更新成功",
|
"groupUpdated": "分组更新成功",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uploadTraffic": "上传流量",
|
"uploadTraffic": "上传流量",
|
||||||
"userStatus": "用户状态",
|
"userStatus": "用户状态",
|
||||||
|
"subscribed": "有有效订阅",
|
||||||
|
"unsubscribed": "无有效订阅",
|
||||||
"userAgent": "用户代理",
|
"userAgent": "用户代理",
|
||||||
"userEmail": "邮箱",
|
"userEmail": "邮箱",
|
||||||
"userEmailPlaceholder": "输入邮箱",
|
"userEmailPlaceholder": "输入邮箱",
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import { Separator } from "@workspace/ui/components/separator";
|
|||||||
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||||
import Empty from "@workspace/ui/composed/empty";
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
import {
|
import { queryServerTotalData } from "@workspace/ui/services/admin/console";
|
||||||
queryServerTotalData,
|
import { getWithdrawalList } from "@workspace/ui/services/admin/withdrawal";
|
||||||
queryTicketWaitReply,
|
|
||||||
} from "@workspace/ui/services/admin/console";
|
|
||||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -41,11 +39,15 @@ import { UserStatisticsCard } from "./user-statistics-card";
|
|||||||
export default function Statistics() {
|
export default function Statistics() {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
const { data: TicketTotal } = useQuery({
|
const { data: PendingWithdrawalTotal } = useQuery({
|
||||||
queryKey: ["queryTicketWaitReply"],
|
queryKey: ["queryPendingWithdrawalTotal"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await queryTicketWaitReply();
|
const { data } = await getWithdrawalList({
|
||||||
return data.data?.count;
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
return data.data?.total || 0;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { data: ServerTotal } = useQuery({
|
const { data: ServerTotal } = useQuery({
|
||||||
@@ -242,7 +244,7 @@ export default function Statistics() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("withdrawalManagement", "提现管理"),
|
title: t("withdrawalManagement", "提现管理"),
|
||||||
value: TicketTotal || 0,
|
value: PendingWithdrawalTotal || 0,
|
||||||
subtitle: t("pending", "Pending"),
|
subtitle: t("pending", "Pending"),
|
||||||
icon: "uil:clipboard-notes",
|
icon: "uil:clipboard-notes",
|
||||||
href: "/dashboard/withdrawal",
|
href: "/dashboard/withdrawal",
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ function toFormValues(rule?: API.PromoRule): RuleFormValues {
|
|||||||
window_hours: rule?.params?.window_hours,
|
window_hours: rule?.params?.window_hours,
|
||||||
priority: rule?.priority ?? 0,
|
priority: rule?.priority ?? 0,
|
||||||
enabled: rule?.enabled ?? false,
|
enabled: rule?.enabled ?? false,
|
||||||
start_time: rule?.start_time,
|
start_time: rule?.start_time ? rule.start_time * 1000 : undefined,
|
||||||
end_time: rule?.end_time,
|
end_time: rule?.end_time ? rule.end_time * 1000 : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +112,8 @@ function toRequest(values: RuleFormValues): API.CreatePromoRuleRequest {
|
|||||||
params,
|
params,
|
||||||
priority: values.priority,
|
priority: values.priority,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
start_time: values.start_time || undefined,
|
start_time: values.start_time ? Math.floor(values.start_time / 1000) : undefined,
|
||||||
end_time: values.end_time || 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 FamilyManagement from "./family";
|
||||||
import { RemarkForm } from "./remark-form";
|
import { RemarkForm } from "./remark-form";
|
||||||
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
||||||
import { UserDetail } from "./user-detail";
|
|
||||||
import UserForm from "./user-form";
|
import UserForm from "./user-form";
|
||||||
import { UserIdentifier } from "./user-identifier";
|
import { UserIdentifier } from "./user-identifier";
|
||||||
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
||||||
@@ -336,23 +335,31 @@ export default function User() {
|
|||||||
id: "referer_id",
|
id: "referer_id",
|
||||||
accessorKey: "referer_id",
|
accessorKey: "referer_id",
|
||||||
header: t("referer", "Referer"),
|
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",
|
id: "member_status",
|
||||||
accessorKey: "last_login_time",
|
accessorKey: "member_status",
|
||||||
header: t("userStatus", "用户状态"),
|
header: t("userStatus", "用户状态"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const lastLoginTime = (
|
// member_status 由后端基于当前有效订阅计算:有有效订阅时为套餐名,否则为空
|
||||||
|
const memberStatus = (
|
||||||
row.original as API.User & {
|
row.original as API.User & {
|
||||||
last_login_time?: number;
|
member_status?: string;
|
||||||
}
|
}
|
||||||
).last_login_time;
|
).member_status;
|
||||||
const isOnline = Boolean(lastLoginTime);
|
const hasActiveSubscription = Boolean(memberStatus);
|
||||||
const dotClassName = isOnline ? "bg-green-500" : "bg-gray-400";
|
const dotClassName = hasActiveSubscription
|
||||||
const statusLabel = isOnline
|
? "bg-green-500"
|
||||||
? t("active", "Active")
|
: "bg-gray-400";
|
||||||
: t("inactive", "Inactive");
|
const tooltipLabel = hasActiveSubscription
|
||||||
|
? memberStatus
|
||||||
|
: t("unsubscribed", "No active subscription");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -363,18 +370,10 @@ export default function User() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`h-2.5 w-2.5 rounded-full ${dotClassName}`}
|
className={`h-2.5 w-2.5 rounded-full ${dotClassName}`}
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">
|
<span className="sr-only">{tooltipLabel}</span>
|
||||||
{isOnline
|
|
||||||
? `${statusLabel}: ${formatDate(lastLoginTime)}`
|
|
||||||
: statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||||
{isOnline
|
|
||||||
? formatDate(lastLoginTime)
|
|
||||||
: t("neverLoggedIn", "Never logged in")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
@@ -439,9 +438,11 @@ export default function User() {
|
|||||||
function ProfileSheet({
|
function ProfileSheet({
|
||||||
userId,
|
userId,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
|
trigger,
|
||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
onUpdated?: () => void;
|
onUpdated?: () => void;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("user");
|
const { t } = useTranslation("user");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -462,7 +463,7 @@ function ProfileSheet({
|
|||||||
return (
|
return (
|
||||||
<Sheet onOpenChange={setOpen} open={open}>
|
<Sheet onOpenChange={setOpen} open={open}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="default">{t("edit", "Edit")}</Button>
|
{trigger || <Button variant="default">{t("edit", "Edit")}</Button>}
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
className="w-[700px] max-w-full md:max-w-screen-lg"
|
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 }) {
|
function InviteStatsMenuItem({ userId }: { userId: number }) {
|
||||||
const { t } = useTranslation("user");
|
const { t } = useTranslation("user");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|||||||
@@ -316,19 +316,22 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
{
|
{
|
||||||
accessorKey: "speed_limit",
|
accessorKey: "speed_limit",
|
||||||
header: t("speedLimit", "Speed Limit"),
|
header: t("speedLimit", "Speed Limit"),
|
||||||
|
meta: {
|
||||||
|
className: "min-w-[220px] whitespace-normal",
|
||||||
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const userSpeed = row.original.speed_limit ?? 0;
|
const userSpeed = row.original.speed_limit ?? 0;
|
||||||
const effectiveSpeed = row.original.effective_speed ?? 0;
|
const effectiveSpeed = row.original.effective_speed ?? 0;
|
||||||
const planSpeed = resolvePlanSpeed(row.original);
|
const planSpeed = resolvePlanSpeed(row.original);
|
||||||
const speed = effectiveSpeed || userSpeed || planSpeed;
|
const speed = effectiveSpeed || userSpeed || planSpeed;
|
||||||
const source = row.original.is_throttled
|
const source = row.original.is_throttled
|
||||||
? t("effectiveSpeed", "Effective Speed")
|
? t("effectiveSpeed")
|
||||||
: userSpeed > 0
|
: userSpeed > 0
|
||||||
? t("userOverride", "User Override")
|
? t("userOverride")
|
||||||
: t("subscriptionDefault", "Subscription Default");
|
: t("subscriptionDefault");
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Display type="trafficSpeed" value={speed} />
|
<Display type="trafficSpeed" value={speed} />
|
||||||
{row.original.is_throttled && (
|
{row.original.is_throttled && (
|
||||||
@@ -337,19 +340,18 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">
|
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-muted-foreground text-xs leading-5">
|
||||||
{source}
|
<span>{t("current")}</span>
|
||||||
</span>
|
<span>{source}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span>{t("userOverride")}</span>
|
||||||
{t("userOverride", "User Override")}:{" "}
|
<span>
|
||||||
<span className="inline-block">
|
|
||||||
<Display type="trafficSpeed" value={userSpeed} />
|
<Display type="trafficSpeed" value={userSpeed} />
|
||||||
</span>{" "}
|
</span>
|
||||||
/ {t("subscriptionDefault", "Subscription Default")}:{" "}
|
<span>{t("subscriptionDefault")}</span>
|
||||||
<span className="inline-block">
|
<span>
|
||||||
<Display type="trafficSpeed" value={planSpeed} />
|
<Display type="trafficSpeed" value={planSpeed} />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,13 +121,13 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
|||||||
<div className="flex flex-wrap items-center gap-2 font-medium">
|
<div className="flex flex-wrap items-center gap-2 font-medium">
|
||||||
{isThrottled ? (
|
{isThrottled ? (
|
||||||
<>
|
<>
|
||||||
<span>{t("throttled", "Speed Throttled")}</span>
|
<span>{t("throttled")}</span>
|
||||||
<Badge className="text-xs" variant="destructive">
|
<Badge className="text-xs" variant="destructive">
|
||||||
{effectiveSpeed} Mbps
|
{effectiveSpeed} Mbps
|
||||||
</Badge>
|
</Badge>
|
||||||
{baseSpeed > 0 && (
|
{baseSpeed > 0 && (
|
||||||
<span className="text-muted-foreground text-xs line-through">
|
<span className="text-muted-foreground text-xs line-through">
|
||||||
{t("subscriptionDefault", "Subscription Default")}:{" "}
|
{t("subscriptionDefault")}:{" "}
|
||||||
{baseSpeed} Mbps
|
{baseSpeed} Mbps
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -138,24 +138,22 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
|||||||
{t("speedLimit", "Speed Limit")}
|
{t("speedLimit", "Speed Limit")}
|
||||||
</span>
|
</span>
|
||||||
<Badge className="text-xs" variant="secondary">
|
<Badge className="text-xs" variant="secondary">
|
||||||
{effectiveSpeed > 0
|
{effectiveSpeed > 0 ? `${effectiveSpeed} Mbps` : t("unlimited")}
|
||||||
? `${effectiveSpeed} Mbps`
|
|
||||||
: t("unlimited", "Unlimited")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
<SpeedMetric
|
<SpeedMetric
|
||||||
label={t("subscriptionDefault", "Subscription Default")}
|
label={t("subscriptionDefault")}
|
||||||
value={baseSpeed}
|
value={baseSpeed}
|
||||||
/>
|
/>
|
||||||
<SpeedMetric
|
<SpeedMetric
|
||||||
label={t("userOverride", "User Override")}
|
label={t("userOverride")}
|
||||||
value={userSpeed}
|
value={userSpeed}
|
||||||
/>
|
/>
|
||||||
<SpeedMetric
|
<SpeedMetric
|
||||||
label={t("effectiveSpeed", "Effective Speed")}
|
label={t("effectiveSpeed")}
|
||||||
value={effectiveSpeed}
|
value={effectiveSpeed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +171,7 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
|||||||
{rules.length > 0 && (
|
{rules.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="font-medium text-foreground text-xs">
|
<p className="font-medium text-foreground text-xs">
|
||||||
{t("trafficLimitRules", "Traffic Limit Rules")}
|
{t("trafficLimitRules")}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{rules.map((rule, index) => (
|
{rules.map((rule, index) => (
|
||||||
@@ -188,8 +186,8 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
|
|||||||
value: rule.stat_value,
|
value: rule.stat_value,
|
||||||
type:
|
type:
|
||||||
rule.stat_type === "hour"
|
rule.stat_type === "hour"
|
||||||
? t("hour", "Hour")
|
? t("hour")
|
||||||
: t("day", "Day"),
|
: t("day"),
|
||||||
speed: rule.speed_limit,
|
speed: rule.speed_limit,
|
||||||
})}
|
})}
|
||||||
</div>
|
</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="rounded-md border bg-background/60 p-2">
|
||||||
<div className="text-muted-foreground text-xs">{label}</div>
|
<div className="text-muted-foreground text-xs">{label}</div>
|
||||||
<div className="font-medium text-foreground">
|
<div className="font-medium text-foreground">
|
||||||
{value > 0 ? `${value} Mbps` : t("unlimited", "Unlimited")}
|
{value > 0 ? `${value} Mbps` : t("unlimited")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -20,10 +26,11 @@ import {
|
|||||||
} from "@workspace/ui/components/sheet";
|
} from "@workspace/ui/components/sheet";
|
||||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
import { DatePicker } from "@workspace/ui/composed/date-picker";
|
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 { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
|
import { cn } from "@workspace/ui/lib/utils";
|
||||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||||
|
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react";
|
||||||
import { type KeyboardEvent, type ReactNode, useState } from "react";
|
import { type KeyboardEvent, type ReactNode, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -87,6 +94,199 @@ const normalizeLimitRule = (
|
|||||||
speed_limit: Math.max(0, Number(item.speed_limit) || 0),
|
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({
|
export function SubscriptionForm({
|
||||||
trigger,
|
trigger,
|
||||||
title,
|
title,
|
||||||
@@ -317,98 +517,11 @@ export function SubscriptionForm({
|
|||||||
{t("trafficLimitRules", "Traffic Limit Rules")}
|
{t("trafficLimitRules", "Traffic Limit Rules")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ArrayInput<SubscriptionTrafficLimitRule>
|
<TrafficLimitRuleEditor
|
||||||
className="grid grid-cols-2 gap-3 lg:grid-cols-4"
|
|
||||||
fields={[
|
|
||||||
{
|
|
||||||
name: "stat_type",
|
|
||||||
type: "select",
|
|
||||||
placeholder: t("statType", "Statistics Type"),
|
|
||||||
value: "day",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: t("statTypeHour", "Hour"),
|
|
||||||
value: "hour",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("statTypeDay", "Day"),
|
|
||||||
value: "day",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stat_value",
|
|
||||||
type: "number",
|
|
||||||
placeholder: t("statValue", "Time Value"),
|
|
||||||
min: 1,
|
|
||||||
step: 1,
|
|
||||||
onKeyDown: (
|
|
||||||
e: KeyboardEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (e.key === "." || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
formatOutput: (value: string | number) => {
|
|
||||||
const num = Number(value);
|
|
||||||
return String(
|
|
||||||
Number.isNaN(num) ? 1 : Math.floor(num)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "traffic_usage",
|
|
||||||
type: "number",
|
|
||||||
placeholder: t(
|
|
||||||
"trafficUsage",
|
|
||||||
"Traffic Usage (GB)"
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
onKeyDown: (
|
|
||||||
e: KeyboardEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (e.key === "." || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
formatOutput: (value: string | number) => {
|
|
||||||
const num = Number(value);
|
|
||||||
return String(
|
|
||||||
Number.isNaN(num) ? 0 : Math.floor(num)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "speed_limit",
|
|
||||||
type: "number",
|
|
||||||
placeholder: t(
|
|
||||||
"speedLimitMbps",
|
|
||||||
"Speed Limit (Mbps)"
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
onKeyDown: (
|
|
||||||
e: KeyboardEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
if (e.key === "." || e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
formatOutput: (value: string | number) => {
|
|
||||||
const num = Number(value);
|
|
||||||
return String(
|
|
||||||
Number.isNaN(num) ? 0 : Math.floor(num)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={(items) => {
|
onChange={(items) => {
|
||||||
field.onChange(items.map(normalizeLimitRule));
|
field.onChange(items.map(normalizeLimitRule));
|
||||||
}}
|
}}
|
||||||
value={
|
value={field.value || []}
|
||||||
field.value && field.value.length > 0
|
|
||||||
? field.value
|
|
||||||
: [defaultLimitRule]
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ export function ProTable<
|
|||||||
<TableHead
|
<TableHead
|
||||||
className={cn(
|
className={cn(
|
||||||
"!z-auto",
|
"!z-auto",
|
||||||
getTableHeaderClass(header.column.id)
|
getTableHeaderClass(header.column.id),
|
||||||
|
getColumnClassName(header)
|
||||||
)}
|
)}
|
||||||
key={header.id}
|
key={header.id}
|
||||||
>
|
>
|
||||||
@@ -362,7 +363,10 @@ export function ProTable<
|
|||||||
.filter((cell) => cell.column.id !== "sortable")
|
.filter((cell) => cell.column.id !== "sortable")
|
||||||
.map((cell) => (
|
.map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
className={getTableCellClass(cell.column.id)}
|
className={cn(
|
||||||
|
getTableCellClass(cell.column.id),
|
||||||
|
getColumnClassName(cell)
|
||||||
|
)}
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -381,7 +385,10 @@ export function ProTable<
|
|||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
className={getTableCellClass(cell.column.id)}
|
className={cn(
|
||||||
|
getTableCellClass(cell.column.id),
|
||||||
|
getColumnClassName(cell)
|
||||||
|
)}
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
@@ -476,3 +483,17 @@ function getTableCellClass(columnId: string) {
|
|||||||
}
|
}
|
||||||
return "truncate";
|
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[];
|
user_devices: UserDevice[];
|
||||||
remark: string;
|
remark: string;
|
||||||
rules: string[];
|
rules: string[];
|
||||||
|
/** 当前有效订阅的套餐名(由后端计算),无有效订阅时为空 */
|
||||||
|
member_status?: string;
|
||||||
|
last_login_time?: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
deleted_at?: number;
|
deleted_at?: number;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-expect-error
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user