merge: 同步 upstream/main 新功能到定制版本
- feat: Add slider verification code (bd67997) - fix bug: Inventory cannot be zero (1f7a6ee) - fix: resolve merge conflicts and lint errors
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeGroup {
|
||||
@@ -15,12 +15,15 @@ interface NodeGroup {
|
||||
|
||||
interface TrafficRangeConfigProps {
|
||||
nodeGroups: NodeGroup[];
|
||||
onTrafficUpdate: (nodeGroupId: number, fields: { min_traffic_gb?: number; max_traffic_gb?: number }) => Promise<void>;
|
||||
onTrafficUpdate: (
|
||||
nodeGroupId: number,
|
||||
fields: { min_traffic_gb?: number; max_traffic_gb?: number }
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UpdatingNode {
|
||||
nodeGroupId: number;
|
||||
field: 'min_traffic_gb' | 'max_traffic_gb';
|
||||
field: "min_traffic_gb" | "max_traffic_gb";
|
||||
}
|
||||
|
||||
interface NodeGroupTempValues {
|
||||
@@ -28,20 +31,30 @@ interface NodeGroupTempValues {
|
||||
max_traffic_gb?: number;
|
||||
}
|
||||
|
||||
export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: TrafficRangeConfigProps) {
|
||||
export default function TrafficRangeConfig({
|
||||
nodeGroups,
|
||||
onTrafficUpdate,
|
||||
}: TrafficRangeConfigProps) {
|
||||
const { t } = useTranslation("group");
|
||||
const [updatingNodes, setUpdatingNodes] = useState<UpdatingNode[]>([]);
|
||||
// 使用对象存储每个节点组的临时值
|
||||
const [temporaryValues, setTemporaryValues] = useState<Record<number, NodeGroupTempValues>>({});
|
||||
const [temporaryValues, setTemporaryValues] = useState<
|
||||
Record<number, NodeGroupTempValues>
|
||||
>({});
|
||||
|
||||
// Get the display value (temporary or actual)
|
||||
const getDisplayValue = (nodeGroupId: number, field: 'min_traffic_gb' | 'max_traffic_gb'): number => {
|
||||
const getDisplayValue = (
|
||||
nodeGroupId: number,
|
||||
field: "min_traffic_gb" | "max_traffic_gb"
|
||||
): number => {
|
||||
const temp = temporaryValues[nodeGroupId];
|
||||
if (temp && temp[field] !== undefined) {
|
||||
return temp[field]!;
|
||||
}
|
||||
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
|
||||
return field === 'min_traffic_gb' ? (nodeGroup?.min_traffic_gb ?? 0) : (nodeGroup?.max_traffic_gb ?? 0);
|
||||
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
|
||||
return field === "min_traffic_gb"
|
||||
? (nodeGroup?.min_traffic_gb ?? 0)
|
||||
: (nodeGroup?.max_traffic_gb ?? 0);
|
||||
};
|
||||
|
||||
// Validate traffic ranges: no overlaps
|
||||
@@ -57,22 +70,34 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
|
||||
// Check if min >= max (both > 0)
|
||||
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||||
return { valid: false, error: t("minCannotExceedMax", "Minimum traffic cannot exceed maximum traffic") };
|
||||
return {
|
||||
valid: false,
|
||||
error: t(
|
||||
"minCannotExceedMax",
|
||||
"Minimum traffic cannot exceed maximum traffic"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check for overlaps with other node groups
|
||||
const otherGroups = nodeGroups
|
||||
.filter(ng => ng.id !== nodeGroupId)
|
||||
.map(ng => {
|
||||
.filter((ng) => ng.id !== nodeGroupId)
|
||||
.map((ng) => {
|
||||
const temp = temporaryValues[ng.id];
|
||||
return {
|
||||
id: ng.id,
|
||||
name: ng.name,
|
||||
min: temp?.min_traffic_gb !== undefined ? temp.min_traffic_gb : (ng.min_traffic_gb ?? 0),
|
||||
max: temp?.max_traffic_gb !== undefined ? temp.max_traffic_gb : (ng.max_traffic_gb ?? 0),
|
||||
min:
|
||||
temp?.min_traffic_gb !== undefined
|
||||
? temp.min_traffic_gb
|
||||
: (ng.min_traffic_gb ?? 0),
|
||||
max:
|
||||
temp?.max_traffic_gb !== undefined
|
||||
? temp.max_traffic_gb
|
||||
: (ng.max_traffic_gb ?? 0),
|
||||
};
|
||||
})
|
||||
.filter(ng => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
|
||||
.filter((ng) => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
|
||||
.sort((a, b) => a.min - b.min);
|
||||
|
||||
for (const other of otherGroups) {
|
||||
@@ -85,7 +110,11 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
if (currentMax > other.min && otherMax > minTraffic) {
|
||||
return {
|
||||
valid: false,
|
||||
error: t("rangeOverlap", "Range overlaps with node group \"{{name}}\"", { name: other.name })
|
||||
error: t(
|
||||
"rangeOverlap",
|
||||
'Range overlaps with node group "{{name}}"',
|
||||
{ name: other.name }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -94,31 +123,39 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
};
|
||||
|
||||
const handleTrafficBlur = async (nodeGroupId: number) => {
|
||||
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
|
||||
const nodeGroup = nodeGroups.find((ng) => ng.id === nodeGroupId);
|
||||
if (!nodeGroup) return;
|
||||
|
||||
const tempValues = temporaryValues[nodeGroupId];
|
||||
if (!tempValues) return;
|
||||
|
||||
// 获取当前的临时值或实际值
|
||||
const currentMin = tempValues.min_traffic_gb !== undefined
|
||||
? tempValues.min_traffic_gb
|
||||
: (nodeGroup.min_traffic_gb ?? 0);
|
||||
const currentMax = tempValues.max_traffic_gb !== undefined
|
||||
? tempValues.max_traffic_gb
|
||||
: (nodeGroup.max_traffic_gb ?? 0);
|
||||
const currentMin =
|
||||
tempValues.min_traffic_gb !== undefined
|
||||
? tempValues.min_traffic_gb
|
||||
: (nodeGroup.min_traffic_gb ?? 0);
|
||||
const currentMax =
|
||||
tempValues.max_traffic_gb !== undefined
|
||||
? tempValues.max_traffic_gb
|
||||
: (nodeGroup.max_traffic_gb ?? 0);
|
||||
|
||||
// 只要有一个字段被修改了就保存
|
||||
const hasMinChange = tempValues.min_traffic_gb !== undefined;
|
||||
const hasMaxChange = tempValues.max_traffic_gb !== undefined;
|
||||
if (!hasMinChange && !hasMaxChange) {
|
||||
if (!(hasMinChange || hasMaxChange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证
|
||||
const validation = validateTrafficRange(nodeGroupId, currentMin, currentMax);
|
||||
const validation = validateTrafficRange(
|
||||
nodeGroupId,
|
||||
currentMin,
|
||||
currentMax
|
||||
);
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.error || t("validationFailed", "Validation failed"));
|
||||
toast.error(
|
||||
validation.error || t("validationFailed", "Validation failed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,15 +168,24 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
|
||||
// 标记为更新中(只标记被修改的字段)
|
||||
if (hasMinChange) {
|
||||
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'min_traffic_gb' }]);
|
||||
setUpdatingNodes((prev) => [
|
||||
...prev,
|
||||
{ nodeGroupId, field: "min_traffic_gb" },
|
||||
]);
|
||||
}
|
||||
if (hasMaxChange) {
|
||||
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'max_traffic_gb' }]);
|
||||
setUpdatingNodes((prev) => [
|
||||
...prev,
|
||||
{ nodeGroupId, field: "max_traffic_gb" },
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 一次性传递两个字段
|
||||
const fieldsToUpdate: { min_traffic_gb?: number; max_traffic_gb?: number } = {};
|
||||
const fieldsToUpdate: {
|
||||
min_traffic_gb?: number;
|
||||
max_traffic_gb?: number;
|
||||
} = {};
|
||||
if (currentMin !== originalMin) {
|
||||
fieldsToUpdate.min_traffic_gb = currentMin;
|
||||
}
|
||||
@@ -152,40 +198,44 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
}
|
||||
} finally {
|
||||
// 移除更新状态
|
||||
setUpdatingNodes(prev => prev.filter(u => !(u.nodeGroupId === nodeGroupId)));
|
||||
setUpdatingNodes((prev) =>
|
||||
prev.filter((u) => !(u.nodeGroupId === nodeGroupId))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUpdating = (nodeGroupId: number) => {
|
||||
return updatingNodes.some(u => u.nodeGroupId === nodeGroupId);
|
||||
};
|
||||
const isUpdating = (nodeGroupId: number) =>
|
||||
updatingNodes.some((u) => u.nodeGroupId === nodeGroupId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="grid grid-cols-12 gap-2 font-medium text-muted-foreground text-sm">
|
||||
<div className="col-span-6">{t("nodeGroup", "Node Group")}</div>
|
||||
<div className="col-span-3">{t("minTrafficGB", "Min (GB)")}</div>
|
||||
<div className="col-span-3">{t("maxTrafficGB", "Max (GB)")}</div>
|
||||
</div>
|
||||
|
||||
{nodeGroups.map((nodeGroup) => (
|
||||
<div key={nodeGroup.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<div
|
||||
className="grid grid-cols-12 items-center gap-2"
|
||||
key={nodeGroup.id}
|
||||
>
|
||||
<div className="col-span-6">
|
||||
<div className="font-medium">{nodeGroup.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroup.id}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t("id", "ID")}: {nodeGroup.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 relative">
|
||||
<div className="relative col-span-3">
|
||||
<Input
|
||||
type="number"
|
||||
disabled={isUpdating(nodeGroup.id)}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0"
|
||||
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
|
||||
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||
onChange={(e) => {
|
||||
const newValue = parseFloat(e.target.value) || 0;
|
||||
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||
// 更新临时状态
|
||||
setTemporaryValues(prev => ({
|
||||
setTemporaryValues((prev) => ({
|
||||
...prev,
|
||||
[nodeGroup.id]: {
|
||||
...prev[nodeGroup.id],
|
||||
@@ -194,26 +244,26 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
},
|
||||
}));
|
||||
}}
|
||||
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||
disabled={isUpdating(nodeGroup.id)}
|
||||
placeholder="0"
|
||||
step={1}
|
||||
type="number"
|
||||
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
|
||||
/>
|
||||
{isUpdating(nodeGroup.id) && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<div className="-translate-y-1/2 absolute top-1/2 right-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 relative">
|
||||
<div className="relative col-span-3">
|
||||
<Input
|
||||
type="number"
|
||||
disabled={isUpdating(nodeGroup.id)}
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0"
|
||||
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
|
||||
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||
onChange={(e) => {
|
||||
const newValue = parseFloat(e.target.value) || 0;
|
||||
const newValue = Number.parseFloat(e.target.value) || 0;
|
||||
// 更新临时状态
|
||||
setTemporaryValues(prev => ({
|
||||
setTemporaryValues((prev) => ({
|
||||
...prev,
|
||||
[nodeGroup.id]: {
|
||||
...prev[nodeGroup.id],
|
||||
@@ -222,11 +272,13 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
},
|
||||
}));
|
||||
}}
|
||||
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||
disabled={isUpdating(nodeGroup.id)}
|
||||
placeholder="0"
|
||||
step={1}
|
||||
type="number"
|
||||
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
|
||||
/>
|
||||
{isUpdating(nodeGroup.id) && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<div className="-translate-y-1/2 absolute top-1/2 right-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
@@ -235,7 +287,7 @@ export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: Traf
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
|
||||
<div className="rounded-md bg-muted p-4 text-muted-foreground text-sm">
|
||||
<strong>{t("note", "Note")}:</strong>{" "}
|
||||
{t(
|
||||
"trafficRangesNote",
|
||||
|
||||
Reference in New Issue
Block a user