Compare commits

...

1 Commits

Author SHA1 Message Date
shanshanzhong147 9172107ea9 修复(#120): 补齐用户订阅限速配置前端实现 2026-05-28 22:00:53 -07:00
4 changed files with 397 additions and 59 deletions
@@ -32,11 +32,27 @@ import { formatDate } from "@/utils/common";
import { SubscriptionDetail } from "./subscription-detail"; import { SubscriptionDetail } from "./subscription-detail";
import { SubscriptionForm } from "./subscription-form"; import { SubscriptionForm } from "./subscription-form";
type SubscriptionFormPayload = Parameters<
React.ComponentProps<typeof SubscriptionForm>["onSubmit"]
>[0];
interface SharedInfo { interface SharedInfo {
ownerUserId: number; ownerUserId: number;
familyId: 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 }) { export default function UserSubscription({ userId }: { userId: number }) {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -164,9 +180,8 @@ export default function UserSubscription({ userId }: { userId: number }) {
onSubmit={async (values) => { onSubmit={async (values) => {
setLoading(true); setLoading(true);
await updateUserSubscribe({ await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id, user_subscribe_id: row.id,
...values, ...toUserSubscribePayload(values),
}); });
toast.success(t("updateSuccess", "Updated successfully")); toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh(); ref.current?.refresh();
@@ -352,7 +367,7 @@ export default function UserSubscription({ userId }: { userId: number }) {
setLoading(true); setLoading(true);
await createUserSubscribe({ await createUserSubscribe({
user_id: Number(userId), user_id: Number(userId),
...values, ...toUserSubscribePayload(values),
}); });
toast.success(t("createSuccess", "Created successfully")); toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh(); ref.current?.refresh();
@@ -20,83 +20,189 @@ import { AlertTriangle, Gauge } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { Display } from "@/components/display";
import { IpLink } from "@/components/ip-link"; import { IpLink } from "@/components/ip-link";
import { formatDate } from "@/utils/common"; import { formatDate } from "@/utils/common";
function formatRules(rules?: API.TrafficLimitRule[]) {
return rules && rules.length > 0 ? rules : [];
}
function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) { function SpeedLimitCard({ subscriptionId }: { subscriptionId: number }) {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [detail, setDetail] = useState<API.UserSubscribeDetail | null>(null); const [detail, setDetail] = useState<API.UserSubscribeDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
getUserSubscribeById({ id: subscriptionId }).then(({ data }) => { setLoading(true);
if (data.data) setDetail(data.data); setError(false);
}); getUserSubscribeById({ id: subscriptionId })
.then(({ data }) => {
setDetail(data.data || null);
})
.catch(() => {
setError(true);
setDetail(null);
})
.finally(() => {
setLoading(false);
});
}, [subscriptionId]); }, [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 baseSpeed = detail.subscribe?.speed_limit ?? 0;
const userSpeed = detail.speed_limit ?? 0;
const effectiveSpeed = detail.effective_speed ?? 0; const effectiveSpeed = detail.effective_speed ?? 0;
const isThrottled = detail.is_throttled; const isThrottled = detail.is_throttled;
const packageRules = formatRules(detail.subscribe?.traffic_limit);
if (baseSpeed === 0 && !isThrottled) return null; const userRules = formatRules(detail.traffic_limit);
return ( return (
<div <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 isThrottled
? "border-destructive/40 bg-destructive/5 text-destructive" ? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-border bg-muted/40 text-foreground" : "border-border bg-muted/40 text-foreground"
}`} }`}
> >
{isThrottled ? ( <div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" /> {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" /> ) : (
)} <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"> <div className="min-w-0 space-y-1">
{isThrottled ? ( <div className="flex flex-wrap items-center gap-2 font-medium">
<> <span>
<span>{t("throttled", "Speed Throttled")}</span> {isThrottled
<Badge className="text-xs" variant="destructive"> ? t("throttled", "Speed Throttled")
{effectiveSpeed} Mbps : t("speedLimitOverview", "Speed Limit Overview")}
</Badge> </span>
{baseSpeed > 0 && ( <Badge
<span className="text-muted-foreground text-xs line-through"> className="text-xs"
{baseSpeed} Mbps variant={isThrottled ? "destructive" : "secondary"}
</span> >
)} {effectiveSpeed > 0
</> ? `${effectiveSpeed} Mbps`
) : ( : t("unlimited", "Unlimited")}
<> </Badge>
<span className="text-muted-foreground"> </div>
{t("speedLimit", "Speed Limit")} {isThrottled && detail.throttle_rule && (
</span> <p className="text-muted-foreground text-xs">
<Badge className="text-xs" variant="secondary"> {detail.throttle_rule}
{effectiveSpeed > 0 </p>
? `${effectiveSpeed} Mbps` )}
: t("unlimited", "Unlimited")} {isThrottled && detail.throttle_start && detail.throttle_end && (
</Badge> <p className="text-muted-foreground text-xs">
</> {formatDate(detail.throttle_start)} ~{" "}
{formatDate(detail.throttle_end)}
</p>
)} )}
</div> </div>
{isThrottled && detail.throttle_rule && ( </div>
<p className="text-muted-foreground text-xs">
{detail.throttle_rule} <div className="grid gap-2 md:grid-cols-3">
</p> {[
)} {
{isThrottled && detail.throttle_start && detail.throttle_end && ( label: t("packageSpeedLimit", "Package Speed Limit"),
<p className="text-muted-foreground text-xs"> value: <Display type="trafficSpeed" unlimited value={baseSpeed} />,
{formatDate(detail.throttle_start)} ~{" "} },
{formatDate(detail.throttle_end)} {
</p> 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>
</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({ export function SubscriptionDetail({
trigger, trigger,
userId, userId,
@@ -157,9 +263,8 @@ export function SubscriptionDetail({
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
checked={row.getValue("enabled")} checked={row.getValue("enabled")}
onChange={(checked) => { disabled
console.log("Switch:", checked); title={t("readOnly", "Read only")}
}}
/> />
), ),
}, },
@@ -3,6 +3,7 @@ import { Button } from "@workspace/ui/components/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -17,8 +18,10 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@workspace/ui/components/sheet"; } from "@workspace/ui/components/sheet";
import { Textarea } from "@workspace/ui/components/textarea";
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 { unitConversion } from "@workspace/ui/utils/unit-conversions"; import { unitConversion } from "@workspace/ui/utils/unit-conversions";
@@ -33,20 +36,67 @@ interface Props {
title: string; title: string;
loading?: boolean; loading?: boolean;
initialData?: API.UserSubscribe; 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({ const formSchema = z.object({
subscribe_id: z.number().optional(), subscribe_id: z.number().optional(),
traffic: z.number().optional(), traffic: z.number().optional(),
speed_limit: 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(), expired_at: z.number().nullish().optional(),
upload: z.number().optional(), upload: z.number().optional(),
download: z.number().optional(), download: z.number().optional(),
id: 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({ export function SubscriptionForm({
trigger, trigger,
title, title,
@@ -57,11 +107,18 @@ export function SubscriptionForm({
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const form = useForm({ const form = useForm<SubscriptionFormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
subscribe_id: initialData?.subscribe_id || 0, subscribe_id: initialData?.subscribe_id || 0,
traffic: initialData?.traffic || 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, upload: initialData?.upload || 0,
download: initialData?.download || 0, download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0, expired_at: initialData?.expire_time || 0,
@@ -69,8 +126,18 @@ export function SubscriptionForm({
}, },
}); });
const handleSubmit = async (values: any) => { const handleSubmit = async (values: SubscriptionFormValues) => {
const success = await onSubmit(values); const trafficLimitFromJson = parseTrafficLimitJson(
values.traffic_limit_json
);
const success = await onSubmit({
...values,
traffic_limit: normalizeTrafficLimitRules(
trafficLimitFromJson.length > 0
? trafficLimitFromJson
: values.traffic_limit
),
});
if (success) { if (success) {
setOpen(false); setOpen(false);
form.reset(); form.reset();
@@ -154,6 +221,33 @@ export function SubscriptionForm({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="upload" name="upload"
@@ -183,6 +277,114 @@ export function SubscriptionForm({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="download" name="download"
+16
View File
@@ -398,6 +398,8 @@ declare namespace API {
expired_at: number; expired_at: number;
traffic: number; traffic: number;
subscribe_id: number; subscribe_id: number;
speed_limit?: number;
traffic_limit?: TrafficLimitRule[];
}; };
type CurrencyConfig = { type CurrencyConfig = {
@@ -2051,6 +2053,7 @@ declare namespace API {
replacement: number; replacement: number;
inventory: number; inventory: number;
traffic: number; traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit: number; speed_limit: number;
device_limit: number; device_limit: number;
quota: number; quota: number;
@@ -2434,6 +2437,8 @@ declare namespace API {
expired_at: number; expired_at: number;
upload: number; upload: number;
download: number; download: number;
speed_limit?: number;
traffic_limit?: TrafficLimitRule[];
}; };
type User = { type User = {
@@ -2522,6 +2527,8 @@ declare namespace API {
finished_at: number; finished_at: number;
reset_time: number; reset_time: number;
traffic: number; traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit?: number;
download: number; download: number;
upload: number; upload: number;
token: string; token: string;
@@ -2544,6 +2551,8 @@ declare namespace API {
expire_time: number; expire_time: number;
reset_time: number; reset_time: number;
traffic: number; traffic: number;
traffic_limit?: TrafficLimitRule[];
speed_limit?: number;
download: number; download: number;
upload: number; upload: number;
token: string; token: string;
@@ -2559,6 +2568,13 @@ declare namespace API {
updated_at: number; updated_at: number;
}; };
type TrafficLimitRule = {
stat_type: "hour" | "day";
stat_value: number;
traffic_usage: number;
speed_limit: number;
};
type UserSubscribeLog = { type UserSubscribeLog = {
id: number; id: number;
user_id: number; user_id: number;