- 订阅表单 speed_limit 去掉 bitsToMb/mbToBits 转换,直接存 Mbps
- traffic_limit speed_limit 标签从 KB 改为 Mbps
- 节点组 speed_limit 标签从 KB/s 改为 Mbps
- Display trafficSpeed 类型直接显示 {value} Mbps
Co-Authored-By: claude-flow <ruv@ruv.net>
527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@workspace/ui/components/dialog";
|
||
import { Input } from "@workspace/ui/components/input";
|
||
import { Label } from "@workspace/ui/components/label";
|
||
import { Switch } from "@workspace/ui/components/switch";
|
||
import { Textarea } from "@workspace/ui/components/textarea";
|
||
import { AlertCircle, Loader2 } from "lucide-react";
|
||
import { type RefObject, useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
interface NodeGroupFormProps {
|
||
initialValues?: Partial<API.NodeGroup>;
|
||
allNodeGroups?: API.NodeGroup[];
|
||
currentGroupId?: number;
|
||
loading?: boolean;
|
||
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
|
||
title: string;
|
||
trigger: React.ReactNode;
|
||
}
|
||
|
||
const NodeGroupForm = ({
|
||
initialValues,
|
||
allNodeGroups = [],
|
||
currentGroupId,
|
||
loading,
|
||
onSubmit,
|
||
title,
|
||
trigger,
|
||
ref,
|
||
}: NodeGroupFormProps & { ref?: RefObject<HTMLButtonElement | null> }) => {
|
||
const { t } = useTranslation("group");
|
||
const [open, setOpen] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [conflictError, setConflictError] = useState<string>("");
|
||
|
||
const [values, setValues] = useState({
|
||
name: "",
|
||
description: "",
|
||
sort: 0,
|
||
for_calculation: true,
|
||
is_expired_group: false,
|
||
expired_days_limit: 7,
|
||
max_traffic_gb_expired: 0,
|
||
speed_limit: 0,
|
||
min_traffic_gb: 0,
|
||
max_traffic_gb: 0,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setConflictError(""); // 重置冲突错误
|
||
if (initialValues) {
|
||
setValues({
|
||
name: initialValues.name || "",
|
||
description: initialValues.description || "",
|
||
sort: initialValues.sort ?? 0,
|
||
for_calculation: initialValues.for_calculation ?? true,
|
||
is_expired_group: initialValues.is_expired_group ?? false,
|
||
expired_days_limit: initialValues.expired_days_limit ?? 7,
|
||
max_traffic_gb_expired: initialValues.max_traffic_gb_expired ?? 0,
|
||
speed_limit: initialValues.speed_limit ?? 0,
|
||
min_traffic_gb: initialValues.min_traffic_gb ?? 0,
|
||
max_traffic_gb: initialValues.max_traffic_gb ?? 0,
|
||
});
|
||
} else {
|
||
setValues({
|
||
name: "",
|
||
description: "",
|
||
sort: 0,
|
||
for_calculation: true,
|
||
is_expired_group: false,
|
||
expired_days_limit: 7,
|
||
max_traffic_gb_expired: 0,
|
||
speed_limit: 0,
|
||
min_traffic_gb: 0,
|
||
max_traffic_gb: 0,
|
||
});
|
||
}
|
||
}
|
||
}, [initialValues, open]);
|
||
|
||
// 检测流量区间冲突
|
||
const checkTrafficRangeConflict = (
|
||
minTraffic: number,
|
||
maxTraffic: number
|
||
): string => {
|
||
// 如果 min=0 且 max=0,表示不参与流量分组,跳过所有验证
|
||
if (minTraffic === 0 && maxTraffic === 0) {
|
||
return "";
|
||
}
|
||
|
||
// 验证区间有效性:min 必须 < max(除非 max=0 表示无上限)
|
||
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||
return t("invalidRange", "Min traffic must be less than max traffic");
|
||
}
|
||
|
||
// 处理 max=0 的情况,表示无上限,使用一个很大的数代替
|
||
const actualMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
|
||
|
||
// 检查与其他节点组的冲突
|
||
for (const group of allNodeGroups) {
|
||
// 跳过当前编辑的节点组
|
||
if (currentGroupId && group.id === currentGroupId) {
|
||
continue;
|
||
}
|
||
|
||
// 跳过没有设置流量区间的节点组(min=0 且 max=0 表示未配置)
|
||
const existingMin = group.min_traffic_gb ?? 0;
|
||
const existingMax = group.max_traffic_gb ?? 0;
|
||
if (existingMin === 0 && existingMax === 0) {
|
||
continue;
|
||
}
|
||
|
||
// 处理现有节点组 max=0 的情况
|
||
const actualExistingMax =
|
||
existingMax === 0 ? Number.MAX_VALUE : existingMax;
|
||
|
||
// 检测区间重叠
|
||
// 两个区间 [min1, max1] 和 [min2, max2] 重叠的条件:
|
||
// max1 > min2 && max2 > min1
|
||
const hasOverlap =
|
||
actualMax > existingMin && actualExistingMax > minTraffic;
|
||
|
||
if (hasOverlap) {
|
||
return t("rangeConflict", {
|
||
name: group.name,
|
||
min: existingMin.toString(),
|
||
max: existingMax === 0 ? "∞" : existingMax.toString(),
|
||
});
|
||
}
|
||
}
|
||
|
||
return "";
|
||
};
|
||
|
||
// 检测过期节点组冲突
|
||
const checkExpiredGroupConflict = async (
|
||
isExpiredGroup: boolean
|
||
): Promise<string> => {
|
||
if (!isExpiredGroup) {
|
||
return "";
|
||
}
|
||
|
||
// 检查是否已存在其他过期节点组
|
||
const existingExpiredGroup = allNodeGroups.find(
|
||
(group) => group.is_expired_group && group.id !== currentGroupId
|
||
);
|
||
|
||
if (existingExpiredGroup) {
|
||
return t(
|
||
"expiredGroupExists",
|
||
`System already has an expired node group: ${existingExpiredGroup.name}`
|
||
);
|
||
}
|
||
|
||
// 检查当前节点组是否被订阅商品使用
|
||
if (currentGroupId) {
|
||
try {
|
||
const { getSubscribeList } = await import(
|
||
"@workspace/ui/services/admin/subscribe"
|
||
);
|
||
const { data } = await getSubscribeList({
|
||
page: 1,
|
||
size: 1,
|
||
node_group_id: currentGroupId,
|
||
});
|
||
|
||
if (data.data && data.data.total > 0) {
|
||
return t(
|
||
"nodeGroupUsedBySubscribe",
|
||
"This node group is used as default node group in subscription products, cannot set as expired group"
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to check subscribe usage:", error);
|
||
}
|
||
}
|
||
|
||
return "";
|
||
};
|
||
|
||
// 检查是否存在其他过期节点组(用于隐藏开关)
|
||
const hasOtherExpiredGroup = allNodeGroups.some(
|
||
(group) => group.is_expired_group && group.id !== currentGroupId
|
||
);
|
||
|
||
// 当前是否是过期节点组(编辑模式下)
|
||
const isCurrentExpiredGroup = initialValues?.is_expired_group ?? false;
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
// 检测过期节点组冲突
|
||
const expiredGroupConflict = await checkExpiredGroupConflict(
|
||
values.is_expired_group
|
||
);
|
||
if (expiredGroupConflict) {
|
||
setConflictError(expiredGroupConflict);
|
||
return;
|
||
}
|
||
|
||
// 仅在非过期节点组时检测流量区间冲突
|
||
if (!values.is_expired_group) {
|
||
const conflict = checkTrafficRangeConflict(
|
||
values.min_traffic_gb,
|
||
values.max_traffic_gb
|
||
);
|
||
if (conflict) {
|
||
setConflictError(conflict);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setSubmitting(true);
|
||
const success = await onSubmit(values);
|
||
setSubmitting(false);
|
||
if (success) {
|
||
setOpen(false);
|
||
setConflictError("");
|
||
setValues({
|
||
name: "",
|
||
description: "",
|
||
sort: 0,
|
||
for_calculation: true,
|
||
is_expired_group: false,
|
||
expired_days_limit: 7,
|
||
max_traffic_gb_expired: 0,
|
||
speed_limit: 0,
|
||
min_traffic_gb: 0,
|
||
max_traffic_gb: 0,
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog onOpenChange={setOpen} open={open}>
|
||
<DialogTrigger asChild ref={ref}>
|
||
{trigger}
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-[500px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{title}</DialogTitle>
|
||
<DialogDescription>
|
||
{t("nodeGroupFormDescription", "Configure node group settings")}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">{t("name", "Name")} *</Label>
|
||
<Input
|
||
id="name"
|
||
onChange={(e) => setValues({ ...values, name: e.target.value })}
|
||
placeholder={t("namePlaceholder", "Enter name")}
|
||
required
|
||
value={values.name}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="description">
|
||
{t("description", "Description")}
|
||
</Label>
|
||
<Textarea
|
||
id="description"
|
||
onChange={(e) =>
|
||
setValues({ ...values, description: e.target.value })
|
||
}
|
||
placeholder={t("descriptionPlaceholder", "Enter description")}
|
||
rows={3}
|
||
value={values.description}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
|
||
<Input
|
||
id="sort"
|
||
min={0}
|
||
onChange={(e) =>
|
||
setValues({
|
||
...values,
|
||
sort: Number.parseInt(e.target.value, 10) || 0,
|
||
})
|
||
}
|
||
type="number"
|
||
value={values.sort}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="for_calculation">
|
||
{t("forCalculation", "For Calculation")}
|
||
</Label>
|
||
<p className="text-muted-foreground text-sm">
|
||
{values.is_expired_group
|
||
? t(
|
||
"expiredGroupForCalculationDescription",
|
||
"Expired-only node groups cannot participate in group calculation"
|
||
)
|
||
: t(
|
||
"forCalculationDescription",
|
||
"Whether this node group participates in grouping calculation"
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={values.for_calculation}
|
||
disabled={values.is_expired_group}
|
||
id="for_calculation"
|
||
onCheckedChange={(checked) =>
|
||
setValues({ ...values, for_calculation: checked })
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{/* 仅在没有其他过期节点组或当前就是过期节点组时显示 */}
|
||
{(!hasOtherExpiredGroup || isCurrentExpiredGroup) && (
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-0.5">
|
||
<Label htmlFor="is_expired_group">
|
||
{t("isExpiredGroup", "Expired Node Group")}
|
||
</Label>
|
||
<p className="text-muted-foreground text-sm">
|
||
{t(
|
||
"isExpiredGroupDescription",
|
||
"Allow expired users to use limited nodes"
|
||
)}
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={values.is_expired_group}
|
||
id="is_expired_group"
|
||
onCheckedChange={async (checked) => {
|
||
setValues({
|
||
...values,
|
||
is_expired_group: checked,
|
||
for_calculation: checked ? false : values.for_calculation,
|
||
min_traffic_gb: checked ? 0 : values.min_traffic_gb,
|
||
max_traffic_gb: checked ? 0 : values.max_traffic_gb,
|
||
});
|
||
// 实时检测过期节点组冲突
|
||
const conflict = await checkExpiredGroupConflict(checked);
|
||
setConflictError(conflict);
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{values.is_expired_group && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="expired_days_limit">
|
||
{t("expiredDaysLimit", "Expired Days Limit")}
|
||
</Label>
|
||
<p className="text-muted-foreground text-sm">
|
||
{t(
|
||
"expiredDaysLimitDescription",
|
||
"Number of days after expiration that users can still access nodes"
|
||
)}
|
||
</p>
|
||
<Input
|
||
id="expired_days_limit"
|
||
min={1}
|
||
onChange={(e) =>
|
||
setValues({
|
||
...values,
|
||
expired_days_limit:
|
||
Number.parseInt(e.target.value, 10) || 7,
|
||
})
|
||
}
|
||
type="number"
|
||
value={values.expired_days_limit}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="max_traffic_gb_expired">
|
||
{t(
|
||
"maxTrafficGBExpired",
|
||
"Max Traffic for Expired Users (GB)"
|
||
)}
|
||
</Label>
|
||
<p className="text-muted-foreground text-sm">
|
||
{t(
|
||
"maxTrafficGBExpiredDescription",
|
||
"Maximum traffic allowed for expired users (0 = unlimited)"
|
||
)}
|
||
</p>
|
||
<Input
|
||
id="max_traffic_gb_expired"
|
||
min={0}
|
||
onChange={(e) =>
|
||
setValues({
|
||
...values,
|
||
max_traffic_gb_expired:
|
||
Number.parseInt(e.target.value, 10) || 0,
|
||
})
|
||
}
|
||
type="number"
|
||
value={values.max_traffic_gb_expired}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="speed_limit">
|
||
{t("speedLimit", "Speed Limit (Mbps)")}
|
||
</Label>
|
||
<Input
|
||
id="speed_limit"
|
||
min={0}
|
||
onChange={(e) =>
|
||
setValues({
|
||
...values,
|
||
speed_limit: Number.parseInt(e.target.value, 10) || 0,
|
||
})
|
||
}
|
||
type="number"
|
||
value={values.speed_limit}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{!values.is_expired_group && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>{t("trafficRangeGB", "Traffic Range (GB)")}</Label>
|
||
</div>
|
||
<p className="text-muted-foreground text-sm">
|
||
{t(
|
||
"trafficRangeDescription",
|
||
"Users with traffic >= Min and < Max will be assigned to this node group"
|
||
)}
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="min_traffic_gb">
|
||
{t("minTrafficGB", "Min Traffic (GB)")}
|
||
</Label>
|
||
<Input
|
||
id="min_traffic_gb"
|
||
min={0}
|
||
onChange={(e) => {
|
||
const newValue = Number.parseFloat(e.target.value) || 0;
|
||
setValues({ ...values, min_traffic_gb: newValue });
|
||
// 实时检测冲突
|
||
const conflict = checkTrafficRangeConflict(
|
||
newValue,
|
||
values.max_traffic_gb
|
||
);
|
||
setConflictError(conflict);
|
||
}}
|
||
step={1}
|
||
type="number"
|
||
value={values.min_traffic_gb}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="max_traffic_gb">
|
||
{t("maxTrafficGB", "Max Traffic (GB)")}
|
||
</Label>
|
||
<Input
|
||
id="max_traffic_gb"
|
||
min={0}
|
||
onChange={(e) => {
|
||
const newValue = Number.parseFloat(e.target.value) || 0;
|
||
setValues({ ...values, max_traffic_gb: newValue });
|
||
// 实时检测冲突
|
||
const conflict = checkTrafficRangeConflict(
|
||
values.min_traffic_gb,
|
||
newValue
|
||
);
|
||
setConflictError(conflict);
|
||
}}
|
||
step={1}
|
||
type="number"
|
||
value={values.max_traffic_gb}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* 显示冲突错误 */}
|
||
{conflictError && (
|
||
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||
<span>{conflictError}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
className="rounded-md border px-4 py-2 text-sm"
|
||
disabled={submitting || loading}
|
||
onClick={() => setOpen(false)}
|
||
type="button"
|
||
>
|
||
{t("cancel", "Cancel")}
|
||
</button>
|
||
<button
|
||
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm disabled:opacity-50"
|
||
disabled={submitting || loading || !!conflictError}
|
||
type="submit"
|
||
>
|
||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{t("save", "Save")}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
NodeGroupForm.displayName = "NodeGroupForm";
|
||
|
||
export default NodeGroupForm;
|