From 2a3d5a3234f369b4eef0d8d903944ec0e4280c4f Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Fri, 29 May 2026 00:31:56 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#117):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=A2=E9=98=85=E9=99=90=E9=80=9F=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sections/user/user-subscription/index.tsx | 53 ++++- .../user-subscription/subscription-detail.tsx | 138 +++++++++-- .../user-subscription/subscription-form.tsx | 221 +++++++++++++++++- packages/ui/src/services/admin/typings.d.ts | 23 ++ 4 files changed, 407 insertions(+), 28 deletions(-) diff --git a/apps/admin/src/sections/user/user-subscription/index.tsx b/apps/admin/src/sections/user/user-subscription/index.tsx index 36793e0..92d3612 100644 --- a/apps/admin/src/sections/user/user-subscription/index.tsx +++ b/apps/admin/src/sections/user/user-subscription/index.tsx @@ -30,13 +30,30 @@ import { Display } from "@/components/display"; import { useGlobalStore } from "@/stores/global"; import { formatDate } from "@/utils/common"; import { SubscriptionDetail } from "./subscription-detail"; -import { SubscriptionForm } from "./subscription-form"; +import { + SubscriptionForm, + type SubscriptionFormValues, +} from "./subscription-form"; interface SharedInfo { ownerUserId: number; familyId: number; } +function toUserSubscribePayload(values: SubscriptionFormValues) { + return { + ...values, + expired_at: values.expired_at || 0, + subscribe_id: values.subscribe_id || 0, + traffic: values.traffic || 0, + upload: values.upload || 0, + download: values.download || 0, + speed_limit: values.speed_limit || 0, + device_limit: values.device_limit || 0, + traffic_limit: values.traffic_limit || [], + }; +} + export default function UserSubscription({ userId }: { userId: number }) { const { t } = useTranslation("user"); const [loading, setLoading] = useState(false); @@ -166,7 +183,7 @@ export default function UserSubscription({ userId }: { userId: number }) { await updateUserSubscribe({ user_id: Number(userId), user_subscribe_id: row.id, - ...values, + ...toUserSubscribePayload(values), }); toast.success(t("updateSuccess", "Updated successfully")); ref.current?.refresh(); @@ -296,15 +313,39 @@ export default function UserSubscription({ userId }: { userId: number }) { accessorKey: "speed_limit", header: t("speedLimit", "Speed Limit"), cell: ({ row }) => { - const speed = row.original?.subscribe?.speed_limit; - return ; + const userSpeed = row.original.speed_limit ?? 0; + const effectiveSpeed = row.original.effective_speed ?? 0; + const planSpeed = row.original.subscribe?.speed_limit ?? 0; + const speed = effectiveSpeed || userSpeed || planSpeed; + const source = + userSpeed > 0 + ? t("userOverride", "User Override") + : t("subscriptionDefault", "Subscription Default"); + + return ( +
+
+ + {row.original.is_throttled && ( + + {t("throttled", "Throttled")} + + )} +
+ + {source} + +
+ ); }, }, { accessorKey: "device_limit", header: t("deviceLimit", "Device Limit"), cell: ({ row }) => { - const limit = row.original?.subscribe?.device_limit; + const limit = + row.original.device_limit || + row.original?.subscribe?.device_limit; return ; }, }, @@ -352,7 +393,7 @@ export default function UserSubscription({ userId }: { userId: number }) { setLoading(true); await createUserSubscribe({ user_id: Number(userId), - ...values, + ...toUserSubscribePayload(values), }); toast.success(t("createSuccess", "Created successfully")); ref.current?.refresh(); diff --git a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx index 51a2085..24b6085 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-detail.tsx @@ -7,6 +7,7 @@ import { SheetTitle, SheetTrigger, } from "@workspace/ui/components/sheet"; +import { Skeleton } from "@workspace/ui/components/skeleton"; import { Switch } from "@workspace/ui/components/switch"; import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ProTable } from "@workspace/ui/composed/pro-table/pro-table"; @@ -16,7 +17,7 @@ import { kickOfflineByUserDevice, } from "@workspace/ui/services/admin/user"; import { deviceIdToHash } from "@workspace/ui/utils/device"; -import { AlertTriangle, Gauge } from "lucide-react"; +import { AlertCircle, AlertTriangle, Gauge } from "lucide-react"; import { type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -26,20 +27,81 @@ import { formatDate } from "@/utils/common"; function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { const { t } = useTranslation("user"); const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); useEffect(() => { - getUserSubscribeById({ id: subscriptionId }).then(({ data }) => { - if (data.data) setDetail(data.data); - }); + let ignore = false; + + setIsLoading(true); + setIsError(false); + getUserSubscribeById({ id: subscriptionId }) + .then(({ data }) => { + if (!ignore) setDetail(data.data || null); + }) + .catch(() => { + if (!ignore) { + setDetail(null); + setIsError(true); + } + }) + .finally(() => { + if (!ignore) setIsLoading(false); + }); + + return () => { + ignore = true; + }; }, [subscriptionId]); - if (!detail || detail.status !== 1) return null; + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (isError) { + return ( +
+ + + {t("subscriptionDetailLoadFailed", "Failed to load speed settings.")} + +
+ ); + } + + if (!detail) { + return ( +
+ {t("subscriptionDetailEmpty", "No subscription details found.")} +
+ ); + } + + if (detail.status !== 1) return null; const baseSpeed = detail.subscribe?.speed_limit ?? 0; + const userSpeed = detail.speed_limit ?? 0; const effectiveSpeed = detail.effective_speed ?? 0; const isThrottled = detail.is_throttled; + const rules = detail.traffic_limit || []; + const hasLimitConfig = + baseSpeed > 0 || userSpeed > 0 || effectiveSpeed > 0 || rules.length > 0; - if (baseSpeed === 0 && !isThrottled) return null; + if (!(hasLimitConfig || isThrottled)) { + return ( +
+ {t("speedLimitEmpty", "No user-level speed limit rules configured.")} +
+ ); + } return (
)} -
+
{isThrottled ? ( <> @@ -64,6 +126,7 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { {baseSpeed > 0 && ( + {t("subscriptionDefault", "Subscription Default")}:{" "} {baseSpeed} Mbps )} @@ -81,6 +144,20 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { )}
+
+ + + +
{isThrottled && detail.throttle_rule && (

{detail.throttle_rule} @@ -92,6 +169,46 @@ function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { {formatDate(detail.throttle_end)}

)} + {rules.length > 0 && ( +
+

+ {t("trafficLimitRules", "Traffic Limit Rules")} +

+
+ {rules.map((rule, index) => ( +
+ {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", "Hour") + : t("day", "Day"), + speed: rule.speed_limit, + })} +
+ ))} +
+
+ )} +
+
+ ); +} + +function SpeedMetric({ label, value }: { label: string; value: number }) { + const { t } = useTranslation("user"); + + return ( +
+
{label}
+
+ {value > 0 ? `${value} Mbps` : t("unlimited", "Unlimited")}
); @@ -155,12 +272,7 @@ export function SubscriptionDetail({ accessorKey: "enabled", header: t("enable", "Enable"), cell: ({ row }) => ( - { - console.log("Switch:", checked); - }} - /> + ), }, { accessorKey: "id", header: "ID" }, diff --git a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx index 37d1f93..f7e49f2 100644 --- a/apps/admin/src/sections/user/user-subscription/subscription-form.tsx +++ b/apps/admin/src/sections/user/user-subscription/subscription-form.tsx @@ -3,6 +3,7 @@ import { Button } from "@workspace/ui/components/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -19,10 +20,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 { unitConversion } from "@workspace/ui/utils/unit-conversions"; -import { type ReactNode, useState } from "react"; +import { type KeyboardEvent, type ReactNode, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -33,18 +35,50 @@ interface Props { title: string; loading?: boolean; initialData?: API.UserSubscribe; - onSubmit: (values: any) => Promise; + onSubmit: (values: SubscriptionFormValues) => Promise; } +type SubscriptionTrafficLimitRule = API.UserSubscribeTrafficLimitRule; + +const defaultLimitRule: SubscriptionTrafficLimitRule = { + stat_type: "day", + stat_value: 1, + traffic_usage: 0, + speed_limit: 0, +}; + const formSchema = z.object({ subscribe_id: z.number().optional(), - traffic: z.number().optional(), - speed_limit: z.number().optional(), - device_limit: z.number().optional(), + traffic: z.number().min(0).optional(), + speed_limit: z.number().min(0).optional(), + device_limit: z.number().min(0).optional(), expired_at: z.number().nullish().optional(), - upload: z.number().optional(), - download: z.number().optional(), + upload: z.number().min(0).optional(), + download: z.number().min(0).optional(), id: z.number().optional(), + traffic_limit: z + .array( + z.object({ + stat_type: z.string(), + stat_value: z.number().int().min(1), + traffic_usage: z.number().min(0), + speed_limit: z.number().min(0), + }) + ) + .optional(), +}); + +type SubscriptionFormValues = z.infer; + +export type { SubscriptionFormValues }; + +const normalizeLimitRule = ( + item: SubscriptionTrafficLimitRule +): SubscriptionTrafficLimitRule => ({ + stat_type: item.stat_type || "day", + stat_value: Math.max(1, Math.floor(Number(item.stat_value) || 1)), + traffic_usage: Math.max(0, Number(item.traffic_usage) || 0), + speed_limit: Math.max(0, Number(item.speed_limit) || 0), }); export function SubscriptionForm({ @@ -57,11 +91,14 @@ export function SubscriptionForm({ const { t } = useTranslation("user"); const [open, setOpen] = useState(false); - const form = useForm({ + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { subscribe_id: initialData?.subscribe_id || 0, traffic: initialData?.traffic || 0, + speed_limit: initialData?.speed_limit || 0, + device_limit: initialData?.device_limit || 0, + traffic_limit: initialData?.traffic_limit || [], upload: initialData?.upload || 0, download: initialData?.download || 0, expired_at: initialData?.expire_time || 0, @@ -69,7 +106,7 @@ export function SubscriptionForm({ }, }); - const handleSubmit = async (values: any) => { + const handleSubmit = async (values: SubscriptionFormValues) => { const success = await onSubmit(values); if (success) { setOpen(false); @@ -135,6 +172,7 @@ export function SubscriptionForm({ )} /> + ( + + {t("speedLimit", "Speed Limit")} + + { + form.setValue(field.name, value as number); + }} + suffix="Mbps" + /> + + + {t( + "userSpeedLimitDescription", + "Set to 0 to use the subscription default speed limit." + )} + + + + )} + /> + ( + + {t("deviceLimit", "Device Limit")} + + { + form.setValue(field.name, value as number); + }} + /> + + + + )} + /> )} /> + ( + + + {t("trafficLimitRules", "Traffic Limit Rules")} + + + + 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 + ) => { + 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 + ) => { + 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 + ) => { + if (e.key === "." || e.key === ",") { + e.preventDefault(); + } + }, + formatOutput: (value: string | number) => { + const num = Number(value); + return String( + Number.isNaN(num) ? 0 : Math.floor(num) + ); + }, + }, + ]} + onChange={(items) => { + field.onChange(items.map(normalizeLimitRule)); + }} + value={ + field.value && field.value.length > 0 + ? field.value + : [defaultLimitRule] + } + /> + + + {t( + "trafficLimitDescription", + "Limit speed when usage reaches the configured amount in the selected time window." + )} + + + + )} + />