hi-frontend/apps/admin/src/sections/group/node-group-form.tsx
shanshanzhong 785c832e02
Some checks failed
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
Issue Check Inactive / issue-check-inactive (push) Has been cancelled
fix: 限速输入统一为 Mbps,去掉 mbToBits 转换
- 订阅表单 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>
2026-03-27 02:47:40 -07:00

527 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;