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."
+ )}
+
+
+
+ )}
+ />