修复(#120): 补齐用户订阅限速配置前端实现

This commit is contained in:
2026-05-28 22:00:53 -07:00
parent 100b641552
commit 9172107ea9
4 changed files with 397 additions and 59 deletions
@@ -32,11 +32,27 @@ import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail";
import { SubscriptionForm } from "./subscription-form";
type SubscriptionFormPayload = Parameters<
React.ComponentProps<typeof SubscriptionForm>["onSubmit"]
>[0];
interface SharedInfo {
ownerUserId: number;
familyId: number;
}
function toUserSubscribePayload(values: SubscriptionFormPayload) {
return {
subscribe_id: values.subscribe_id ?? 0,
traffic: values.traffic ?? 0,
expired_at: values.expired_at ?? 0,
upload: values.upload ?? 0,
download: values.download ?? 0,
speed_limit: values.speed_limit ?? 0,
traffic_limit: values.traffic_limit ?? [],
};
}
export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user");
const [loading, setLoading] = useState(false);
@@ -164,9 +180,8 @@ export default function UserSubscription({ userId }: { userId: number }) {
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
...toUserSubscribePayload(values),
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
@@ -352,7 +367,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();
@@ -20,83 +20,189 @@ import { AlertTriangle, Gauge } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common";
function formatRules(rules?: API.TrafficLimitRule[]) {
return rules && rules.length > 0 ? rules : [];
}
function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
const { t } = useTranslation("user");
const [detail, setDetail] = useState<API.UserSubscribeDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
getUserSubscribeById({ id: subscriptionId }).then(({ data }) => {
if (data.data) setDetail(data.data);
});
setLoading(true);
setError(false);
getUserSubscribeById({ id: subscriptionId })
.then(({ data }) => {
setDetail(data.data || null);
})
.catch(() => {
setError(true);
setDetail(null);
})
.finally(() => {
setLoading(false);
});
}, [subscriptionId]);
if (!detail || detail.status !== 1) return null;
if (loading) {
return (
<div className="mb-4 rounded-lg border bg-muted/30 p-3 text-muted-foreground text-sm">
{t("loading", "Loading")}...
</div>
);
}
if (error) {
return (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-destructive text-sm">
{t("loadFailed", "Failed to load data")}
</div>
);
}
if (!detail) {
return (
<div className="mb-4 rounded-lg border bg-muted/30 p-3 text-muted-foreground text-sm">
{t("noSubscriptionDetail", "No subscription detail")}
</div>
);
}
const baseSpeed = detail.subscribe?.speed_limit ?? 0;
const userSpeed = detail.speed_limit ?? 0;
const effectiveSpeed = detail.effective_speed ?? 0;
const isThrottled = detail.is_throttled;
if (baseSpeed === 0 && !isThrottled) return null;
const packageRules = formatRules(detail.subscribe?.traffic_limit);
const userRules = formatRules(detail.traffic_limit);
return (
<div
className={`mb-4 flex items-start gap-3 rounded-lg border p-3 text-sm ${
className={`mb-4 space-y-4 rounded-lg border p-3 text-sm ${
isThrottled
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border bg-muted/40 text-foreground"
}`}
>
{isThrottled ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<Gauge className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 font-medium">
{isThrottled ? (
<>
<span>{t("throttled", "Speed Throttled")}</span>
<Badge className="text-xs" variant="destructive">
{effectiveSpeed} Mbps
</Badge>
{baseSpeed > 0 && (
<span className="text-muted-foreground text-xs line-through">
{baseSpeed} Mbps
</span>
)}
</>
) : (
<>
<span className="text-muted-foreground">
{t("speedLimit", "Speed Limit")}
</span>
<Badge className="text-xs" variant="secondary">
{effectiveSpeed > 0
? `${effectiveSpeed} Mbps`
: t("unlimited", "Unlimited")}
</Badge>
</>
<div className="flex items-start gap-3">
{isThrottled ? (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<Gauge className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2 font-medium">
<span>
{isThrottled
? t("throttled", "Speed Throttled")
: t("speedLimitOverview", "Speed Limit Overview")}
</span>
<Badge
className="text-xs"
variant={isThrottled ? "destructive" : "secondary"}
>
{effectiveSpeed > 0
? `${effectiveSpeed} Mbps`
: t("unlimited", "Unlimited")}
</Badge>
</div>
{isThrottled && detail.throttle_rule && (
<p className="text-muted-foreground text-xs">
{detail.throttle_rule}
</p>
)}
{isThrottled && detail.throttle_start && detail.throttle_end && (
<p className="text-muted-foreground text-xs">
{formatDate(detail.throttle_start)} ~{" "}
{formatDate(detail.throttle_end)}
</p>
)}
</div>
{isThrottled && detail.throttle_rule && (
<p className="text-muted-foreground text-xs">
{detail.throttle_rule}
</p>
)}
{isThrottled && detail.throttle_start && detail.throttle_end && (
<p className="text-muted-foreground text-xs">
{formatDate(detail.throttle_start)} ~{" "}
{formatDate(detail.throttle_end)}
</p>
)}
</div>
<div className="grid gap-2 md:grid-cols-3">
{[
{
label: t("packageSpeedLimit", "Package Speed Limit"),
value: <Display type="trafficSpeed" unlimited value={baseSpeed} />,
},
{
label: t("userSpeedOverride", "User Speed Override"),
value: <Display type="trafficSpeed" unlimited value={userSpeed} />,
},
{
label: t("effectiveSpeedLimit", "Effective Speed Limit"),
value: (
<Display type="trafficSpeed" unlimited value={effectiveSpeed} />
),
},
].map((item) => (
<div
className="rounded-md border bg-background/70 p-2"
key={item.label}
>
<div className="text-muted-foreground text-xs">{item.label}</div>
<div className="mt-1 font-medium">{item.value}</div>
</div>
))}
</div>
<div className="grid gap-3 md:grid-cols-2">
<TrafficRulesBlock
emptyText={t("noPackageTrafficRules", "No package traffic rules")}
rules={packageRules}
title={t("packageTrafficRules", "Package Traffic Rules")}
/>
<TrafficRulesBlock
emptyText={t("noUserTrafficRules", "No user traffic rules")}
rules={userRules}
title={t("userTrafficRules", "User Traffic Rules")}
/>
</div>
</div>
);
}
function TrafficRulesBlock({
title,
rules,
emptyText,
}: {
title: string;
rules: API.TrafficLimitRule[];
emptyText: string;
}) {
return (
<div className="rounded-md border bg-background/70 p-2">
<div className="font-medium text-muted-foreground text-xs">{title}</div>
{rules.length === 0 ? (
<div className="mt-2 text-muted-foreground text-xs">{emptyText}</div>
) : (
<div className="mt-2 space-y-2">
{rules.map((rule, index) => (
<div
className="rounded border bg-muted/30 px-2 py-1.5 text-xs"
key={`${rule.stat_type}-${rule.stat_value}-${index}`}
>
<div className="flex flex-wrap gap-x-2 gap-y-1">
<span>
{rule.stat_value} {rule.stat_type}
</span>
<span>{rule.traffic_usage} GB</span>
<span>{rule.speed_limit} Mbps</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
export function SubscriptionDetail({
trigger,
userId,
@@ -157,9 +263,8 @@ export function SubscriptionDetail({
cell: ({ row }) => (
<Switch
checked={row.getValue("enabled")}
onChange={(checked) => {
console.log("Switch:", checked);
}}
disabled
title={t("readOnly", "Read only")}
/>
),
},
@@ -3,6 +3,7 @@ import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -17,8 +18,10 @@ import {
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Textarea } from "@workspace/ui/components/textarea";
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";
@@ -33,20 +36,67 @@ interface Props {
title: string;
loading?: boolean;
initialData?: API.UserSubscribe;
onSubmit: (values: any) => Promise<boolean>;
onSubmit: (values: SubscriptionFormValues) => Promise<boolean>;
}
const trafficLimitRuleSchema = z.object({
stat_type: z.enum(["hour", "day"]),
stat_value: z.number().int().min(1),
traffic_usage: z.number().int().min(0),
speed_limit: z.number().int().min(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_limit: z.array(trafficLimitRuleSchema).optional(),
traffic_limit_json: z.string().superRefine((value, ctx) => {
if (!value.trim()) return;
try {
const parsed: unknown = JSON.parse(value);
const result = z.array(trafficLimitRuleSchema).safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid traffic limit JSON",
});
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
}
}),
expired_at: z.number().nullish().optional(),
upload: z.number().optional(),
download: z.number().optional(),
id: z.number().optional(),
});
type SubscriptionFormValues = z.infer<typeof formSchema>;
type TrafficLimitFormRule = z.infer<typeof trafficLimitRuleSchema>;
function parseTrafficLimitJson(value: string): API.TrafficLimitRule[] {
if (!value.trim()) return [];
const parsed: unknown = JSON.parse(value);
const result = z.array(trafficLimitRuleSchema).safeParse(parsed);
return result.success ? normalizeTrafficLimitRules(result.data) : [];
}
function normalizeTrafficLimitRules(
rules?: TrafficLimitFormRule[]
): API.TrafficLimitRule[] {
return (rules || []).map((item) => ({
stat_type: item.stat_type === "hour" ? "hour" : "day",
stat_value: Number(item.stat_value) || 1,
traffic_usage: Number(item.traffic_usage) || 0,
speed_limit: Number(item.speed_limit) || 0,
}));
}
export function SubscriptionForm({
trigger,
title,
@@ -57,11 +107,18 @@ export function SubscriptionForm({
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const form = useForm({
const form = useForm<SubscriptionFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
subscribe_id: initialData?.subscribe_id || 0,
traffic: initialData?.traffic || 0,
speed_limit: initialData?.speed_limit || 0,
traffic_limit: normalizeTrafficLimitRules(initialData?.traffic_limit),
traffic_limit_json: JSON.stringify(
normalizeTrafficLimitRules(initialData?.traffic_limit),
null,
2
),
upload: initialData?.upload || 0,
download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0,
@@ -69,8 +126,18 @@ export function SubscriptionForm({
},
});
const handleSubmit = async (values: any) => {
const success = await onSubmit(values);
const handleSubmit = async (values: SubscriptionFormValues) => {
const trafficLimitFromJson = parseTrafficLimitJson(
values.traffic_limit_json
);
const success = await onSubmit({
...values,
traffic_limit: normalizeTrafficLimitRules(
trafficLimitFromJson.length > 0
? trafficLimitFromJson
: values.traffic_limit
),
});
if (success) {
setOpen(false);
form.reset();
@@ -154,6 +221,33 @@ export function SubscriptionForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="speed_limit"
render={({ field }) => (
<FormItem>
<FormLabel>{t("speedLimit", "Speed Limit")}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t("unlimited", "Unlimited")}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
suffix="Mbps"
/>
</FormControl>
<FormDescription>
{t(
"userSpeedLimitDescription",
"Set a user-level speed override. Use 0 to inherit the subscription limit."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="upload"
@@ -183,6 +277,114 @@ export function SubscriptionForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="traffic_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("trafficLimitRules", "Traffic Limit Rules")}
</FormLabel>
<FormControl>
<ArrayInput<API.TrafficLimitRule>
className="grid grid-cols-1 gap-3 md:grid-cols-4"
fields={[
{
name: "stat_type",
type: "select",
placeholder: t("statType", "Statistics Type"),
options: [
{ label: t("hour", "Hour"), value: "hour" },
{ label: t("day", "Day"), value: "day" },
],
},
{
name: "stat_value",
type: "number",
placeholder: t("statValue", "Time Value"),
min: 1,
},
{
name: "traffic_usage",
type: "number",
placeholder: t(
"trafficUsageGb",
"Traffic Usage (GB)"
),
min: 0,
},
{
name: "speed_limit",
type: "number",
placeholder: t(
"speedLimitMbps",
"Speed Limit (Mbps)"
),
min: 0,
},
]}
onChange={(items) => {
const normalized =
normalizeTrafficLimitRules(items);
field.onChange(normalized);
form.setValue(
"traffic_limit_json",
JSON.stringify(normalized, null, 2),
{ shouldValidate: true }
);
}}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"userTrafficLimitDescription",
"Optional user-level traffic throttle rules. Traffic usage is stored in GB for each rule."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="traffic_limit_json"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("trafficLimitJson", "Traffic Limit JSON")}
</FormLabel>
<FormControl>
<Textarea
className="min-h-32 font-mono text-xs"
placeholder='[{"stat_type":"day","stat_value":1,"traffic_usage":10,"speed_limit":5}]'
{...field}
onChange={(event) => {
field.onChange(event.target.value);
try {
const parsed = JSON.parse(event.target.value);
const result = z
.array(trafficLimitRuleSchema)
.safeParse(parsed);
if (result.success) {
form.setValue("traffic_limit", result.data);
}
} catch {
// Validation message is handled by zod.
}
}}
/>
</FormControl>
<FormDescription>
{t(
"trafficLimitJsonDescription",
"Use JSON when pasting existing rules or clearing all rules with []."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="download"
+16
View File
@@ -398,6 +398,8 @@ declare namespace API {
expired_at: number;
traffic: number;
subscribe_id: number;
speed_limit?: number;
traffic_limit?: TrafficLimitRule[];
};
type CurrencyConfig = {
@@ -2051,6 +2053,7 @@ declare namespace API {
replacement: number;
inventory: number;
traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit: number;
device_limit: number;
quota: number;
@@ -2434,6 +2437,8 @@ declare namespace API {
expired_at: number;
upload: number;
download: number;
speed_limit?: number;
traffic_limit?: TrafficLimitRule[];
};
type User = {
@@ -2522,6 +2527,8 @@ declare namespace API {
finished_at: number;
reset_time: number;
traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit?: number;
download: number;
upload: number;
token: string;
@@ -2544,6 +2551,8 @@ declare namespace API {
expire_time: number;
reset_time: number;
traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit?: number;
download: number;
upload: number;
token: string;
@@ -2559,6 +2568,13 @@ declare namespace API {
updated_at: number;
};
type TrafficLimitRule = {
stat_type: "hour" | "day";
stat_value: number;
traffic_usage: number;
speed_limit: number;
};
type UserSubscribeLog = {
id: number;
user_id: number;